First slice of the REST API (ersatztv#2). Closes#34. Part of #2. Design: docs/rest-api.md.
Idiomatic REST over the existing MediatR handlers — thin controllers, no new business logic. Establishes the conventions the later slices (#35/#36/#37) copy.
Foundation
NotFoundError : BaseError (Core) to distinguish 404 from 422.
ApiKeyAuthorizationFilter: optional X-Api-Key on mutating actions, gated by config Api:WriteKey (no-op when unset, preserving current open LAN behavior). Fully independent of the IPTV JWT toggle — /iptv/* and all reads are untouched, so Jellyfin/Dispatcharr need no changes. Applied at controller-class level so every mutating action (incl. ResetPlayout) is covered fail-safe.
Channels
POST /api/channels (201+Location) · PUT /api/channels/{id} (200/404) · DELETE /api/channels/{id} (204/404) · GET /api/channels/{id} (200/404). Request DTOs → existing CreateChannel/UpdateChannel/DeleteChannel; responses reuse ChannelViewModel. Ported page-only validations into the handlers (ShowInEpg-when-disabled, external-logo-URL, Group NotEmpty) + added FFmpegProfile/Watermark/Filler existence checks on update for parity with create.
How it was built
Implemented via an LSP-verified multi-agent workflow, then a 5-dimension adversarial review + fix pass. The review caught (and the fix closed) real issues the first implementation missed:
🔴HIGH:ResetPlayout write path bypassed the API-key filter → fixed (filter moved to class level + reflection-based regression test).
🟡MED×2:Group NotEmpty parity gap; FFmpegProfileId/Watermark/Filler existence not validated on update.
🐛Latent 500: the existing .Filter(c>0) existence pattern threw TaskCanceledException on not-found → rewritten to AnyAsync.
⏸️MED deferred → #43: a global default-deny model for all /api/* writers (other controllers stay open when a key is set; needs a designed scanner-callback exemption).
Notable side effect
ErsatzTV.Tests lacked Microsoft.NET.Test.Sdk + NSubstitute, so dotnet test was silently running zero tests (the pre-existing MultiSelectBaseTests never executed). Added the missing refs by name (CPM preserved). Split into its own commit (test: make ErsatzTV.Tests execute its tests).
First slice of the REST API (ersatztv#2). **Closes #34. Part of #2.** Design: `docs/rest-api.md`.
Idiomatic REST over the existing MediatR handlers — thin controllers, no new business logic. Establishes the conventions the later slices (#35/#36/#37) copy.
## Foundation
- **`NotFoundError : BaseError`** (Core) to distinguish 404 from 422.
- **`ApiResults`** mapping helpers over `Either`/`Option` → 201+`Location` / 200 / 204 / 404 / 422. Additive — existing `*ToActionResult` extensions untouched (IPTV etc. unaffected).
- **`ApiKeyAuthorizationFilter`**: optional `X-Api-Key` on mutating actions, gated by config `Api:WriteKey` (no-op when unset, preserving current open LAN behavior). **Fully independent of the IPTV JWT toggle** — `/iptv/*` and all reads are untouched, so **Jellyfin/Dispatcharr need no changes**. Applied at controller-class level so every mutating action (incl. `ResetPlayout`) is covered fail-safe.
## Channels
`POST /api/channels` (201+Location) · `PUT /api/channels/{id}` (200/404) · `DELETE /api/channels/{id}` (204/404) · `GET /api/channels/{id}` (200/404). Request DTOs → existing `CreateChannel`/`UpdateChannel`/`DeleteChannel`; responses reuse `ChannelViewModel`. Ported page-only validations into the handlers (ShowInEpg-when-disabled, external-logo-URL, Group NotEmpty) + added FFmpegProfile/Watermark/Filler existence checks on update for parity with create.
## How it was built
Implemented via an LSP-verified multi-agent workflow, then a **5-dimension adversarial review + fix pass**. The review caught (and the fix closed) real issues the first implementation missed:
- 🔴 **HIGH:** `ResetPlayout` write path bypassed the API-key filter → fixed (filter moved to class level + reflection-based regression test).
- 🟡 **MED×2:** `Group` NotEmpty parity gap; `FFmpegProfileId`/Watermark/Filler existence not validated on update.
- 🐛 **Latent 500:** the existing `.Filter(c>0)` existence pattern threw `TaskCanceledException` on not-found → rewritten to `AnyAsync`.
- ⏸️ **MED deferred → #43:** a *global* default-deny model for all `/api/*` writers (other controllers stay open when a key is set; needs a designed scanner-callback exemption).
## Notable side effect
`ErsatzTV.Tests` lacked `Microsoft.NET.Test.Sdk` + `NSubstitute`, so `dotnet test` was silently running **zero** tests (the pre-existing `MultiSelectBaseTests` never executed). Added the missing refs by name (CPM preserved). Split into its own commit (`test: make ErsatzTV.Tests execute its tests`).
## Verification (independently re-run, post-rebase)
- Build **0 warnings / 0 errors**.
- `ErsatzTV.Tests` **46/46** pass under `TZ=UTC` (38 new).
- `ErsatzTV.Architecture.Tests` **5/5** (layering intact).
- Rebased on current `main` (incl. #24 + #28).
## Known low-severity (documented, not blocking)
- `GET /api/channels/{id}` returns `PlayoutCount=0` (pre-existing handler doesn't `Include(Playouts)`).
- Successful `POST` has an unreachable `None` read-back branch returning bare 404.
- Update-path coverage gaps for name / external-logo validation.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
ErsatzTV.Tests had NUnit3TestAdapter + Shouldly but lacked Microsoft.NET.Test.Sdk and NSubstitute, so 'dotnet test' ran zero tests (no test host); the pre-existing MultiSelectBaseTests never executed. Add Microsoft.NET.Test.Sdk, NSubstitute, and Testably.Abstractions.Testing by name — versions already centralized in Directory.Packages.props (CPM preserved, no Version= attributes).
Surfaced while wiring tests for #34. Refs #34
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
First slice of the REST API (ersatztv#2). Idiomatic REST over existing MediatR handlers; design in docs/rest-api.md.
Foundation: NotFoundError : BaseError (Core) to split 404 from 422; ApiResults mapping helpers (Either/Option -> 201+Location / 200 / 204 / 404 / 422), additive (existing *ToActionResult untouched); ApiKeyAuthorizationFilter (optional X-Api-Key on mutating actions, gated by Api:WriteKey, no-op when unset) — independent of the IPTV JWT toggle so Jellyfin/Dispatcharr reads are unaffected, applied at controller-class level so every mutating action incl. ResetPlayout is covered fail-safe.
Channels: POST /api/channels (201+Location), PUT/{id} (200/404), DELETE/{id} (204/404), GET/{id} (200/404). Request DTOs -> existing commands; responses reuse ChannelViewModel. Ported page-only validations into handlers (ShowInEpg-when-disabled, external-logo-URL, Group NotEmpty) + FFmpegProfile/Watermark/Filler existence on update for create-parity. Fixed a latent 500: the .Filter(c>0) existence pattern threw TaskCanceledException on not-found; rewritten to AnyAsync.
Tests (NUnit, TZ=UTC): handler success/404/422, ApiResults mapping, API-key filter, controller status/Location/mapping, a controller-security regression net, and a create->read->delete EF integration test on a new in-memory SQLite harness. ErsatzTV.Tests 46/46, Architecture 5/5.
Refs #34
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
timothy
merged commit 8f7d240fc8 into main2026-06-27 16:14:15 +02:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
First slice of the REST API (ersatztv#2). Closes #34. Part of #2. Design:
docs/rest-api.md.Idiomatic REST over the existing MediatR handlers — thin controllers, no new business logic. Establishes the conventions the later slices (#35/#36/#37) copy.
Foundation
NotFoundError : BaseError(Core) to distinguish 404 from 422.ApiResultsmapping helpers overEither/Option→ 201+Location/ 200 / 204 / 404 / 422. Additive — existing*ToActionResultextensions untouched (IPTV etc. unaffected).ApiKeyAuthorizationFilter: optionalX-Api-Keyon mutating actions, gated by configApi:WriteKey(no-op when unset, preserving current open LAN behavior). Fully independent of the IPTV JWT toggle —/iptv/*and all reads are untouched, so Jellyfin/Dispatcharr need no changes. Applied at controller-class level so every mutating action (incl.ResetPlayout) is covered fail-safe.Channels
POST /api/channels(201+Location) ·PUT /api/channels/{id}(200/404) ·DELETE /api/channels/{id}(204/404) ·GET /api/channels/{id}(200/404). Request DTOs → existingCreateChannel/UpdateChannel/DeleteChannel; responses reuseChannelViewModel. Ported page-only validations into the handlers (ShowInEpg-when-disabled, external-logo-URL, Group NotEmpty) + added FFmpegProfile/Watermark/Filler existence checks on update for parity with create.How it was built
Implemented via an LSP-verified multi-agent workflow, then a 5-dimension adversarial review + fix pass. The review caught (and the fix closed) real issues the first implementation missed:
ResetPlayoutwrite path bypassed the API-key filter → fixed (filter moved to class level + reflection-based regression test).GroupNotEmpty parity gap;FFmpegProfileId/Watermark/Filler existence not validated on update..Filter(c>0)existence pattern threwTaskCanceledExceptionon not-found → rewritten toAnyAsync./api/*writers (other controllers stay open when a key is set; needs a designed scanner-callback exemption).Notable side effect
ErsatzTV.TestslackedMicrosoft.NET.Test.Sdk+NSubstitute, sodotnet testwas silently running zero tests (the pre-existingMultiSelectBaseTestsnever executed). Added the missing refs by name (CPM preserved). Split into its own commit (test: make ErsatzTV.Tests execute its tests).Verification (independently re-run, post-rebase)
ErsatzTV.Tests46/46 pass underTZ=UTC(38 new).ErsatzTV.Architecture.Tests5/5 (layering intact).main(incl. #24 + #28).Known low-severity (documented, not blocking)
GET /api/channels/{id}returnsPlayoutCount=0(pre-existing handler doesn'tInclude(Playouts)).POSThas an unreachableNoneread-back branch returning bare 404.🤖 Generated with Claude Code
First slice of the REST API (ersatztv#2). Idiomatic REST over existing MediatR handlers; design in docs/rest-api.md. Foundation: NotFoundError : BaseError (Core) to split 404 from 422; ApiResults mapping helpers (Either/Option -> 201+Location / 200 / 204 / 404 / 422), additive (existing *ToActionResult untouched); ApiKeyAuthorizationFilter (optional X-Api-Key on mutating actions, gated by Api:WriteKey, no-op when unset) — independent of the IPTV JWT toggle so Jellyfin/Dispatcharr reads are unaffected, applied at controller-class level so every mutating action incl. ResetPlayout is covered fail-safe. Channels: POST /api/channels (201+Location), PUT/{id} (200/404), DELETE/{id} (204/404), GET/{id} (200/404). Request DTOs -> existing commands; responses reuse ChannelViewModel. Ported page-only validations into handlers (ShowInEpg-when-disabled, external-logo-URL, Group NotEmpty) + FFmpegProfile/Watermark/Filler existence on update for create-parity. Fixed a latent 500: the .Filter(c>0) existence pattern threw TaskCanceledException on not-found; rewritten to AnyAsync. Tests (NUnit, TZ=UTC): handler success/404/422, ApiResults mapping, API-key filter, controller status/Location/mapping, a controller-security regression net, and a create->read->delete EF integration test on a new in-memory SQLite harness. ErsatzTV.Tests 46/46, Architecture 5/5. Refs #34 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>