The auto-pin-to-prod job designed on the unmerged `ci/auto-bump-prod-compose`
branch (3d6ac883) never landed on main — so v* releases (v26.5.0, v26.6.0) did
NOT auto-bump the server-management compose pin (it sat at 26.5.0). The docs
(homelab-docs Docker/ErsatzTV.md, ci-cd.md) described the auto-bump as if live.
Restore the job verbatim (its credentials already exist: the `ersatztv-ci-deploy`
write deploy key, id 5, on server-management + the SERVERMGMT_DEPLOY_KEY secret
here). On a v* tag, after the test-gated image builds, it rewrites the pinned
`ersatztv:<version>` tag in docker/bumblebee/stacks/media-servers/compose.yaml
and pushes to server-management `master` → the Gitea->Komodo webhook redeploys
prod with a pre-deploy backup. Idempotent (no-op if already pinned).
docs/ci-cd.md updated to match (release procedure + the stale ":prod pin" claim,
which was actually an immutable :<version> pin since 2026-07-07).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Codex-review nits: drop the now-inert "MudBlazor" Serilog level override in
appsettings.json (package removed) and reword the PlaylistController comment
that referenced the deleted Blazor MultiSelectBase.AddItemsToPlaylist path. No
behavior change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cold-review Low-1: ErsatzTV/Extensions/NavigationManagerExtensions.cs survived
the removal — the last non-deleted .cs still importing
Microsoft.AspNetCore.Components/JSInterop and calling the deleted
blazorHelpers.scrollToFragment JS. Fully unreferenced (compiled only via the
shared framework). Removing it completes the Blazor deletion.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The ChicoryTV React SPA (web/, served at /app) now has full parity for every
route the Blazor UI served, so the legacy Blazor Server / MudBlazor UI is
deleted. This is the milestone-capping removal of #91 phase (b).
Deleted: ErsatzTV/Pages/**, Shared/**, ViewModels/** (39 edit VMs),
Validators/** (10 edit-VM validators), App.razor, _Imports.razor,
Locals/{Shared,Pages}/** (Blazor loc resx; Locals/Resources.* kept),
wwwroot/css + wwwroot/lib, libman.json, and the orphaned MultiSelectBaseTests.
Startup.cs (surgical, not wholesale): removed AddRazorPages/AuthorizeFolder,
AddServerSideBlazor, AddMudServices, AddSortable, AddCourier, the HtmlSanitizer
registration, the Blazor-attached OIDC UseAuthentication/UseAuthorization
middleware (per the #206 auth-posture sign-off), MapBlazorHub, and
MapFallbackToPage("/_Host"). Renamed the branch blazor->legacy; it still
co-hosts MapControllers, /docs (Scalar), dev MapOpenApi and the redirect
middleware. Replaced the _Host fallback with a catch-all (MapFallback ->
302 /app) that excludes /api|/artwork|/docs|/openapi (genuine 404) per #204.
Kept all OIDC/JWT/API-key service wiring (inert unless configured; real auth
is #197), ConditionalIptvAuthorizeFilter, ApiKeyAuthorizationFilter.
Pruned 9 now-unused packages (all verified zero remaining consumers) from
Directory.Packages.props + ErsatzTV.csproj: MudBlazor, Heron.MudCalendar,
Blazored.FluentValidation, BlazorSortable, MediatR.Courier.DependencyInjection,
Markdig, HtmlSanitizer, Chronic.Core, NaturalSort.Extension. Also removed the
now-dead #25 razor-Sonar NoWarn.
LegacyUiRedirects: added the 14 /media/sources/* -> /app/libraries/* redirects
(SPA screens landed in #202) and lifted the #204-era /media/sources prefix ban.
Tests: Release build clean; full solution suite green. Updated Startup
source-text tests + added regression coverage that Blazor wiring is gone, the
catch-all is wired, and all 14 media-sources routes redirect.
Docs: blazor-route-parity.md (phase b COMPLETE), decisions.md (removal entry),
CLAUDE.md, contributing.md, README.md all updated in this PR.
Rollback: tag blazor-final is cut on pre-merge main as the first merge action.
Part of #91.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adversarial review caught a HIGH the fan-out introduced: activating Version as an
IsConcurrencyToken on Playout/Collection makes EF append `WHERE Version=@orig` to
EVERY root UPDATE, so a non-If-Match writer that saves via plain SaveChangesAsync
now throws DbUpdateConcurrencyException → 500 when a replace-all editor bumps the
row between its load and save. Realistic two-tab trigger (edit playout settings while
editing its alt-schedules; edit a collection's name while reordering) — a new crash,
previously silent last-write-wins.
Fix: shared ConcurrencyExtensions.SaveChangesForcingVersion — on a concurrency
failure it adopts the stored token as original+current (client-wins merge scoped to
the token, never reverting the concurrent bump) and retries, i.e. Phase-1 force-write
semantics for a missing If-Match. Applied to the exposed UPDATE writers:
UpdatePlayout, Update{Sequential,Scripted,ExternalJson}Playout, UpdateOnDemandCheckpoint,
UpdateCollection. Non-vacuous test proves the write lands and the bump survives.
Deletes + repo-mediated Add* writers (rarer / join-rows-only) re-scoped onto #269.
Refs #253#269
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Installs husky git hooks (via web/'s lint-staged + npm, since the JS/TS
project lives in web/ with no root package.json) to catch lint, format,
type, and generated-API-drift errors locally before they reach CI.
Hooks (committed at repo root under .husky/):
- pre-commit: (a) lint-staged runs eslint --fix on staged
web/src/**/*.{ts,tsx} + a project-wide typecheck; (b) if any *.cs are
staged, dotnet format --verify-no-changes on just those files (skipped
when no .cs staged, so web-only commits skip the sln load).
- pre-push: CI-parity gate — cd web && check:api && lint && typecheck &&
build. Blocks pushing drift or a change that breaks an unstaged file.
- commit-msg: requires a Co-Authored-By trailer (merge commits exempt).
Wiring: web/package.json gains husky + lint-staged devDeps, a lint-staged
config, and a `prepare` script (cd .. && husky) that points git's
core.hooksPath at the repo-root .husky dir on npm install. A fresh
`web/` npm install installs all four hooks automatically.
Monorepo/worktree gotchas handled:
- husky init hard-checks for .git in cwd, so `prepare` cd's to the repo
root before invoking husky (npm keeps web/node_modules/.bin on PATH).
- git exports GIT_DIR while running hooks; in a worktree/subdir that made
pre-push's `git diff` (check:api) mislocate the working tree and pass
silently on drift — pre-push now unsets GIT_DIR/GIT_WORK_TREE/GIT_INDEX_FILE.
docs/ci-cd.md: new "Pre-commit hooks (web/)" section covering all four.
Verified: eslint error blocks commit; clean commit passes; bad-format .cs
blocks (dotnet format ~6-7s scoped), good .cs passes; check:api drift and
a lint error each block `git push --dry-run`, clean state passes; missing
Co-Authored-By blocks commit-msg, present passes; non-web/.cs commits skip
lint/format. npm run lint clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Last SPA pre-work before deleting Blazor Libraries.razor (#91 phase b):
wire the shipped scanLibrary(id, deep) + scanCollections(family, id, deep)
clients (F9 API, #235) into LibrariesScreen so the SPA reaches parity with
Libraries.razor's four scan actions.
- Deep Scan Library button on each remote (Plex/Jellyfin/Emby) library row,
threading `deep` through the existing optimistic-pending/poll hook (quick +
deep share the per-library lock).
- External Collections section (quick + deep per remote source). Rows derive
client-side from getMediaSources(): the media-sources API handler already
filters each source's `libraries` to sync-enabled entries, so a remote
source with a non-empty libraries list is exactly GetExternalCollections's
Libraries.Any(ShouldSyncItems) filter — no new endpoint.
- useCollectionsScan hook: collections scans have no scan-status poll surface
(the endpoint is library-keyed; Blazor observed collections locks via
in-process IEntityLocker events), so pending is optimistic + timeout-bounded
(409 benign, 404/network surfaces the error). Follow-up #271 for a proper
collections status surface.
Pure SPA change (no backend/OpenAPI). Docs: blazor-route-parity.md §5 (SPA
affordance DONE), decisions.md (derive-vs-endpoint + optimistic-timeout).
Refs #91
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The prior fix set setItemsLoading/setItemsLoaded at the top of loadItems, but the
activeId effect calls loadItems synchronously → react-hooks/set-state-in-effect lint
error (Main Lint SPA, the real CI failure). Move the not-loaded/loading gating into
reloadAfterConflict (an event handler, lint-clean), which is exactly the 412 conflict-
reload path Codex's Medium-3 targeted; canEdit stays false through that reload window.
Behavior unchanged; the normal switch/initial-load paths (guarded separately by the
#242 dirty-guard) are left untouched. Local: lint clean, vitest 667, check:api no drift,
full dotnet solution test green.
Independent Codex review of #268 found two Blockers the fork missed + two Mediums:
- Blocker: replace PUTs returned the handler's item snapshot but re-queried the root
for the ETag separately, so a racing writer could pair stale items with a newer ETag
(silent overwrite). All four controllers now reload root-then-items (version-first,
fail-safe) and 404 when the root is gone between commit and reload — matching the
Block reference. Fixes the Blocker + the Medium '200 without ETag' case together.
- Blocker: PlaylistsScreen loaded items+root via Promise.all (concurrent), pairing a
stale name with the current ETag; now sequential (items-with-meta first, then root).
- Medium: SchedulesScreen loadItems now marks not-loaded/loading up front so canEdit is
false through the 412 conflict reload (no stale-draft edits lost).
Controller unit-test mocks updated to stub the new reload query. Full suite green
(ErsatzTV.Tests 1334, web 667, check:api no drift).
Codex-review Low: NotFound previously reached the catch-all by coincidence; a
future enum value would silently 404. Explicit arm + UnreachableException fallback
so an unmapped outcome fails loudly rather than mis-mapping to 404.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The new POST /api/media-sources/{plex|jellyfin|emby}/{id}/scan-collections
endpoints acquire a per-provider collections lock (409 if held) and hand the
single release to the ScannerService finally. But SchedulerService's periodic
collection scans were enqueued WITHOUT the lock, and ScannerService's finally
released the collections lock whenever held with no ownership check. A
scheduler-queued scan running while an API request held the lock cross-released
the API's lock (#250 bug class), letting a second API request get a spurious
202 instead of 409.
Fix (mirrors the SynchronizePlexLibraryByIdIfNeeded(Unlock: !networksFollow)
library-scan precedent):
- Add `bool Unlock = true` (4th positional param) to the three
Synchronize{Plex,Jellyfin,Emby}Collections records; default keeps the
controller + Libraries.razor call sites compiling and releasing on run.
- ScannerService: the three collection finallys now honor `request.Unlock`
(the concrete typed request is in scope in each method) so a batch member
with Unlock:false never releases a lock it doesn't own.
- SchedulerService: replace the unlocked per-source enqueue with a lock-once
per-provider batch — LockX Collections() once, enqueue each source with
Unlock:isLast (last message owns the release), compensating unlock in catch,
and SKIP the whole provider loop if the lock is already held. A naive
"lock-per-source, skip if held" would deterministically starve the 2nd+
source; lock-once-batch does not.
Tests (ErsatzTV.Tests/Services/): ScannerServiceCollectionLockTests drives the
real ScannerService read loop + real EntityLocker and asserts Unlock:false
leaves a held lock intact while Unlock:true releases (all three providers);
SchedulerServiceCollectionLockTests reflect-invokes ScanPlexMediaSources and
asserts it locks once + skips the enqueue when held, and hands the release to
the last message when acquired. Proven non-vacuous: reverting the Plex fix
fails exactly the three Plex tests.
No OpenAPI/v1.json change (internal channel-message record, not a DTO).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire the frozen ETag/If-Match/412 recipe (Block reference implementation)
onto the Template and DecoTemplate aggregates:
- ReplaceTemplateItems / ReplaceDecoTemplateItems commands gain
Option<int> ExpectedVersion; ToCommand() on the request DTOs threads it
through from If-Match.
- Handlers introduce the version check as a standalone Either after
validation (never via Apply), bump Version unconditionally before
saving, and persist through SaveChangesWithConcurrencyGuard so a losing
writer maps to 412 instead of 500. DecoTemplate's post-commit playout
Reset enqueue now only runs after a successful save.
- TemplateViewModel / DecoTemplateViewModel carry Version (header-only,
not echoed in the response body), populated in Mapper.
- TemplateController / DecoTemplateController: GET items emits a strong
ETag of the root's version; PUT parses If-Match (400 on malformed),
threads the expected version into the command, and returns the new
ETag from the refreshed root on success. Both PUT actions now use the
handler's returned item list directly instead of re-querying items.
- SPA: templates.ts / decoTemplates.ts gain getXItemsWithMeta and an
If-Match-aware replaceX; TemplateEditor / DecoTemplateEditor hold the
ETag in a ref, read items-with-meta first on load, and open a
"changed elsewhere" ConfirmDialog on a 412 instead of navigating away.
Tests: new ReplaceTemplateItemsHandlerConcurrencyTests /
ReplaceDecoTemplateItemsHandlerConcurrencyTests mirror the Block
concurrency contract tests (stale/matching/absent If-Match, no-op bump,
racing-save 412, non-vacuous backstop). TemplateControllerTests /
DecoTemplateControllerTests gain ETag/If-Match/412 coverage.
TemplatesScreen.test.tsx / DecoTemplatesScreen.test.tsx gain a 412
conflict-dialog test mirroring BlocksScreen's.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fans the frozen ETag/If-Match/412 recipe (Block reference, #253) onto the
Playlist aggregate:
- ReplacePlaylistItems command carries ExpectedVersion; the handler runs
CheckVersion as a standalone Either after validation (so a stale write
survives as 412, not flattened to 422 by Apply/Join), bumps Version
unconditionally before saving, and persists via
SaveChangesWithConcurrencyGuard (EF concurrency-token backstop).
- PlaylistViewModel carries Version; the items GET sets a strong ETag and
the PUT parses If-Match, threads it into the command, and returns the
refreshed ETag on success (400 on a malformed If-Match).
- Sibling item-adding handlers (AddItemsToPlaylist, AddMovie/Episode/
Season/ShowToPlaylist) bump Version too, since they mutate the same
editor-visible item list.
- SPA: playlists.ts exposes getPlaylistItemsWithMeta and an
If-Match-aware updatePlaylist; PlaylistEditor holds the ETag in a ref,
round-trips it on save, and opens a "changed elsewhere" ConfirmDialog on
412 (mirrors BlockEditor).
Tests: new ReplacePlaylistItemsHandlerConcurrencyTests (stale/match/
force-write/no-op-bump/racing-save), new PlaylistController tests
(ETag on GET items, 400/412/thread-version/force-write on PUT), and a
vitest 412-conflict-dialog test for PlaylistsScreen. dotnet test:
1304/1304 green. web: npm run typecheck clean, npm run build clean,
vitest 664/664 green.
Ref #253 PR2.
Closes the two F9 Libraries.razor parity gaps and normalizes scan-show error
mapping to ProblemDetails.
TASK 1 — library-wide deep scan:
- QueueLibraryScanByLibraryId gains optional `bool DeepScan = false`; handler
threads it into ForceSynchronize{Plex,Jellyfin,Emby}LibraryById.
- POST /api/libraries/{id}/scan?deep=false binds it via [FromQuery].
TASK 2 — external-collections scan (new endpoints):
- POST /api/media-sources/{plex|jellyfin|emby}/{id}/scan-collections?deep=false
acquires the per-source collections lock (§3b: lock IS the running scan → 409),
enqueues Synchronize{X}Collections(id, ForceScan:true, deep) to the scanner
channel, returns 202; compensating-unlock on enqueue throw.
TASK 3 — scan-show normalization:
- New QueueShowScanResult enum; handler returns it instead of bool.
- POST /api/libraries/{id}/scan-show now maps 202/404/409/422 (all errors
ProblemDetails) instead of 200/404/400-anonymous-object.
- Updated the lone Blazor caller (TelevisionSeasonList.razor).
Tests: LibrariesController (scan deep=true, scan-show enum→status), the three
media-source controllers (scan-collections route/404/409/202/compensating-unlock),
and handler tests for both changed handlers (deep threading + show-scan outcomes).
Docs: api-conventions §3b exemplar + blazor-route-parity §5 F9 gate.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Slice C of the async-op contract normalization:
- channel reset (POST /api/channels/{channelNumber}/playout/reset) now
returns 202 Accepted (was 200 Ok) — it only queues a background rebuild
- reset-all (POST /api/playouts/reset-all) still 202 but now returns a
ResetAllPlayoutsResponseModel body reporting QueuedPlayoutIds /
SkippedLocked / SkippedUnsupported instead of silently swallowing skips;
handler returns a new ResetAllPlayoutsResult record
- single-playout GET (GET /api/playouts/{id}) now exposes IsLocked on
PlayoutResponseModel, set from IEntityLocker.IsPlayoutLocked mirroring
the list projection — gives a polling client the lock flag
Tests: channel reset asserts 202; reset-all asserts 202 + skipped-body
shape; single GET asserts IsLocked; new ResetAllPlayoutsHandlerTests
(in-memory SQLite) asserts locked/ExternalJson/None land in skipped lists
and eligible playouts in queued. docs/api-conventions.md §3a updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The global Trakt lock is acquired by SchedulerService.RefreshTraktLists /
MatchTraktLists (and TraktController) and released only when the *terminal*
message of a batch — the one carrying Unlock: true (list == traktLists.Last())
— is processed by WorkerService, whose handler (AddTraktListHandler /
MatchTraktListItemsHandler) calls IEntityLocker.UnlockTrakt() in a finally.
WorkerService.ExecuteAsync breaks out of the read loop on
stoppingToken.IsCancellationRequested (and exits on channel completion /
reader cancellation) BEFORE processing the next message. If shutdown lands
after a batch is enqueued but before its terminal Unlock: true message is
handled, UnlockTrakt() never runs and the in-memory Trakt lock leaks for the
rest of the process lifetime (subsequent Trakt operations 409 forever).
Fix (option a): make the batch-release loss-tolerant with a compensating
release in a finally around the read loop — if the Trakt lock is still held
when the worker stops, release it. Chosen over tracking pending ownership
(b) because the lock is a global singleton and WorkerService is its sole
batch-release site, so "held at shutdown" unambiguously means "the terminal
release was lost"; covers all three exit paths (break / channel completion /
cancellation) in one place. Same lock-lifecycle class as #231/#233/#234.
Regression test: WorkerServiceTests gates the first (non-terminal) batch
message on the stopping token, then StopAsync-cancels so the worker breaks
before the terminal Unlock: true message — asserts the lock is released and
the terminal message was never processed. Proven non-vacuous: inverting the
finally condition fails the test.
Backend-only; no controller/DTO/SPA/OpenAPI impact.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Slice A of the async-op API contract normalization.
MaintenanceController:
- EmptyTrash error path: was 500 text/plain (error.ToString()); now maps the
BaseError Left through ApiResults.ToErrorResult() -> 404 (NotFoundError) / 422
ProblemDetails. Success stays 200 OkResult. Added ProducesResponseType 200 + 422.
- CleanArtwork: fire-and-forget enqueue of DeleteOrphanedArtwork was a silent 200;
now returns 202 Accepted (AcceptedResult) since it queues background work.
Added ProducesResponseType 202. (Controller does not derive from ControllerBase,
so results are built directly as before.)
TroubleshootController.TroubleshootPlayback (GET|HEAD /api/troubleshoot/playback.m3u8):
- Two bare body-less NotFound() call sites conflated "not found" with "prepare/
playback failure". Both now return a ProblemDetails body:
* prepare-failure (result.IsLeft): mapped through error.ToErrorResult() -> 404 for
NotFoundError (unknown media item/channel) else 422 for a validation BaseError.
* terminal fall-through (prepare ok but no playable output): kept 404 with a
distinguishing ApiResults.NotFoundProblem(...) detail.
- Added ProducesResponseType 404 + 422 (409 already present).
Consumer check: the SPA (PlaybackTroubleshootingScreen) feeds the playback.m3u8 URL
straight to hls.js via HlsPlayer, which never inspects the HTTP status code — playback
state is surfaced via the separate /api/troubleshoot/playback/status poll. So the
404->422 split for the validation subcase is safe; no player code branches on the
status code.
Tests: MaintenanceControllerTests (200/422/202 + enqueue assertion),
TroubleshootControllerTests (prepare 404 NotFoundError, 422 validation). All green;
Api error-metadata/contract/security scans still pass.
Note: OpenAPI artifacts (v1.json / v1.d.ts) intentionally NOT regenerated here — the
orchestrator regenerates once after all #235 slices merge.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Independent adversarial review of PR #266 found two sibling handlers with the
identical post-commit `_smartCollectionCache.Refresh(cancellationToken)` pattern
that the sweep missed (only UpdateSmartCollection was caught). Same audit#22 F4
class: a late client-disconnect after the commit lands would throw and leave the
in-memory smart-collection cache stale vs the committed DB. → CancellationToken.None.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Backend mutation-hardening cluster from the 2026-07-11 mutation-safety audit
sweep (adversarial-reviewer #22/#23), the parallel-safe backend-isolated slice.
audit#22 F4 — standardize post-commit enqueues on CancellationToken.None:
20 command handlers under MediaCollections/, ProgramSchedules/, Playouts/,
Channels/ threaded the request cancellationToken into work that runs AFTER
SaveChangesAsync commits (WriteAsync rebuild/refresh enqueues, mediator.Publish,
reindex, cache Refresh, and post-commit lookups that gate an enqueue). A late
client-disconnect then turns an already-durable commit into a thrown request AND
drops the side effect. Generalizes the #251 deco-handler fix. Excludes
BuildPlayoutHandler (worker/background token, not a client-disconnect token),
the config/FFmpeg multi-upsert handlers (partial-commit case, separate
follow-up), and response-projection reloads (correctly keep the request token).
audit#22 F2 — DeleteChannelHandler/DeletePlayoutHandler now delete the channel
guide {number}.xml through IFileSystem.File.Delete (observable under
MockFileSystem) and BEFORE the commit (a post-commit delete orphans the xml on a
crash; the xml is regenerable on demand, so pre-commit delete is the safe order).
audit#23 F4 — ReplacePlayoutAlternateScheduleItemsHandler rejects an empty item
list in the handler (not only the controller pre-guard) so a direct caller can't
trip the Max()-on-empty crash.
Docs: api-conventions.md §7a (post-commit token convention + boundaries),
decisions.md entry (rationale, sweep scope, #253 PR2-4 coordination note).
Tests: guide-cache-delete-through-FS for both delete handlers, empty-list guard.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Codex re-review of the fix commit confirmed both prior findings resolved and raised one
new Medium: RFC 7232 would 412 (not 400) a syntactically-valid but non-matching If-Match
(non-canonical "03", weak W/"3", tag lists, empty, overflow). Deferred to #197 (cold
contract pass) as #265 — fail-safe today (the mutation is rejected, never applied) and no
first-party client is affected. Records the deferral where the #253 fan-out will copy the
parser: a code comment in ConcurrencyHeaders + a note in api-conventions §7a.
Refs #253#265
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>