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 two React SPA editor screens over the existing REST APIs, retiring the
last two "Classic UI only" media-collection editors.
MultiCollectionsScreen (/app/multi-collections, #151): list + in-screen
editor. Name + a list of manual/smart collection items, each with a
Schedule-As-Group toggle; two add-pickers dedupe already-present entries.
Items save with playbackOrder "Chronological" (matches the Blazor editor).
RerunCollectionsScreen (/app/rerun-collections, #152): list + in-screen
editor. Name, a Collection Type select restricted to the REST-supported
selection set (IsSupportedSelectionType) — Collection/MultiCollection/
SmartCollection plus the media-item types TelevisionShow/TelevisionSeason/
Artist/Movie/Episode/MusicVideo/OtherVideo/Song/Image/RemoteStream (every
one maps to a working LibraryBrowseMediaType picker); Playlist is excluded.
A conditional single-selection picker per type, and First/Rerun playback
order selects with the Blazor type-conditional option sets (reset on type
change).
Both screens toggle list<->editor via local state (mirroring
CollectionsScreen), no sub-path routing. Wired into App.tsx (imports,
ScreenId, routes, mediaNavIds, ScreenContent). Added messageFrom*Error
helpers + type re-export to the api clients.
Tests: multiCollections/rerunCollections api-client param+body tests,
both screen tests (list render, create body, rerun type-switch swaps
picker + order options, delete), and an App.tsx nav-registration test.
Docs: blazor-route-parity.md (rows moved to SPA DONE), domain-model.md.
web verify gate: 353 tests pass, eslint clean, tsc -b + vite build clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three CI wins from ersatztv#190, all config-only:
- Cache ~/.nuget/packages via actions/cache@v4 in both the `test` and
`migrations` jobs, keyed on Directory.Packages.props + global.json, so
dotnet restore no longer starts from scratch every run.
- Shallow-checkout the `test` job (fetch-depth: 1) since it never runs
git describe/log; only `build` needs full history for versioning. The
`migrations` job's checkout was already effectively shallow (default
fetch-depth 1), documented explicitly.
- Reorder the Dockerfile so the openapi-generator jar wget happens before
the openapi spec COPY, decoupling the ~30MB download layer from spec
changes (e.g. v1.json) that previously busted it on every API PR.
Documented all three in docs/ci-cd.md alongside the existing job/Dockerfile
notes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The rerun request mapping's catch-all routed Playlist/RerunFirstRun/RerunRerun/
SearchQuery/Fake* to MediaItemId, so POST/PUT with those persisted a wrong-kind id
and returned 201 instead of 422. Guard both verbs with IsSupportedSelectionType.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a paged collection-items endpoint reusing LibraryBrowseItemResponseModel
so the SPA lists a manual collection's full contents (all media kinds), replacing
the lossy Lucene name-based preview. Confirms POST /items already returns 422 for
bogus ids (guarded by ValidateMediaItems, fb3f2856); adds endpoint-level coverage.
fixes#155
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds MultiCollectionController + RerunCollectionController (list/get/create/update/
delete) over the existing MediatR handlers, with response/request DTOs, controller +
contract tests, regenerated OpenAPI + TS types, and thin SPA api-client modules. SPA
editor screens are a follow-up. Rerun request DTO resolves the chosen entity id into
the minimal VM the handler reads (documented inline).
Refs #151, #152
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- #184: ApiControllerSecurityTests scans ErsatzTV.Controllers.Api assembly
instead of a hand-maintained array (9 controllers were unlisted; 2 mutating)
- Trakt matched-items link now navigates to SPA /app/search (was Classic UI)
- GET /api/search runs its 10 per-kind queries in parallel (context-safe)
- Remove dead "New Group" header buttons from blocks/templates/decos/deco-templates
- docs/api-conventions.md §6 updated for the assembly-scan change
fixes#184
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Only run generate-endpoint-index.py if the GenerateOpenApiDocuments build
succeeded, so a failed build can't render docs/endpoint-index.md from a
stale/partial v1.json.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds docs/testing.md as the authoritative testing map (consolidated
from docs/contributing.md §8, now shrunk to a pointer), and a
generated docs/endpoint-index.md via scripts/generate-endpoint-index.py
(hooked into scripts/update-openapi.sh). Updates docs/README.md's
reading order and removes the "still to come" placeholder.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the SPA screens over the new detail/info/image endpoints:
- Detail pages at /app/media/{movies|shows|seasons|artists}/{id}. MediaBrowseScreen
cards for movies/shows/artists (and season/show drill-in cards) navigate to them.
Shows list seasons -> seasons list episodes -> artists list music videos, each via
the browse parentId drill-in, paged. Layout: fanart/poster, title/year/plot, chip
lists, cast row, movie path + FileNotFound/Unavailable warnings. A "Media Info"
dialog (streams + chapters) backs onto GET /api/media-items/{id}/info. Add-to-
collection/playlist deferred to #153/#155 (TODO left in code).
- Image folder browser at /app/media/images/browser: lazy expandable tree, per-folder
image/subfolder counts + duration, edit dialog (PUT set/clear), and a per-folder
search link (library_folder_id:{id}). Reachable via a "Folder Browser" button on the
Images browse view.
New /app/media sub-paths are owned by a MediaRouteScreen wrapper that tracks pathname
locally + listens for popstate (App-level routing returns the same 'media' route object
for base and sub-paths). Client modules web/src/api/mediaDetail.ts + imageFolders.ts,
typed via the generated v1.d.ts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add REST endpoints backing the SPA media detail pages and image browser:
- GET /api/movies/{id}, /api/shows/{id}, /api/seasons/{id}, /api/artists/{id}
wrapping the existing detail queries; 404 on None.
- GET /api/media-items/{id}/info wrapping GetMediaItemInfo; UnableToLocateMediaItem
-> 404, other errors -> 422.
- GET /api/images/folders?parentId= and PUT /api/images/folders/{id}/duration
(validates null-or-positive -> 400; existence guard via new ImageFolderExists
query -> 404).
- Extend GetLibraryBrowseItems parentId drill-in to Episode (episodes of a season,
episode-number order) and MusicVideo (an artist's music videos, album/track order),
alongside the existing TelevisionSeason branch.
Response DTOs live in ErsatzTV.Core/Api/* and never expose Application VMs. Artwork
values are rooted for the SPA via a shared ErsatzTV.Core/Api/ApiArtwork helper
(mirrors the #180/#181 browse-handler logic; handles jellyfin/emby proxy prefixes,
http passthrough, empty). Regenerated OpenAPI v1.json + web v1.d.ts. New controllers
registered in ApiControllerSecurityTests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two new System-area screens over the new endpoints:
- /app/troubleshooting/blocks: pick a Block playout, browse its blocks
(grouped, name filter), drill into paged block history, click a row to
decode its details.
- /app/troubleshooting/yaml: paste YAML, toggle import schema, validate;
shows messages + JSON conversion.
Adds api/playoutHistory client + validateSequentialSchedule; regenerated
generated/v1.d.ts. Async loads follow the LogsScreen seq-guard pattern
(no synchronous setState inside effects).
Refs #145#158
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Route assertions, paging clamp, 404/422/400 paths and mediator interaction
for the new PlayoutController/TroubleshootController endpoints; handler tests
for ValidateSequentialSchedule (valid/invalid/throwing) and
GetPlayoutHistoryDetails (found/not-found/malformed JSON via in-memory SQLite).
Refs #145#158
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds the REST surface for the #145 troubleshooting leftovers (#158 items 4-5):
- GET /api/playouts/{id}/blocks - blocks a block playout schedules
- GET /api/playouts/{id}/blocks/{blockId}/history - paged block history
- GET /api/playouts/history/{id} - decode a history row by id
- POST /api/troubleshoot/validate-schedule - validate sequential YAML
New MediatR queries GetPlayoutHistoryDetails (Either, 404 unknown row /
422 malformed JSON) and ValidateSequentialSchedule (wraps
ISequentialScheduleValidator, never throws). DecodePlayoutHistoryHandler
and the new by-id handler now share PlayoutHistoryDecoder. Regenerated
openapi/v1.json.
Refs #145#158
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Part of the #180 library picker fixes (SPA side).
Channel builder: TelevisionSeason is removed from the library grid fan-out, so a
multi-season show renders as one tile instead of one tile per season. Show tiles
gain a "Seasons" drill-in affordance (both grid and compact layouts) that opens a
dialog listing that show's seasons (via the new GET /api/library/browse?parentId=
&mediaType=TelevisionSeason), each with title, artwork and an Add button that
drops the specific season into the lineup.
Collections add-items dialog: the default search fan-out now excludes seasons,
and a media-kind filter row (All / Movies / Shows / Seasons / Artists,
default = All-without-seasons) keeps seasons reachable when explicitly selected.
Client: getLibraryBrowseItems gains an optional parentId param. Tests cover the
param mapping, the builder no longer requesting TelevisionSeason, and the
collections dialog default-excluding vs explicitly-including seasons.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Part of the #180 library picker fixes (API side).
Broken artwork: GetLibraryBrowseItemsHandler.Artwork returned Blazor-convention
values (a bare cache filename, or a RELATIVE jellyfin/emby proxy path). Blazor's
GetPosterUrl prefixes those with "artwork/posters/" against <base href="/">, but
the React SPA renders item.artwork raw as <img src> from under /app/, so every
image 404'd. The handler now returns rooted, directly-usable URLs
(/artwork/posters/... or /artwork/thumbnails/... per ArtworkKind; jellyfin/emby
mapped to their proxy routes with fillHeight/maxHeight; absolute http(s) URLs
passed through; empty stays empty).
Seasons with no poster of their own now fall back to the parent show's poster
(SeasonArtwork + the extra ShowMetadata.Artwork include).
Season drill-in: GET /api/library/browse gains an optional `parentId` query
param that, with mediaType=TelevisionSeason, returns that show's seasons
(season-number order, bypassing Lucene) so the SPA can expand a show into its
seasons. OpenAPI spec regenerated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
AlternateScheduleSelector.GetScheduleForDate constructs
new DateTime(year, StartMonth, StartDay) and only recovers from an
out-of-range *day* (rolling to the 1st of the next month); an out-of-range
*month* (e.g. 0 or 13) throws again from inside that recovery path and is
never caught, crashing playout building.
Validate StartMonth/EndMonth (1..12) and StartDay/EndDay (1..31) on both
PUT /api/playouts/{id}/alternate-schedules and PUT /api/playouts/{id}/templates
whenever an item sets LimitToDateRange, returning 422 before dispatching to
the mediator. Both actions already documented 422 in their ProducesResponseType
metadata.
The last row in the alternate-schedules editor is the catch-all default:
ReplacePlayoutAlternateScheduleItemsHandler excludes the highest-index item
from the saved rows entirely and only keeps its ProgramScheduleId as the
playout's default schedule, so any recurrence edits made to that row (days of
week/month, months, date range) were silently discarded on save.
Disable the recurrence controls (chips, selects, limit-to-date-range
checkbox) whenever the selected row is the default, and show an inline note
explaining why. The Templates editor is unaffected — its handler saves every
row's recurrence fields as-is, so RecurrenceFields there keeps full editing.
routeFromLocation() returns the same ScreenRoute object reference for
/app/playouts and any /app/playouts/{id}/* sub-path, so navigateToPath's
pushState + synthetic popstate caused App's setActiveRoute(routeFromLocation())
to bail via Object.is and never re-invoke ScreenContent. Only a hard reload
picked up the new sub-route.
Mirror the existing BlocksScreen/TemplatesScreen/DecosScreen pattern: add a
PlayoutsRouteScreen wrapper that owns its own pathname state and popstate
listener, so client-side navigation into and out of the alternate-schedules
and templates editors re-renders correctly.
Adds two sub-path editors under the Playouts screen:
- /app/playouts/{id}/alternate-schedules (Classic playouts)
- /app/playouts/{id}/templates (Block playouts)
Each has a reorderable priority table (up/down/delete) plus a selected-row
detail panel: schedule or template+deco-template pickers, a limit-to-date-range
toggle gating month/day/year selects, and day-of-week / day-of-month / month
multi-select chips with Weekdays/Weekends helpers. Entry points on the playout
card are kind-gated. The Block playout card also gets a default-deco select
wired to PUT /api/playouts/{id}/deco, using the new decoName read field.
DayOfWeek is overridden to day-name strings in the API client (the wire format
is Newtonsoft StringEnumConverter, though the OpenAPI schema types it as number).
Deviations from Blazor: chip multi-select instead of MudSelect, flat grouped
template pickers instead of a group->item cascade, a shorter year range, and the
template calendar preview is omitted.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds per-playout REST for classic-playout alternate schedules and
block-playout templates, plus the default-deco read-side deferred from S3:
- GET/PUT /api/playouts/{id}/alternate-schedules (Classic only; 422 otherwise)
- GET/PUT /api/playouts/{id}/templates (Block only; 422 otherwise)
- PlayoutResponseModel gains decoId/decoName (GetPlayoutById includes Deco)
PUT assigns Index from array order (top = highest priority, last = catch-all
default), mirroring the Blazor editors. Alternate-schedule PUT requires a
non-empty list and existing ProgramScheduleIds; template PUT requires existing
TemplateIds and any supplied DecoTemplateId. Regenerates the OpenAPI spec.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Out-of-range TimeSpans (25:00:00, 1.06:00:00, negatives) previously
persisted raw while the read side projected .TimeOfDay, so display and
playout behavior diverged silently.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>