Backend slice for the ChicoryTV playouts and collections screens.
PlayoutController:
- POST /api/playouts/{id}/erase-items (204; 404 pre-check; 422 unless
Block/Sequential/Scripted) -> ErasePlayoutItems
- POST /api/playouts/{id}/erase-items-and-history (204; 404; 422 unless
Classic/Block/Sequential/Scripted) -> ErasePlayoutHistory
- GET /api/playouts/items/{id}/scheduling-context (200/404) decodes a
playout item's stored context by row id via a new
GetPlayoutItemSchedulingContext query that reuses ProcessSchedulingContext
- PlayoutItemResponseModel gains HasSchedulingContext (no raw JSON in list)
- PlayoutListItemResponseModel gains PlayoutMode (ChannelNumber already present)
CollectionController:
- PUT /api/collections/{id}/custom-order (204; 404 pre-check; 422) with
UpdateCollectionCustomOrderRequest deriving CustomIndex from array order
- GetCollectionItemsHandler orders by CustomIndex (nulls last) then title/id
when the collection's UseCustomPlaybackOrder is set
Tests: controller route + behavior tests, OpenAPI ProblemDetails TestCases,
GetCollectionItems custom-order handler test. Regenerated v1.json, v1.d.ts,
endpoint-index.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adversarial-review follow-ups:
- OpenApiSerializerContractTests: add a fifth case, a fully-populated
ChannelResponseModel — the only DTO with a [JsonProperty("ffmpegProfile")]
override, i.e. the attribute path of NewtonsoftSchemaNamingTransformer that
the existing four cases never exercised.
- NewtonsoftSchemaNamingTransformer: a rename colliding with an existing schema
key now throws InvalidOperationException (naming the type and keys) instead of
silently overwriting/dropping a property — the generator must never emit a
lossy spec.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The OpenAPI document is generated from System.Text.Json metadata, whose
camelCase drifted from the runtime MVC serializer (Newtonsoft via
CustomContractResolver/CustomNamingStrategy): the spec said "fFmpegProfileId"
and "fFmpegProfile" while the wire emits "ffmpegProfileId" (naming-strategy
special case) and "ffmpegProfile" (ChannelResponseModel's [JsonProperty]
override). That fed the SPA the wrong keys.
Add NewtonsoftSchemaNamingTransformer, an OpenAPI schema transformer registered
on all three documents that renames each object schema's Properties (and
Required) keys through the SAME Newtonsoft contract resolver the runtime uses,
so the spec matches the wire format by construction. Regenerate v1.json.
Guard with OpenApiSerializerContractTests: serializes fully-populated DTOs
through the runtime Newtonsoft settings and pins the v1.json schema property
sets to the emitted keys, failing if generation drifts again.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adversarial review follow-ups on the #193 rerun selection validation:
- RerunCollectionEditor.razor: remove the CollectionType.Playlist
dropdown option — RerunCollection has no PlaylistId column, so the
option previously persisted a broken row and now fails validation.
- RerunCollectionSelectionValidation: replace FindAsync with a
non-tracking AnyAsync(EF.Property<int>("Id") == id) existence check
so nothing is materialized/tracked into the save-path context.
- Tests: pin the deliberate 422-not-404 choice
(ShouldNotBeOfType<NotFoundError>) and cover the unsupported-type
default arm (Playlist -> "Unsupported collection type"), which was
reachable via the Blazor editor.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review SHOULD-FIX (#145): the SPA graphics picker could go stale because
Blazor ran RefreshGraphicsElements (disk->DB sync) before listing, while the
API endpoint never refreshed — a newly added .yml would not appear. GET
/api/graphics-elements?refresh=true now sends RefreshGraphicsElements before
the list query; default false leaves existing callers untouched. The playback
troubleshooting screen passes refresh=true. Controller tests cover
refresh-iff-true ordering; regenerated OpenAPI v1.json (endpoint index and
generated TS schemas unchanged - query params are not part of either).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add three GET endpoints to TroubleshootController for the SPA port of the
playback troubleshooting page:
- /api/troubleshoot/playback/stream-selectors (List<string>)
- /api/troubleshoot/playback/subtitles/{mediaItemId} (404 pre-check via
GetMediaItemInfo; maps SubtitleViewModel -> TroubleshootingSubtitleResponseModel)
- /api/troubleshoot/playback/status (TroubleshootingPlaybackStatusResponseModel:
idle/running/completed/failed + exitCode/speed + logs.txt tail)
Regenerate v1.json, endpoint-index.md, and the web API types.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add a singleton ITroubleshootingPlaybackStatusStore (Core, alongside
TroubleshootingNotifier) that records the exit code + speed of the most
recent troubleshooting playback session. A new MediatR notification
handler writes to it on PlaybackTroubleshootingCompletedNotification, and
PrepareTroubleshootingPlaybackHandler resets it when a new session starts
(both the channel and media-item lock paths).
Co-Authored-By: Claude Fable 5 <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>
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>
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>
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>
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 (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.
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>
Adds DecoTemplateController mirroring TemplateController: CRUD for deco
template groups, deco templates (flat list + by-id), item listing, and a
full-replace PUT for a deco template's item list.
Hardening (deviation from a literal port of the existing handlers, matching
the #144 S2 fix for ReplaceTemplateItemsHandler):
- CreateDecoTemplateHandler now validates DecoTemplateGroupId exists before
insert (previously a bad id hit the FK constraint at SaveChanges and
surfaced as a 500; now a 422).
- ReplaceDecoTemplateItemsHandler now rejects invalid items (unknown DecoId,
StartTime >= EndTime unless EndTime is the end-of-day sentinel 00:00:00,
or overlapping ranges) with a 422 instead of silently dropping/persisting
them - the same silent-drop/silent-overlap bug class already fixed for
templates.
Response DTOs serialize the raw item TimeSpans (via .TimeOfDay), so an
end-of-day item still round-trips as StartTime=22:00:00/EndTime=00:00:00
regardless of the ViewModel's day-wrapping DateTime representation.
Adds DecoController (groups + decos CRUD + full-state PUT), playlist and
artist/multi-collection search picker wrappers, a PlaylistController for the
break-content playlist cascade, and PUT /api/playouts/{id}/deco. Fixes the
CreateDecoHandler missing deco-group existence check (FK 500 -> 422), matching
the CreateBlock/CreateTemplate precedent. ReplaceDecoRequest.ToCommand rejects
Merge on the non-mergeable sections (422).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds TemplateController mirroring BlockController: groups CRUD, template
CRUD, item replace (PUT full-replace via ReplaceTemplateItems), and a
copy endpoint. Also wires the missing POST /api/blocks/{id}/copy since
CopyBlock's CQRS shape is directly analogous to CopyTemplate.
Fixes CreateTemplateHandler to validate the target template group exists
before insert (previously an unhandled FK violation -> 500), matching the
CreateBlockHandler fix (1e34e00e) for the same class of bug.
New DTOs in ErsatzTV.Core/Api/Scheduling; TimeSpan start times serialize
the same way as PlayoutResponseModel.DailyRebuildTime.
#144 S2 (#162)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds BlockController (block groups + blocks CRUD, items GET, full replace,
non-persisting preview) mirroring ScheduleController, plus four scheduling
search picker endpoints on SearchController (collections, television shows,
television seasons, smart collections). Response DTOs in ErsatzTV.Core/Api/
Scheduling; request DTOs with ToCommand index auto-assignment.
#144 S1 (#162)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A rejected scripted schedule file no longer leaves DailyRebuildTime
half-applied; the file update is the only fallible step post-pre-check.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- CreatePlayoutRequest now carries a PlayoutScheduleKind discriminator, a
nullable ProgramScheduleId, and a ScheduleFile so POST /api/playouts can
create Classic, Block, Sequential, Scripted, or ExternalJson playouts (not
just Classic). ToCommand() validates per-kind requirements and returns
Either<BaseError, CreatePlayout>, surfacing 422 on mismatched fields via the
existing ToErrorResult() mapping.
- Add PUT /api/playouts/{id} (UpdatePlayoutDetailsRequest): DailyRebuildTime is
always applied (null clears the daily reset, matching the Blazor
SchedulePlayoutReset "Do not automatically reset" semantics); ScheduleFile is
only valid for Sequential/Scripted/ExternalJson playouts (422 otherwise) and
dispatches the matching Update*Playout command.
- Playout existence is checked via GetPlayoutById (real 404) before dispatching
UpdatePlayout, since the command's own "Playout does not exist." validation
produces a plain BaseError (422), not NotFoundError -- an existing quirk in
UpdatePlayoutHandler left as-is (out of scope for this slice).
- Extend ApiErrorResponseMetadataTests + PlayoutControllerTests for the new
Update action and the widened Create action (block/sequential/file-kind
validation paths).
- Regenerate ErsatzTV/wwwroot/openapi/v1.json via update-openapi.sh.
Adds TraktController (GET/POST /api/trakt/lists, GET/PUT/DELETE by id,
POST .../match, GET /api/trakt/status) covering the API gap for SPA
parity issue #142. Add/match/delete dispatch to the same
ChannelWriter<IBackgroundServiceRequest> the classic Blazor "Trakt
Lists" page uses, gated by the existing IEntityLocker (LockTrakt/
IsTraktLocked) singleton; GET /api/trakt/status wraps IsTraktLocked as
the HTTP-observable substitute for the Blazor page's OnTraktChanged
event. URL validation mirrors AddTraktListHandler.ValidateUrl's regexes
(replicated, since that method is private to the handler and returns a
handler-private record) so an obviously-invalid URL gets a synchronous
422 before dispatch.
Adds TraktListResponseModel/PagedTraktListsResponseModel/
TraktStatusResponseModel DTOs, AddTraktListRequest/UpdateTraktListRequest,
controller tests, ApiControllerSecurityTests + OpenApiErrorResponseContractTests
coverage, and regenerates the checked-in OpenAPI document.
Backend plumbing for the SPA transcoding editors (#143 blocker):
- FFmpeg profile response DTO round-trip: add NormalizeAudio, NormalizeVideo,
PadMode, TargetLoudness, NormalizeColors, ResolutionId (keep Resolution name
for compat); DeinterlaceVideo now bool (was bool?). #nullable enable on the DTO.
- New GET /api/ffmpeg/hardware-acceleration-kinds wrapping
GetSupportedHardwareAccelerationKinds (returns enum names as strings).
- Filler preset CRUD: GET-by-id/POST/PUT/DELETE on FillerPresetController with
full request/response DTOs; new FillerPresetFullResponseModel +
GetFillerPresetByIdForApi. CreateFillerPreset now returns the new id
(CreateFillerPresetResult) to match sibling Create commands and enable a
201+Location; updated the one Blazor call site.
- Watermark CRUD: GET-by-id/POST/PUT/DELETE on WatermarkController with full
DTOs (path+contentType, all 15 fields); new WatermarkFullResponseModel +
GetWatermarkByIdForApi.
- Startup: register WatermarkLocation/WatermarkSize as OpenAPI string enums
(they live in ErsatzTV.FFmpeg.State and were documented as ints, mismatching
the Newtonsoft StringEnumConverter runtime serialization).
- Tests: full CRUD controller tests for filler + watermark; contract-test
entries (404/401/422) for the new mutating endpoints; regenerated v1.json.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add GET /api/logs (thin wrapper over GetRecentLogEntries; paged, filtered,
clamped pageSize like LibraryBrowseController) and GET /api/troubleshoot/info
(wraps GetTroubleshootingInfo; General section serialized to the same JSON
shape the legacy Blazor Troubleshooting page renders, plus per-platform
capability dumps). Also adds [EndpointGroupName("general")] to the existing
troubleshoot playback endpoints so they surface in the OpenAPI spec (#158
item 3).
Add a discoverable "Open Classic UI" link in the SPA's Settings → System
About card, pointing at /system/health (a real full-page navigation, not
the SPA's pushState router), so un-migrated Blazor-only admin screens
stay reachable now that root / redirects to the SPA.
Also add a lightweight "Opens in ChicoryTV" tooltip cue (small icon) on
the Blazor sidebar's redirecting MudNavLink entries (per
LegacyUiRedirects.Map), so it's clear before clicking which sidebar
entries leave the Blazor UI vs. stay in it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Make the ChicoryTV SPA the default UI: GET/HEAD requests to / and to
legacy Blazor routes with SPA equivalents (channels, schedules,
playouts, libraries, all settings pages, channel add) now 302 to their
/app counterparts, preserving query strings and the ETV_BASE_URL path
base. 302 not 301: the map will grow as parity lands and permanent-
redirect caching would make rollback painful.
Blazor-only functionality (collections, media browse/search, trakt,
filler presets, watermarks, ffmpeg profiles, blocks/decos/templates,
playout detail editors, logs, troubleshooting, channel edit) keeps
serving Blazor; the Blazor home stays reachable at /system/health.
Parity gaps are tracked in #140-#146; Blazor removal is phase (b).
Also adds an /app smoke assertion to the docker build workflow.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adversarial review found the previous multi-flood design non-viable:
PlayoutModeSchedulerFlood never yields to a following Dynamic-start
schedule item, so only the first item ever played, and grouping
media items into a Collection silently dropped the requested order.
Redesign:
- Single-item lineup: one ProgramScheduleItemFlood referencing the
target directly (media item / collection / smart / multi / rerun /
playlist); no generated collection or playlist. Response PlaylistId
is null.
- Multi-item lineup (>= 2): one generated IsSystem Playlist in a
get-or-created IsSystem PlaylistGroup ("Channel Lineups"), one
PlaylistItem per entry in lineup order with PlayAll=true, referenced
by a single Flood schedule item. Rerun collections and playlists are
rejected (422) in multi lineups (PlaylistItem/CollectionKey lack the
fields to enumerate them).
Review fixes:
- Normalize + strict-validate MediaType<->CollectionType pairs once up
front (422 on mismatch / wrong id / not exactly one id).
- Reject MultiCollection with non-Shuffle order (mirrors
PlayoutModeMustBeValid), Mirror playout source, all via 422.
- OnDemand parity: queue TimeShiftOnDemandPlayout post-commit.
- De-collide generated ProgramSchedule and Playlist names against their
unique indexes instead of leaking a UNIQUE-constraint DbUpdateException.
- Generic 422 on save failure + ILogger; AnyAsync existence checks;
Either/Validation unwrap via Match; XML doc on request DTO + endpoint.
- Response model: ChannelId, PlaylistId (nullable), ProgramScheduleId,
PlayoutId (CollectionId removed). Regenerated OpenAPI v1.json + v1.d.ts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>