BlocksScreen couldn't load/save blocks containing Artist or
MultiCollection items (creatable upstream; ReplaceBlockItemsHandler
already supports both) - itemFromResponse dropped multiCollectionId
and left selectionName empty for those kinds, and toRequestItem
hardcoded multiCollectionId to null, so re-saving such a block
produced a 422 and made it uneditable. Carry the ids/names through
DraftItem and render them read-only (parity with Blazor, which
doesn't offer these kinds in the creation picker either).
Also key the item AsyncPicker by selection identity + collection
type so switching rows or collection type remounts it with clean
state, instead of carrying over a stale query/options list that
could store a wrong-kind id.
POST /api/blocks with a nonexistent blockGroupId returned an
unhandled DbUpdateException (HTTP 500) because CreateBlockHandler
never checked the referenced block group before inserting, letting
the SQLite FK constraint raise. Add a BaseError validation (422),
matching the "does not exist" precedent already used elsewhere.
Found via live E2E testing.
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>
- Enable the previously-disabled "Add Playout" button on the Playouts screen:
a new dialog picks kind (Classic/Block/Sequential/Scripted/ExternalJson),
channel (annotating channels that already appear in the loaded playouts list
as "already has a playout" -- the channel list DTO has no playout-count
field, so this is a best-effort annotation; a real conflict still surfaces
via the server's 422 in the dialog's error banner), a schedule picker for
Classic, and a schedule-file input with the same helper text as the Blazor
PlayoutEditor for the file-backed kinds. On success the new playout is
selected and the list refreshes.
- Add an "Edit details" affordance on the Playout detail card: a dialog to set
the daily reset time (half-hour options mirroring SchedulePlayoutReset.razor,
plus "Do not automatically reset") and, for Sequential/Scripted/ExternalJson
playouts, the schedule file.
- Add App.test.tsx coverage: classic-playout creation (asserts the POST body
and dialog close), inline 422 error surfacing, daily-rebuild-time edit
(asserts the PUT body), and schedule-file-field visibility gated by kind.
Extends mockDashboardApi with method-aware POST /api/playouts and
PUT /api/playouts/{id} handling.
Regenerate v1.d.ts from the updated OpenAPI spec and add createPlayout()/
updatePlayoutDetails() to the playouts API client, with URL/body-assert tests
following the ffmpegProfiles.test.ts pattern.
- 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.
GET /api/library/browse without mediaType now spans all 10 media kinds
(was 4: Movie/TelevisionShow/TelevisionSeason/Artist), which flooded two
pickers with episodes/songs/etc. mixed in with the intended top-level
items. Fan both consumers out per-kind and merge, restoring pre-#168
scoping without touching the API:
- ChannelBuilder's library browse tab now fans out Movie/TelevisionShow/
TelevisionSeason/Artist per page (mirrors the existing loadCollections
pattern) and sums per-kind totalCount so paging/load-more still works.
- CollectionsScreen's add-items search now fans out over the same 4
addable kinds instead of filtering one unscoped page client-side.
Extended App.test.tsx and CollectionsScreen.test.tsx to assert the
per-kind requests and updated a few fixtures that relied on the old
unscoped browse call returning collection-kind items under the library
tab.
The five Libraries polling tests mock window.setInterval to capture the
poll handler, but that handler is registered in a passive useEffect that
only runs after an out-of-act state commit (the async fetch that flips
hasActiveScans). findBy*/waitFor resolve on the DOM mutation via
MutationObserver, which can win the race against React's passive-effect
flush under CI load - leaving intervalHandlers empty when the test
invokes it. The forEach was then a silent no-op: no poll fetch, so
'route delta' timed out at scan-status count > 1, and 'stops polling'
never cleared the 75% label.
Fix: new runPollTick helper flushes pending effects with an empty
act(), asserts the poll handler is actually registered (turning any
future regression into a clear failure instead of a timeout), and
invokes the handlers inside act() so fetch microtasks and state updates
are fully applied before assertions run. All post-tick waitFor blocks
become plain synchronous expects - nothing depends on wall-clock
scheduling anymore. Also aligned the 'stops polling' setInterval mock
with its siblings' >=10000ms cadence filter so it captures exactly the
poll handler, not waitFor's internal 50ms interval callback.
Verified: 20x full App.test.tsx loop under CPU contention, 0 failures;
full vitest suite 202 passed; tsc + eslint clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- MediaBrowseScreen (/app/media): one parameterized screen for all 9 top-level
kinds with an in-screen kind switcher, per-kind search box, 100/page paging
- SearchScreen (/app/search): grouped per-kind results with counts + 'See all';
TopBar search input now navigates here
- TrashScreen (/app/trash): state:FileNotFound results, multi-select + Empty Trash
via DELETE /api/media-items and /api/maintenance/empty_trash (confirm dialogs)
- Shared media/mediaKinds (icon/label maps, hueOf, duration helpers) + MediaPosterCard;
ChannelBuilder and CollectionsScreen now reuse the single-source maps (their
exhaustive Record<LibraryBrowseMediaType> had to cover the 6 new kinds anyway)
- api: search.ts, mediaItems.ts, maintenance.ts (+ URL-assert tests)
- One consolidated 'Browse' nav entry with an in-screen kind switcher instead of
9 per-kind nav rows (deviation from Blazor's per-kind Media links)
- Nav: Media group gains Browse/Search/Trash; Settings Classic-UI help trimmed
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds web/src/api/trakt.ts wrapping the new /api/trakt/* endpoints
(#160) plus URL-assert tests, and web/src/screens/TraktListsScreen.tsx:
a table (slug, name, match count) with add-by-URL, match/refresh,
delete, and a sub-path editor at /app/trakt-lists/{id} (slug read-only,
autoRefresh/generatePlaylist toggles, save via PUT) - same allowSubPaths
pattern as ChannelEditScreen/SettingsScreen. Since add/match/delete are
async background jobs, the screen polls GET /api/trakt/status while
busy (disabling actions) and refreshes the list on the busy -> idle
transition.
"View matched items" links out to the classic Blazor search page
(/search?query=trakt_list:{traktId}) since the SPA has no search
screen yet (#161) - same interim deep-link pattern used elsewhere.
Registered in App.tsx's Media nav group; SettingsScreen's Classic-UI
help text no longer lists trakt now that it's SPA-native.
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.
Live E2E found PUT/DELETE on a missing filler preset or watermark returned 422
instead of 404. Root cause: the must-exist checks fed a NotFoundError through
LanguageExt Validation, whose aggregation to Either flattens the BaseError
subtype, so ApiResults.ToErrorResult never saw a NotFoundError. The precedent
handlers (DeleteProgramSchedule, DeleteFFmpegProfile, UpdateProgramSchedule)
avoid this by resolving must-exist as an Option and returning the NotFoundError
directly as an Either Left via Option.Match — restructured the four
filler/watermark Update/Delete handlers to that pattern (remaining name
validation still 422s). No Blazor behavior change (it only reads error.Value).
Verified live: PUT/DELETE missing → 404, GET missing → 404, empty-name POST →
422; happy-path CRUD unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three new SPA list+editor surfaces, closing the Blazor-only gap found in the
#91 phase-(b) recon:
- FFmpeg Profiles (System group): list with resolution/video/audio summary +
add/copy/delete; full General/Video/Audio editor. Per-hwaccel video-format
and profile rules ported from FFmpegProfileEditViewModelValidator; video
preset list ported from FFmpegLibraryHelper.PresetsForFFmpegProfile /
AvailablePresets.ForAccelAndFormat (cited in code). Resolutions from
/api/settings/resolutions, hwaccel kinds from the new lookup endpoint.
Copy = client-side clone into the create form (no server copy endpoint).
- Filler Presets (Media group): list + editor with the Blazor VM interlocks
(Fallback/Tail force mode None; non-MidRoll clears expression; Fallback
disables chapters; Playlist collection type forces Count mode; duration/
count/pad fields gated by mode). Collection pickers use the existing
/api/library/browse client for all seven collection types incl. Playlist.
- Watermarks (System group): list + editor with mode-driven field enabling,
ChannelLogo vs Custom image source, upload via /api/artwork/uploads
(target=watermark) and preview via /artwork/watermarks/{path}.
- App.tsx: ScreenIds/routes (sub-paths /add + /{id}) and nav registration;
api modules ffmpegProfiles/fillerPresets/watermarks with URL-assert tests;
SettingsScreen Classic-UI help text updated (these are no longer
Blazor-only) and the FFmpeg-profiles card now links to the new screen.
- Regenerated web/src/api/generated/v1.d.ts from the #159 OpenAPI changes.
web verification: typecheck clean; vitest 188/188 (14 files); vite build OK;
eslint clean on touched files.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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 typed API wrappers (api/logs.ts, api/troubleshoot.ts) and two new
screens: LogsScreen (paged, level-badged, server-side filtered log table)
and TroubleshootingScreen (General JSON viewer with copy, plus per-platform
NVIDIA/QSV/VAAPI/VideoToolbox capability tabs when populated). Both are
registered under a new "System" nav group in App.tsx alongside Settings.
Settings' Classic UI help text and About card now point at the new screens
instead of the Blazor logs/troubleshooting pages.
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).
Replaces the PlaceholderScreen at /app/collections with a real screen that
covers manual and smart collections, matching the SPA's extracted-screen
pattern (screens/CollectionsScreen.tsx; App.tsx gets only the route branch +
import).
Manual collections: list, create, rename, delete, and a per-row toggle for
UseCustomPlaybackOrder (PUT). Item management: an add-items picker that searches
the library (library/browse) and buckets results into the typed
AddItemsToCollectionRequest, plus per-item remove.
Smart collections: list, create, edit (name + query), delete, with a live
result preview that runs the query through library/browse.
API layer: new api/collections.ts owns the typed CRUD (getCollections/
getSmartCollections moved here from schedules.ts; schedules imports them). Unit
tests cover the client, the add-items bucket mapping, and the screen.
Honest gap: no API endpoint lists a manual collection's items by id. The items
view uses a best-effort collection:"name" Lucene search (movies/shows/seasons/
artists only) behind a prominent note. Multi/rerun/playlist collections are out
of scope (no API) and pointed to the Classic UI. Follow-ups #151/#152/#153.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add a full ChannelEditScreen so the SPA can edit an existing channel
(the list/builder could only create). Mirrors the Blazor ChannelEditor
field set via hydrate-then-PUT against GET/PUT /api/channels/{id}.
- api/channels.ts: getChannelById + updateChannel (typed from generated
ChannelViewModel / UpdateChannelRequest).
- screens/ChannelEditScreen.tsx: section-rail form (General, Playout,
Streaming, Stream selection, Music video, Branding) following the
SettingsScreen draft/saved dirtiness + floating save bar pattern. Logo
upload reuses uploadArtwork; option data via existing pickers clients.
- App.tsx: /app/edit-channel/{id} route (allowSubPaths, screen parses the
id); repoint the channel-table edit pencil at it.
- Tests: API-client GET/PUT + screen hydrate/edit-save/404 coverage.
Deferred per scope: ExternalLogoUrl (backend contract gap), and the
/channels/numbers bulk renumber (existing Renumber toolbar = parity).
Language codes / stream selector / credits template are free-text inputs
(no enumeration endpoints exist); mirror-source list can't filter to
Generated-only (ChannelResponseModel omits playoutSource).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>