feat: playlist CRUD API + SPA editor (#153) #195

Merged
timothy merged 5 commits from feat/153-playlists into main 2026-07-08 22:09:06 +02:00
Owner

Closes #153.

Completes the last collection-editing API gap for #91 phase (b): full playlist management as a REST API and a ChicoryTV SPA editor. (Multi/rerun editors landed in #194; playback troubleshooting #145 is the only remaining functional gate.)

Backend — Playlist CRUD (7d585021)

9 new endpoints on PlaylistController (mirrors MultiCollectionController/CollectionController), reusing existing MediatR handlers plus one new command:

  • Groups: POST/PUT/DELETE /api/playlists/groups[/{id}] (create / rename / delete)
  • Playlists: GET /api/playlists/{id}, GET .../{id}/items, POST /api/playlists (CreatePlaylistInGroup — renamed to dodge a route-Name collision with ScriptedScheduleController), PUT /api/playlists/{id} (rename + replace items), DELETE /api/playlists/{id}, POST /api/playlists/preview
  • New Application command RenamePlaylistGroup (name validation mirroring CreatePlaylistGroup).
  • New DTOs: PlaylistItemResponseModel, PlaylistPreviewItemResponseModel, request DTOs (all #nullable enable). PUT re-indexes items by array position (api-conventions §7). Preview TimeSpan → hh:mm:ss InvariantCulture. 404 via controller existence pre-checks; 422 for validation.

SPA — Playlist editor at /app/playlists (79bdd86e)

Group tree (create/rename/delete groups, create/delete playlists) + item editor (per-item CollectionType→picker→playback-order over the 12 playlist item types, Count, Play All, Show-In-EPG, Move Up/Down, Copy, Remove) + playout preview. isSystem groups/playlists render read-only (Preview stays available). Mirrors the RerunCollectionsScreen type-coupling machinery and CollectionsScreen dialogs.

Review hardening (59edec1e, a6064ecc)

Adversarial fork review of both halves. Backend findings fixed:

  • HIGH — PUT /api/playlists/{id} now guards IsSystem → 422 (a system/generated playlist could previously be renamed/wiped via the API, which the delete path + Blazor already blocked). Root cause noted: LanguageExtensions.Apply collapses the NotFoundError subtype, so the controller pre-check is what yields a real 404 — also fixed a pre-existing missing→422 lie on the group PUT.
  • MEDIUM — system playlist-group rename guarded → 422 (controller + defense-in-depth handler).
  • MEDIUM — POST /api/playlists/preview validates the draft's per-type required ids at the controller boundary → 422 instead of a possible 500.
  • SPA Low — playlist tree uses Promise.allSettled so one group's transient fetch failure shows that group empty rather than blanking the whole tree.
    SPA review otherwise clean (no aliasing on Copy, correct per-item id, reorder = saved order, edit-load hardening).

Tests / docs / verification

  • Backend: 913 ErsatzTV.Tests pass (24 new PlaylistControllerTests + OpenAPI contract rows + new PlaylistGroupHandlerTests). SPA: 369 web tests, lint, build, check:api all green. Full solution builds clean.
  • OpenAPI spec + endpoint-index + SPA client regenerated (committed).
  • Live-E2E passed (scripts/e2e-local.sh + Playwright): group/playlist CRUD, item add/reorder/copy/remove, save+reload persistence, type-coupling, 12-type dropdown, graceful 422 on preview — zero unexpected console errors.
  • docs/blazor-route-parity.md #153 rows → SPA DONE; docs/domain-model.md playlist row → /app/playlists.

🤖 Generated with Claude Code

Closes #153. Completes the last collection-editing API gap for #91 phase (b): full playlist management as a REST API **and** a ChicoryTV SPA editor. (Multi/rerun editors landed in #194; playback troubleshooting #145 is the only remaining functional gate.) ## Backend — Playlist CRUD (`7d585021`) 9 new endpoints on `PlaylistController` (mirrors `MultiCollectionController`/`CollectionController`), reusing existing MediatR handlers plus one new command: - Groups: `POST/PUT/DELETE /api/playlists/groups[/{id}]` (create / rename / delete) - Playlists: `GET /api/playlists/{id}`, `GET .../{id}/items`, `POST /api/playlists` (`CreatePlaylistInGroup` — renamed to dodge a route-Name collision with `ScriptedScheduleController`), `PUT /api/playlists/{id}` (rename + replace items), `DELETE /api/playlists/{id}`, `POST /api/playlists/preview` - New Application command `RenamePlaylistGroup` (name validation mirroring `CreatePlaylistGroup`). - New DTOs: `PlaylistItemResponseModel`, `PlaylistPreviewItemResponseModel`, request DTOs (all `#nullable enable`). PUT re-indexes items by array position (api-conventions §7). Preview TimeSpan → `hh:mm:ss` InvariantCulture. 404 via controller existence pre-checks; 422 for validation. ## SPA — Playlist editor at `/app/playlists` (`79bdd86e`) Group tree (create/rename/delete groups, create/delete playlists) + item editor (per-item CollectionType→picker→playback-order over the 12 playlist item types, Count, Play All, Show-In-EPG, Move Up/Down, Copy, Remove) + playout preview. `isSystem` groups/playlists render read-only (Preview stays available). Mirrors the `RerunCollectionsScreen` type-coupling machinery and `CollectionsScreen` dialogs. ## Review hardening (`59edec1e`, `a6064ecc`) Adversarial fork review of both halves. Backend findings fixed: - **HIGH** — `PUT /api/playlists/{id}` now guards `IsSystem` → 422 (a system/generated playlist could previously be renamed/wiped via the API, which the delete path + Blazor already blocked). Root cause noted: `LanguageExtensions.Apply` collapses the `NotFoundError` subtype, so the controller pre-check is what yields a real 404 — also fixed a pre-existing missing→422 lie on the group PUT. - **MEDIUM** — system playlist-group rename guarded → 422 (controller + defense-in-depth handler). - **MEDIUM** — `POST /api/playlists/preview` validates the draft's per-type required ids at the controller boundary → 422 instead of a possible 500. - SPA **Low** — playlist tree uses `Promise.allSettled` so one group's transient fetch failure shows that group empty rather than blanking the whole tree. SPA review otherwise clean (no aliasing on Copy, correct per-item id, reorder = saved order, edit-load hardening). ## Tests / docs / verification - Backend: **913 `ErsatzTV.Tests` pass** (24 new `PlaylistControllerTests` + OpenAPI contract rows + new `PlaylistGroupHandlerTests`). SPA: **369 web tests**, lint, build, `check:api` all green. Full solution builds clean. - OpenAPI spec + endpoint-index + SPA client regenerated (committed). - **Live-E2E passed** (`scripts/e2e-local.sh` + Playwright): group/playlist CRUD, item add/reorder/copy/remove, save+reload persistence, type-coupling, 12-type dropdown, graceful 422 on preview — zero unexpected console errors. - `docs/blazor-route-parity.md` #153 rows → **SPA DONE**; `docs/domain-model.md` playlist row → `/app/playlists`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
timothy added 5 commits 2026-07-08 21:36:40 +02:00
Add write endpoints to PlaylistController for the playlist editor: group
create/rename/delete, playlist create/read/update/delete, item list read,
and draft playout preview. Introduces a RenamePlaylistGroup command/handler
(the one missing Application-layer operation) plus request/response DTOs.

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Hardening from adversarial review of the #153 playlist API:

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Use Promise.allSettled when loading each group's playlists so one group's
transient error shows that group empty rather than blanking the whole tree.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Merge remote-tracking branch 'origin/main' into feat/153-playlists
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 18m53s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 11m21s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
d15102e14e
timothy merged commit 81bcfec8ad into main 2026-07-08 22:09:06 +02:00
timothy deleted branch feat/153-playlists 2026-07-08 22:09:06 +02:00
Sign in to join this conversation.