Compare commits

...
Author SHA1 Message Date
timothyandtimothy df8c5202d6 fix(409): report never-scanned LastScan as null for API/MCP parity (migration + read coercion) (#459)
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / CI image pin matches docker/ci (push) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build CI Toolchain Image / Build & push CI image (push) Failing after 10m41s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 18m20s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 18m31s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m58s
Co-authored-by: Timothy <timothy.look@gmail.com>
Co-committed-by: Timothy <timothy.look@gmail.com>
2026-07-18 19:39:25 +00:00
timothyandClaude Opus 4.8 2fff5fc05d Merge pull request 'feat(420): skip re-validating an already-green identical merge tree' (#455) from feat/398-420-ci-build-once into main
Build ErsatzTV Image / CI image pin matches docker/ci (push) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 12s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 25s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 26s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m56s
feat(420): skip re-validating an already-green identical merge tree (#455)

Standalone cross-run tree-identity skip. Build-once (#398) was measured and rejected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 19:14:27 +00:00
timothyandClaude Opus 4.8 dd55f00ed4 docs(420): sharpen the skip's honest hit-rate (rebase-before-merge) + cite live combined-status check
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 6s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 7s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 10s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 5m18s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 7s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 6s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 14m21s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 18m49s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Review nits from the scoped #420 review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 20:53:23 +02:00
timothy 922b1ef53c docs(398,420): document the #420 skip + record build-once measured-and-rejected 2026-07-18 20:53:23 +02:00
timothy eb12176fe1 ci(420): skip re-validating an already-green identical tree on merge-to-main 2026-07-18 20:52:44 +02:00
timothy 8152afead1 Merge pull request 'feat(403): make unsupported PlaybackOrder loud at build time + tripwire' (#457) from feat/403-loud-playbackorder into main
Build ErsatzTV Image / CI image pin matches docker/ci (push) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 7m7s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 14m26s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 20m27s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m3s
2026-07-18 17:10:35 +00:00
timothyandClaude Opus 4.8 1d95cbfee6 fix(403): accurate Classic fallback message + engine-coverage tripwire
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 8s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 5s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 6s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m20s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 5m1s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 9s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 14m21s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 14m18s
Review follow-ups (cold review of PR #457):
- Marathon can reach PlayoutBuilder's default arm via `goto default` when
  its enumerator can't be built; the new warning claimed "Marathon is not
  supported by classic scheduling", which is false. Distinguish
  supported-but-failed-to-build (logs "could not build") from genuinely
  unsupported using PlaybackOrderSupport, which also makes Classic a
  runtime consumer of the matrix.
- PlaybackOrderSupportTests now asserts every SchedulingEngineKind has a
  matrix entry, so a new engine kind fails the test instead of throwing
  KeyNotFoundException at runtime.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 18:32:14 +02:00
timothyandtimothy ca87336600 fix(401): reject Mirror channel with playout (422) + repair dead else-branch (#456)
Build ErsatzTV Image / CI image pin matches docker/ci (push) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 7m20s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 18m24s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 14m3s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m29s
Co-authored-by: Timothy <timothy.look@gmail.com>
Co-committed-by: Timothy <timothy.look@gmail.com>
2026-07-18 16:27:26 +00:00
timothyandtimothy f62ff0eebc fix(327): validate playlist name on rename (reject empty/whitespace/too-long) (#454)
Build ErsatzTV Image / CI image pin matches docker/ci (push) Has been skipped
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 / Functional E2E (curl contracts) (push) Has been cancelled
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Co-authored-by: Timothy <timothy.look@gmail.com>
Co-committed-by: Timothy <timothy.look@gmail.com>
2026-07-18 16:27:19 +00:00
timothyandClaude Opus 4.8 0f34c86afa feat(403): make unsupported PlaybackOrder loud at build time + tripwire
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 9s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 5s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 6s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 17s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m21s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 14m14s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 18m31s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 37m32s
Adding a new PlaybackOrder was unsafe by construction: three build-time
dispatch sites turned an unknown value into an enumerator silently.
Classic substituted RandomizedMediaCollectionEnumerator (the // TODO
default arm), PlaylistEnumerator had no default arm so the item was
dropped, and BlockPlayoutBuilder's allow-list continue skipped it.
(#70 already made YAML/Scripted log a warning and MultiCollectionGroup
throws.)

- each silent site now logs a Warning naming the order + engine + the
  fallback taken; the fallback itself is preserved so a live channel
  never goes dark on one misconfigured item and scheduler goldens do
  not move.
- PlaylistEnumerator.Create gained an optional Option<ILogger> (it was
  static with no logger -- why the drop was unreportable); loggered
  callers pass it.
- BlockPlayoutBuilder gained an explicit Random arm (it previously
  reached an enumerator only via the coincidental _ => fallback) and a
  loud defensive fallback.
- new PlaybackOrderSupport matrix (per SchedulingEngineKind) + tripwire
  PlaybackOrderSupportTests: Supported ∪ Unsupported must partition the
  enum for every engine, so a new order fails the test until classified.
  BlockPlayoutBuilder consumes the matrix for its allow-list.
- write-path rejection left unchanged (#70 closed the persistence hole;
  the perimeter has been wrong three times per decisions.md); reverse
  _ => None mappings reviewed and deferred (different axis; making them
  loud would warn on legit enumerator types).

docs/decisions.md updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 18:23:21 +02:00
timothyandtimothy b1521f9047 fix(409,447): LibrariesScreen 'Never scanned' label + stabilize flaky scan-poll test (#451)
Build ErsatzTV Image / CI image pin matches docker/ci (push) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Has started running
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been cancelled
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (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: Timothy <timothy.look@gmail.com>
Co-committed-by: Timothy <timothy.look@gmail.com>
2026-07-18 16:18:45 +00:00
timothyandtimothy ebb9a40dc3 fix(410): log scan cancellation below ERROR (user-initiated, not a failure) (#453)
Build ErsatzTV Image / CI image pin matches docker/ci (push) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Has started running
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Has been cancelled
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been cancelled
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been cancelled
Co-authored-by: Timothy <timothy.look@gmail.com>
Co-committed-by: Timothy <timothy.look@gmail.com>
2026-07-18 16:18:02 +00:00
timothyandtimothy ab8e5d7a91 ci(338): distinguish ZAP warning (exit 2) from failure (exit 1) in security-scan (#452)
Build ErsatzTV Image / CI image pin matches docker/ci (push) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (push) Has been cancelled
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been cancelled
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been cancelled
Co-authored-by: Timothy <timothy.look@gmail.com>
Co-committed-by: Timothy <timothy.look@gmail.com>
2026-07-18 16:16:41 +00:00
timothyandtimothy 11ec07ee1b fix(367): correct Plex budget-exhausted guidance when no servers exist (#450)
Build ErsatzTV Image / CI image pin matches docker/ci (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m10s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 14m38s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 20m18s
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Co-authored-by: Timothy <timothy.look@gmail.com>
Co-committed-by: Timothy <timothy.look@gmail.com>
2026-07-18 16:15:37 +00:00
timothyandtimothy 6462c36983 fix(310): strip UTF-8 BOM from the legacy .cs files #269 touched (#449)
Build ErsatzTV Image / CI image pin matches docker/ci (push) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Has been cancelled
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 / Functional E2E (curl contracts) (push) Has been cancelled
Co-authored-by: Timothy <timothy.look@gmail.com>
Co-committed-by: Timothy <timothy.look@gmail.com>
2026-07-18 16:15:35 +00:00
timothy 6e97664487 Merge pull request #448 (feat/396): collapsible sidebar + nav-group accordions
Build ErsatzTV Image / CI image pin matches docker/ci (push) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 5m15s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 19m30s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
Fixes #396. Force-merged (operator-authorized): all real checks passed green on 8cef07a6 (Build & test, EF migration, Functional E2E, formatting, docs, API sync, decisions.md append-only); the red status was a cancelled redundant re-run.
2026-07-18 15:24:53 +00:00
timothyandClaude Opus 4.8 8cef07a673 fix(396): mark active route in a default-collapsed group; fix mobile brand bar
Build ErsatzTV Image / CI image pin matches docker/ci (pull_request) Successful in 6s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 7s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 6s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Has been cancelled
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Has been cancelled
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been cancelled
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Has been cancelled
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Has been cancelled
Cold-review findings:
- #1 (CONFIRMED): deep-linking/reloading into a route inside a default-collapsed
  labeled group (e.g. /app/settings, /app/libraries) left the expanded sidebar
  with no active indicator, violating "active route marked in both states". Now
  the group that contains the active route is force-shown (and its header shows
  expanded) WITHOUT persisting — navigating away reverts to the stored preference.
  Regression test added.
- #2 (mobile): a persisted-collapsed state hid the brand logo+wordmark while the
  toggle is also hidden at <=980px, leaving an empty header. Restore the brand in
  the mobile query (collapse is desktop-only).

Verified live (Playwright): /app/settings deep-link shows Settings active +
aria-current, System auto-revealed, Media stays collapsed, nothing persisted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 16:43:33 +02:00
timothyandClaude Opus 4.8 eb47aed767 feat(396): collapsible sidebar + nav-group accordions
Add two independent, persisted collapse states to the shell sidebar:
- a header toggle that collapses it to a 60px icon rail
- collapsible accordions per labeled nav group (Media, System);
  the unlabeled Primary group is always open, default-collapsed groups

State + persistence live in web/src/app/sidebarState.ts (useSidebarState);
AppShell stamps ctv-app-shell-collapsed on the shell root and the rail look
is CSS-driven. Two namespaced localStorage keys (ctv-sidebar-collapsed,
ctv-sidebar-groups) per the persisted-UI-state convention. In the rail,
accordions are ignored (all items shown icon-only, label kept in the a11y
tree + surfaced as a title tooltip, badges as a corner dot); active-route
indicator works in both states; grid-column transition respects
prefers-reduced-motion.

Tests: colocated sidebarState.test.ts + a new describe in App.test.tsx
(default-collapsed, accordion toggle+persist, rail, reload persistence).
Docs: spa-conventions §13 + decisions.md 2026-07-18.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 16:43:33 +02:00
46 changed files with 16355 additions and 107 deletions
+59 -32
View File
@@ -36,6 +36,14 @@ name: Build ErsatzTV Image
# `if:`-skip a required job: on Gitea 1.25.4 a skipped job reports commit-status state `skipped`
# (verified, throwaway PR #418) and we don't rely on how branch protection treats a skipped
# REQUIRED context. See docs/ci-cd.md -> "Docs-only skip".
#
# ALREADY-VALIDATED SKIP (ersatztv#420): a second, sibling gate in `test`, `migrations` and
# `functional-e2e` only (NOT `build`). On a push-to-main merge commit, `id: revalidate` runs
# `scripts/ci-detect-already-validated.sh`, which emits `skip=true` only when the merged tree is
# byte-identical to a PR head that already has a green Gitea combined status — i.e. the exact
# source was already validated in the PR run. Every heavy step in those three jobs additionally
# gates on `steps.revalidate.outputs.skip != 'true'`. `build` is untouched and always runs on
# main, so the image is still built (from already-validated source) even when the skip fires.
on:
workflow_dispatch:
@@ -103,9 +111,9 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
with:
# 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
# git history/tags are needed by the `build` job's `git describe` (ersatztv#190) and,
# here, by the #420 revalidate step's `HEAD^2` tree comparison on a main merge commit.
fetch-depth: 2
# ersatztv#416: is this a docs-only change? If so, every heavy step below is skipped and this
# REQUIRED job reports success in seconds. It still RUNS (never `if:`-skipped) so the required
@@ -113,9 +121,14 @@ jobs:
- name: Detect docs-only changes
id: detect
run: scripts/ci-detect-docs-only.sh
- name: Detect already-validated tree (#420)
id: revalidate
env:
ETV_STATUS_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }}
run: scripts/ci-detect-already-validated.sh
- name: Cache NuGet packages
if: steps.detect.outputs.docs_only != 'true'
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
uses: actions/cache@v4
with:
path: ~/.nuget/packages
@@ -123,13 +136,13 @@ jobs:
restore-keys: nuget-${{ runner.os }}-
- name: Restore
if: steps.detect.outputs.docs_only != 'true'
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
run: dotnet restore
# Replaces setup-node's built-in `cache: npm`. The toolchain image supplies node/npm, but
# the SPA's package downloads are project deps, so they stay cached per lockfile.
- name: Cache npm packages
if: steps.detect.outputs.docs_only != 'true'
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
uses: actions/cache@v4
with:
path: ~/.npm
@@ -137,45 +150,45 @@ jobs:
restore-keys: npm-${{ runner.os }}-
- name: Install SPA dependencies
if: steps.detect.outputs.docs_only != 'true'
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
working-directory: web
run: npm ci
- name: Check generated SPA API client
if: steps.detect.outputs.docs_only != 'true'
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
working-directory: web
run: npm run check:api
- name: Lint SPA
if: steps.detect.outputs.docs_only != 'true'
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
working-directory: web
run: npm run lint
- name: Typecheck SPA
if: steps.detect.outputs.docs_only != 'true'
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
working-directory: web
run: npm run typecheck
- name: Test SPA
if: steps.detect.outputs.docs_only != 'true'
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
working-directory: web
run: npm test -- --run
- name: Build SPA
if: steps.detect.outputs.docs_only != 'true'
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
working-directory: web
run: npm run build
- name: Strip Scanner project ref (matches Docker build)
if: steps.detect.outputs.docs_only != 'true'
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
run: sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
- name: Build
if: steps.detect.outputs.docs_only != 'true'
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
run: dotnet build --configuration Release --no-restore
- name: Test
if: steps.detect.outputs.docs_only != 'true'
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
run: >-
dotnet test --configuration Release --no-build --blame-hang-timeout "2m" --verbosity normal
--collect:"XPlat Code Coverage" --settings coverlet.runsettings --results-directory ./coverage
@@ -186,7 +199,7 @@ jobs:
# floor later"), so this step is purely informational — continue-on-error keeps a missing
# report or a transient tool-install failure from ever blocking a build.
- name: Coverage summary
if: steps.detect.outputs.docs_only != 'true'
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
continue-on-error: true
run: |
set -euo pipefail
@@ -239,7 +252,7 @@ jobs:
# `defaults.run.shell: bash` means `-e -o pipefail` is on, so a failed `cat`/redirect here
# would redden a green test job. `continue-on-error` is what actually makes it advisory,
# the same guarantee the Coverage summary step above uses.
if: ${{ always() && steps.detect.outputs.docs_only != 'true' }}
if: ${{ always() && steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' }}
continue-on-error: true
run: |
mib() { echo "$(( ${1:-0} / 1048576 ))"; }
@@ -339,17 +352,24 @@ 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)
with:
# was the default fetch-depth: 1 (ersatztv#190); bumped to 2 so the #420 revalidate
# step's `HEAD^2` tree comparison can resolve on a main merge commit.
fetch-depth: 2
# ersatztv#416: docs-only? Skip the build + migration replay; the job still reports success in
# seconds. REQUIRED context, so it always RUNS (never `if:`-skipped). See the workflow header.
- name: Detect docs-only changes
id: detect
run: scripts/ci-detect-docs-only.sh
- name: Detect already-validated tree (#420)
id: revalidate
env:
ETV_STATUS_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }}
run: scripts/ci-detect-already-validated.sh
- name: Cache NuGet packages
if: steps.detect.outputs.docs_only != 'true'
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
uses: actions/cache@v4
with:
path: ~/.nuget/packages
@@ -357,11 +377,11 @@ jobs:
restore-keys: nuget-${{ runner.os }}-
- name: Restore
if: steps.detect.outputs.docs_only != 'true'
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
run: dotnet restore
- name: Build
if: steps.detect.outputs.docs_only != 'true'
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
run: dotnet build --configuration Release --no-restore
# dotnet-ef is baked into the CI toolchain image (docker/ci/Dockerfile) and already on PATH
@@ -369,7 +389,7 @@ jobs:
# SQLite is the prod provider; both checks validated locally.
- name: SQLite — model drift + apply all migrations to a fresh DB
if: steps.detect.outputs.docs_only != 'true'
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
run: |
set -euo pipefail
echo "::group::SQLite model drift (has-pending-model-changes)"
@@ -385,7 +405,7 @@ jobs:
# MySql uses ServerVersion.AutoDetect (connects at config time), so it runs against the
# service container above. MySql__ConnectionString maps to config key "MySql:ConnectionString".
- name: MySql — model drift + apply all migrations to a fresh DB
if: steps.detect.outputs.docs_only != 'true'
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
env:
# DefaultCommandTimeout is raised from MySqlConnector's 30s default: replaying every
# migration to a fresh DB issues DDL commands that can exceed 30s when two migration jobs
@@ -440,15 +460,22 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 1
# bumped from 1 (ersatztv#190 default) so the #420 revalidate step's `HEAD^2` tree
# comparison can resolve on a main merge commit.
fetch-depth: 2
# ersatztv#416: docs-only? Skip the boot + curl harness (advisory job; safe to no-op).
- name: Detect docs-only changes
id: detect
run: scripts/ci-detect-docs-only.sh
- name: Detect already-validated tree (#420)
id: revalidate
env:
ETV_STATUS_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }}
run: scripts/ci-detect-already-validated.sh
- name: Cache NuGet packages
if: steps.detect.outputs.docs_only != 'true'
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
uses: actions/cache@v4
with:
path: ~/.nuget/packages
@@ -456,11 +483,11 @@ jobs:
restore-keys: nuget-${{ runner.os }}-
- name: Restore
if: steps.detect.outputs.docs_only != 'true'
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
run: dotnet restore
- name: Cache npm packages
if: steps.detect.outputs.docs_only != 'true'
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
uses: actions/cache@v4
with:
path: ~/.npm
@@ -468,17 +495,17 @@ jobs:
restore-keys: npm-${{ runner.os }}-
- name: Install SPA dependencies
if: steps.detect.outputs.docs_only != 'true'
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
working-directory: web
run: npm ci
- name: Build SPA
if: steps.detect.outputs.docs_only != 'true'
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
working-directory: web
run: npm run build
- name: Build (Release)
if: steps.detect.outputs.docs_only != 'true'
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
run: dotnet build ErsatzTV.sln --configuration Release --no-restore
# The old `command -v ffmpeg || sudo apt-get install ffmpeg` step is gone (ersatztv#390):
@@ -489,7 +516,7 @@ jobs:
# the image, so still no per-run install. The scan flow self-skips if ffmpeg is ever absent.
- name: Boot instance and run functional-E2E harness
if: steps.detect.outputs.docs_only != 'true'
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
run: |
set -euo pipefail
export ETV_BUILD_CONFIG=Release ETV_UI_PORT=8409
@@ -52,16 +52,6 @@ public class UpdateChannelHandler(
UpdateChannel update,
CancellationToken cancellationToken)
{
// don't save mirror when playout exists
if (c.Playouts.Count > 0)
{
update = update with
{
PlayoutSource = ChannelPlayoutSource.Generated,
MirrorSourceChannelId = null
};
}
bool hasEpgChange = c.PlayoutSource != update.PlayoutSource || c.ShowInEpg != update.ShowInEpg;
c.Name = update.Name;
@@ -140,6 +130,8 @@ public class UpdateChannelHandler(
c.PlayoutMode = ChannelPlayoutMode.Continuous;
hasEpgChange |= c.MirrorSourceChannelId != update.MirrorSourceChannelId;
hasEpgChange |= c.PlayoutOffset != update.PlayoutOffset;
c.MirrorSourceChannelId = update.MirrorSourceChannelId;
c.PlayoutOffset = update.PlayoutOffset;
}
else
{
@@ -147,8 +139,6 @@ public class UpdateChannelHandler(
c.PlayoutOffset = null;
}
c.MirrorSourceChannelId = update.MirrorSourceChannelId;
c.PlayoutOffset = update.PlayoutOffset;
c.StreamingMode = update.StreamingMode;
c.WatermarkId = update.WatermarkId;
c.FallbackFillerId = update.FallbackFillerId;
@@ -194,7 +184,7 @@ public class UpdateChannelHandler(
{
Validation<BaseError, Channel> channelValidation = (ValidateName(request),
await ValidateNumber(dbContext, request, cancellationToken),
await MirrorSourceMustBeValid(dbContext, request, cancellationToken),
await MirrorSourceMustBeValid(dbContext, request, channel, cancellationToken),
ValidateShowInEpg(request.IsEnabled, request.ShowInEpg),
ValidateLogo(request.Logo?.Path))
.Apply((_, _, _, _, _) => channel);
@@ -269,6 +259,7 @@ public class UpdateChannelHandler(
private static async Task<Validation<BaseError, Unit>> MirrorSourceMustBeValid(
TvContext dbContext,
UpdateChannel request,
Channel channel,
CancellationToken cancellationToken)
{
if (request.PlayoutSource is not ChannelPlayoutSource.Mirror)
@@ -276,6 +267,18 @@ public class UpdateChannelHandler(
return Unit.Default;
}
// a channel with its own playout already built (Generated mode) cannot become a Mirror —
// Mirror channels relay another channel's playout and never build one of their own, so
// switching this transition on would strand the existing playout. This used to be
// silently coerced back to Generated (issue #401); reject the transition instead so the
// caller sees why the requested Mirror source was not applied. A round-trip that keeps
// PlayoutSource as Generated never reaches this check.
if (channel.Playouts.Count > 0)
{
return BaseError.New(
"Channel cannot switch to Mirror playout source while it has a playout; reset or delete the existing playout first.");
}
Option<Channel> maybeMirrorSource = await dbContext.Channels
.AsNoTracking()
.SelectOneAsync(
@@ -1,4 +1,4 @@
using System.Threading.Channels;
using System.Threading.Channels;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Application.Search;
using ErsatzTV.Core;
@@ -74,7 +74,12 @@ public class ReplacePlaylistItemsHandler(IDbContextFactory<TvContext> dbContextF
CancellationToken cancellationToken) =>
PlaylistMustExist(dbContext, request.PlaylistId, cancellationToken)
.BindT(playlist => CollectionTypesMustBeValid(request, playlist))
.BindT(playlist => PlaybackOrdersMustBeSupported(request, playlist));
.BindT(playlist => PlaybackOrdersMustBeSupported(request, playlist))
.BindT(playlist => ValidateName(request).Map(_ => playlist));
private static Validation<BaseError, string> ValidateName(ReplacePlaylistItems request) =>
request.NotEmpty(x => x.Name)
.Bind(_ => request.NotLongerThan(50)(x => x.Name));
private static Validation<BaseError, Playlist> PlaybackOrdersMustBeSupported(
ReplacePlaylistItems request,
@@ -1,4 +1,4 @@
using System.Threading.Channels;
using System.Threading.Channels;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
@@ -10,6 +10,16 @@ public class GetAllMediaSourcesForApiHandler(
IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetAllMediaSourcesForApi, List<MediaSourceResponseModel>>
{
// A never-scanned library has a null LastScan at runtime, but historical DB rows still carry the
// 0001-01-01 MinValue sentinel written by the old Reset_* migrations. Coerce any such residual
// sentinel to null so the API/MCP surface reports "never scanned" as null (parity with the UI),
// regardless of DB history or provider. Belt-and-suspenders alongside the NullOutNeverScannedLastScan
// data migration.
private static readonly DateTime NeverScannedThreshold = new(2000, 1, 1);
private static DateTime? NormalizeLastScan(DateTime? lastScan) =>
lastScan is { } value && value < NeverScannedThreshold ? null : lastScan;
public async Task<List<MediaSourceResponseModel>> Handle(
GetAllMediaSourcesForApi request,
CancellationToken cancellationToken)
@@ -36,7 +46,7 @@ public class GetAllMediaSourcesForApiHandler(
l.Id,
l.Name,
l.MediaKind,
l.LastScan,
NormalizeLastScan(l.LastScan),
itemCountsByLibrary.TryGetValue(l.Id, out int count) ? count : 0))
.ToList();
@@ -0,0 +1,93 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Core.Tests.Scheduling;
[TestFixture]
public class PlaybackOrderSupportTests
{
private static readonly PlaybackOrder[] AllOrders = Enum.GetValues<PlaybackOrder>();
// The tripwire (#403): every engine must classify every PlaybackOrder value as either supported or
// explicitly unsupported. Adding a new order without classifying it here fails this test, which forces the
// author to wire it into (or deliberately reject it from) each dispatch site instead of letting it degrade
// silently.
[Test]
public void EveryOrder_IsClassified_ForEveryEngine()
{
foreach (SchedulingEngineKind engine in PlaybackOrderSupport.Engines)
{
foreach (PlaybackOrder order in AllOrders)
{
PlaybackOrderSupport.IsClassified(engine, order).ShouldBeTrue(
$"PlaybackOrder.{order} is not classified for {engine}. Add it to PlaybackOrderSupport " +
"(supported or unsupported) AND wire it into that engine's dispatch switch (#403).");
}
}
}
// Every SchedulingEngineKind must have a matrix entry, or Matrix[engine] throws KeyNotFoundException at
// runtime instead of failing here. This is the engine-axis counterpart to the order tripwire.
[Test]
public void EveryEngineKind_HasAMatrixEntry()
{
var classified = PlaybackOrderSupport.Engines.ToHashSet();
foreach (SchedulingEngineKind engine in Enum.GetValues<SchedulingEngineKind>())
{
classified.ShouldContain(engine,
$"SchedulingEngineKind.{engine} has no PlaybackOrderSupport matrix entry (#403).");
}
}
// The two sets must partition the enum: no order both supported and unsupported, and together they cover
// exactly the enum (no stale entry for a removed value, no missing value).
[Test]
public void SupportedAndUnsupported_ArePartition_ForEveryEngine()
{
var all = AllOrders.ToHashSet();
foreach (SchedulingEngineKind engine in PlaybackOrderSupport.Engines)
{
IReadOnlySet<PlaybackOrder> supported = PlaybackOrderSupport.SupportedBy(engine);
IReadOnlySet<PlaybackOrder> unsupported = PlaybackOrderSupport.UnsupportedBy(engine);
supported.Intersect(unsupported).ShouldBeEmpty(
$"{engine}: an order is listed as both supported and unsupported");
var union = supported.Concat(unsupported).ToHashSet();
union.ShouldBe(all, ignoreOrder: true,
$"{engine}: supported unsupported does not equal the PlaybackOrder enum");
}
}
// Guards the specific fragility called out in #403: Random is in Block's allow-list, so it must be
// supported by Block (it previously worked only via the switch's coincidental Random fallback).
[Test]
public void Block_Supports_Random()
{
PlaybackOrderSupport.IsSupported(SchedulingEngineKind.Block, PlaybackOrder.Random).ShouldBeTrue();
}
// WeightedShuffle (#70) is Classic-only; the other engines must classify it as unsupported so the
// write-path guards and this matrix agree.
[Test]
public void WeightedShuffle_IsClassicOnly()
{
PlaybackOrderSupport.IsSupported(SchedulingEngineKind.Classic, PlaybackOrder.WeightedShuffle)
.ShouldBeTrue();
foreach (SchedulingEngineKind engine in PlaybackOrderSupport.Engines)
{
if (engine == SchedulingEngineKind.Classic)
{
continue;
}
PlaybackOrderSupport.IsSupported(engine, PlaybackOrder.WeightedShuffle).ShouldBeFalse(
$"{engine} must not support WeightedShuffle (#70 is Classic-only)");
}
}
}
@@ -1,6 +1,7 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Scheduling;
using Microsoft.Extensions.Logging;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
@@ -433,6 +434,67 @@ public class PlaylistEnumeratorTests
items.ShouldBe([11, 12, 10, 21, 22, 20, 12, 10, 11, 22, 20, 21]);
}
// #403: an order the playlist engine doesn't handle must be dropped LOUDLY (a warning), not silently.
[Test]
public async Task Test_UnsupportedOrder_Drops_Item_And_Logs_Warning()
{
IMediaCollectionRepository repo = Substitute.For<IMediaCollectionRepository>();
var logger = new RecordingLogger();
var playlistItemMap = new Dictionary<PlaylistItem, List<MediaItem>>
{
{
new PlaylistItem
{
Id = 1,
PlaybackOrder = PlaybackOrder.Chronological,
PlayAll = false,
CollectionType = CollectionType.Collection,
CollectionId = 1
},
[FakeMovie(10), FakeMovie(11)]
},
{
// WeightedShuffle (#70) is Classic-only; the playlist switch has no arm for it.
new PlaylistItem
{
Id = 2,
PlaybackOrder = PlaybackOrder.WeightedShuffle,
PlayAll = false,
CollectionType = CollectionType.Collection,
CollectionId = 2
},
[FakeMovie(20), FakeMovie(21)]
}
};
PlaylistEnumerator enumerator = await PlaylistEnumerator.Create(
repo,
playlistItemMap,
new CollectionEnumeratorState(),
shufflePlaylistItems: false,
batchSize: Option<int>.None,
CancellationToken.None,
logger);
// the unsupported item (20, 21) is dropped; only the chronological item (10, 11) cycles
var items = new List<int>();
for (var i = 0; i < 4; i++)
{
items.AddRange(enumerator.Current.Map(mi => mi.Id));
enumerator.MoveNext(Option<DateTimeOffset>.None);
}
items.ShouldContain(10);
items.ShouldContain(11);
items.ShouldNotContain(20);
items.ShouldNotContain(21);
// and it said so, rather than dropping silently
logger.Entries.ShouldContain(
e => e.Level == LogLevel.Warning && e.Message.Contains("not supported by playlist"));
}
private static Movie FakeMovie(int id) => new()
{
Id = id,
@@ -445,4 +507,27 @@ public class PlaylistEnumeratorTests
}
]
};
private sealed class RecordingLogger : ILogger
{
public List<(LogLevel Level, string Message)> Entries { get; } = [];
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception exception,
Func<TState, Exception, string> formatter) =>
Entries.Add((logLevel, formatter(state, exception)));
private sealed class NullScope : IDisposable
{
public static readonly NullScope Instance = new();
public void Dispose() { }
}
}
}
@@ -51,15 +51,6 @@ public class BlockPlayoutBuilder(
referenceData.Channel.Number,
referenceData.Channel.Name);
List<PlaybackOrder> allowedPlaybackOrders =
[
PlaybackOrder.Chronological,
PlaybackOrder.SeasonEpisode,
PlaybackOrder.Shuffle,
PlaybackOrder.Random,
PlaybackOrder.RandomRotation
];
int daysToBuild = await GetDaysToBuild(cancellationToken);
// get blocks to schedule
@@ -163,8 +154,14 @@ public class BlockPlayoutBuilder(
foreach (BlockItem blockItem in effectiveBlock.Block.Items.OrderBy(i => i.Index))
{
// TODO: support other playback orders
if (!allowedPlaybackOrders.Contains(blockItem.PlaybackOrder))
if (!PlaybackOrderSupport.IsSupported(SchedulingEngineKind.Block, blockItem.PlaybackOrder))
{
// Skipping the item silently means it never airs and nothing says why (#403).
logger.LogWarning(
"Playback order {PlaybackOrder} is not supported by block scheduling; " +
"block item {BlockItemId} will be skipped",
blockItem.PlaybackOrder,
blockItem.Id);
continue;
}
@@ -394,14 +391,31 @@ public class BlockPlayoutBuilder(
referenceData.PlayoutHistory.Append(result.AddedHistory).ToList(),
blockItem,
historyKey),
_ => new RandomizedMediaCollectionEnumerator(
// Random is in Block's allow-list; give it an explicit arm rather than relying on the fallback
// below (it previously worked only by coincidence -- #403).
PlaybackOrder.Random => new RandomizedMediaCollectionEnumerator(
collectionItems,
new CollectionEnumeratorState { Seed = new Random().Next(), Index = 0 })
new CollectionEnumeratorState { Seed = new Random().Next(), Index = 0 }),
_ => UnsupportedBlockOrderFallback(blockItem.PlaybackOrder, collectionItems)
};
return enumerator;
}
// Defensive: the allow-list in Build already skips unsupported orders, so this should be unreachable. If a
// supported-but-unhandled order ever lands here, be loud instead of silently rotating Random (#403).
private IMediaCollectionEnumerator UnsupportedBlockOrderFallback(
PlaybackOrder playbackOrder,
List<MediaItem> collectionItems)
{
logger.LogWarning(
"Playback order {PlaybackOrder} reached the block enumerator without a handler; falling back to Random",
playbackOrder);
return new RandomizedMediaCollectionEnumerator(
collectionItems,
new CollectionEnumeratorState { Seed = new Random().Next(), Index = 0 });
}
private static PlayoutBuildResult CleanUpHistory(
PlayoutReferenceData referenceData,
DateTimeOffset start,
@@ -257,7 +257,8 @@ public class SchedulingEngine(
state,
shufflePlaylistItems: false,
batchSize: Option<int>.None,
CancellationToken.None);
CancellationToken.None,
Optional((ILogger)logger));
string historyKey = HistoryDetails.KeyForSchedulingContent(key, PlaybackOrder.None);
var details = new EnumeratorDetails(enumerator, historyKey, PlaybackOrder.None);
@@ -0,0 +1,134 @@
using ErsatzTV.Core.Domain;
using OrderSet = System.Collections.Generic.HashSet<ErsatzTV.Core.Domain.PlaybackOrder>;
namespace ErsatzTV.Core.Scheduling;
/// <summary>
/// The scheduling engine families that turn a <see cref="PlaybackOrder" /> into an enumerator, and which
/// orders each one actually handles. This is the single declared support matrix for #403.
/// </summary>
/// <remarks>
/// Each engine keeps BOTH a <c>Supported</c> and an <c>Unsupported</c> set, maintained by hand ON PURPOSE:
/// <c>PlaybackOrderSupportTests</c> asserts the two sets partition every <see cref="PlaybackOrder" /> value
/// (union is total, intersection empty), so adding a new order fails that test until it is consciously
/// classified here. Deriving <c>Unsupported</c> as "everything not supported" would let a new order fall
/// through silently — the very defect #403 exists to kill — so it is spelled out instead.
///
/// Membership here mirrors the executable dispatch in each builder (<c>PlayoutBuilder</c>,
/// <c>PlaylistEnumerator</c>, <c>BlockPlayoutBuilder</c>, <c>EnumeratorCache</c> [YAML],
/// <c>SchedulingEngine.EnumeratorForContent</c> [Scripted]); the builders remain the real logic. Only
/// <see cref="SchedulingEngineKind.Block" /> consumes this table at runtime (its allow-list), so the table
/// is not merely test scaffolding. When you add a case to one of those switches, update the matching set
/// here.
/// </remarks>
public static class PlaybackOrderSupport
{
private sealed record EngineSupport(
IReadOnlySet<PlaybackOrder> Supported,
IReadOnlySet<PlaybackOrder> Unsupported);
private static readonly IReadOnlyDictionary<SchedulingEngineKind, EngineSupport> Matrix =
new Dictionary<SchedulingEngineKind, EngineSupport>
{
// PlayoutBuilder.GetMediaCollectionEnumerator switch; default arm falls back to Random (now loud).
[SchedulingEngineKind.Classic] = new EngineSupport(
new OrderSet
{
PlaybackOrder.Chronological,
PlaybackOrder.SeasonEpisode,
PlaybackOrder.Random,
PlaybackOrder.Shuffle,
PlaybackOrder.ShuffleInOrder,
PlaybackOrder.MultiEpisodeShuffle,
PlaybackOrder.Marathon,
PlaybackOrder.WeightedShuffle
},
new OrderSet { PlaybackOrder.None, PlaybackOrder.RandomRotation }),
// PlaylistEnumerator.Create switch; no default arm meant a null enumerator -> silent drop (now loud).
[SchedulingEngineKind.Playlist] = new EngineSupport(
new OrderSet
{
PlaybackOrder.Chronological,
PlaybackOrder.SeasonEpisode,
PlaybackOrder.Random,
PlaybackOrder.Shuffle,
PlaybackOrder.ShuffleInOrder,
PlaybackOrder.MultiEpisodeShuffle
},
new OrderSet
{
PlaybackOrder.None,
PlaybackOrder.RandomRotation,
PlaybackOrder.Marathon,
PlaybackOrder.WeightedShuffle
}),
// BlockPlayoutBuilder allow-list (this very set) + the GetEnumerator switch.
[SchedulingEngineKind.Block] = new EngineSupport(
new OrderSet
{
PlaybackOrder.Chronological,
PlaybackOrder.SeasonEpisode,
PlaybackOrder.Shuffle,
PlaybackOrder.Random,
PlaybackOrder.RandomRotation
},
new OrderSet
{
PlaybackOrder.None,
PlaybackOrder.ShuffleInOrder,
PlaybackOrder.MultiEpisodeShuffle,
PlaybackOrder.Marathon,
PlaybackOrder.WeightedShuffle
}),
// EnumeratorCache (YAML, non-playlist path); unsupported orders LogWarning + None.
[SchedulingEngineKind.Yaml] = new EngineSupport(
new OrderSet { PlaybackOrder.Chronological, PlaybackOrder.Shuffle },
new OrderSet
{
PlaybackOrder.None,
PlaybackOrder.Random,
PlaybackOrder.SeasonEpisode,
PlaybackOrder.ShuffleInOrder,
PlaybackOrder.MultiEpisodeShuffle,
PlaybackOrder.RandomRotation,
PlaybackOrder.Marathon,
PlaybackOrder.WeightedShuffle
}),
// SchedulingEngine.EnumeratorForContent (Scripted); unsupported orders LogWarning + None.
[SchedulingEngineKind.Scripted] = new EngineSupport(
new OrderSet { PlaybackOrder.Chronological, PlaybackOrder.Shuffle },
new OrderSet
{
PlaybackOrder.None,
PlaybackOrder.Random,
PlaybackOrder.SeasonEpisode,
PlaybackOrder.ShuffleInOrder,
PlaybackOrder.MultiEpisodeShuffle,
PlaybackOrder.RandomRotation,
PlaybackOrder.Marathon,
PlaybackOrder.WeightedShuffle
})
};
public static IReadOnlyCollection<SchedulingEngineKind> Engines => Matrix.Keys.ToList();
/// <summary>The orders <paramref name="engine" /> can turn into an enumerator.</summary>
public static IReadOnlySet<PlaybackOrder> SupportedBy(SchedulingEngineKind engine) => Matrix[engine].Supported;
/// <summary>The orders explicitly known NOT to be handled by <paramref name="engine" />.</summary>
public static IReadOnlySet<PlaybackOrder> UnsupportedBy(SchedulingEngineKind engine) => Matrix[engine].Unsupported;
public static bool IsSupported(SchedulingEngineKind engine, PlaybackOrder order) =>
Matrix[engine].Supported.Contains(order);
/// <summary>
/// True when <paramref name="order" /> appears in either set for <paramref name="engine" />. A new enum
/// value is classified in neither until a human adds it — which is what the tripwire test asserts.
/// </summary>
public static bool IsClassified(SchedulingEngineKind engine, PlaybackOrder order) =>
Matrix[engine].Supported.Contains(order) || Matrix[engine].Unsupported.Contains(order);
}
+16 -1
View File
@@ -3,6 +3,7 @@ using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Scheduling;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Core.Scheduling;
@@ -121,7 +122,8 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
CollectionEnumeratorState state,
bool shufflePlaylistItems,
Option<int> batchSize,
CancellationToken cancellationToken)
CancellationToken cancellationToken,
Option<ILogger> logger = default)
{
var result = new PlaylistEnumerator
{
@@ -203,6 +205,19 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
break;
case PlaybackOrder.Random:
enumerator = new RandomizedMediaCollectionEnumerator(items, initState);
break;
default:
// An order the playlist engine doesn't handle (#403). Leaving the enumerator null drops
// this item from the playlist silently -- and null is a legitimate state above
// (SeasonEpisode with Count == 0), so nothing downstream can flag it. Say so here.
foreach (ILogger log in logger)
{
log.LogWarning(
"Playback order {PlaybackOrder} is not supported by playlist scheduling; " +
"this item will be dropped from the playlist",
playlistItem.PlaybackOrder);
}
break;
}
}
+19 -2
View File
@@ -1324,7 +1324,8 @@ public class PlayoutBuilder : IPlayoutBuilder
state,
marathonShuffleGroups,
batchSize: Option<int>.None,
cancellationToken);
cancellationToken,
Optional((ILogger)_logger));
}
}
@@ -1458,7 +1459,23 @@ public class PlayoutBuilder : IPlayoutBuilder
goto default;
default:
// TODO: handle this error case differently?
// Say so instead of silently substituting a different, arbitrary-looking order; keep airing on
// the Random fallback so the channel does not go dark on one misconfigured item (#403). A
// supported order can also land here via `goto default` (Marathon that couldn't build its
// enumerator) -- distinguish the two so the log doesn't claim a supported order is unsupported.
if (PlaybackOrderSupport.IsSupported(SchedulingEngineKind.Classic, playbackOrder))
{
_logger.LogWarning(
"Could not build a {PlaybackOrder} enumerator for classic scheduling; falling back to Random",
playbackOrder);
}
else
{
_logger.LogWarning(
"Playback order {PlaybackOrder} is not supported by classic scheduling; falling back to Random",
playbackOrder);
}
return new RandomizedMediaCollectionEnumerator(mediaItems, state);
}
}
@@ -0,0 +1,23 @@
namespace ErsatzTV.Core.Scheduling;
/// <summary>
/// The scheduling engine families that dispatch a <see cref="ErsatzTV.Core.Domain.PlaybackOrder" /> into an
/// enumerator. Used to key the <see cref="PlaybackOrderSupport" /> matrix (#403).
/// </summary>
public enum SchedulingEngineKind
{
/// <summary>Classic schedules — <c>PlayoutBuilder</c>.</summary>
Classic,
/// <summary>Playlist items — <c>PlaylistEnumerator</c>.</summary>
Playlist,
/// <summary>Block scheduling — <c>BlockPlayoutBuilder</c>.</summary>
Block,
/// <summary>YAML (sequential) scheduling — <c>EnumeratorCache</c>.</summary>
Yaml,
/// <summary>Scripted scheduling — <c>SchedulingEngine.EnumeratorForContent</c>.</summary>
Scripted
}
@@ -175,7 +175,8 @@ public class EnumeratorCache(IMediaCollectionRepository mediaCollectionRepositor
state,
shufflePlaylistItems: false,
batchSize: Option<int>.None,
cancellationToken);
cancellationToken,
Optional(logger));
}
var parsedOrder = Enum.Parse<PlaybackOrder>(content.Order, true);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,23 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class NullOutNeverScannedLastScan : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("UPDATE Library SET LastScan = NULL WHERE LastScan IS NOT NULL AND LastScan < '2000-01-01'");
migrationBuilder.Sql("UPDATE LibraryPath SET LastScan = NULL WHERE LastScan IS NOT NULL AND LastScan < '2000-01-01'");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// irreversible data migration; there is no way to recover the original sentinel values
}
}
}
@@ -0,0 +1,23 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class NullOutNeverScannedLastScan : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("UPDATE Library SET LastScan = NULL WHERE LastScan IS NOT NULL AND LastScan < '2000-01-01'");
migrationBuilder.Sql("UPDATE LibraryPath SET LastScan = NULL WHERE LastScan IS NOT NULL AND LastScan < '2000-01-01'");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// irreversible data migration; there is no way to recover the original sentinel values
}
}
}
@@ -1,5 +1,6 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Jellyfin;
@@ -95,5 +96,83 @@ public class SynchronizeJellyfinLibraryByIdHandlerTests
Arg.Any<CancellationToken>());
await libraryRepository.Received(1).UpdateLastScan(library);
}
[Test]
public async Task Should_Not_Log_Error_When_Scan_Is_Canceled()
{
var scannerProxy = Substitute.For<IScannerProxy>();
var mediaSourceRepository = Substitute.For<IMediaSourceRepository>();
var jellyfinSecretStore = Substitute.For<IJellyfinSecretStore>();
var jellyfinMovieLibraryScanner = Substitute.For<IJellyfinMovieLibraryScanner>();
var jellyfinTelevisionLibraryScanner = Substitute.For<IJellyfinTelevisionLibraryScanner>();
var jellyfinMusicVideoLibraryScanner = Substitute.For<IJellyfinMusicVideoLibraryScanner>();
var libraryRepository = Substitute.For<ILibraryRepository>();
var configElementRepository = Substitute.For<IConfigElementRepository>();
var logger = Substitute.For<ILogger<SynchronizeJellyfinLibraryByIdHandler>>();
var library = new JellyfinLibrary
{
Id = 42,
Name = "Concerts",
MediaKind = LibraryMediaKind.MusicVideos,
MediaSourceId = 7
};
var mediaSource = new JellyfinMediaSource
{
Id = 7,
Connections =
[
new JellyfinConnection
{
Address = "http://jellyfin.example",
JellyfinMediaSourceId = 7
}
]
};
mediaSourceRepository.GetJellyfinByLibraryId(library.Id).Returns(Some(mediaSource).AsTask());
mediaSourceRepository.GetJellyfinLibrary(library.Id).Returns(Some(library).AsTask());
jellyfinSecretStore.ReadSecrets().Returns(new JellyfinSecrets
{
Address = "http://jellyfin.example",
ApiKey = "abc"
});
configElementRepository.GetValue<int>(
Arg.Is<ConfigElementKey>(key => key.Key == ConfigElementKey.LibraryRefreshInterval.Key),
Arg.Any<CancellationToken>())
.Returns(Task.FromResult<Option<int>>(Some(0)));
jellyfinMusicVideoLibraryScanner.ScanLibrary(
Arg.Any<JellyfinConnectionParameters>(),
library,
true,
Arg.Any<CancellationToken>())
.Returns(Left<BaseError, Unit>(new ScanCanceled()).AsTask());
var handler = new SynchronizeJellyfinLibraryByIdHandler(
scannerProxy,
mediaSourceRepository,
jellyfinSecretStore,
jellyfinMovieLibraryScanner,
jellyfinTelevisionLibraryScanner,
jellyfinMusicVideoLibraryScanner,
libraryRepository,
configElementRepository,
logger);
await handler.Handle(
new SynchronizeJellyfinLibraryById("http://ersatztv.example", library.Id, true, true),
CancellationToken.None);
// a user-initiated cancellation is not a failure and must not be logged at ERROR (#410)
await libraryRepository.DidNotReceive().UpdateLastScan(Arg.Any<Library>());
logger.ReceivedCalls()
.Any(call => call.GetArguments().ElementAtOrDefault(2)?.ToString()
== "Error synchronizing jellyfin library: Scan was canceled")
.ShouldBeFalse();
logger.ReceivedCalls()
.Any(call => call.GetArguments().ElementAtOrDefault(2)?.ToString()
== "Scan of jellyfin library Concerts was canceled")
.ShouldBeTrue();
}
}
}
@@ -1,5 +1,6 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Scanner.Application.MediaSources;
using ErsatzTV.Scanner.Core.Interfaces;
@@ -96,6 +97,27 @@ public class ScanLocalLibraryHandlerTests
ShouldHaveLogged("Error scanning local library path /movies: scan failed");
}
[Test]
public async Task Should_Not_Log_Error_When_A_Path_Scan_Is_Canceled()
{
ScanResult(Left<BaseError, Unit>(new ScanCanceled()));
await Handler().Handle(
new ScanLocalLibrary("http://ersatztv.example", _library.Id, true),
CancellationToken.None);
// a user-initiated cancellation is not a failure and must not be logged at ERROR (#410);
// it's still correctly excluded from the "last scan" stamp, same as any other failure
_library.LastScan.ShouldBeNull();
await _libraryRepository.DidNotReceive().UpdateLastScan(Arg.Any<Library>());
_logger.ReceivedCalls()
.Any(call => call.GetArguments().ElementAtOrDefault(2)?.ToString()
== "Error scanning local library path /movies: Scan was canceled")
.ShouldBeFalse();
ShouldHaveLogged("Scan of local library path /movies was canceled");
}
[Test]
public async Task Should_Not_Set_Library_LastScan_When_A_Later_Path_Fails()
{
@@ -1,6 +1,7 @@
using ErsatzTV.Core;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Emby;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Scanner.Core.Interfaces;
@@ -86,7 +87,14 @@ public class SynchronizeEmbyLibraryByIdHandler : IRequestHandler<SynchronizeEmby
foreach (BaseError error in result.LeftToSeq())
{
_logger.LogError("Error synchronizing emby library: {Error}", error);
if (error is ScanCanceled)
{
_logger.LogInformation("Scan of emby library {Name} was canceled", parameters.Library.Name);
}
else
{
_logger.LogError("Error synchronizing emby library: {Error}", error);
}
}
return result.Map(_ => parameters.Library.Name);
@@ -1,5 +1,6 @@
using ErsatzTV.Core;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Jellyfin;
@@ -95,7 +96,16 @@ public class
foreach (BaseError error in result.LeftToSeq())
{
_logger.LogError("Error synchronizing jellyfin library: {Error}", error);
if (error is ScanCanceled)
{
_logger.LogInformation(
"Scan of jellyfin library {Name} was canceled",
parameters.Library.Name);
}
else
{
_logger.LogError("Error synchronizing jellyfin library: {Error}", error);
}
}
return result.Map(_ => parameters.Library.Name);
@@ -1,6 +1,7 @@
using System.Diagnostics;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Scanner.Core.Interfaces;
using ErsatzTV.Scanner.Core.Interfaces.Metadata;
@@ -152,13 +153,23 @@ public class ScanLocalLibraryHandler : IRequestHandler<ScanLocalLibrary, Either<
// a failed path now suppresses the library-level scan time below, so without this the
// user sees "Never scanned" with nothing explaining why. The remote scanners log the
// same way.
// same way. A cancellation is user-initiated, not a failure, so it's logged separately
// at a lower level; genuine errors still log at ERROR.
foreach (BaseError error in result.LeftToSeq())
{
_logger.LogError(
"Error scanning local library path {Path}: {Error}",
libraryPath.Path,
error);
if (error is ScanCanceled)
{
_logger.LogInformation(
"Scan of local library path {Path} was canceled",
libraryPath.Path);
}
else
{
_logger.LogError(
"Error scanning local library path {Path}: {Error}",
libraryPath.Path,
error);
}
}
}
}
@@ -1,5 +1,6 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Plex;
@@ -96,7 +97,14 @@ public class SynchronizePlexLibraryByIdHandler : IRequestHandler<SynchronizePlex
foreach (BaseError error in result.LeftToSeq())
{
_logger.LogError("Error synchronizing plex library: {Error}", error);
if (error is ScanCanceled)
{
_logger.LogInformation("Scan of plex library {Name} was canceled", parameters.Library.Name);
}
else
{
_logger.LogError("Error synchronizing plex library: {Error}", error);
}
}
return result.Map(_ => parameters.Library.Name);
@@ -1,6 +1,7 @@
using ErsatzTV.Application.Channels;
using ErsatzTV.Core;
using LanguageExt;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
@@ -114,6 +115,77 @@ public class UpdateChannelHandlerTests : ChannelHandlerTestBase
error.Value.ShouldContain("FFmpegProfile");
}
[Test]
public async Task Should_Reject_Mirror_Transition_When_Channel_Has_Playout()
{
await SeedFFmpegProfile();
await SeedChannel(1, "5"); // has a playout below
await SeedChannel(2, "6"); // valid mirror source (Generated, no playouts of its own)
await SeedPlayout(1, channelId: 1);
UpdateChannel update = MakeUpdate(1, number: "5") with
{
PlayoutSource = ChannelPlayoutSource.Mirror,
MirrorSourceChannelId = 2
};
Either<BaseError, ChannelViewModel> result = await MakeHandler().Handle(update, CancellationToken.None);
BaseError error = LeftOf(result);
error.ShouldNotBeOfType<NotFoundError>();
error.Value.ShouldContain("Mirror");
// the channel must NOT have been silently coerced/saved as Generated (issue #401: no
// silent 200, the caller's requested transition is rejected outright)
await using TvContext context = Db.CreateContext();
var channel = await context.Channels.SingleAsync(c => c.Id == 1);
channel.PlayoutSource.ShouldBe(ChannelPlayoutSource.Generated);
channel.MirrorSourceChannelId.ShouldBeNull();
}
[Test]
public async Task Should_Allow_GetPut_Roundtrip_Of_Generated_Channel_With_Playout()
{
await SeedFFmpegProfile();
await SeedChannel(1, "5");
await SeedPlayout(1, channelId: 1);
// client GETs the channel (PlayoutSource: Generated) and PUTs the same value back
// unchanged; this must still succeed even though the channel has a playout.
Either<BaseError, ChannelViewModel> result =
await MakeHandler().Handle(MakeUpdate(1, number: "5", name: "Renamed"), CancellationToken.None);
result.IsRight.ShouldBeTrue();
}
[Test]
public async Task Should_Null_Mirror_Only_Fields_When_Saving_Generated_Channel_With_Playout()
{
await SeedFFmpegProfile();
await SeedChannel(1, "5"); // has a playout below
await SeedChannel(2, "6"); // stray reference target
await SeedPlayout(1, channelId: 1);
// request keeps PlayoutSource: Generated but carries stray Mirror-only fields — e.g. a
// client that never cleared the fields after flipping the UI back from Mirror. These
// must never persist onto a Generated channel.
UpdateChannel update = MakeUpdate(1, number: "5") with
{
MirrorSourceChannelId = 2,
PlayoutOffset = TimeSpan.FromHours(1)
};
Either<BaseError, ChannelViewModel> result = await MakeHandler().Handle(update, CancellationToken.None);
result.IsRight.ShouldBeTrue();
await using TvContext context = Db.CreateContext();
var channel = await context.Channels.SingleAsync(c => c.Id == 1);
channel.PlayoutSource.ShouldBe(ChannelPlayoutSource.Generated);
channel.MirrorSourceChannelId.ShouldBeNull();
channel.PlayoutOffset.ShouldBeNull();
}
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,120 @@
using ErsatzTV.Application;
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
using NUnit.Framework;
using Shouldly;
using static LanguageExt.Prelude;
namespace ErsatzTV.Tests.Application.MediaCollections;
/// <summary>
/// Issue #327: ReplacePlaylistItemsHandler (playlist rename) did no name validation, so a playlist
/// could be renamed to an empty/whitespace or over-long name even though CreatePlaylistHandler
/// already refuses those on create. Mirrors RenamePlaylistGroupHandler's ValidateName combinator
/// (NotEmpty + NotLongerThan(50)) so create and rename enforce the same rule.
/// </summary>
[TestFixture]
public class ReplacePlaylistItemsHandlerNameValidationTests
{
private InMemoryTvContext _db = null!;
[SetUp]
public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
private async Task SeedPlaylistAsync()
{
await using TvContext ctx = _db.CreateContext();
ctx.Playlists.Add(
new Playlist
{
Id = 1,
PlaylistGroupId = 1,
Name = "Kids",
IsSystem = false,
Version = 1,
Items = new List<PlaylistItem>()
});
await ctx.SaveChangesAsync();
}
private static ReplacePlaylistItems Command(string name) =>
new(
1,
name,
new List<ReplacePlaylistItem>
{
new(0, CollectionType.Movie, null, null, null, 55, PlaybackOrder.Shuffle, null, false, true)
},
None);
private async Task<string> ReadNameAsync()
{
await using TvContext ctx = _db.CreateContext();
return await ctx.Playlists.Where(p => p.Id == 1).Select(p => p.Name).SingleAsync();
}
private static BaseError? LeftOrNull<T>(Either<BaseError, T> result) =>
result.Match<BaseError?>(Right: _ => null, Left: e => e);
[Test]
public async Task Empty_Name_Should_Be_Rejected_And_Not_Mutate()
{
await SeedPlaylistAsync();
var handler = new ReplacePlaylistItemsHandler(_db.Factory);
Either<BaseError, List<PlaylistItemViewModel>> result =
await handler.Handle(Command(string.Empty), CancellationToken.None);
LeftOrNull(result).ShouldNotBeNull();
(await ReadNameAsync()).ShouldBe("Kids");
}
[Test]
public async Task Whitespace_Name_Should_Be_Rejected_And_Not_Mutate()
{
await SeedPlaylistAsync();
var handler = new ReplacePlaylistItemsHandler(_db.Factory);
Either<BaseError, List<PlaylistItemViewModel>> result =
await handler.Handle(Command(" "), CancellationToken.None);
LeftOrNull(result).ShouldNotBeNull();
(await ReadNameAsync()).ShouldBe("Kids");
}
[Test]
public async Task Over_Long_Name_Should_Be_Rejected_And_Not_Mutate()
{
await SeedPlaylistAsync();
var handler = new ReplacePlaylistItemsHandler(_db.Factory);
string tooLong = new string('a', 51);
Either<BaseError, List<PlaylistItemViewModel>> result =
await handler.Handle(Command(tooLong), CancellationToken.None);
LeftOrNull(result).ShouldNotBeNull();
(await ReadNameAsync()).ShouldBe("Kids");
}
[Test]
public async Task Valid_Rename_Should_Succeed()
{
await SeedPlaylistAsync();
var handler = new ReplacePlaylistItemsHandler(_db.Factory);
string maxLength = new string('a', 50);
Either<BaseError, List<PlaylistItemViewModel>> result =
await handler.Handle(Command(maxLength), CancellationToken.None);
result.IsRight.ShouldBeTrue();
(await ReadNameAsync()).ShouldBe(maxLength);
}
}
@@ -1,4 +1,5 @@
using ErsatzTV.Application.MediaSources;
using ErsatzTV.Core.Api.MediaSources;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
@@ -66,6 +67,54 @@ public class GetAllMediaSourcesForApiHandlerTests
result.ShouldBeEmpty();
}
[Test]
public async Task Handle_Should_Report_Sentinel_And_Null_LastScan_As_Null()
{
await using (TvContext context = _db.CreateContext())
{
var source = new LocalMediaSource
{
Libraries =
[
new LocalLibrary
{
Name = "Never Scanned Sentinel",
MediaKind = LibraryMediaKind.Movies,
LastScan = new DateTime(1, 1, 1),
Paths = [MakePath("/media/sentinel", 0)]
},
new LocalLibrary
{
Name = "Never Scanned Null",
MediaKind = LibraryMediaKind.Movies,
LastScan = null,
Paths = [MakePath("/media/nullscan", 0)]
},
new LocalLibrary
{
Name = "Really Scanned",
MediaKind = LibraryMediaKind.Movies,
LastScan = new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc),
Paths = [MakePath("/media/scanned", 0)]
}
]
};
context.MediaSources.Add(source);
await context.SaveChangesAsync();
}
var handler = new GetAllMediaSourcesForApiHandler(_db.Factory);
var result = await handler.Handle(new GetAllMediaSourcesForApi(), CancellationToken.None);
MediaSourceLibraryResponseModel[] libraries = result.Single().Libraries.ToArray();
libraries.Single(l => l.Name == "Never Scanned Sentinel").LastScan.ShouldBeNull();
libraries.Single(l => l.Name == "Never Scanned Null").LastScan.ShouldBeNull();
libraries.Single(l => l.Name == "Really Scanned").LastScan
.ShouldBe(new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc));
}
[Test]
public async Task Handle_Should_Exclude_Unconfigured_And_Not_Synced_Libraries()
{
@@ -64,6 +64,19 @@ public abstract class ChannelHandlerTestBase
await context.SaveChangesAsync();
}
protected async Task SeedPlayout(int id, int channelId)
{
await using TvContext context = Db.CreateContext();
context.Playouts.Add(
new Playout
{
Id = id,
ChannelId = channelId,
ScheduleKind = PlayoutScheduleKind.Classic
});
await context.SaveChangesAsync();
}
protected static CreateChannel MakeCreate(
string number = "5",
int ffmpegProfileId = 1,
+68 -9
View File
@@ -369,9 +369,55 @@ API path / no `.cs` changed → they pass in ~5s), so they needed no change. `do
`decisions-guard` and `ci-image-pin` keep running on docs-only changes — the first two are *about*
docs and must.
Not in scope: the separate redundancy of running the **whole matrix on a PR and again on the
merge-to-`main`** over identical code (ersatztv#420), and the within-run triple `dotnet build`
(ersatztv#398).
Not in scope: the within-run triple `dotnet build` (ersatztv#398; measured and rejected as
build-once — see `docs/decisions.md`). The separate redundancy of running the **whole matrix on a
PR and again on the merge-to-`main`** over identical code (ersatztv#420) is addressed below.
### Cross-run tree-identity skip (ersatztv#420)
A merge to `main` re-runs the entire matrix over code the PR's last run already validated — the
same redundancy as docs-only, but for identical code rather than docs. On a `push` to `main` that
is a real merge commit, `test`/`migrations`/`functional-e2e` each run
`scripts/ci-detect-already-validated.sh` as a **second** detect step (`id: revalidate`, right after
the docs-only detect), and every heavy step gains an added `&& steps.revalidate.outputs.skip !=
'true'` to its existing `if:`.
**Skip condition — all four required, else fail-safe `skip=false`:**
- the event is a push to `refs/heads/main`;
- `HEAD` has a second parent `HEAD^2` (a real merge commit — the PR head CI already validated;
squash, rebase, fast-forward, or a direct push have no `HEAD^2`, so they run);
- `git rev-parse HEAD^{tree}` equals `HEAD^2^{tree}` — main did not advance since the PR's last run,
a byte-identical tree;
- `HEAD^2` has a **green Gitea combined commit status**, queried via the API with
`ETV_STATUS_AUTH`. Trusting the aggregate `.state` is sound: a `skipped` context does **not** drag
the combined state below `success` (verified live against this instance — a real merge commit with
four `skipped` PR-only contexts still reported `.state == success`), and the two required jobs
never report `skipped` (they always run and report a real `success`/`failure`), so
`.state == success` implies they were green.
Those three jobs check out `fetch-depth: 2` so `HEAD^2` and its tree resolve.
**Why it's safe: `build` is not gated.** The three heavy jobs skip their *steps* (same
required-context reasoning as docs-only — they still run and report `success` in seconds), but
**`build` always runs on `main`, ungated**, building and pushing the image from that identical,
already-validated tree. No image ships from unvalidated source. The required contexts are
unchanged (`Build & test (.NET)`, `EF migration integrity (SQLite + MySql)`) — no branch-protection
change.
**Fail-safe bias.** Any uncertainty — not a main push, no `HEAD^2`, a differing tree, a
missing/failing/non-`success` status, missing auth — resolves to `skip=false` and runs the full
matrix. A false skip could ship an under-validated image, so every ambiguous case runs everything.
**Honest limitation — this fires rarely here, by design.** The tree is identical only on a
*fast-forward-equivalent* merge: main did not advance since the PR's last green run **and** the PR
head was not rebased at merge time. Two routine patterns defeat it in this repo: (1) under parallel
merges main usually advances; and (2) — the bigger one — the standard workflow **rebases a PR
before merging** to resolve the append-only `docs/decisions.md` conflict (see MEMORY: the
"decisions.md conflict treadmill"), which mints a new head SHA whose tree was never itself
CI-validated, so the tree-match check correctly declines. So the skip is a genuine but *occasional*
win (clean, up-to-date, un-rebased merges in quiet periods) — correct-but-conservative by
construction, not a general dedup. It never fires unsafely; when in doubt it runs the full matrix.
### `docs-reminder` job (non-blocking, PR-only)
@@ -566,12 +612,25 @@ running product from outside our C#/review stack.
several minutes, and is noisy (expect to tune, not take raw). The continuous layer is the per-PR
white-box gates + the weekly `dependency-scan`; this is the per-release black-box pass. Re-run it each
release and before any change to the exposure posture.
- **Triage.** ZAP exits non-zero on any FAIL-level alert; the tooling is noisy, so triage each finding
false-positive vs real. Real, in-scope, go-live-blocking findings get fixed (e.g. the security headers
from the #319 baseline; the Microsoft.OpenApi pin above); LAN-expected noise (Private-IP disclosure) is
revisited only for genuine remote exposure. nuclei (template-based CVE fingerprinting) is an optional
third pass — deferred while its template fetch is blocked in the runner env (pre-seed a template volume
to add it); ZAP covers the DAST baseline and semgrep the SAST, so it is not on the critical path.
- **Exit-code contract (ersatztv#338).** `zap-api-scan.py`'s raw exit code is NOT a simple pass/fail — it
conflates a clean run with a warnings-only run unless you know its wrapper contract: **0** clean (no
FAIL or WARN alerts), **2** WARN-only (triage required, but **not** release-blocking), **1** FAIL (at
least one FAIL-level alert — release-blocking), **124** the script's own `timeout` wrapper killed a
hung post-scan cleanup (the report written before the hang is still usable — triage it), any other
code means the scanner/tool itself errored (not a scan result at all). `scripts/security-scan.sh`
encodes this in `classify_zap_exit()` and prints an unambiguous `==> ZAP result: <PASS|WARN|FAIL|
TIMEOUT|TOOL ERROR> ...` line; the script's own exit status reflects that classification (0 for
clean/WARN, 1 for FAIL/timeout/tool-error) rather than ZAP's raw code, so a warnings-only run no longer
reads as a failed scan. Found when the v26.8.0 release scan (#335) returned raw exit 2 for a report with
`FAIL-NEW: 0` and two known/expected warning classes — the shell result looked like a failure though the
release gate had actually passed. Run `scripts/security-scan.sh --selftest` for a docker-free regression
check of the classification logic.
- **Triage.** Triage each WARN/FAIL finding false-positive vs real. Real, in-scope, go-live-blocking
findings get fixed (e.g. the security headers from the #319 baseline; the Microsoft.OpenApi pin above);
LAN-expected noise (Private-IP disclosure) is revisited only for genuine remote exposure. nuclei
(template-based CVE fingerprinting) is an optional third pass — deferred while its template fetch is
blocked in the runner env (pre-seed a template volume to add it); ZAP covers the DAST baseline and
semgrep the SAST, so it is not on the critical path.
## Static analysis & formatting
+135
View File
@@ -96,6 +96,9 @@ in-file entries.
- [2026-07-18 — SmartCollection rule builder: compile-only closed subset, no stored AST, one-level nesting (#176)](#2026-07-18--smartcollection-rule-builder-compile-only-closed-subset-no-stored-ast-one-level-nesting-176)
- [2026-07-18 — Auto-Tune per-source weights ride #70's MultiCollection machinery; created at tune time, not a post-hoc PUT (#425)](#2026-07-18--auto-tune-per-source-weights-ride-70s-multicollection-machinery-created-at-tune-time-not-a-post-hoc-put-425)
- [2026-07-18 — Search all-items is paged to cap DoS exposure; SPA add-all pages to completeness (#293)](#2026-07-18--search-all-items-is-paged-to-cap-dos-exposure-spa-add-all-pages-to-completeness-293)
- [2026-07-18 — Unsupported PlaybackOrder is loud at build time; a declared support matrix and tripwire test make new orders safe by construction (#403)](#2026-07-18--unsupported-playbackorder-is-loud-at-build-time-a-declared-support-matrix-and-tripwire-test-make-new-orders-safe-by-construction-403)
- [2026-07-18 — CI build-once was measured and rejected; keep the #420 tree-skip](#2026-07-18--ci-build-once-was-measured-and-rejected-keep-the-420-tree-skip)
- [2026-07-18 — Never-scanned `LastScan` surfaces as null at the API boundary, not the 0001-01-01 MinValue sentinel (#409)](#2026-07-18--never-scanned-lastscan-surfaces-as-null-at-the-api-boundary-not-the-0001-01-01-minvalue-sentinel-409)
---
@@ -1807,3 +1810,135 @@ page to completeness** — rather than option (b) (a generous cap + truncation s
instead of the single-shot fetch; the #221 stale-query guard and the single add POST are unchanged.
- **Out of scope (unchanged):** the add POST itself still accepts the full merged id set in one request body
— bounding *that* surface is a separate concern (see #308 for the add path); #293 is the GET.
## 2026-07-18 — Collapsible sidebar + nav-group accordions: two `ctv-sidebar-*` localStorage keys, labeled groups default-collapsed (#396)
The shell sidebar (`web/src/app/AppShell.tsx`) gained (a) a header toggle that collapses it to a 60px
icon rail and (b) collapsible accordions per **labeled** nav group (Media, System); the unlabeled
**Primary** group is always open. Mirrors the Claude Design prototype's updated `Sidebar`.
- **State lives in a small hook, not App.** `web/src/app/sidebarState.ts` `useSidebarState()` owns both
pieces of state + persistence; `AppShell` consumes it (nothing else needs it) and stamps
`ctv-app-shell-collapsed` on the shell root so the collapse is CSS-driven from one class.
- **Persistence keys use the established `ctv-` hyphen convention, NOT the prototype's dotted names.**
The issue quoted `ctv.sidebar.collapsed` / `ctv.sidebar.groups`, but every existing client-local pref
is hyphenated (`ctv-theme`, `ctv-logs-page-size` — spa-conventions §5d), so we use
**`ctv-sidebar-collapsed`** (`"1"`/`"0"`) and **`ctv-sidebar-groups`** (JSON `{groupKey: boolean}`,
boolean = *collapsed*). Deliberate deviation from the issue's literal key text in favour of the repo
convention the issue itself points to; helpers validate/parse defensively (bad JSON / non-boolean
values → default).
- **Labeled groups default to COLLAPSED** (absent `ctv-sidebar-groups` entry ⇒ collapsed), so a fresh
load shows only Primary — matching the prototype ("default-collapsed, leaving only Primary visible").
A behavior change for existing users; `App.test.tsx`'s shell/nav suite seeds the two groups open
because it clicks Media/System nav links directly (the accordion behavior is covered in its own
describe).
- **Accordions apply only in the expanded sidebar.** In the rail, group-collapse is ignored — every
item renders as an icon (label kept in the a11y tree via an sr-only span so the accessible name/tests
survive; surfaced as a native `title` tooltip), groups separated by a hairline divider, numeric
badges shown as a corner dot. The active-route indicator (left rail bar + active background) works in
both states.
- **Group keys are explicit + stable** (`SidebarNavGroupDefinition.key`: `'media'`, `'system'`) rather
than derived from the label, so renaming a label doesn't silently orphan persisted state.
- No route/screen was added or redirected (shell-chrome only), so no `blazor-route-parity.md` change.
## 2026-07-18 — Unsupported PlaybackOrder is loud at build time; a declared support matrix and tripwire test make new orders safe by construction (#403)
`#70` closed the *persistence* hole for `WeightedShuffle` (the write path rejects it on the engines that
can't handle it) and made **YAML + Scripted** log a warning; `MultiCollectionGroup` already threw. It left
the three still-**silent** build-time dispatch sites — the ones this issue names — as an explicit non-goal.
`#403` makes those loud and adds the by-construction net.
- **Loud, but NON-FATAL, at every build site.** On an order it doesn't handle, each site now logs a
`Warning` (naming the order + engine + the fallback taken) instead of silently substituting/dropping/
skipping: Classic's `PlayoutBuilder` `default:` arm (was `// TODO`, silently returned Random), the
`PlaylistEnumerator.Create` switch (had **no** `default:` arm → null enumerator → item dropped), and
`BlockPlayoutBuilder`'s allow-list `continue` (silent skip). The actual fallback is **preserved** — a
live channel airing wrong-but-something beats going dark on one misconfigured item — so scheduler goldens
do not move. This matches the log-and-continue posture `#70` already shipped for YAML/Scripted.
- `PlaylistEnumerator.Create` was `static` with **no logger**, which is *why* the playlist drop was
unreportable. It gained an optional `Option<ILogger> logger = default` (mirrors the existing
`GetStartTimeAfter(..., Option<ILogger>.None)` pattern); the three callers that have a logger pass it,
the rest default to `None`.
- `BlockPlayoutBuilder`'s `GetEnumerator` switch gained an explicit `PlaybackOrder.Random` arm. Random is
in Block's allow-list but previously reached an enumerator only via the switch's coincidental `_ =>`
Random fallback; the fallback is now a loud helper (defensive — the allow-list should make it
unreachable).
- **Safe-by-construction net: a declared support matrix + a tripwire test.** `PlaybackOrderSupport`
(`ErsatzTV.Core/Scheduling/`) declares, per `SchedulingEngineKind` (Classic / Playlist / Block / Yaml /
Scripted), BOTH a `Supported` and an `Unsupported` set. `PlaybackOrderSupportTests` asserts the two sets
**partition** every `PlaybackOrder` value (union total, intersection empty) for every engine — so adding a
new enum value lands in neither set and **fails the test until it is consciously classified** and wired
into the matching dispatch switch. Both sets are hand-maintained on purpose: deriving `Unsupported` as
"everything not supported" would let a new order fall through silently, which is the exact defect being
killed. The matrix is not pure test scaffolding — `BlockPlayoutBuilder` consumes it at runtime for its
allow-list (replacing the previously duplicated inline list).
- **Write-path rejection is deliberately UNCHANGED.** `#70`'s per-engine guards already close the real
persistence exposure, and the decisions log records that the "which order reaches which engine" perimeter
has been wrong three times — always by enumerating from a list instead of grepping every writer.
Broadening write-path rejection here would risk newly-`400`ing configs that currently save-and-fall-back,
for no safety gain, so it was left alone. This PR hardens the *build-time* sites and adds the tripwire.
- **Reverse-mappings deferred (a different axis).** The three `_ => PlaybackOrder.None` arms that map an
enumerator *class* back to a `PlaybackOrder` for `PlayoutHistory` were left alone: that path is reached in
normal operation by legitimately-supported enumerator types (`ShuffleInOrderCollectionEnumerator`,
`SeasonEpisodeMediaCollectionEnumerator`) that simply aren't reverse-mapped, so making it "loud" would
emit false-positive warnings. Completing that reverse map is a separate concern from "an unsupported
*order* degrades silently" and is not part of #403's scope.
## 2026-07-18 — CI build-once was measured and rejected; keep the #420 tree-skip
Build-once (a `compile` job producing a single artifact, consumed by `test`/`migrations`/
`functional-e2e` via `--no-build`) was fully implemented and went **green on CI** (PR #455, run
830), then **rejected on measurement**: it traded a ~12% slot-occupancy saving for a ~4085%
**per-run wall-clock regression**.
- **Why it regressed.** The `bin`+`obj` artifact is 2.5 GB raw / 972 MB gz; tar alone costs ~82s CPU
plus ~180s transport, consuming most of the ~465s the shared compile was meant to save. Worse,
`compile` serializes **before** `migrations`' long ef-replay, which is pure DB work a shared build
cannot shorten — the bottleneck was never the redundant compiles.
- **Incidental finding worth recording.** `actions/upload-artifact@v4` does not work on this Gitea
instance — it throws `GHESNotSupportedError`, because the `@actions/artifact` v2 client library
rejects any non-`github.com` host. `@v3` is required for any future artifact use here.
- **Kept: the #420 cross-run tree-identity skip** (`docs/ci-cd.md` → Cross-run tree-identity skip).
It is independent of build-once — it only *skips* redundant work on identical-tree main pushes, at
zero wall-clock cost, rather than trying to share a build across jobs. Don't re-attempt build-once
unless the runner's artifact storage or network changes materially.
Refs: #398 (closed), #420, PR #455.
## 2026-07-18 — Never-scanned `LastScan` surfaces as null at the API boundary, not the 0001-01-01 MinValue sentinel (#409)
`Library.LastScan` / `LibraryPath.LastScan` are `DateTime?`; a never-scanned library is `null` at
runtime for a freshly-created row. But the `0001-01-01 00:00:00` MinValue sentinel still appears in the
DB from **two** sources — and the second is ongoing, not historical:
1. Old `Reset_*` migrations wrote it via raw SQL (`UPDATE Library SET LastScan = '0001-01-01 00:00:00'`).
2. **Live code still writes it today**: `MediaSourceRepository` sets `library.LastScan =
SystemTime.MinValueUtc` on the Plex/Jellyfin/Emby remove-and-recreate (disable-sync) flows
(`MediaSourceRepository.cs:480/611/976`). So the sentinel keeps being written during normal use.
`GetAllMediaSourcesForApiHandler` projected `l.LastScan` straight onto its `DateTime?` DTO, so that
sentinel leaked to API/MCP clients as a fake midnight timestamp — the SPA papered over it with a
client-side year<1900 heuristic (#409 first pass). Decision: the API is the right place to be honest, so
**never-scanned reports as null** for API/MCP parity with the UI, via two layers:
- **Read-boundary coercion — the load-bearing, ongoing guard** (provider/history-independent):
`GetAllMediaSourcesForApiHandler.NormalizeLastScan` maps any `< 2000-01-01` value to null. Because
source #2 above keeps writing the sentinel, this coercion is *permanent*, not a stopgap — a
migration-only fix would regress the next time a user toggles a library's sync off.
- **Data migration** (`NullOutNeverScannedLastScan`, dual-provider): a one-time cleanup of the historical
residue — `UPDATE Library/LibraryPath SET LastScan = NULL WHERE LastScan IS NOT NULL AND LastScan <
'2000-01-01'`. Data-only (empty `Up`/`Down` otherwise, both `TvContextModelSnapshot.cs` byte-identical).
The `< '2000-01-01'` predicate matches the `0001-01-01` sentinel robustly on both providers (ISO-text
compare on SQLite, whatever the out-of-range zero date stored on MySQL — where no `Reset_*LastScan`
migration ever ran, so it's a safe no-op there) without depending on the exact stored bytes; no real
scan predates ErsatzTV. `Down` is a no-op — the original sentinel is unrecoverable and worthless.
`GetAllMediaSourcesForApiHandler` is the **only** API/MCP-facing consumer of `LastScan` (grepped
`LastScan` under `ErsatzTV.Application/**/Queries` and `ErsatzTV/Controllers`); the other reads are
internal scanner code that coalesces to `SystemTime.MinValueUtc` for its own non-nullable
`DateTimeOffset` scan-comparison needs and never serializes it to a client. The DTO field was already
`DateTime?`, so the OpenAPI schema is unchanged (no regen). The SPA's `hasScanned` heuristic in
`LibrariesScreen.tsx` was removed — both call sites revert to a plain null/truthy check now that the API
is honest. (Follow-up option, not done here: have `MediaSourceRepository` write `null` instead of
`MinValue` so the data is clean at rest too; the read coercion makes that non-urgent.)
+31
View File
@@ -459,3 +459,34 @@ integration.
This module is intentionally reusable beyond SmartCollections — ChannelBuilder and Auto-Tune's
inline query editing (#69) are candidate future consumers, tracked as separate follow-up issues
rather than wired in #176.
## 13. Collapsible sidebar + nav-group accordions (#396)
The shell sidebar (`web/src/app/AppShell.tsx`) supports two independent, persisted collapse states.
Both are shell chrome — no screen participates.
- **State + persistence** live in `web/src/app/sidebarState.ts` (`useSidebarState()`), consumed by
`AppShell` alone. It follows the §5d client-local-prefs pattern (a try/catch `getStorage()`, a
validating getter, a write-through setter) over two namespaced keys:
- `ctv-sidebar-collapsed` — `"1"`/`"0"`; is the sidebar collapsed to the 60px **icon rail**?
- `ctv-sidebar-groups` — JSON `{groupKey: boolean}` where the boolean is **collapsed**. A labeled
group with **no stored entry defaults to collapsed**, so a fresh load shows only the always-open
**Primary** group. (Keys are the `ctv-` hyphen form, not the prototype's `ctv.sidebar.*` — see
`decisions.md` 2026-07-18.)
- **`AppShell` stamps `ctv-app-shell-collapsed` on the shell root** when collapsed; the rail look is
entirely CSS-driven from that one class (`shell.css` narrows the tracked `--sidebar-w` to 60px and
transitions `grid-template-columns`; `@media (prefers-reduced-motion: reduce)` drops the transition).
- **Nav is inventory-driven** from `sidebarNavGroups` (`app/routes.tsx`). Only **labeled** groups are
collapsible; each has an explicit stable `key` (`'media'`, `'system'`) used for the persisted map —
don't derive the key from the label (a rename would orphan persisted state). The unlabeled Primary
group is always rendered.
- **Accordions apply only in the expanded sidebar.** In the rail, group-collapse is ignored: every
item renders (icon-only), groups separated by a `.ctv-nav-divider`. The nav item's **label stays in
the a11y tree** (visually hidden via CSS, not `display:none`) so the accessible name — and every
`getByRole('link', { name })` test — still resolves; the label is also passed as the native `title`
tooltip (`NavItem` gained a `title` prop). Numeric badges collapse to a corner dot. The active-route
indicator works in both states.
- **Testing note**: `App.test.tsx`'s shell/nav suite clicks Media/System nav links directly, so its
`beforeEach` **seeds both groups open** (`ctv-sidebar-groups`); the default-collapsed / accordion /
rail behavior is covered in its own `describe('collapsible sidebar (#396)')`, and the persistence
helpers have a colocated `sidebarState.test.ts`.
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
# scripts/ci-detect-already-validated.sh — emit `skip=true|false` to $GITHUB_OUTPUT for the
# docker-build.yml cross-run tree-identity gate (ersatztv#420). On a merge-to-main push, if the
# merged tree is byte-identical to a PR head that ALREADY passed `test`+`migrations` (a GREEN
# combined commit status), the heavy compile/test/migrations work is redundant -- the exact same
# source was already validated. `build` still runs and still builds+pushes the image, so no image
# ever ships from unvalidated source.
#
# The bias is ALWAYS toward running MORE, never less: a false 'true' would ship (or claim to
# validate) unreviewed/unvalidated source, so every ambiguous or unverifiable case resolves to
# skip=false. It is fine (just wasteful) to re-run on an identical tree; it is a correctness bug
# to skip validation on a tree that differs or was never proven green.
#
# Runs identically locally and in CI. Locally (no $GITHUB_OUTPUT) it prints the decision to
# stdout; e.g. GITHUB_EVENT_NAME=push GITHUB_REF=refs/heads/main scripts/ci-detect-already-validated.sh
set -euo pipefail
out="${GITHUB_OUTPUT:-/dev/stdout}"
event="${GITHUB_EVENT_NAME:-}"
ref="${GITHUB_REF:-}"
emit() {
echo "skip=$1" >> "$out"
echo "-> skip=$1"
}
# Only a push directly to main can possibly be a merge-to-main we can cross-check against an
# already-validated PR head. Everything else (pull_request, tag push, workflow_dispatch, a push
# to any other branch) -> always run.
if [ "$event" != "push" ] || [ "$ref" != "refs/heads/main" ]; then
echo "event='${event:-<none>}' ref='${ref:-<none>}' (need push to refs/heads/main); running full validation (safe default)"
emit false
exit 0
fi
# Deepen history so HEAD's second parent (and its tree) are resolvable -- a shallow checkout may
# have neither. Mirrors the docs-only/docs-reminder jobs' fetch style. Non-fatal: a failed
# deepen still falls through to the HEAD^2 check below, which then fails safe.
git fetch --deepen=2 origin 2>/dev/null || git fetch --unshallow origin 2>/dev/null || true
# HEAD must be a real merge commit with a second parent -- that second parent is the PR head CI
# actually validated. No second parent (a direct/fast-forward/squash push) -> nothing to compare
# against -> always run.
pr_head="$(git rev-parse --verify -q HEAD^2 || true)"
if [ -z "$pr_head" ]; then
echo "HEAD has no second parent (not a merge commit); running full validation (safe default)"
emit false
exit 0
fi
merge_tree="$(git rev-parse --verify -q 'HEAD^{tree}' || true)"
pr_tree="$(git rev-parse --verify -q "${pr_head}^{tree}" || true)"
if [ -z "$merge_tree" ] || [ -z "$pr_tree" ]; then
echo "could not resolve HEAD or HEAD^2 tree; running full validation (safe default)"
emit false
exit 0
fi
if [ "$merge_tree" != "$pr_tree" ]; then
echo "merged tree ($merge_tree) != PR head $pr_head tree ($pr_tree) -- main advanced since the PR was validated; running full validation"
emit false
exit 0
fi
# Merge tree matches the PR head tree exactly. Confirm that PR head was actually validated green
# before trusting it -- query the Gitea combined commit status API. Auth is required (private
# instance); a missing/failing/non-success response always falls through to skip=false.
if [ -z "${ETV_STATUS_AUTH:-}" ]; then
echo "ETV_STATUS_AUTH not set; cannot verify PR head status; running full validation (safe default)"
emit false
exit 0
fi
status_url="http://192.168.1.95:3000/api/v1/repos/timothy/ersatztv/commits/${pr_head}/status"
status_json="$(curl -sf -u "$ETV_STATUS_AUTH" "$status_url" || true)"
if [ -z "$status_json" ]; then
echo "status API request for PR head ${pr_head} failed; running full validation (safe default)"
emit false
exit 0
fi
state="$(printf '%s' "$status_json" | jq -r '.state // empty' 2>/dev/null || true)"
if [ "$state" != "success" ]; then
echo "PR head ${pr_head} combined status is '${state:-<unknown>}', not 'success'; running full validation (safe default)"
emit false
exit 0
fi
echo "merged tree == green PR head ${pr_head} (status=success) -> skipping re-validation"
emit true
+56 -5
View File
@@ -23,12 +23,60 @@
# ETV_SCAN_OUT host dir for reports (default: /tmp/etv-scan-out)
# ETV_SCAN_SKIP_SEMGREP=1 skip the SAST pass (DAST only)
#
# Reports land in $ETV_SCAN_OUT: zap-api-report.{html,md,json}. Exit code is ZAP's:
# 0 = no FAIL-level alerts, non-zero = at least one FAIL (triage it — this tooling is noisy; expect to
# tune the ignore list in .zap/ rather than take the raw report at face value).
# Reports land in $ETV_SCAN_OUT: zap-api-report.{html,md,json}. zap-api-scan.py's exit code is NOT a
# simple pass/fail — its wrapper contract (ersatztv#338, found via the #335 exact-image release scan
# returning 2 for a clean-enough report):
# 0 clean — no FAIL or WARN alerts
# 2 WARN — only WARN-level alerts (triage, but NOT release-blocking) — do not fail the job on this
# 1 FAIL — at least one FAIL-level alert — release-blocking
# 124 our own `timeout` wrapper killed a hung wrapper (see the NOTE below) — report is still usable
# other the scanner/tool itself errored (bad args, crash) — not a scan result at all
# classify_zap_exit() (below) is the single place this contract is encoded; the script's own final exit
# code reflects the CLASSIFICATION (0 for clean/WARN, 1 for FAIL/timeout/tool-error), not ZAP's raw code.
set -euo pipefail
# classify_zap_exit RC — sets ZAP_CLASS_MSG to an unambiguous one-line classification of ZAP's raw exit
# code and returns the exit status this SCRIPT should use for that outcome (see contract above). Kept as
# a standalone function (not inlined) so `--selftest` can exercise it without docker/ZAP.
classify_zap_exit() {
local rc="$1"
case "$rc" in
0) ZAP_CLASS_MSG="PASS — no FAIL or WARN alerts"; return 0 ;;
2) ZAP_CLASS_MSG="WARN — WARN-level alerts only; triage required, NOT release-blocking"; return 0 ;;
1) ZAP_CLASS_MSG="FAIL — at least one FAIL-level alert; release-blocking, do not ship"; return 1 ;;
124) ZAP_CLASS_MSG="TIMEOUT — wrapper hit ETV_SCAN_TIMEOUT; triage the partial report before shipping"; return 1 ;;
*) ZAP_CLASS_MSG="TOOL ERROR (raw exit $rc) — scanner itself failed, this is not a scan result"; return 1 ;;
esac
}
if [ "${1:-}" = "--selftest" ]; then
# Lightweight regression check for the classification contract above (ersatztv#338 Done-when) — no
# docker/ZAP required, so it can run anywhere (incl. off bumblebee, incl. in per-PR CI if ever added).
selftest_failed=0
selftest_check() {
local rc="$1" want_status="$2" want_substr="$3" got_status
if classify_zap_exit "$rc"; then got_status=0; else got_status=$?; fi
if [ "$got_status" != "$want_status" ] || [[ "$ZAP_CLASS_MSG" != *"$want_substr"* ]]; then
echo "SELFTEST FAIL: rc=$rc -> status=$got_status msg='$ZAP_CLASS_MSG' (want status=$want_status, msg containing '$want_substr')" >&2
selftest_failed=1
else
echo "selftest ok: rc=$rc -> status=$got_status ($ZAP_CLASS_MSG)"
fi
}
selftest_check 0 0 "PASS"
selftest_check 2 0 "WARN"
selftest_check 1 1 "FAIL"
selftest_check 124 1 "TIMEOUT"
selftest_check 3 1 "TOOL ERROR"
selftest_check 77 1 "TOOL ERROR"
if [ "$selftest_failed" = 0 ]; then
echo "selftest: all ZAP exit classifications OK"; exit 0
else
echo "selftest: FAILURES ABOVE" >&2; exit 1
fi
fi
IMAGE="${1:-192.168.1.95:3000/timothy/ersatztv:latest}"
PORT="${2:-8411}"
OUT="${ETV_SCAN_OUT:-/tmp/etv-scan-out}"
@@ -87,10 +135,13 @@ timeout "${ETV_SCAN_TIMEOUT:-45m}" docker run --rm --network host -v "$OUT":/zap
-z "replacer.full_list(0).description=apikey;replacer.full_list(0).enabled=true;replacer.full_list(0).matchtype=REQ_HEADER;replacer.full_list(0).matchstr=X-Api-Key;replacer.full_list(0).regex=false;replacer.full_list(0).replacement=$KEY"
zap_rc=$?
[ "$zap_rc" = 124 ] && echo "NOTE: ZAP wrapper hit the ${ETV_SCAN_TIMEOUT:-45m} timeout (usually a post-scan cleanup hang) — the report written before the hang is still valid; triage it."
classify_zap_exit "$zap_rc"
zap_class_status=$?
set -e
# (The ZAP container + target + temp dir are reaped by cleanup() on the EXIT trap — every path, incl.
# a `timeout` kill or Ctrl-C mid-scan.)
echo "==> ZAP report: $OUT/zap-api-report.html (+ .md/.json). Exit code: $zap_rc"
echo "==> ZAP report: $OUT/zap-api-report.html (+ .md/.json)"
echo "==> ZAP result: $ZAP_CLASS_MSG (raw exit $zap_rc)"
if [ "${ETV_SCAN_SKIP_SEMGREP:-0}" = "1" ]; then
echo "==> semgrep SAST skipped (ETV_SCAN_SKIP_SEMGREP=1)"
@@ -104,4 +155,4 @@ else
"$REPO_ROOT" || echo "(semgrep reported findings — triage in the report above)"
fi
exit "$zap_rc"
exit "$zap_class_status"
+105
View File
@@ -16,6 +16,13 @@ describe('ChicoryTV SPA scaffold', () => {
beforeEach(() => {
window.localStorage.clear();
// #396: the sidebar's Media/System nav groups now default to COLLAPSED. The shell/nav tests
// below click nav links inside those groups directly, so keep them expanded here; the
// collapse/accordion behavior itself is covered in its own describe block at the end.
window.localStorage.setItem(
'ctv-sidebar-groups',
JSON.stringify({ media: false, system: false })
);
document.documentElement.removeAttribute('data-theme');
window.history.replaceState(null, '', '/app');
vi.restoreAllMocks();
@@ -632,6 +639,104 @@ describe('ChicoryTV SPA scaffold', () => {
expect(window.location.pathname).toBe('/app/settings/scanner');
expect(window.fetch).toHaveBeenCalledWith('/api/v1/settings/scanner', expect.any(Object));
});
// #396 — collapsible sidebar + collapsible nav groups. These tests want the real defaults
// (Media/System groups collapsed), so undo the outer beforeEach's "keep groups open" seed.
describe('collapsible sidebar (#396)', () => {
beforeEach(() => {
window.localStorage.removeItem('ctv-sidebar-groups');
});
it('collapses Media/System nav groups by default, leaving Primary visible', () => {
render(<App />);
// Primary group items always render.
expect(screen.getByRole('link', { name: 'Dashboard' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Channels' })).toBeInTheDocument();
// Labeled groups start collapsed → their items are not rendered, but the accordion headers are.
expect(screen.queryByRole('link', { name: 'Logs' })).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'Libraries' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Media', expanded: false })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'System', expanded: false })).toBeInTheDocument();
});
it('expands a nav group when its header is clicked and persists the open state', () => {
render(<App />);
const systemHeader = screen.getByRole('button', { name: 'System', expanded: false });
fireEvent.click(systemHeader);
expect(screen.getByRole('button', { name: 'System', expanded: true })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Logs' })).toBeInTheDocument();
const stored = JSON.parse(window.localStorage.getItem('ctv-sidebar-groups') ?? '{}');
expect(stored.system).toBe(false);
});
it('restores expanded groups from localStorage on load', () => {
window.localStorage.setItem('ctv-sidebar-groups', JSON.stringify({ media: false }));
render(<App />);
// Media restored open; System still default-collapsed.
expect(screen.getByRole('link', { name: 'Libraries' })).toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'Logs' })).not.toBeInTheDocument();
});
it('collapses the sidebar to the icon rail and persists it', () => {
const { container } = render(<App />);
const shell = container.querySelector('.ctv-app-shell');
expect(shell).not.toHaveClass('ctv-app-shell-collapsed');
fireEvent.click(screen.getByRole('button', { name: 'Collapse sidebar' }));
expect(shell).toHaveClass('ctv-app-shell-collapsed');
expect(window.localStorage.getItem('ctv-sidebar-collapsed')).toBe('1');
// The toggle now offers to expand again.
expect(screen.getByRole('button', { name: 'Expand sidebar' })).toBeInTheDocument();
});
it('shows every group item in the rail regardless of accordion state (labels kept for a11y)', () => {
// Sidebar collapsed on load; groups at their default (collapsed) accordion state.
window.localStorage.setItem('ctv-sidebar-collapsed', '1');
const { container } = render(<App />);
expect(container.querySelector('.ctv-app-shell')).toHaveClass('ctv-app-shell-collapsed');
// Accordions do not apply in the rail: Media/System items are all present (label stays in the
// a11y tree so the link keeps its accessible name), and no accordion header is rendered.
expect(screen.getByRole('link', { name: 'Logs' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Libraries' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'System' })).not.toBeInTheDocument();
});
it('restores the collapsed sidebar from localStorage on load', () => {
window.localStorage.setItem('ctv-sidebar-collapsed', '1');
const { container } = render(<App />);
expect(container.querySelector('.ctv-app-shell')).toHaveClass('ctv-app-shell-collapsed');
expect(screen.getByRole('button', { name: 'Expand sidebar' })).toBeInTheDocument();
});
it('reveals + marks the active route even when its group is default-collapsed, without persisting', () => {
// Deep-link into a route inside the default-collapsed System group (expanded sidebar).
window.history.replaceState(null, '', '/app/settings');
render(<App />);
// The active item is rendered and marked, and its group header shows expanded…
const settingsLink = screen.getByRole('link', { name: 'Settings' });
expect(settingsLink).toHaveAttribute('aria-current', 'page');
expect(screen.getByRole('button', { name: 'System', expanded: true })).toBeInTheDocument();
// …but a sibling default-collapsed group (Media) stays collapsed…
expect(screen.queryByRole('link', { name: 'Libraries' })).not.toBeInTheDocument();
// …and the reveal did NOT write a stored preference (it reverts on navigating away).
expect(window.localStorage.getItem('ctv-sidebar-groups')).toBeNull();
});
});
});
function jsonResponse(body: unknown, status = 200): Response {
+99 -11
View File
@@ -17,6 +17,8 @@ import {
ClipboardCopy,
Info,
ListVideo,
PanelLeftClose,
PanelLeftOpen,
Plus,
Search
} from 'lucide-react';
@@ -29,8 +31,7 @@ import {
import {
Button,
IconButton,
NavItem,
NavSection
NavItem
} from '../components';
import {
designSystemThemes,
@@ -38,6 +39,7 @@ import {
} from '../designSystem';
import { usePrimaryActionHandler } from '../primaryAction';
import { navigateToPath } from '../routing';
import { useSidebarState } from './sidebarState';
import { DashboardHealthSummary } from '../screens/DashboardScreen';
import { UnauthorizedBanner } from '../UnauthorizedBanner';
import { UserMenu } from '../UserMenu';
@@ -53,10 +55,12 @@ type NavigateHandler = (route: ScreenRoute, event: MouseEvent) => void;
function SidebarNavGroup({
activeRoute,
collapsed,
ids,
onNavigate
}: {
activeRoute: ScreenRoute | null;
collapsed: boolean;
ids: readonly ScreenId[];
onNavigate: NavigateHandler;
}) {
@@ -70,6 +74,9 @@ function SidebarNavGroup({
key={route.id}
icon={route.icon}
label={route.label}
// In the collapsed rail, the label is visually hidden (kept for a11y) so surface it
// as a native tooltip; expanded shows the label inline so a tooltip would be redundant.
title={collapsed ? route.label : undefined}
active={route.id === activeRoute?.id}
badge={route.badge}
badgeTone="warn"
@@ -111,12 +118,20 @@ function ThemeSwitcher({
function Sidebar({
activeRoute,
collapsed,
healthState,
onNavigate
isGroupCollapsed,
onNavigate,
onToggleCollapsed,
onToggleGroup
}: {
activeRoute: ScreenRoute | null;
collapsed: boolean;
healthState: DashboardHealthQueryState;
isGroupCollapsed: (groupKey: string) => boolean;
onNavigate: NavigateHandler;
onToggleCollapsed: () => void;
onToggleGroup: (groupKey: string) => void;
}) {
return (
<aside className="ctv-sidebar">
@@ -125,15 +140,78 @@ function Sidebar({
<span className="ctv-brand-wordmark">
Chicory<span>TV</span>
</span>
<button
type="button"
className="ctv-sidebar-toggle"
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
aria-pressed={collapsed}
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
onClick={onToggleCollapsed}
>
{collapsed ? (
<PanelLeftOpen aria-hidden="true" size={17} />
) : (
<PanelLeftClose aria-hidden="true" size={17} />
)}
</button>
</div>
<nav aria-label="Primary" className="ctv-nav">
{sidebarNavGroups.map(({ ids, label }) => (
<Fragment key={label ?? 'Primary'}>
{label ? <NavSection>{label}</NavSection> : null}
<SidebarNavGroup activeRoute={activeRoute} ids={ids} onNavigate={onNavigate} />
</Fragment>
))}
{sidebarNavGroups.map((group) => {
const { ids, key, label } = group;
// Primary (unlabeled) is always open, in both the rail and the expanded sidebar.
if (!label) {
return (
<SidebarNavGroup
key="Primary"
activeRoute={activeRoute}
collapsed={collapsed}
ids={ids}
onNavigate={onNavigate}
/>
);
}
const groupKey = key ?? label;
// Accordions only apply in the expanded sidebar; the rail always shows every item
// (separated by a hairline divider), so group-collapse is ignored there.
const groupCollapsed = isGroupCollapsed(groupKey);
// Always reveal the group that contains the active route so its active item is visible +
// highlighted in the expanded sidebar, even when the group's stored state is collapsed
// (e.g. a fresh deep-link to /app/settings). This does NOT persist — navigating away
// reverts to the stored preference (acceptance: "active route marked in both states").
const containsActive =
activeRoute != null && ids.includes(activeRoute.id);
const expanded = containsActive || !groupCollapsed;
const showItems = collapsed || expanded;
return (
<Fragment key={groupKey}>
{collapsed ? (
<div className="ctv-nav-divider" role="presentation" />
) : (
<button
type="button"
className="ctv-nav-group-header"
aria-expanded={expanded}
onClick={() => onToggleGroup(groupKey)}
>
<span>{label}</span>
<ChevronDown className="ctv-nav-group-chevron" aria-hidden="true" size={14} />
</button>
)}
{showItems ? (
<SidebarNavGroup
activeRoute={activeRoute}
collapsed={collapsed}
ids={ids}
onNavigate={onNavigate}
/>
) : null}
</Fragment>
);
})}
</nav>
<div className="ctv-sidebar-health">
@@ -348,9 +426,19 @@ export function AppShell({
route: ScreenRoute | null;
theme: DesignSystemThemeId;
}) {
const { collapsed, isGroupCollapsed, toggleCollapsed, toggleGroup } = useSidebarState();
return (
<div className="ctv-app-shell">
<Sidebar activeRoute={route} healthState={healthState} onNavigate={onNavigate} />
<div className={`ctv-app-shell${collapsed ? ' ctv-app-shell-collapsed' : ''}`}>
<Sidebar
activeRoute={route}
collapsed={collapsed}
healthState={healthState}
isGroupCollapsed={isGroupCollapsed}
onNavigate={onNavigate}
onToggleCollapsed={toggleCollapsed}
onToggleGroup={toggleGroup}
/>
<div className="ctv-shell-body">
<TopBar route={route} />
<UnauthorizedBanner />
+5
View File
@@ -464,6 +464,9 @@ export const routeById = new Map(routes.map((route) => [route.id, route]));
export interface SidebarNavGroupDefinition {
label?: string;
// Stable key for persisted collapse state (ctv-sidebar-groups). Only labeled (collapsible)
// groups need one; the unlabeled Primary group is always open (#396).
key?: string;
ids: readonly ScreenId[];
}
@@ -485,6 +488,7 @@ export const sidebarNavGroups: SidebarNavGroupDefinition[] = [
},
{
label: 'Media',
key: 'media',
ids: [
'media',
'search',
@@ -500,6 +504,7 @@ export const sidebarNavGroups: SidebarNavGroupDefinition[] = [
},
{
label: 'System',
key: 'system',
ids: [
'settings',
'apiKey',
+62
View File
@@ -0,0 +1,62 @@
import { act, renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it } from 'vitest';
import {
getStoredSidebarCollapsed,
getStoredSidebarGroups,
useSidebarState
} from './sidebarState';
describe('sidebarState persistence (#396)', () => {
beforeEach(() => {
window.localStorage.clear();
});
it('defaults to expanded when nothing is stored', () => {
expect(getStoredSidebarCollapsed()).toBe(false);
expect(getStoredSidebarGroups()).toEqual({});
});
it('reads the collapsed flag only from the exact "1" sentinel', () => {
window.localStorage.setItem('ctv-sidebar-collapsed', '1');
expect(getStoredSidebarCollapsed()).toBe(true);
window.localStorage.setItem('ctv-sidebar-collapsed', '0');
expect(getStoredSidebarCollapsed()).toBe(false);
});
it('ignores malformed or non-boolean group JSON', () => {
window.localStorage.setItem('ctv-sidebar-groups', 'not json');
expect(getStoredSidebarGroups()).toEqual({});
window.localStorage.setItem('ctv-sidebar-groups', JSON.stringify(['media']));
expect(getStoredSidebarGroups()).toEqual({});
window.localStorage.setItem(
'ctv-sidebar-groups',
JSON.stringify({ media: true, system: 'nope' })
);
expect(getStoredSidebarGroups()).toEqual({ media: true });
});
it('treats an unknown labeled group as collapsed by default, and toggles + persists it', () => {
const { result } = renderHook(() => useSidebarState());
expect(result.current.isGroupCollapsed('media')).toBe(true);
act(() => result.current.toggleGroup('media'));
expect(result.current.isGroupCollapsed('media')).toBe(false);
expect(getStoredSidebarGroups()).toEqual({ media: false });
});
it('toggles + persists the sidebar collapsed flag', () => {
const { result } = renderHook(() => useSidebarState());
expect(result.current.collapsed).toBe(false);
act(() => result.current.toggleCollapsed());
expect(result.current.collapsed).toBe(true);
expect(getStoredSidebarCollapsed()).toBe(true);
});
});
+104
View File
@@ -0,0 +1,104 @@
import { useCallback, useState } from 'react';
// Persisted UI state for the shell sidebar (issue #396). Two client-local preferences,
// namespaced `ctv-*` per the persisted-UI-state convention (spa-conventions.md §5d):
// - ctv-sidebar-collapsed: "1" | "0" — is the sidebar collapsed to the icon rail?
// - ctv-sidebar-groups: JSON {groupKey: boolean} — is a labeled nav group collapsed?
// A labeled group with no stored entry defaults to COLLAPSED, so a fresh load shows only the
// always-open Primary group. (The prototype named these keys `ctv.sidebar.*`; we use the
// established `ctv-` hyphen form — see docs/decisions.md 2026-07-18.)
const COLLAPSED_KEY = 'ctv-sidebar-collapsed';
const GROUPS_KEY = 'ctv-sidebar-groups';
function getStorage(): Storage | null {
try {
return window.localStorage;
} catch {
// Storage can be disabled/unavailable (privacy mode, sandboxed iframe) — degrade to defaults.
return null;
}
}
export function getStoredSidebarCollapsed(): boolean {
return getStorage()?.getItem(COLLAPSED_KEY) === '1';
}
function setStoredSidebarCollapsed(collapsed: boolean): void {
try {
getStorage()?.setItem(COLLAPSED_KEY, collapsed ? '1' : '0');
} catch {
// ignore write failures (quota / unavailable) — in-memory state still updates
}
}
export function getStoredSidebarGroups(): Record<string, boolean> {
const raw = getStorage()?.getItem(GROUPS_KEY);
if (!raw) {
return {};
}
try {
const parsed: unknown = JSON.parse(raw);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const out: Record<string, boolean> = {};
for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
if (typeof value === 'boolean') {
out[key] = value;
}
}
return out;
}
} catch {
// malformed JSON — fall through to defaults
}
return {};
}
function setStoredSidebarGroups(groups: Record<string, boolean>): void {
try {
getStorage()?.setItem(GROUPS_KEY, JSON.stringify(groups));
} catch {
// ignore write failures — in-memory state still updates
}
}
export interface SidebarState {
/** Is the sidebar collapsed to the icon rail? */
collapsed: boolean;
toggleCollapsed: () => void;
/** Is a labeled nav group collapsed? Unknown groups default to collapsed. */
isGroupCollapsed: (groupKey: string) => boolean;
toggleGroup: (groupKey: string) => void;
}
export function useSidebarState(): SidebarState {
const [collapsed, setCollapsed] = useState<boolean>(getStoredSidebarCollapsed);
const [groups, setGroups] = useState<Record<string, boolean>>(getStoredSidebarGroups);
const toggleCollapsed = useCallback(() => {
setCollapsed((previous) => {
const next = !previous;
setStoredSidebarCollapsed(next);
return next;
});
}, []);
const isGroupCollapsed = useCallback(
// Absent entry ⇒ default collapsed for labeled groups.
(groupKey: string) => groups[groupKey] ?? true,
[groups]
);
const toggleGroup = useCallback((groupKey: string) => {
setGroups((previous) => {
const currentlyCollapsed = previous[groupKey] ?? true;
const next = { ...previous, [groupKey]: !currentlyCollapsed };
setStoredSidebarGroups(next);
return next;
});
}, []);
return { collapsed, toggleCollapsed, isGroupCollapsed, toggleGroup };
}
+82
View File
@@ -861,6 +861,88 @@
text-transform: uppercase;
}
/* Collapsible nav-group header (accordion toggle) — the interactive replacement for the static
NavSection label in the expanded sidebar (#396). */
.ctv-nav-group-header {
box-sizing: border-box;
width: 100%;
display: flex;
align-items: center;
gap: 6px;
margin: 8px 0 2px;
padding: 6px 10px;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-disabled);
cursor: pointer;
font-size: var(--text-2xs);
font-weight: var(--weight-semibold);
letter-spacing: var(--tracking-caps);
line-height: 1;
text-align: left;
text-transform: uppercase;
transition: color var(--dur-fast) var(--ease-standard);
}
.ctv-nav-group-header:hover {
color: var(--text-secondary);
}
.ctv-nav-group-header:focus-visible {
outline: none;
box-shadow: var(--ring-focus);
}
.ctv-nav-group-header > span {
flex: 1;
min-width: 0;
}
.ctv-nav-group-chevron {
flex: 0 0 auto;
transition: transform var(--dur-fast) var(--ease-standard);
}
/* Collapsed group ⇒ chevron points right (closed). */
.ctv-nav-group-header[aria-expanded='false'] .ctv-nav-group-chevron {
transform: rotate(-90deg);
}
/* ---- Collapsed (icon-rail) nav items (#396) ---- */
.ctv-app-shell-collapsed .ctv-nav-item {
justify-content: center;
gap: 0;
padding: 0;
}
/* Keep the label in the a11y tree (accessible name) but visually hidden in the rail; the native
title tooltip surfaces it for sighted mouse users. */
.ctv-app-shell-collapsed .ctv-nav-label {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
/* A numeric badge collapses to a small corner dot in the rail. */
.ctv-app-shell-collapsed .ctv-nav-badge {
position: absolute;
top: 5px;
right: 9px;
min-width: 0;
width: 7px;
height: 7px;
padding: 0;
font-size: 0;
line-height: 0;
}
.ctv-tabs {
display: flex;
gap: 2px;
+7 -1
View File
@@ -13,6 +13,9 @@ export interface NavItemProps {
href?: string;
onClick?: (e: MouseEvent) => void;
style?: CSSProperties;
// Native tooltip; used by the collapsed sidebar rail to surface the label that is visually
// hidden there (the label stays in the a11y tree for the accessible name — #396).
title?: string;
}
export function NavItem({
@@ -23,7 +26,8 @@ export function NavItem({
badgeTone = 'warn',
href,
onClick,
style
style,
title
}: NavItemProps) {
const content = (
<>
@@ -44,6 +48,7 @@ export function NavItem({
onClick={onClick}
className={classNames('ctv-nav-item', active && 'ctv-nav-item-active')}
style={style}
title={title}
>
{content}
</a>
@@ -57,6 +62,7 @@ export function NavItem({
onClick={onClick}
className={classNames('ctv-nav-item', active && 'ctv-nav-item-active')}
style={style}
title={title}
>
{content}
</button>
+1 -1
View File
@@ -57,7 +57,7 @@ const MESSAGES: Record<PinFlowStatus, string> = {
success: 'Connected to Plex.',
'authorized-no-servers': 'Signed in to Plex, but no eligible servers were discovered.',
timeout: 'Plex sign-in timed out — try again.',
'budget-exhausted': 'Still working — this can take a while on a large first sync. Use Refresh to check again.'
'budget-exhausted': 'Still working — this can take a while on a large first sync. Check again to see if it finished.'
};
const TERMINAL: ReadonlySet<PinFlowStatus> = new Set<PinFlowStatus>([
+34 -2
View File
@@ -186,6 +186,28 @@ describe('LibrariesScreen', () => {
expect(handle.fetchSpy).toHaveBeenCalledWith('/api/v1/libraries/scan-status', expect.any(Object));
});
it('renders "Never scanned" for a null lastScan (#409)', async () => {
// The API reports never-scanned libraries as a null lastScan (not the historical
// 0001-01-01 MinValue sentinel - see docs/decisions.md #409), so the SPA only needs a
// plain null/truthy check here.
mockApi({
mediaSources: [
mediaSource({
libraries: [
library({ id: 31, itemCount: 0, lastScan: null, mediaKind: 'Movies', name: 'Movies' }),
library({ id: 32, itemCount: 0, lastScan: null, mediaKind: 'Shows', name: 'TV Shows' })
]
})
]
});
render(<LibrariesScreen />);
expect((await screen.findAllByText('Movies')).length).toBeGreaterThan(0);
expect(screen.getAllByText('Never scanned').length).toBeGreaterThanOrEqual(2);
expect(screen.queryByText(/Last scan/)).not.toBeInTheDocument();
});
it('converts 0 and 1 fractional scan-status percents to 0% and 100%', async () => {
mockApi({
libraryScanStatuses: [
@@ -255,8 +277,18 @@ describe('LibrariesScreen', () => {
const sourceFetchesBeforeCompletion = fetchCount(handle, '/api/v1/media-sources');
await runPollTick(intervalHandlers);
expect(screen.queryByText('75%')).not.toBeInTheDocument();
expect(fetchCount(handle, '/api/v1/media-sources')).toBe(sourceFetchesBeforeCompletion + 1);
// loadScanStatuses (the interval handler) kicks off its own Promise.all(...).then(...) chain
// (libraries.ts) rather than being itself async/awaitable, so `handler()` returns void - the
// state update that clears the progress bar and the follow-up loadSources() refetch both land a
// few microtask ticks after runPollTick's `act(async () => ...)` callback has already resolved.
// Asserting synchronously right after the tick races that chain (#447); waitFor lets both
// settle before asserting, without weakening either check.
await waitFor(() => {
expect(screen.queryByText('75%')).not.toBeInTheDocument();
});
await waitFor(() => {
expect(fetchCount(handle, '/api/v1/media-sources')).toBe(sourceFetchesBeforeCompletion + 1);
});
});
it('reconciles a 409 "already scanning" against scan-status without an error toast (#232)', async () => {
+42
View File
@@ -266,4 +266,46 @@ describe('PlexSourceScreen', () => {
expect(screen.queryByText(/Your Plex account is authorized/i)).toBeNull();
expect(screen.queryByText(/no eligible servers were discovered/i)).toBeNull();
});
// #367: budget-exhausted told the user to "Use Refresh to check again", but the Servers card (the
// only place Refresh lives) doesn't render with zero servers, so the instruction pointed at nothing.
// The message now describes checking again generically, and a global "Check again" button re-checks
// GET /api/v1/media-sources/plex without resuming the (already spent) timed poll loop.
it('budget-exhausted with no servers offers a working "Check again" control instead of a dead Refresh reference', async () => {
const stateRef = { current: { isAuthorized: false, isLocked: false, servers: [] } as PlexState };
const counter = { state: 0 };
installFetch(stateRef, counter);
vi.spyOn(window, 'open').mockReturnValue({} as Window);
vi.useFakeTimers();
render(<PlexSourceScreen />);
await vi.waitFor(() => expect(screen.getByRole('button', { name: 'Sign in to Plex' })).toBeTruthy());
stateRef.current = { isAuthorized: true, isLocked: true, servers: [] };
fireEvent.click(screen.getByRole('button', { name: 'Sign in to Plex' }));
await vi.waitFor(() => expect(screen.getByText(/discovering your Plex servers|Waiting/i)).toBeTruthy());
await vi.advanceTimersByTimeAsync(152_000); // consume the whole 150s budget
await vi.waitFor(() => expect(screen.getByText(/Still working/i)).toBeTruthy());
// The message no longer references a "Refresh" control that isn't rendered (no Servers card yet).
expect(screen.queryByText(/Use Refresh/i)).toBeNull();
expect(screen.queryByRole('heading', { name: 'Servers' })).toBeNull();
// A real, working "Check again" control is offered instead.
const checkAgainButton = screen.getByRole('button', { name: 'Check again' });
expect(checkAgainButton).toBeTruthy();
const callsBeforeCheck = counter.state;
// The next sync completes and a server is discovered by the time the user checks again.
stateRef.current = { isAuthorized: true, isLocked: false, servers: [{ id: 3, name: 'Attic Server', address: 'http://plex:32400' }] };
fireEvent.click(checkAgainButton);
// Check again performs its own fetch (does not rely on the exhausted timed poll loop resuming).
await vi.waitFor(() => expect(counter.state).toBeGreaterThan(callsBeforeCheck));
await vi.waitFor(() => expect(screen.getByRole('heading', { name: 'Servers' })).toBeTruthy());
expect(screen.getByText('Attic Server')).toBeTruthy();
expect(screen.getByText('Connected to Plex.')).toBeTruthy();
});
});
+51 -1
View File
@@ -28,13 +28,16 @@ type BootState =
// — the poll runs against GET /api/v1/media-sources/plex every 2s and, crucially, keeps polling while
// authorized-but-still-locked ("finalizing / discovering servers"); the terminal success signal is
// the lock RELEASING (see pinFlowPoll.ts). Server rows offer Refresh / Edit Libraries / Edit Path
// Replacements. Refresh is disabled while the Plex lock is held.
// Replacements; Refresh is disabled while the Plex lock is held. The budget-exhausted terminal (no
// servers may exist yet) instead offers a global "Check again" affordance (#367) so its message
// always points at a control that's actually rendered.
export function PlexSourceScreen() {
const [boot, setBoot] = useState<BootState>({ status: 'loading' });
const [actionError, setActionError] = useState<string | null>(null);
const [confirmSignOut, setConfirmSignOut] = useState(false);
const [signingOut, setSigningOut] = useState(false);
const [refreshingId, setRefreshingId] = useState<number | null>(null);
const [checkingAgain, setCheckingAgain] = useState(false);
// Pin-flow UI state.
const [pinState, setPinState] = useState<PinFlowState | null>(null);
@@ -162,6 +165,41 @@ export function PlexSourceScreen() {
});
};
// The global "Check again" affordance for the budget-exhausted terminal (#367): that state has no
// per-server Refresh (the Servers card only renders once a server is discovered), so the message
// must point at something that actually exists. This does a single one-off re-check of
// GET /api/v1/media-sources/plex rather than resuming the timed poll loop — the 150s budget is
// spent; re-arming it silently would contradict "exhausted" and could spin forever on a sync that
// never finishes.
const checkAgain = () => {
if (checkingAgain) {
return;
}
setCheckingAgain(true);
setActionError(null);
getPlexState()
.then((state) => {
if (!activeRef.current) {
return;
}
setBoot({ status: 'ready', state });
const elapsed = Date.now() - startedAtRef.current;
const next = evaluatePinFlow(
{ isLocked: state.isLocked, isAuthorized: state.isAuthorized, hasServers: (state.servers ?? []).length > 0 },
{ budgetExhausted: isPinFlowBudgetExhausted(elapsed) }
);
setPinState(next);
setCheckingAgain(false);
})
.catch((error: unknown) => {
if (!activeRef.current) {
return;
}
setCheckingAgain(false);
setActionError(messageFromMediaSourcesError(error, 'Unable to check Plex status'));
});
};
const refresh = (serverId: number) => {
setRefreshingId(serverId);
setActionError(null);
@@ -284,6 +322,18 @@ export function PlexSourceScreen() {
{(popupBlocked || (polling && authUrl)) && authUrl && (
<a href={authUrl} target="_blank" rel="noreferrer noopener">Open the Plex sign-in page</a>
)}
{pinState?.status === 'budget-exhausted' && (
<Button
disabled={checkingAgain}
loading={checkingAgain}
onClick={checkAgain}
size="sm"
startIcon={<RefreshCw aria-hidden="true" size={14} />}
variant="secondary"
>
Check again
</Button>
)}
</div>
)}
{!polling && !pinState && !hasPlexAccount && (
+96 -1
View File
@@ -26,6 +26,21 @@ body {
color: var(--text-primary);
font-family: var(--font-sans, system-ui, sans-serif);
font-size: var(--text-sm, 13px);
/* Animate the sidebar collapse/expand (#396). Only the grid track width transitions so the
content reflows smoothly without a layout jump. */
transition: grid-template-columns var(--dur-base, 140ms) var(--ease-standard);
}
/* Collapsed sidebar = the 60px icon rail (#396). Narrowing the tracked custom property drives the
grid-template-columns transition above. */
.ctv-app-shell-collapsed {
--sidebar-w: 60px;
}
@media (prefers-reduced-motion: reduce) {
.ctv-app-shell {
transition: none;
}
}
.ctv-sidebar {
@@ -97,6 +112,72 @@ body {
font-size: var(--text-2xs, 11px);
}
/* Sidebar collapse toggle in the brand header (#396). */
.ctv-sidebar-toggle {
margin-left: auto;
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 0;
border-radius: var(--radius-sm, 5px);
background: transparent;
color: var(--text-disabled);
cursor: pointer;
transition:
background var(--dur-fast) var(--ease-standard),
color var(--dur-fast) var(--ease-standard);
}
.ctv-sidebar-toggle:hover {
background: var(--ctv-accent-soft);
color: var(--text-primary);
}
.ctv-sidebar-toggle:focus-visible {
outline: none;
box-shadow: var(--ring-focus);
}
/* Hairline divider shown between nav groups in the collapsed rail (accordions don't apply
there, so the group label/chevron is replaced by a plain separator #396). */
.ctv-nav-divider {
height: 1px;
margin: var(--space-4, 8px) var(--space-4, 8px);
background: var(--border-hairline);
}
/* ---- Collapsed (icon-rail) sidebar overrides (#396) ---- */
.ctv-app-shell-collapsed .ctv-brand {
padding: 0;
justify-content: center;
}
.ctv-app-shell-collapsed .ctv-brand img,
.ctv-app-shell-collapsed .ctv-brand-wordmark {
display: none;
}
.ctv-app-shell-collapsed .ctv-sidebar-toggle {
margin-left: 0;
}
/* Footer: hide the version text block and the health label, keep the status dot centered. */
.ctv-app-shell-collapsed .ctv-sidebar-health {
justify-content: center;
padding: var(--space-6, 12px) 0;
}
.ctv-app-shell-collapsed .ctv-sidebar-health > div {
display: none;
}
.ctv-app-shell-collapsed .ctv-sidebar-health .ctv-status-dot > span:last-child {
display: none;
}
.ctv-shell-body {
min-width: 0;
height: 100vh;
@@ -3730,10 +3811,24 @@ body {
}
.ctv-nav,
.ctv-sidebar-health {
.ctv-sidebar-health,
.ctv-sidebar-toggle {
display: none;
}
/* The collapse feature is desktop-only (the nav is hidden here). If the sidebar was collapsed on
desktop, don't let the rail's brand-hiding leave an empty header on mobile restore the brand
(#396). */
.ctv-app-shell-collapsed .ctv-brand {
padding: 0 14px;
justify-content: flex-start;
}
.ctv-app-shell-collapsed .ctv-brand img,
.ctv-app-shell-collapsed .ctv-brand-wordmark {
display: revert;
}
.ctv-topbar {
flex-wrap: wrap;
height: auto;