Compare commits

...
Author SHA1 Message Date
timothy 282f59ea60 Merge remote-tracking branch 'origin/main' into feat/multi-rerun-collections-api
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m15s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 3m51s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
# Conflicts:
#	docs/blazor-route-parity.md
#	docs/endpoint-index.md
2026-07-08 18:43:08 +02:00
timothy ca1265214e Merge pull request 'feat(api): GET /api/collections/{id}/items (paged) — full collection contents (#155)' (#189) from feat/collections-api into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m45s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m55s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 7m18s
2026-07-08 16:38:24 +00:00
timothyandClaude Opus 4.8 dc459c6cd5 fix(api): reject unsupported CollectionType on rerun-collection create/update (#152 review)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m25s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m42s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
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>
2026-07-08 18:30:06 +02:00
timothyandClaude Opus 4.8 cf85bf3093 docs: regenerate endpoint-index after merging main (includes GET collection items) (#155)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m27s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m45s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 18:28:20 +02:00
timothy 72fc8cc850 Merge remote-tracking branch 'origin/main' into feat/collections-api 2026-07-08 18:28:03 +02:00
timothyandClaude Opus 4.8 f869dfe87a feat(api): GET /api/collections/{id}/items (paged) + confirm POST-items 422 guard (#155)
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>
2026-07-08 18:26:52 +02:00
timothy 16c8789638 Merge remote-tracking branch 'origin/main' into feat/multi-rerun-collections-api
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m32s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m37s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-08 18:19:29 +02:00
timothyandClaude Opus 4.8 a496dad9c9 feat(api): REST CRUD for multi-collections and rerun-collections (#151, #152)
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>
2026-07-08 18:18:32 +02:00
timothy 311411fa38 Merge pull request 'feat: quick wins — security-registry scan, Trakt SPA link, parallel search, dead "New Group" sweep' (#187) from feat/quick-wins into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m44s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m55s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m3s
2026-07-08 06:03:24 +00:00
timothy a7d3fc3055 Merge pull request 'docs: testing map + generated endpoint index (#185)' (#186) from feat/185-docs-part2 into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m55s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m59s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
2026-07-08 05:52:30 +00:00
timothyandClaude Opus 4.8 316b673c3b docs: correct §6 wording — scan matches controller classes, not ControllerBase subtypes (review)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m31s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m33s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 07:51:06 +02:00
timothyandClaude Opus 4.8 9f73542296 feat: quick wins — security-registry scan, Trakt SPA link, parallel search, dead "New Group" sweep
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m25s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m33s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
- #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>
2026-07-08 07:47:49 +02:00
timothyandClaude Opus 4.8 6902f559f0 docs: guard endpoint-index regen on successful spec build (#185 review)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m34s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m41s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
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>
2026-07-08 07:42:38 +02:00
timothyandClaude Opus 4.8 0290594f0b docs: testing map + generated endpoint index (#185)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m36s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m45s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
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>
2026-07-08 07:38:47 +02:00
timothyandClaude Fable 5 50ae0a7f3b docs: onboarding/convention docs part 1 + handoff past #180/#182/#183 (parity endgame)
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m53s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m56s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m52s
Adds docs/README.md (index), api-conventions.md, spa-conventions.md, e2e-local.md +
scripts/e2e-local.sh, blazor-route-parity.md (#91 phase-b tracker), domain-model.md,
decisions.md. Rule (CLAUDE.md): read these at session start instead of re-recon; update
in the same PR that changes a convention/route/decision. Part 2 = #185.

Handoff: #180/#141/#158/#161 closed (PRs #181/#182/#183); #145 playback-only; #91
readiness plan posted; next prompt = #185 + quick wins + #155/#151/#152/#153.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 00:03:01 +02:00
timothy dbda2d8991 Merge pull request 'feat(web+api): media detail endpoints+pages, image folder browser (#141, #161 items 3+5)' (#183) from feat/141-media-detail into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m21s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m30s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m20s
2026-07-07 21:08:43 +00:00
timothyandClaude Fable 5 685894517d merge main into feat/141-media-detail (post-#182); regenerate OpenAPI artifacts
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m2s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m23s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 23:01:17 +02:00
timothyandClaude Fable 5 d5aa838b22 feat(web): media detail pages + image folder browser (#141/#161)
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>
2026-07-07 22:56:48 +02:00
timothyandClaude Fable 5 cd31c755bb feat(api): media detail + info + image-folder endpoints (#141/#161)
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>
2026-07-07 22:43:47 +02:00
timothy 1c883ac601 Merge pull request 'feat(web+api): block-playout history + sequential-YAML validator (#145, #158 items 4–5)' (#182) from feat/145-block-history into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m29s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m44s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m9s
2026-07-07 20:32:31 +00:00
timothyandClaude Fable 5 d2096c0675 merge main into feat/145-block-history (post-#181); regenerate OpenAPI artifacts
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m45s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m52s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 22:25:49 +02:00
timothyandClaude Fable 5 822f3692dc test(web): api client + screen tests for new troubleshooting screens
Client param-mapping tests for playoutHistory + validateSequentialSchedule,
and screen-level flow tests (Block playout filtering + playout->block->
history->decode drilldown; YAML validate valid/invalid/import-toggle).

Refs #145 #158

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 22:11:04 +02:00
timothyandClaude Fable 5 ec7531d821 feat(web): block-playout troubleshooting + sequential schedule validator screens
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>
2026-07-07 22:10:57 +02:00
timothyandClaude Fable 5 7787b17495 test(api): controller + handler tests for block history and schedule validation
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>
2026-07-07 22:10:46 +02:00
timothyandClaude Fable 5 f0a423c2de feat(api): block-playout history + sequential-schedule validation endpoints
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>
2026-07-07 22:10:37 +02:00
timothy 3ec3ab5fe0 Merge pull request 'fix(web+api): library picker artwork URLs + season drill-in instead of tile flooding (#180)' (#181) from fix/180-library-picker into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m43s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m43s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m0s
2026-07-07 20:00:38 +00:00
timothyandClaude Fable 5 82d3b586f2 docs(api): describe library-browse parentId param in OpenAPI (#180 review)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m38s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m53s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 21:50:17 +02:00
timothyandClaude Fable 5 d05e442605 fix(web): stop season-tile flooding in channel builder and collections (#180)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m8s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m16s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
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>
2026-07-07 21:42:18 +02:00
timothyandClaude Fable 5 54b18e73cd fix(api): root library-browse artwork URLs and add season drill-in (#180)
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>
2026-07-07 21:42:10 +02:00
timothyandClaude Fable 5 e58da21a56 docs: queue #180 (channel builder season tiles + broken artwork) first for next session
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m41s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m58s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m37s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 21:18:01 +02:00
timothyandClaude Fable 5 a709dccec7 docs: advance ChicoryTV queue past #144/#162 (scheduling parity DONE, PRs #170-#175/#179); next = #145/#141 leftovers
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m42s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m56s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m24s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 21:05:59 +02:00
timothy c480fbe8d5 Merge pull request 'feat(web+api): playout alternate schedules + templates editors, default-deco read-side (#144 S6, #162)' (#179) from feat/144-s6-playout-editors into main
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (push) Has been cancelled
2026-07-07 19:03:10 +00:00
timothy d7617b7456 fix(api): validate date-range month/day bounds on playout PUT actions
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m37s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m59s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
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.
2026-07-07 20:56:45 +02:00
timothy fb76f34fc6 fix(web): disable recurrence controls on the alternate-schedules default row
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.
2026-07-07 20:56:38 +02:00
timothy 0bc74f9b1d fix(web): playout alternate-schedule/template links didn't render on click
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.
2026-07-07 20:56:32 +02:00
timothy 4f472134fb Merge remote-tracking branch 'origin/main' into feat/144-s6-playout-editors 2026-07-07 20:30:37 +02:00
timothyandClaude Fable 5 ca3fe0c87b feat(web): playout alternate-schedule and template editors on the REST API (#144 S6 (#162))
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>
2026-07-07 20:29:40 +02:00
timothyandClaude Fable 5 50b13afac3 feat(api): REST endpoints for playout alternate schedules and templates (#144 S6 (#162))
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>
2026-07-07 20:29:29 +02:00
timothy 4ea5273770 Merge pull request 'feat(web+api): deco templates (#144 S4, #162)' (#175) from feat/144-s4-deco-templates into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m24s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m38s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m4s
2026-07-07 18:06:36 +00:00
timothyandClaude Fable 5 c745d01a2c fix(app): bound deco template item times to a single day (#144 S4 review)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m19s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m29s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
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>
2026-07-07 20:00:48 +02:00
timothy 0d9504c16b Merge remote-tracking branch 'origin/main' into feat/144-s4-deco-templates 2026-07-07 19:52:01 +02:00
timothy 2b54a2d591 feat(web): Deco Templates screen (list + editor) on the REST API (#144 S4 (#162))
Adds DecoTemplatesScreen mirroring TemplatesScreen: list mode groups deco
templates by deco template group with create/delete dialogs; editor mode
(/app/deco-templates/{id}) has an add-content row (deco group -> deco ->
start time -> duration hours/minutes pickers), a time-sorted items table
displaying end-of-day items as "24:00", and client-side validation mirroring
ReplaceDecoTemplateItemsHandler's hardened rules (start must precede its
effective end time; no overlapping ranges, with EndTime 00:00:00 treated as
end-of-day for both checks) that disables Save on violation.

No copy action: neither the Blazor DecoTemplateEditor nor the CQRS layer has
a deco-template copy command (unlike Templates, which has both Copy* and a
/copy route), so this isn't a parity gap - just matches upstream scope.

Registered at /app/deco-templates in the primary nav, next to Decos.
2026-07-07 19:51:11 +02:00
timothy cda5c12e34 feat(api): REST endpoints for deco templates and deco template groups (#144 S4 (#162))
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.
2026-07-07 19:51:00 +02:00
timothy 0c355e1132 Merge pull request 'feat(web+api): decos + deco groups, playout default deco, playlist/search pickers (#144 S3, #162)' (#174) from feat/144-s3-decos into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m25s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m39s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m2s
2026-07-07 17:42:30 +00:00
timothyandClaude Fable 5 d71a609b4f fix(app): persist MediaItemId on deco break-content updates and adds (#144 S3 review)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m26s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m28s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
UpdateDecoHandler copied every break-content field except MediaItemId,
so TelevisionShow/Season/Artist break content validated then persisted
with a null MediaItemId (upstream bug surfaced by the new REST API).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 19:36:37 +02:00
timothy 4c215f6f73 Merge remote-tracking branch 'origin/main' into feat/144-s3-decos 2026-07-07 19:23:47 +02:00
timothyandClaude Fable 5 a52939f292 feat(web): Decos screen (list + 6-section editor) on the REST API (#144 S3 (#162))
Adds the /app/decos screen (dual-mode list/editor) mirroring the legacy Blazor
deco form: deco/watermark/graphics/break-content/default-filler/dead-air
sections with mode-gated controls, watermark/graphics multi-selects, break
content with a playlist group->playlist cascade and per-placement uniqueness,
and default-filler/dead-air typeahead pickers. Adds decos/playlists api modules,
artist/multi-collection search wrappers, and regenerated types.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 19:22:49 +02:00
timothyandClaude Fable 5 57bb320af8 feat(api): REST endpoints for decos, deco groups, playout default deco (#144 S3 (#162))
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>
2026-07-07 19:22:39 +02:00
timothy 86f280f9d0 Merge pull request 'feat(web+api): scheduling templates + block/template copy (#144 S2, #162)' (#173) from feat/144-s2-templates into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m42s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m3s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m58s
2026-07-07 17:00:45 +00:00
timothy d48e48f1be fix(templates-screen): exclude placeholder blocks, warn on midnight crossing
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m24s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m28s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
GET /api/blocks emits a synthesized negative-id "(none)" row per empty
block group; the block picker was including it as a selectable option.
Filter to b.id > 0, mirroring the backend's real block ids.

Also mirror ReplaceTemplateItemsHandler's new end-of-day validation on
the client: flag an item whose start time + block duration crosses
midnight and disable Save, same as the existing overlap-warning path.

Adds coverage: block picker excludes negative-id rows; validate() flags
a midnight-crossing item.
2026-07-07 18:54:34 +02:00
timothy 0299293e14 fix(templates): validate blockId existence and end-of-day in ReplaceTemplateItems
PUT /api/templates/{id} with an unknown blockId threw KeyNotFoundException
(HTTP 500) because the dictionary indexer assumed every referenced block
had been loaded. Validate all blockIds exist before building the overlap
list and return a 422 with "[BlockId] {id} does not exist." on failure,
matching ReplaceBlockItemsHandler's message convention.

Also reject items whose start time + block duration crosses midnight
(> 24:00). Previously these were silently accepted by PUT, then dropped
by GetTemplateItemsHandler's end-of-day filter on every subsequent GET,
so a saved item would vanish and be deleted on the next save.
2026-07-07 18:54:30 +02:00
timothy 2071858523 Merge remote-tracking branch 'origin/main' into feat/144-s2-templates 2026-07-07 18:42:11 +02:00
timothyandClaude Fable 5 a0521cd6dc feat(web): Templates screen (list + editor) on the REST API
Adds a ChicoryTV Templates screen under /app/templates: grouped template
list with group/template create, copy, and delete, and a
/app/templates/{id} editor (name, add-content row that picks a block
group -> block -> five-minute start time, a time-sorted item table with
remove, and a client-side overlap warning that mirrors
ReplaceTemplateItemsHandler's server-side check before allowing save).

Deviates from the Blazor TemplateEditor.razor's drag-to-place day
calendar: uses a plain sorted table + picker row instead, per the S2
spec (parity, not a pixel port).

Adds web/src/api/templates.ts client + copyBlock to blocks.ts (S1 left
the block-copy endpoint unwired; CopyBlock's CQRS shape is identical to
CopyTemplate so it rides along here), plus regenerated v1.d.ts types.

#144 S2 (#162)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 18:37:59 +02:00
timothyandClaude Fable 5 0203b5b154 chore(api): regenerate OpenAPI spec for templates + block copy endpoints
#144 S2 (#162)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 18:31:46 +02:00
timothyandClaude Fable 5 ae10eba4d7 feat(api): REST endpoints for templates, template groups, items + block copy
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>
2026-07-07 18:31:17 +02:00
timothy 3b7d5fd5a1 Merge pull request 'feat(web+api): blocks CRUD, items editor & preview (#144 S1, #162)' (#171) from feat/144-s1-blocks into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m27s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m46s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m16s
2026-07-07 16:25:25 +00:00
timothy e3f2cd631a Merge remote-tracking branch 'origin/main' into feat/144-s1-blocks
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m31s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m42s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-07 18:15:29 +02:00
timothy a20fef6096 fix(web): round-trip legacy Artist/MultiCollection block items; fix stale AsyncPicker state
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.
2026-07-07 18:15:21 +02:00
timothy 1e34e00e31 fix(api): validate block group exists on block creation
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.
2026-07-07 18:15:12 +02:00
timothy a228927b4f Merge pull request 'feat(web+api): playout create for all kinds + detail updates (#144 S5, #162)' (#170) from feat/144-s5-playout-create into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m11s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m30s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m21s
2026-07-07 16:09:56 +00:00
timothyandClaude Fable 5 ecd9d526d0 feat(web): Blocks screen (list + editor) on the REST API
Adds a ChicoryTV Blocks screen under /app/blocks: grouped block list with
group/block create + delete, and a /app/blocks/{id} editor (name, duration
hours + 5-min increments, stop-scheduling, item table with add/remove/move/
duplicate, per-item collection-type pickers via debounced search endpoints,
conditional playback-order, EPG/watermark toggles, watermark & graphics
multi-selects, and a non-persisting playout preview dialog).

Adds web/src/api/blocks.ts client + regenerated v1.d.ts types.

#144 S1 (#162)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 17:51:15 +02:00
timothyandClaude Fable 5 6b5bc8e29a feat(api): REST endpoints for blocks, block groups, items, preview + scheduling search pickers
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>
2026-07-07 17:42:40 +02:00
timothyandClaude Fable 5 f06bc064a0 fix(api): apply schedule-file update before daily rebuild time in PUT /api/playouts/{id} (#170 review)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m24s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m38s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
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>
2026-07-07 17:30:38 +02:00
timothyandClaude Fable 5 15f2beb7f9 fix(web): derive schedules-loading state instead of sync setState in effect (lint)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m25s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m43s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 17:28:13 +02:00
timothy 605b325370 feat(web): wire up Add Playout dialog and playout-details editing (#144 S5 (#162))
Build ErsatzTV Image / Build & test (.NET) (pull_request) Failing after 42s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 3m36s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
- 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.
2026-07-07 17:09:02 +02:00
timothy 9b7718fd7c feat(web): playout create/update API client for all schedule kinds (#144 S5 (#162))
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.
2026-07-07 17:08:49 +02:00
timothy 5f202a2175 feat(api): REST endpoints to create playouts of all kinds + update details (#144 S5 (#162))
- 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.
2026-07-07 17:08:43 +02:00
timothyandClaude Fable 5 e00b7e4d11 docs: advance ChicoryTV queue past parity pass 2 (#142/#143 closed, #141/#145 partial, #157 fixed; PRs #165-#169); next = #144 scheduling slices
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m38s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m49s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m49s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 16:24:18 +02:00
timothy 3c37ec5f38 Merge pull request 'feat(web+api): media browse, search & trash for SPA parity (#141)' (#168) from feat/141-media-browse into main
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
2026-07-07 14:20:23 +00:00
timothy f1fae6397e Merge remote-tracking branch 'origin/main' into feat/141-media-browse
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m17s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 3m16s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-07 16:07:18 +02:00
timothy 72b6d0a76a fix(web): scope library/add-item pickers to their 4 media kinds (#168 review)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m33s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m46s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
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.
2026-07-07 16:06:50 +02:00
timothy 117afa310e Merge pull request 'test(web): make Libraries scan-polling tests deterministic (fixes #157)' (#169) from fix/157-flaky-scan-polling into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m8s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m36s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m14s
2026-07-07 14:02:31 +00:00
timothyandClaude Fable 5 7c4cf55715 test(web): make Libraries scan-polling tests deterministic (fixes #157)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m28s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m43s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
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>
2026-07-07 15:51:57 +02:00
timothy 7083301ce6 Merge remote-tracking branch 'origin/main' into feat/141-media-browse
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m37s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m5s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
# Conflicts:
#	ErsatzTV.Tests/Controllers/OpenApiErrorResponseContractTests.cs
#	web/src/App.tsx
#	web/src/screens/SettingsScreen.tsx
2026-07-07 15:50:03 +02:00
timothyandClaude Fable 5 14cafca740 feat(web): media browse, global search & trash screens (#141)
- 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>
2026-07-07 15:39:54 +02:00
timothyandClaude Fable 5 cf21f889bf ci: retrigger main build (flaky Libraries scan-polling test, see #157)
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m2s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m16s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m49s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 15:33:54 +02:00
timothy 063dae731f Merge pull request 'feat(web+api): Trakt lists screen + REST API for SPA parity (#142)' (#167) from feat/142-trakt-lists into main
Build ErsatzTV Image / Build & test (.NET) (push) Failing after 1m18s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
2026-07-07 13:31:05 +00:00
timothyandClaude Fable 5 9c03184707 fix: address #167 review — release Trakt lock on failed enqueue; mark busy after generatePlaylist save
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m2s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m30s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 15:26:01 +02:00
timothyandClaude Fable 5 6c68c291a0 feat(api): browse all media kinds, grouped search, delete media-items (#141, #161)
- Extend /api/library/browse to episodes, music videos, songs, other videos,
  images and remote streams (new LibraryBrowseMediaType values + hydrators);
  add optional Subtitle to LibraryBrowseItemResponseModel for leaf-item context
- Add GET /api/search: grouped per-kind results reusing the browse query/shape;
  empty query -> 422
- Add DELETE /api/media-items: body { ids }, empty -> 422, success -> 204
- Tests: SearchController, MediaItemsController, security + OpenAPI contract entries
- Regenerate openapi v1.json + web v1.d.ts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 15:23:07 +02:00
timothy 1b9d85896c Merge remote-tracking branch 'origin/main' into feat/142-trakt-lists
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m28s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m40s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
# Conflicts:
#	ErsatzTV.Tests/Controllers/ApiControllerSecurityTests.cs
#	web/src/App.tsx
#	web/src/screens/SettingsScreen.tsx
2026-07-07 15:13:22 +02:00
timothyandClaude Fable 5 e2663758e1 ci: retrigger main build (flaky Libraries scan-polling test, see #157)
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m16s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m41s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m57s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 15:10:31 +02:00
timothy 5c932fdc54 feat(web): Trakt Lists screen for SPA parity (#142)
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.
2026-07-07 15:09:30 +02:00
timothy 4306b39ef8 feat(api): Trakt lists REST endpoints (#160)
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.
2026-07-07 15:09:12 +02:00
timothy e0f36a12fb Merge pull request 'feat(web+api): FFmpeg profile, filler preset & watermark editors for SPA parity (#143)' (#166) from feat/143-transcoding-editors into main
Build ErsatzTV Image / Build & test (.NET) (push) Failing after 1m14s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 3m28s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been skipped
2026-07-07 13:06:38 +00:00
timothyandClaude Fable 5 66abb24402 test: cover FillerPresetController + WatermarkController in API security test (#166 review)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m15s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m36s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 15:01:10 +02:00
timothy 7454411dee Merge remote-tracking branch 'origin/main' into feat/143-transcoding-editors
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m10s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m30s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
# Conflicts:
#	web/src/App.tsx
#	web/src/screens/SettingsScreen.tsx
2026-07-07 14:55:55 +02:00
timothyandClaude Fable 5 bef33c2950 fix(api): 404 for missing filler preset / watermark on PUT and DELETE (#159)
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>
2026-07-07 14:48:38 +02:00
timothyandClaude Fable 5 e95eed11dd feat(web): FFmpeg profile, filler preset, watermark editors (#143)
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>
2026-07-07 14:33:01 +02:00
timothyandClaude Fable 5 46c8c2e2dc feat(api): filler preset + watermark editor CRUD; ffmpeg profile round-trip (#159)
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>
2026-07-07 14:06:26 +02:00
timothy e0a860e577 Merge pull request 'feat(web+api): logs & troubleshooting screens for SPA parity (#145)' (#165) from feat/145-logs-troubleshooting into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 3m51s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m16s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m56s
2026-07-07 12:00:28 +00:00
timothyandClaude Fable 5 24a26cbe34 fix: address #165 review findings (stale-response guard, security-test coverage, route/help-text nits)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m14s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m37s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 13:55:05 +02:00
timothy 5530db7089 feat(web): logs and troubleshooting screens for SPA parity (#145)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m26s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m39s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
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.
2026-07-07 13:42:01 +02:00
timothy bb34014a07 chore(api): regenerate OpenAPI spec + SPA types for logs/troubleshoot-info (#145) 2026-07-07 13:34:53 +02:00
timothy dfa561a97e feat(api): logs and troubleshooting-info endpoints for SPA parity (#145, #158)
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).
2026-07-07 13:34:47 +02:00
timothyandClaude Fable 5 6e091c98ae ci: retrigger main build (flaky Libraries scan-polling test, see #157)
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m15s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m43s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m38s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 13:05:56 +02:00
timothyandClaude Fable 5 22fa3ecb08 docs: advance ChicoryTV queue past parity #140/#146/#147 (PRs #150/#154/#156); gaps #151-#153/#155 filed; next prompt = parity #141-#145
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m33s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m55s
Build ErsatzTV Image / Build & push image (amd64) (push) Failing after 43s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 12:57:51 +02:00
timothy d58deddc48 Merge pull request 'feat(web): collections management screen — manual + smart collections (#140)' (#156) from feat/140-collections-screen into main
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (push) Has been cancelled
2026-07-07 10:54:19 +00:00
timothyandClaude Fable 5 5995707c84 feat(web): collections management screen for manual + smart collections (#140)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m37s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 3m30s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
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>
2026-07-07 12:44:51 +02:00
timothy fcdecd16c0 Merge pull request 'feat(web): dedicated channel edit screen (#146)' (#154) from feat/146-channel-edit into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m30s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m50s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m52s
2026-07-07 10:40:17 +00:00
timothy 2b09bffab8 Merge pull request 'feat(web): Classic UI link in Settings + ChicoryTV cue on redirecting Blazor nav links (#147)' (#150) from feat/147-classic-ui-link into main
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
2026-07-07 10:40:09 +00:00
timothyandClaude Fable 5 1f3385a2ff fix(web): review fixes — EPG switch gating, edit route key, stronger PUT assertion (#146)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m17s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m41s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 12:32:29 +02:00
timothyandClaude Fable 5 c21827667a feat(web): dedicated channel edit screen for SPA parity (#146)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m2s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m25s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
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>
2026-07-07 12:22:30 +02:00
timothyandClaude Fable 5 39455a96ed feat(web): Classic UI link in Settings → System (#147)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 3m25s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 3m23s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
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>
2026-07-07 12:11:11 +02:00
timothyandClaude Fable 5 2362850559 docs: post-cutover housekeeping — dep PRs #61/#132 merged, #21/#48/#49/#131 closed, #76 rebased, prod v26.5.0 verified; next prompt = parity #147/#146/#140
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m12s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m41s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m4s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 12:01:07 +02:00
timothy 7e5a6b75ce Merge pull request 'chore(deps): update dependency entityframeworkprofiler.appender to 6.0.6053' (#132) from renovate/entityframeworkprofiler.appender-6.x into main
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
2026-07-07 09:58:02 +00:00
timothy fbb659b19f Merge pull request 'chore(deps): apply open Renovate patch updates' (#61) from issue22-renovate-dashboard into main
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
2026-07-07 09:57:24 +00:00
renovateandtimothy 7319e31fa1 chore(deps): update dependency entityframeworkprofiler.appender to 6.0.6053
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 3m52s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m37s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-07 11:44:15 +02:00
timothy d3f71faf66 chore(deps): apply open Renovate patch updates refs #22
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m26s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m55s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-07 11:42:56 +02:00
timothyandClaude Fable 5 1cf4a7a90c docs: v26.5.0 tagged + deployed to prod (Komodo GitOps pin); deployment section reflects pinned-tag releases
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m21s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m41s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m30s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 11:39:02 +02:00
timothyandClaude Fable 5 21ede49253 docs: advance ChicoryTV issue queue past #91 phase (a) (PR #148); CLAUDE.md reflects SPA-default UI; next prompt = post-cutover housekeeping + parity kickoff
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m11s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m34s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m33s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 11:21:35 +02:00
timothy d04769ccdb Merge pull request 'feat(web): SPA cutover — root route (#91)' (#148) from feat/91-cutover into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m14s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m42s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m23s
2026-07-07 09:12:50 +00:00
timothyandClaude Fable 5 439272b405 feat(web): SPA cutover — root route + legacy redirects (#91)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m11s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m31s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
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>
2026-07-07 11:07:01 +02:00
timothyandClaude Fable 5 b564545ff7 docs: advance ChicoryTV issue queue past #90 (PR #139, tagged v26.4.0); next prompt = #91 cutover
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 3m55s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m36s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m54s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 10:20:36 +02:00
timothy 65b1a5e38c Merge pull request 'feat(web): ChicoryTV rebrand (#90)' (#139) from feat/90-rebrand into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m9s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m39s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m6s
2026-07-07 08:11:34 +00:00
timothyandClaude Fable 5 ef88e93256 feat(web): ChicoryTV rebrand (#90)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m27s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m45s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Complete the SPA-surface rebrand: SVG favicon (chicorytv-icon) served
from web/public, description + theme-color meta, sidebar brand mark
switched to the canonical transparent chicory-mark.svg per the design
system lockup, and the Channel Builder default group ErsatzTV->ChicoryTV
(tests updated). Legacy Blazor UI and backend strings intentionally
untouched (product-wide rebrand = epic #59).

closes #90

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 10:05:39 +02:00
timothyandClaude Fable 5 9497df0d53 docs: advance ChicoryTV issue queue past #93 (PR #138); document design-sync workflow (#92); next prompt = #90 rebrand
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m7s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m34s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m54s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 09:39:27 +02:00
timothy 17f49304e5 Merge pull request 'feat(web): Settings screen (#93)' (#138) from feat/93-settings into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m12s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m34s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
2026-07-07 07:34:47 +00:00
timothyandClaude Fable 5 540def7f17 fix: address #93 adversarial review findings
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m51s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m21s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Backend: 401 documented on all mutating settings/resolution routes;
resolution delete distinguishes 404 (unknown) from 422 (not custom);
XMLTV enum bridging via exhaustive switch instead of int casts;
field-level Arg.Is assertions incl. non-null watermark/filler flow.

Frontend: resolution add/delete failures surfaced inline (were
silent); partial saves merge succeeded groups via allSettled; empty
numeric fields invalid + tunerCount min 1; HLS Direct select shows
out-of-list wire values; media-source rows show derived last-scan.

ErsatzTV.Tests 495; web suite 145.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 09:22:51 +02:00
timothyandClaude Fable 5 ab9d21326b fix(web): settings screen degrades gracefully on partial API failure
Reference data (profiles, watermarks, fillers, media sources, version,
health) now loads via allSettled with per-resource failure notes; only
the settings groups themselves gate the screen. Error state renders
before the loading guard (was unreachable). +2 tests (136 total).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 09:22:51 +02:00
timothyandClaude Fable 5 6938cbab40 fix(api): media-sources 500 on fresh database
GET /api/media-sources threw on any empty database: with zero result
rows, Microsoft.Data.Sqlite reports the COUNT(*) column as BLOB, so
Dapper generated a (long, byte[]) deserializer that cannot match
LibraryItemCount(long, long). Replaced the Dapper query with EF LINQ
GroupBy (provider-agnostic, no schema inference) and added an
empty-database regression test.

Also carries the Scriban.Signed 6.5.2 -> 7.2.5 bump (GHSA-5wr9-m6jw-xx44,
PR #137) so this branch builds; redundant once #137 lands on main.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 09:22:50 +02:00
timothyandClaude Fable 5 c85177071e feat(web): Settings screen (#93)
SettingsScreen with linkable sub-sections (/app/settings/<section>),
settings API module + useSettingsScreenQuery, floating save bar that
PUTs only changed groups, custom resolution add/delete, legacy-UI
callouts for read-only resources. mockDashboardApi extended; 23 new
tests (suite 112 -> 134).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 09:22:50 +02:00
timothyandClaude Fable 5 5f1d3e4d96 design: Settings screen prototype + handoff bundle (#93)
templates/chicorytv-admin/Settings.jsx wired into app.html;
design_handoff_settings/ carries the implementation spec + API mapping.
Synced to the Claude Design project via DesignSync.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 09:22:50 +02:00
timothyandClaude Fable 5 57905bc0f4 feat(api): settings read/write endpoints (#93)
GET/PUT /api/settings/{ffmpeg,playout,xmltv,scanner,logging,ui,hdhr}
wrapping the existing MediatR settings handlers, plus custom resolution
list/create/delete under /api/settings/resolutions. String-enum OpenAPI
schemas extended to OutputFormatKind + LogEventLevel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 09:22:50 +02:00
timothy 82a93ea3be Merge pull request 'fix(deps): bump Scriban.Signed 6.5.2 → 7.2.5 (GHSA-5wr9-m6jw-xx44)' (#137) from fix/scriban-ghsa-5wr9 into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m24s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m54s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m54s
2026-07-07 07:09:56 +00:00
timothyandClaude Fable 5 47c3c3b5e2 fix(deps): bump Scriban.Signed 6.5.2 -> 7.2.5 (GHSA-5wr9-m6jw-xx44)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m59s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m20s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
The advisory published against Scriban < 7.0.0 (sandbox escape) turns
NuGetAudit's NU1904 into a restore failure for every build, blocking all
CI. Full solution builds and both test suites pass on 7.2.5 (XMLTV
goldens unchanged).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 08:59:13 +02:00
timothyandClaude Fable 5 8a323f98bf docs: advance ChicoryTV issue queue past #89 (PR #136); next prompt = #93 Settings
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m34s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m1s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m13s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 23:43:42 +02:00
timothy e1860785cc Merge pull request 'feat(web): Channel Builder (#89)' (#136) from feat/89-channel-builder into main
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (push) Has been cancelled
2026-07-06 21:40:44 +00:00
timothyandClaude Fable 5 7d37e554d3 test(web): Channel Builder coverage — collections fan-out, paging, save-as-template, 413, zero-templates, lineup item shape (refs #89)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m38s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m5s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 23:30:22 +02:00
timothyandClaude Fable 5 54a0760fcd fix(web): Channel Builder review fixes (refs #89)
- Dialog: focus only on open transition (was stealing focus from its own
  inputs every parent re-render); Escape listener via latest-ref
- Advanced overrides: honest 'Inherit from template' semantics - the
  handler coalesces null with the template value so 'None' could never
  clear anything (clear-to-none follow-up: #135); save-as-template now
  composes from the same effective-value helper
- toggle rows: label->div (label activation forwarding double-fired in
  real browsers); clear stale 422 row highlight on lineup mutations
- port .ctv-lift/.ctv-press motion utilities + apply per prototype;
  Advanced expand scroll-into-view; Info glyph on advanced info line
- fixture fidelity: real 'Validation failed' title, verbatim handler
  detail message, omit null keys, collectionKind on rerun fixture
- cache successful artwork upload across create retries

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 23:22:52 +02:00
timothyandClaude Fable 5 579b30d489 feat(web): Channel Builder screen (refs #89)
3-column library-to-lineup builder replacing the four-editor chain:
library browser (search/filter/poster grid, double-click or drag to add,
collections via 5 typed browse calls) · drag-to-reorder lineup with
add-time constraint surfacing (Rerun/Playlist only-valid-alone,
MultiCollection requires shuffle, server lineup[i] 422 mapping) ·
settings rail (AUTO number, channel image w/ guide-logo + bug preview,
shuffle/always-playing toggles with overrides-template tags, template
picker + save-as-template, advanced override/view-defaults accordion).
Creates channel atomically via POST /api/channels/from-lineup, artwork
uploaded at create time. navigateToPath extracted to routing.ts to
avoid an App<->builder import cycle. 16 new tests (106 total).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 22:34:55 +02:00
timothyandClaude Fable 5 8e11362360 feat(web): Dialog primitive + Channel Builder API modules (refs #89)
- components/overlay.tsx: first Dialog/ConfirmDialog (portal, esc/backdrop
  close, scroll lock, aria wiring, reduced-motion aware) + 7 tests
- api: libraryBrowse, channelTemplates (default 404->null), artwork upload
  (multipart /api/artwork/uploads), pickers (client-sorted lists incl.
  /api/ffmpeg/profiles), createChannelFromLineup in channels.ts
- schedules.ts now sources filler/watermark fetchers from pickers.ts
  (avoids duplicate barrel exports)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 22:07:36 +02:00
timothyandClaude Fable 5 2596a433a8 docs: advance ChicoryTV issue queue past #63 (PR #134); Claude-only process; next prompt = #89 Channel Builder
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 3m48s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 3m38s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m0s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 21:33:04 +02:00
timothy a126970dcd Merge pull request 'feat(api): composite create-channel endpoint (#63)' (#134) from feat/63-composite-create into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m20s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m53s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
2026-07-06 19:28:01 +00:00
timothyandClaude Fable 5 cf36c30997 fix(api): rework from-lineup to generated playlist design + review fixes (#63)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m23s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m54s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
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>
2026-07-06 21:09:00 +02:00
timothy 6857a0d191 feat(api): composite create-channel endpoint (#63)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m58s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m42s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-06 20:27:02 +02:00
timothyandClaude Fable 5 16674ba80e docs: advance ChicoryTV issue queue past #64 (PR #133); next prompt = #63 composite create-channel
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m43s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m22s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m32s
Also: record prod cutover as done in CLAUDE.md (fork :prod live since
2026-06-27) and add standing v26.4.0 release-checkpoint note to the handoff.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 19:45:08 +02:00
timothy 4854c45a89 Merge pull request 'feat(api): channel templates entity + CRUD (#64)' (#133) from feat/64-channel-templates into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m23s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m46s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m0s
2026-07-06 17:33:31 +00:00
timothy 1a61b89f04 fix(api): address channel template review
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m22s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m57s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-06 19:22:42 +02:00
timothy 7858ac002a feat(api): add channel templates
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m34s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m58s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-06 18:43:39 +02:00
timothyandClaude Fable 5 836087da09 docs: advance ChicoryTV issue queue past #65 (PR #130); next prompt = #64 Channel Templates
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m30s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m11s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m28s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 07:58:57 +02:00
timothy c4007293bf Merge pull request 'feat(api): library browse + search endpoints (#65)' (#130) from feat/65-library-browse into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m26s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m0s
Dependency vulnerability scan / NuGet vulnerable packages (push) Failing after 53s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m23s
2026-07-06 05:53:39 +00:00
timothy 630119f382 fix(api): address library browse review findings
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m3s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m36s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
refs #65

refs PR #130
2026-07-06 07:45:39 +02:00
timothy 127a130737 feat(api): add library browse endpoint
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m10s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m47s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
refs #65
2026-07-06 00:01:06 +02:00
timothyandClaude Fable 5 a5de775480 docs: advance ChicoryTV issue queue past #85 (PR #129); next prompt = #65 library browse API (Codex)
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m39s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m14s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m32s
Renovate / Renovate (push) Successful in 1m18s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 23:22:10 +02:00
timothy 8a62f22208 Merge pull request 'feat(web): Guide / EPG screen (#85)' (#129) from feat/85-epg into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m22s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m51s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m39s
2026-07-05 21:12:25 +00:00
timothy a8d4979b0c fix(web): address Guide review feedback refs #85
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m48s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m32s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-05 23:06:08 +02:00
timothy 7d93688841 feat(web): add Guide EPG screen refs #85
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m24s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m3s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-05 22:31:30 +02:00
timothyandClaude Fable 5 d9ffbde79c docs: advance ChicoryTV issue queue past #88 (PR #128); next prompt = #85 EPG (Codex)
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m17s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m54s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m28s
Records the #128 review outcome (percent-fraction + poll-start-race fixes,
bee686c8), main d63719ec, baselines (368/493+1skip/75 web), and new lessons
(numeric wire-field units, trigger≠started grace windows, no ref mutation in
updaters).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 19:08:53 +02:00
timothy d63719ece9 Merge pull request 'feat(web): Libraries screen (#88)' (#128) from feat/88-libraries into main
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (push) Has been cancelled
2026-07-05 17:06:50 +00:00
timothyandClaude Fable 5 bee686c846 fix(web): libraries scan-flow review fixes for #88
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m24s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m3s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
- Normalize scan-status percent at the API boundary: the wire value is a
  0-1 fraction despite the field name; the bar previously showed ~0% for
  the whole scan (fixtures now use fractional wire values incl. 0/1)
- Fix the poll-start race: a triggered library enters a pending set that
  survives status pruning until seen active or a 3-tick grace window
  expires; the poll runs while pending ∪ active is nonempty (previously
  the queue-to-start lag could mean polling never started and the button
  re-enabled mid-scan); grace ticks also drain on persistent status-fetch
  errors so the UI can't stick
- Keep setState updaters pure: pending/grace bookkeeping hoisted into
  pruneGraceExpiredPending() outside the updater (StrictMode-safe)
- Wire the prototype's per-source Scan button (one POST per library,
  disabled while any of its libraries is pending/active)
- Honest bare-500 media-sources failure test (backend has no exception
  middleware); loadScanStatuses returns its promise; poll effect depends
  on derived hasActiveScans; synchronous ref double-submit guard;
  --radius-xs fallback 3px; StatusDot label on source cards (75 tests)

Review: PR #128 findings

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 19:01:05 +02:00
timothy 5c4acb9c7b feat(web): add Libraries screen refs #88
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m18s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m0s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-05 18:26:06 +02:00
timothyandClaude Fable 5 223ef70912 docs: advance ChicoryTV issue queue past #87 (PR #127); next prompt = #88 Libraries (Codex)
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m22s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m0s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m1s
Records the #127 review outcome (filler blocker + undefined token), pre-merge
fixes (c4190e3a), main a8dd88ef, baselines (368/493+1skip/63 web), and new
lessons (fixture-vs-actual-query fidelity, pure setState updaters, responsive
collapse, visible deferrals, honest tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 17:53:07 +02:00
timothy a8dd88ef73 Merge pull request 'feat(web): Playouts screen (#87)' (#127) from feat/87-playouts into main
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
2026-07-05 15:50:57 +00:00
timothyandClaude Fable 5 c4190e3a54 fix(web): playouts monitor review fixes for #87
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m28s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m9s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
- Filler unit: treat fillerKind 'None'/null as non-filler (real items no
  longer render the Filler badge); add the prototype's Show filler toggle
  wired to ?showFiller=true (the client previously never requested filler,
  making the filler UI dead code); fixtures now use real wire values
- Hoist selection fetch out of the setState updater (StrictMode-safe)
  via a baseRef snapshot; merge polled channel states functionally
  instead of clobbering with the captured snapshot
- Fix undefined --ctv-surface-1 token on the hero (→ --surface-card per
  prototype); correct --text-lg fallback; collapse the new playout grids
  in the 980px media block (no page-level horizontal scroll)
- Visible deferrals: disabled Reset / Schedule reset buttons with note
  (no per-playout reset endpoint yet)
- itemsLoading spinners on selection switch; time-window now-matching
  with title fallback; rail disabled while reset-all mutates
- Tests: mount-count delta isolation from the Dashboard prefetch;
  reset-all reframed as transport-failure (mock honors fixture status;
  backend is unconditionally 202); rail filter test; paging honesty
  ('shown of total' label); listPlayout() fixture matches the list DTO
  (63 tests)

Review: PR #127 findings

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 17:45:01 +02:00
timothy 1840ef83b1 feat(web): add Playouts monitor screen refs #87
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m14s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m1s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-05 17:10:30 +02:00
timothyandClaude Fable 5 c5021e28da docs: advance ChicoryTV issue queue past #86 (PR #125); next prompt = #87 Playouts (Codex)
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m9s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m48s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m23s
Records the #125 review outcome (refuted reorder blocker → #126), pre-merge
fixes (f18f2dc0), main 229f7cc1, baselines (368/493+1skip/52 web), and new
lessons (mount call-count assertions, no world-refetch, no list[0]-only UIs).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 12:27:06 +02:00
timothy 229f7cc1b0 Merge pull request 'feat(web): Schedule editor screen (#86)' (#125) from feat/86-schedule-editor into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m18s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
2026-07-05 10:22:16 +00:00
timothyandClaude Fable 5 f18f2dc058 fix(web): schedule editor review fixes for #86
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m33s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m16s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
- Add a schedule selector (previously only the first schedule was ever
  shown); switching loads items only, with a pane-level spinner
- Fix duplicated mount effect that fetched the whole screen twice
- Add a collection picker to the add-item flow (was silently using the
  alphabetically-first collection with no way to correct it)
- Reorder now reconciles from the PUT response; add/delete refetch items
  only — pickers are no longer refetched on every mutation
- Guard reorder against double-submit via a ref; remove the inert Save
  button; document the widened item type (OpenAPI gap → #126)
- Inspector fidelity: Tail filler row + Keep multi-part together
- a11y: aria-current for the active item, draggable role description
- CSS: correct --text-md fallbacks; replace nonexistent --text-faint
  with --text-disabled in new blocks
- Tests: single-fetch-on-mount assertion, chosen-collection POST body,
  picker-failure path, collection() fixture with all required fields;
  drop unreachable mock branch (52 tests)

Review: PR #125 findings; refs #126

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 12:16:53 +02:00
timothy 2e58fa1815 feat(web): add schedule editor screen
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m7s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m44s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-05 11:43:04 +02:00
timothyandClaude Fable 5 8f792d964e docs: advance ChicoryTV issue queue past #84 (PR #124); next prompt = #86 Schedule editor (Codex)
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m23s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m2s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m28s
Records the new Fable-prompts/Fable-reviews process, PR #124 review outcome
and pre-merge fixes (ecf6cf75), main 7ee6e987, baselines (368/493+1skip/39 web).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 11:29:53 +02:00
timothy 7ee6e9870c Merge pull request 'feat(web): Channels screen (#84)' (#124) from feat/84-channels-screen into main
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
Build ErsatzTV Image / Build & test (.NET) (push) Has been cancelled
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Has been cancelled
2026-07-05 09:26:22 +00:00
timothyandClaude Fable 5 ecf6cf7513 fix(web): scope channel bulk ops to visible rows, review fixes for #84
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m6s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m58s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
- Derive selectedChannels from visibleChannels and clear selection on
  filter change so bulk operations can never act on rows hidden by the
  active filter (reachable via the 30s state poll under the on-air filter)
- Disable the per-row delete button while a mutation is in flight
- Correct the --pad-cell-x fallback (token resolves to 16px)
- Add tests: bulk renumber request body + selection clearing, and
  selection reset across filter changes

Review: PR #124 findings 1, 2, 4, 8

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 11:20:32 +02:00
timothy 15c6cdd086 feat(web): add Channels screen
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m26s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m55s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
refs #84
2026-07-05 10:41:29 +02:00
timothyandClaude Fable 5 39d76080de docs: advance ChicoryTV issue queue past #109 (PR #123); next prompt = #84 Channels screen (Codex)
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m15s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m56s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m21s
Records: main=4ffd777b, review-fix cycle lesson (fixture values must come from
backend serializers), health no-poll policy, baselines 368/493 + 31 web tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 21:23:24 +02:00
timothy 4ffd777b49 Merge pull request 'feat(web): Dashboard real data sources (#109)' (#123) from feat/109-dashboard-data into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m42s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m21s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m31s
2026-07-04 19:20:38 +00:00
timothyandClaude Fable 5 e6197d63ff fix(web): dashboard review fixes — real health status contract, dedupe version fetch, refresh guard (#109)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m3s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m53s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
- Match the backend's exact health status contract ('pass'|'fail'|'warn'|'info')
  instead of fictional strings; 'info' now renders as a neutral/idle presentation
  and is excluded from failing/warning counts in summarizeHealth.
- Drop the redundant /api/version fetch from getDashboardData/DashboardData;
  SidebarVersion's useDashboardVersionQuery remains the single source.
- Disable "Refresh health" (via Button's loading prop) while a health request
  is in flight to prevent concurrent double-click requests.
- Add the active-flag unmount guard to useDashboardHealthQuery for consistency
  with useDashboardQuery/useChannelsQuery.
- Remove dead .ctv-activity-*/.ctv-release-* CSS left over from the removed
  activity feed/release notes UI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 21:14:44 +02:00
timothy 6a9f1e41a4 fix(web): satisfy dashboard lint checks
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m14s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m48s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
refs #109
2026-07-04 20:55:38 +02:00
timothy a6056f73d8 feat(web): wire dashboard to real API data
Build ErsatzTV Image / Build & test (.NET) (pull_request) Failing after 41s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 3m41s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
refs #109
2026-07-04 20:52:32 +02:00
timothy da7636fdad docs: update ChicoryTV queue after PR 121
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m11s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m4s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m46s
2026-07-04 20:37:29 +02:00
timothy cc6ffcb8c4 Merge pull request 'feat(core): track active sessions for direct streaming modes (#99)' (#121) from feat/99-session-tracking into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m21s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m0s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m35s
Merge pull request 'feat(core): track active sessions for direct streaming modes (#99)' (#121) from feat/99-session-tracking into main
2026-07-04 18:24:15 +00:00
timothy f9bb230673 chore: retrigger PR checks
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m47s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m29s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
refs #99
2026-07-04 20:10:26 +02:00
timothy 6350845101 fix(core): harden direct session tracking
Build ErsatzTV Image / Build & test (.NET) (pull_request) Failing after 3s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 3m38s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
refs #99
2026-07-04 20:05:19 +02:00
timothy 9cd107bc8d feat(core): track direct stream sessions
refs #99
2026-07-04 19:57:50 +02:00
timothyandClaude Fable 5 3e8cfa6288 docs: advance ChicoryTV issue queue past the merge pass (PR #120); next prompt = #109 Dashboard
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m46s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m31s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m45s
Records: main=1b6de047, PR #122 hotfix pending consent, PR #121 (#99) FIX-FIRST
review state, new baselines (364/488), worktree cleanup status.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 19:52:50 +02:00
timothy 692a71fd13 Merge pull request 'fix(docker): use valid node:22-bookworm-slim base for web-build stage' (#122) from fix/dockerfile-node-tag into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m22s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m13s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m50s
2026-07-04 17:42:16 +00:00
timothyandClaude Fable 5 2952aceb5b fix(docker): use valid node:22-bookworm-slim base for web-build stage
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m11s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m36s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
node:22-noble does not exist on Docker Hub (no noble variant of the
official node image; the -noble suffix was carried over from the MS
dotnet image tags). Broke the main image-build job (run 482) after
PR #120 landed, since PR runs skip the build job.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 08:19:12 +02:00
timothy 1b6de047a0 Merge pull request 'feat(web): ChicoryTV SPA foundation (#59 stack)' (#120) from feat/59-spa-foundation into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 3m45s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m8s
Build ErsatzTV Image / Build & push image (amd64) (push) Failing after 13s
2026-07-04 06:11:14 +00:00
timothyandClaude Fable 5 8d89ab1624 fix(api): add nullable context to #96/#98 DTOs; regen OpenAPI + SPA types
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 3m57s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m53s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
ChannelResponseModel and BulkChannelRequests DTOs lacked file-scoped
#nullable enable, so the OpenAPI generator emitted every string
property as a ["null","string"] union (project Nullable default is
disable), polluting SPA typegen with needless `| null` unions.
ChannelStateResponseModel already complied with this convention
(precedent: PlayoutResponseModel). All properties in both files are
always populated (never null in practice), so no property needed to
become explicitly optional/nullable - just adding #nullable enable
was sufficient to clean up the generated schema.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 08:02:28 +02:00
timothy 7bd694394a chore(openapi+web): regenerate v1.json and SPA API types after main merge 2026-07-04 07:55:05 +02:00
timothy 06355b7590 Merge main into feat/59-spa-foundation (post-#119 backend API sync)
# Conflicts:
#	ErsatzTV.Tests/Controllers/ChannelControllerTests.cs
#	ErsatzTV/Controllers/Api/ChannelController.cs
#	ErsatzTV/wwwroot/openapi/v1.json
2026-07-04 07:51:56 +02:00
timothy 68f073ed42 Merge pull request 'feat(api): channel on-air / now-playing state endpoint (#97)' (#112) from issue-97-channel-state-api into docs/59-ui-redesign-brief 2026-07-04 05:48:46 +00:00
timothyandClaude Fable 5 f6306d622f docs: advance ChicoryTV issue queue past #102 (PR #119); next prompt = SPA stack merge pass
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 07:45:34 +02:00
timothy 6f6f37b7f6 Merge pull request 'feat(api): JSON channel guide endpoint (#102)' (#119) from feat/102-json-guide into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m19s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m18s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m42s
2026-07-04 05:43:18 +00:00
timothy 0585f4a7f8 test(api): pin mirror-offset guide behavior; clarify guide end-default wording (#102)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m1s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m57s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
- Add GetChannelGuideDataHandlerTests coverage for the mirror-channel path:
  asserts programme Start/Stop shift by PlayoutOffset and that the source
  channel's seeded PlayoutItem entities are not mutated, pinning the
  WithPlayoutOffset copy fix.
- ChannelController.GetGuide Swagger description said end defaults to "now
  plus XmltvDaysToBuild"; it actually defaults to start plus that window
  (matters when start is supplied). Reworded and regenerated v1.json.
2026-07-04 01:19:58 +02:00
timothyandClaude Fable 5 ef024c0a05 chore(openapi): regenerate v1 for JSON channel guide endpoint (#102)
refs #102

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 00:55:07 +02:00
timothyandClaude Fable 5 12aa57ccc9 test(api): cover JSON channel guide handler and endpoint (#102)
Handler tests (in-memory SQLite harness): ShowInEpg filtering, decimal channel
ordering, time-window filtering, custom-title passthrough, leading pre-roll filler
merge, block split-time-evenly, default start/end from config, empty guide.
Controller tests: query mapping and null-bounds passthrough for GetGuide.

refs #102

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 00:55:07 +02:00
timothyandClaude Fable 5 9ecefd75f3 feat(api): add JSON channel guide endpoint (#102)
Add GET /api/guide returning a per-channel EPG grid (ChannelGuideResponseModel).
start defaults to now, end defaults to now + XmltvDaysToBuild.

Extract the guide-group/filler-merge projection (ChannelGuideProjector) and
programme-metadata resolution (ChannelGuideMetadata) plus the metadata eager-load
(IncludeGuideMetadata) out of RefreshChannelDataHandler so the XMLTV cache builder
and the JSON guide share one source of truth and cannot drift. XMLTV output is
unchanged (ChannelGuideGoldenTests pass without regeneration).

The JSON handler mirrors XMLTV semantics: ShowInEpg-only channels, decimal channel
ordering, ExternalJson playouts skipped, mirror playout offset applied to copies
(never mutating the loaded entities). SubTitle/Category are nullable.

refs #102

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 00:54:58 +02:00
timothyandClaude Fable 5 a867eecaa5 docs: advance ChicoryTV issue queue past #104/#111 (PRs #117/#118); single-session prompt for #102
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 00:05:42 +02:00
timothy 43f81d3a49 Merge pull request 'feat(api): schedule item duration estimates (#111)' (#118) from feat/111-schedule-durations into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m6s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m14s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m36s
2026-07-03 22:00:32 +00:00
timothyandClaude Fable 5 14d06a1e49 docs: note expression-based counts yield null duration estimate
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 2m51s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m22s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Adversarial review finding on #111: MultipleMode.Count supports
expressions the estimator cannot evaluate; document that they
produce a null (unknown) estimate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 23:41:57 +02:00
timothy 4b4e2b2f82 feat(api): add schedule item duration estimates
refs #111
2026-07-03 23:41:57 +02:00
timothyandClaude Fable 5 d0652d4adb wip: partial implementation salvaged from interrupted workflow run
Untrusted draft — no build/test had run yet. Review before building on it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 23:41:57 +02:00
timothy daf94f73f8 Merge pull request 'feat(api): artwork upload endpoint (#104)' (#117) from feat/104-artwork-upload into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m0s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m28s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 6m29s
2026-07-03 21:41:48 +00:00
timothyandClaude Fable 5 3d086aabc1 feat(api): artwork upload endpoint (#104)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m4s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m1s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
POST /api/artwork/uploads (multipart/form-data) accepting logo and
watermark images. Validates content type (png/jpeg/gif/webp) and size
(SystemEnvironment.MaximumUploadMb, default 10MB) mirroring the Blazor
upload path; stores via IImageCache.SaveArtworkToCache; returns
{ path, contentType } consumable by channel create/update.

Includes handler + controller tests, OpenAPI 422 contract-test entry,
and regenerated v1.json.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 23:32:22 +02:00
timothy b9955f4cba Merge pull request 'feat(api): media sources + scan status endpoints (#103 #106)' (#115) from feat/103-media-sources-api into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m33s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m27s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m58s
2026-07-03 21:10:37 +00:00
timothy 69486ab2d6 chore(openapi): regenerate v1 for media sources api (#103 #106)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m12s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m15s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-03 23:03:05 +02:00
timothy 1b7b0e549a feat(api): add media sources and scan status endpoints (#103 #106) 2026-07-03 23:02:58 +02:00
timothyandClaude Fable 5 4d37de3455 docs: Codex prompt B uses effort-tiered subagents; relax build cap to <5 concurrent
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 22:58:18 +02:00
timothyandClaude Fable 5 0f8066f06d docs: advance ChicoryTV issue queue past #100/#101/#107/#110 (PR #116); next prompts #104 (Claude) + #111 (Codex)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 22:53:58 +02:00
timothy abd8ca34c9 Merge pull request 'feat(api): playout read endpoints (#100 #101 #107 #110)' (#116) from feat/playouts-read-api into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m23s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m35s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m42s
2026-07-03 20:50:06 +00:00
timothyandClaude Fable 5 20ca71b388 feat(api): playout read endpoints + reset-all (#100 #101 #107 #110)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 2m59s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m2s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
- GET /api/playouts — paged list over GetPagedPlayouts (query/pageNum/pageSize), list DTO with buildStatus (#100, #107)
- GET /api/playouts/{id}/items — paged future items + UNSCHEDULED gaps over GetFuturePlayoutItemsById; 404 ProblemDetails for unknown playout (matches /api/schedules/{id}/items precedent) (#101)
- buildStatus {lastBuild, success, message} on GET /api/playouts/{id} (BuildStatus now included by GetPlayoutByIdHandler) (#107)
- GET /api/playouts/warnings/count — failed-build count for the warnings badge (#107)
- POST /api/playouts/reset-all — 202 Accepted, wraps ResetAllPlayouts (#110)
- POST /api/channels/{channelNumber}/playout/reset — optional ?mode= override; default branches by ScheduleKind (Classic→Refresh, others→Reset) to match Blazor semantics (#110)
- Option<FillerKind>→FillerKind? projection uses MatchUnsafe (Match throws on null-returning branch; idiom per Health/Mapper.cs)
- Tests: controller unit tests (routes, projections, 404s, reset-mode defaults), OpenAPI ProblemDetails contract entry for /api/playouts/{id}/items get 404
- Regenerated wwwroot/openapi/v1.json

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 22:28:09 +02:00
timothyandClaude Fable 5 dc373e5a53 docs: advance ChicoryTV issue queue past #108 (PR #114); split next handoff into Claude+Codex parallel prompts
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 21:30:33 +02:00
timothy 335a8b8a77 Merge pull request 'feat(api): health endpoints (#108)' (#114) from feat/108-health-api into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m35s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m39s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m15s
2026-07-03 19:28:13 +00:00
timothyandClaude Fable 5 922bec4b24 refactor(api): throw on unmapped health status instead of silent NotApplicable fallthrough (#108)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m23s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m27s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Review finding: NotApplicable is filtered by the handler before mapping,
so a silent ->"info" arm would only mask a missing filter if the mapper
is ever reused unfiltered.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 21:18:41 +02:00
timothyandClaude Fable 5 272174ee75 chore(openapi): regenerate v1.json for health endpoint (#108)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 21:15:38 +02:00
timothyandClaude Fable 5 29407f637b fix(api): align health endpoint with API conventions (#108)
Fix the WIP health-check API slice to match established patterns:

- HealthController: add Name="GetHealthChecks" route name and move
  [EndpointGroupName("general")] to method level, matching
  FillerPresetController/FFmpegProfileController exactly (the
  precedent for parameterless 200-only GET actions).
- HealthCheckResponseModel: enable #nullable for the file and mark
  Link as string? since the mapper can emit null when
  HealthCheckResult.Link is None.
- Mapper: fix a real bug - LanguageExt's Option.Match throws
  ResultIsNullException.ResultIsNull if either branch returns null
  (by design, to catch accidental nulls). The WIP's
  `Link.Match(l => l.Link, () => null)` crashed on every health
  check without a Link. Switch to MatchUnsafe, the LanguageExt-
  sanctioned way to intentionally produce a nullable result from
  Option<T>.
- HealthControllerTests: add the idiomatic-route-assertion test
  (route template + Name) and empty-list case, matching
  FillerPresetControllerTests.

Verified: GetAllHealthCheckResultsForApiHandler already matches the
existing GetAllHealthCheckResultsHandler's cancellation handling
(both swallow TaskCanceledException/OperationCanceledException), so
no change was needed there. Confirmed no OpenApi contract test
enumerates all endpoints for error-response metadata (it's an
explicit TestCase allowlist), so the new GET needed no new entries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 21:14:42 +02:00
timothyandClaude Fable 5 83c9122b6f wip: partial implementation salvaged from interrupted workflow run
Untrusted draft — no build/test had run yet. Review before building on it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 21:07:21 +02:00
timothyandClaude Fable 5 e663cabe9c docs: advance ChicoryTV issue queue past #105 (PR #113)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 22:37:01 +02:00
timothy 86f07594e4 Merge pull request 'feat(api): filler/watermark/graphics list endpoints (#105)' (#113) from feat/105-picker-list-endpoints into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 3m57s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m59s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m50s
2026-07-02 20:35:31 +00:00
timothy 446f50763a chore(openapi): regenerate v1.json for picker list endpoints (#105)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 3m45s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m47s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Adds /api/filler-presets, /api/watermarks, and /api/graphics-elements
to the published OpenAPI document via ./scripts/update-openapi.sh.
2026-07-02 22:06:24 +02:00
timothy 0eaedb9cf6 feat(api): add route names and handler tests for picker list endpoints (#105)
The WIP GET /api/filler-presets, /api/watermarks, and
/api/graphics-elements endpoints omitted the Name= route attribute
used by the other API GET actions (e.g. FFmpegProfileController). Add
Name = "GetFillerPresets" / "GetWatermarks" / "GetGraphicsElements" for
consistency.

Also add handler tests (FillerPresetHandlerTests, WatermarkHandlerTests,
GraphicsElementHandlerTests) covering the GetAll*ForApi handlers using
the InMemoryTvContext harness: multi-row projection to (Id, Name) DTOs,
the empty-DB case, and the graphics-element ordering rule (named
elements sort before elements whose projected Name equals FileName).
2026-07-02 22:06:19 +02:00
timothyandClaude Fable 5 b5714061f8 docs: prescribe per-step subagent models in issue-queue handoff
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:56:43 +02:00
timothyandClaude Fable 5 4db142c2f2 docs: advance ChicoryTV issue queue past #97 (PR #112)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:55:10 +02:00
timothyandClaude Fable 5 73455aae28 fix(api): surface programs not filler in channel state, tighten queries
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m40s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m52s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Adversarial review fixes for the #97 endpoint:

- nowPlaying now resolves like the XMLTV guide: when the wall-clock item
  is pre/mid-roll filler, surface the guide group's program item (title +
  full program bounds) instead of the filler; a guide entry with no
  program item (e.g. fallback loop) reports null. Previously an ad break
  would retitle the channel and reset progress every 30 seconds.
- covering-item lookup now runs one cheap projected query per distinct
  playout offset instead of hydrating the full metadata include tree for
  a window widened by the largest offset across ALL channels (up to 24h);
  metadata is fetched in a second query scoped to the covering guide
  groups, time-bounded to stay safe against GuideGroup recycling (mod
  10000). Regression caught while testing: covering items are keyed by
  (source channel, lookup time) so a source channel no longer inherits
  its offset mirror's lookup result.
- GetChannelStatesForApi now carries Now explicitly (controller passes
  DateTime.UtcNow), making boundary semantics testable: new tests pin
  now == Start (playing) and now == Finish (not playing), plus mid-roll,
  filler-only, and mirror-source-row cases.
- ChannelNowPlayingResponseModel title is non-nullable in the schema
  (#nullable enable) — GetDisplayTitle always returns a string.
- GetState uses the typed-return controller convention; Playouts.Mapper
  reverted to internal (handler lives in the same assembly).
- Spec doc notes the OnDemand drift caveat and filler semantics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:53:21 +02:00
timothyandClaude Fable 5 e66f49e54a fix(api): make channel state NowPlaying nullable in DTO
An off-air channel has no current playout item, so the handler returns
null for NowPlaying; the DTO now declares that (file-scoped #nullable —
ErsatzTV.Core has NRT disabled project-wide). Regenerated OpenAPI and
web types: nowPlaying keeps its nullable oneOf schema (matching the
schema contract test) and channelNumber loses a spurious nullable
annotation left over from an earlier draft — the field is required.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:37:00 +02:00
timothyandClaude Fable 5 3e44947597 docs: add ChicoryTV issue-queue handoff (one issue per session)
Living handoff document driving the sequential backend-gap work
(#97, #100-#111, merge pass, then UX screens). Each session works
one queue item and rewrites this file for the next.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:31:22 +02:00
timothyandClaude Fable 5 8912686a47 wip: partial implementation salvaged from interrupted workflow run
Untrusted draft — no build/test had run yet. Review before building on it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:27:40 +02:00
timothy 07e9d9838d fix(api): resolve mirror channel now playing state 2026-07-02 20:02:01 +02:00
timothy f01ffc6254 test(api): enforce nullable channel now playing schema 2026-07-02 19:50:19 +02:00
timothy 6057bcea7d docs(api): document channel state endpoint 2026-07-02 19:45:53 +02:00
timothy 7b903d4bb4 feat(api): return channel runtime state 2026-07-02 19:37:14 +02:00
timothy 7c1a1d5672 feat(api): add channel state endpoint contract 2026-07-02 19:29:59 +02:00
timothy 1891498563 chore: ignore local worktrees 2026-07-02 19:24:06 +02:00
timothy 7484df1e5b docs(api): plan channel state endpoint 2026-07-02 19:20:50 +02:00
timothy 52bfdbcdce docs(api): specify channel state endpoint 2026-07-02 19:16:07 +02:00
timothy 6b3a698283 feat(api): add bulk channel operations
fixes #98
2026-07-02 19:07:35 +02:00
timothy 32c0d6a6d3 feat(api): enrich channel list response 2026-07-02 18:56:05 +02:00
timothy 97b6c4bee7 fix(web): harden spa foundation
refs #95
2026-07-02 18:36:56 +02:00
timothy 032b9d8116 fix(web): address app shell review findings
refs #94
2026-07-02 18:33:16 +02:00
timothy 6e6fd67fea feat(web): implement ChicoryTV dashboard
refs #83
2026-07-02 18:29:19 +02:00
timothy 5fb3bb2ebc feat(web): add ChicoryTV app shell
refs #82
2026-07-02 18:21:59 +02:00
timothy d1cc12e065 feat(web): add typed API client auth
refs #81
2026-07-02 08:23:19 +02:00
timothy e6df6ac10a feat(web): add ChicoryTV component primitives
refs #80
2026-07-02 08:09:09 +02:00
timothy e282bcecfa feat(frontend): wire ChicoryTV SPA themes
refs #79
2026-07-02 07:56:12 +02:00
timothy b183c6a154 feat(frontend): scaffold ChicoryTV SPA
refs #78
2026-07-02 07:37:00 +02:00
timothyandClaude Opus 4.8 c0b20a0ac5 docs: add auto-tuning screen + borrowed-concept enrichments to brief
Adds the Auto-tuning wizard as hero screen 5 (#69) and annotates the
Channels-list and Schedule-editor sections + prompts with the borrowed
enrichments: lineup health + resume badge (#72, #68), weighted
distribution / reroll / pad-to-boundary / seasonal (#70, #71, #77, #73).

Refs #59 #69

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 00:45:07 +02:00
timothyandClaude Opus 4.8 482396497e docs: link Create Channel backend gaps to epic #62 (#63-#68)
Refs #59 #62

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 23:02:48 +02:00
timothyandClaude Opus 4.8 4229717575 docs: add Create Channel workflow redesign + backend gaps
Replaces the inherited 4-editor channel-creation chain with a single
library-to-lineup builder: browse/search library with artwork, drag
shows into the lineup, friendly toggles (shuffle, always-on), one image
for logo+bug, technical settings via built-in/custom Channel Templates
with advanced override. Lists prerequisite backend gaps (composite
create endpoint, Channel Templates, library browse API, full image-type
support incl. transparent re-encode, unified logo/bug, always-on vs
on-demand) and a Claude Design prompt for the screen.

Refs #59

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 23:00:09 +02:00
timothy d1dfe6eb5a docs: add Codex verification guidance
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m6s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m4s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m23s
2026-07-01 22:39:16 +02:00
timothyandClaude Opus 4.8 0826198d21 docs: add ChicoryTV UI redesign visual-direction brief (Pass 1)
Records the locked design direction for #59: React SPA on the REST API,
API-first (design pulls the API), Komodo-slick look, ChicoryTV dark +
chicory-blue brand. Includes the 4 hero screens with real domain
vocabulary and ready-to-paste Claude Design prompts.

Refs #59

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 20:58:12 +02:00
timothy a2d2f5930c chore: stop tracking local MCP config
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 2m31s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 3m31s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m50s
2026-07-01 19:59:18 +02:00
timothy 45ade1fd78 Merge pull request #56 from issue9-ffmpeg8
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m17s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m13s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
fix(ffmpeg): align transcode pipelines with ffmpeg 8
2026-07-01 17:51:13 +00:00
timothy e170b2dc3c fix(ffmpeg): align transcode pipelines with ffmpeg 8
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m4s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m17s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
refs #9
2026-07-01 19:37:51 +02:00
timothy 183f6c5d2a Merge pull request #55 from issue42-jellyfin-musicvideos
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m20s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m15s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m58s
feat(jellyfin): sync MusicVideo libraries
2026-06-30 20:54:20 +00:00
612 changed files with 113441 additions and 783 deletions
+33 -2
View File
@@ -50,6 +50,37 @@ jobs:
- name: Restore
run: dotnet restore
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22.x'
cache: npm
cache-dependency-path: web/package-lock.json
- name: Install SPA dependencies
working-directory: web
run: npm ci
- name: Check generated SPA API client
working-directory: web
run: npm run check:api
- name: Lint SPA
working-directory: web
run: npm run lint
- name: Typecheck SPA
working-directory: web
run: npm run typecheck
- name: Test SPA
working-directory: web
run: npm test -- --run
- name: Build SPA
working-directory: web
run: npm run build
- name: Strip Scanner project ref (matches Docker build)
run: sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
@@ -243,8 +274,8 @@ jobs:
done
echo " FAIL $path (no 2xx containing '$needle' within timeout)"; return 1
}
if check "/iptv/channels.m3u" "#EXTM3U" && check "/iptv/xmltv.xml" "<tv"; then
echo "Smoke + IPTV E2E passed: channels.m3u + xmltv.xml serve a valid playlist + guide"
if check "/iptv/channels.m3u" "#EXTM3U" && check "/iptv/xmltv.xml" "<tv" && check "/app/" "ChicoryTV"; then
echo "Smoke + IPTV E2E passed: channels.m3u + xmltv.xml serve a valid playlist + guide; /app/ serves the ChicoryTV SPA"
else
echo "===== container logs (tail) ====="; docker logs "$NAME" 2>&1 | tail -n 40 || true
exit 1
+7
View File
@@ -3,9 +3,13 @@
project.lock.json
.DS_Store
*.pyc
.worktrees/
# Claude Code
.mcp/
.mcp.json
.agents/
plugins/
nupkg/
# Visual Studio Code
@@ -48,3 +52,6 @@ scripts/download-test-content.sh
docker-compose.override.yml
ErsatzTV/wwwroot/v2/
ErsatzTV/wwwroot/app/
web/dist/
web/node_modules/
-56
View File
@@ -1,56 +0,0 @@
{
"mcpServers": {
"docker-mcp": {
"command": "uvx",
"args": [
"mcp-server-docker"
],
"env": {
"DOCKER_HOST": "ssh://timothy@192.168.1.99"
}
},
"ssh-mcp": {
"command": "npx",
"args": [
"-y",
"ssh-mcp",
"--",
"--host=192.168.1.99",
"--user=timothy"
],
"env": {}
},
"gitea": {
"command": "gitea-mcp-server",
"args": [
"-t", "stdio",
"-host", "http://192.168.1.95:3000",
"-token", "8341af0733ab9ce084ea7adf38b76aa9ebc3bd67"
],
"env": {}
},
"csharp-lsp": {
"command": "/usr/local/share/dotnet/dotnet",
"args": [
"run",
"--project", "/Users/timothy/ersatztv/.mcp/csharp-lsp-mcp/csharp-lsp-mcp/src/CSharpLspMcp",
"-c", "Release"
],
"env": {
"PATH": "/usr/local/share/dotnet:/Users/timothy/.dotnet/tools:/usr/bin:/bin:/usr/sbin:/sbin"
}
},
"nuget": {
"command": "/usr/local/share/dotnet/dotnet",
"args": [
"dnx",
"NuGet.Mcp.Server",
"--source", "https://api.nuget.org/v3/index.json",
"--yes"
],
"env": {
"DOTNET_ROOT": "/usr/local/share/dotnet"
}
}
}
}
+15
View File
@@ -0,0 +1,15 @@
# Codex Instructions
## Verification Commands
Run .NET restore, build, and test commands outside the sandbox by default in this repo. Sandboxed .NET commands can stall on NuGet/package/compiler cache access, while the same commands complete normally with approved unsandboxed execution.
Preferred verification commands:
```bash
TZ=UTC dotnet restore ErsatzTV.sln -v minimal
TZ=UTC dotnet build ErsatzTV.sln --no-restore -v minimal
TZ=UTC dotnet test ErsatzTV.sln --no-build -v minimal
```
Use scoped escalated execution for these commands rather than first trying a sandboxed run.
+7 -4
View File
@@ -4,7 +4,8 @@ Custom IPTV channel server for Jellyfin. Forked from [ErsatzTV/ErsatzTV](https:/
## Architecture
- **Language**: C# / .NET 10, Blazor Server UI (MudBlazor)
- **Language**: C# / .NET 10
- **UI**: ChicoryTV React SPA (`web/`, Vite, served at `/app`) over the REST API — the default UI; root `/` and migrated legacy routes 302 there (`ErsatzTV/LegacyUiRedirects.cs`). The legacy Blazor Server UI (MudBlazor) still serves the remaining un-migrated admin screens — playback troubleshooting, multi/rerun collections, and playlist editing depth; Blazor home = `/system/health`, reachable via the Settings → System "Classic UI" link. Media detail pages + image folder browser landed in the SPA via #141 (PR #183); its removal is #91 phase (b), gated on #145 (playback troubleshooting) and API gaps #151/#152/#153/#155 (scheduling parity #144/#162 DONE 2026-07-07: blocks/templates/decos/deco-templates/playout editors all in the SPA; #141/#158/#161/#180 also DONE)
- **Pattern**: CQRS via MediatR — queries/commands in `ErsatzTV.Application/`
- **Database**: EF Core (SQLite default, MySQL optional) — context in `ErsatzTV.Infrastructure/Data/TvContext.cs`
- **Media**: FFmpeg via CliWrap, SkiaSharp for logo generation
@@ -14,7 +15,8 @@ Custom IPTV channel server for Jellyfin. Forked from [ErsatzTV/ErsatzTV](https:/
| Project | Role |
|---------|------|
| `ErsatzTV/` | ASP.NET Core host, Blazor pages, API controllers, DI setup |
| `ErsatzTV/` | ASP.NET Core host, API controllers, SPA static hosting, legacy Blazor pages, DI setup |
| `web/` | ChicoryTV React SPA (Vite + TypeScript; builds into `ErsatzTV/wwwroot/app`) |
| `ErsatzTV.Application/` | MediatR handlers (business logic) |
| `ErsatzTV.Core/` | Domain entities, interfaces, no infrastructure deps |
| `ErsatzTV.Infrastructure/` | EF Core repos, data access |
@@ -36,7 +38,7 @@ Custom IPTV channel server for Jellyfin. Forked from [ErsatzTV/ErsatzTV](https:/
- **Docker host**: jazz (192.168.1.99), container `ersatztv`, port 8409
- **Config volume**: `~/downloadswarm/ersatztv/` on jazz → `/config` in container
- **SQLite DB**: `/config/ersatztv.sqlite3` (WAL mode, root-owned)
- **Images** (our fork, built by `.gitea/workflows/docker-build.yml``192.168.1.95:3000/timothy/ersatztv`): push to `main``:latest` + `:<sha>` (test image); push `v*` tag → `:prod` + `:<version>` + `:<sha>`. Prod container still runs upstream `ghcr.io/ersatztv/ersatztv:latest` pending cutover (server-management#481). Pipeline details: `docs/ci-cd.md`.
- **Images** (our fork, built by `.gitea/workflows/docker-build.yml``192.168.1.95:3000/timothy/ersatztv`): push to `main``:latest` + `:<sha>` (test image); push `v*` tag → `:prod` + `:<version>` + `:<sha>`. Prod deploys via **Komodo GitOps**: the `media-servers` compose in `timothy/server-management` (`docker/bumblebee/stacks/media-servers/compose.yaml`) pins the version tag (currently `26.5.0`, deployed 2026-07-07); releasing = tag here, wait for the image build, bump that pin and push (the Komodo pre-deploy hook backs up before recreating). Test container tracks `:latest`. Pipeline details: `docs/ci-cd.md`.
## Development
@@ -54,9 +56,10 @@ docker build -f docker/Dockerfile -t ersatztv:dev .
## Conventions
- **Read [`docs/contributing.md`](docs/contributing.md)** before non-trivial changes — it documents the established patterns (layering, CQRS handlers, LanguageExt, Blazor/MudBlazor, EF Core + dual-provider migrations, the FFmpeg pipeline, analyzers, testing) and the **deviation policy**: match the established style; diverge only with a concrete, stated reason.
- **Convention docs replace re-recon**: before API/SPA/E2E/parity work, read `docs/README.md` (index) → `docs/api-conventions.md`, `docs/spa-conventions.md`, `docs/e2e-local.md`, `docs/domain-model.md`, `docs/blazor-route-parity.md`, `docs/decisions.md`. Any PR that changes a convention, migrates a route, or reverses a decision MUST update the relevant doc in the same PR.
- Follow existing MediatR CQRS pattern for new features
- Domain logic in `ErsatzTV.Core`, infrastructure in `ErsatzTV.Infrastructure`
- Keep Blazor pages thin — delegate to MediatR handlers
- Keep UI thin: the SPA talks to `/api/*` only; legacy Blazor pages delegate to MediatR handlers. New screens go in the SPA (`web/`), never in Blazor
- Test with **NUnit** + Shouldly + NSubstitute (the existing `*.Tests` projects); xUnit is **not** used here
- **Dependencies use Central Package Management**: versions live in the repo-root `Directory.Packages.props`; csproj reference packages by name only. Add/upgrade by editing the central `<PackageVersion>` — never put `Version=` back on a `<PackageReference>` (trips `NU1008`). See `docs/ci-cd.md` → Dependency management.
- **DB migrations target BOTH providers**: a `TvContext` model change needs a migration in `ErsatzTV.Infrastructure.Sqlite` **and** `ErsatzTV.Infrastructure.MySql` — run `scripts/add-migration.sh <Name>` (does both). CI's `migrations` job enforces model-drift + apply-to-fresh-DB per provider. See `docs/ci-cd.md` → Migration integrity.
+5 -5
View File
@@ -6,18 +6,18 @@
<ItemGroup>
<PackageVersion Include="AsyncFixer" Version="2.1.0" />
<PackageVersion Include="Blazored.FluentValidation" Version="2.2.0" />
<PackageVersion Include="BlazorSortable" Version="5.2.1" />
<PackageVersion Include="BlazorSortable" Version="6.0.2" />
<PackageVersion Include="Blurhash.SkiaSharp" Version="2.0.0" />
<PackageVersion Include="Chronic.Core" Version="0.4.0" />
<PackageVersion Include="CliWrap" Version="3.10.0" />
<PackageVersion Include="CliWrap" Version="3.10.2" />
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
<PackageVersion Include="Dapper" Version="2.1.66" />
<PackageVersion Include="Dapper" Version="2.1.79" />
<PackageVersion Include="Destructurama.Attributed" Version="5.2.0" />
<PackageVersion Include="EFCore.BulkExtensions" Version="[9.0.2,10)" />
<PackageVersion Include="EFCore.BulkExtensions.MySql" Version="[9.0.2,10)" />
<PackageVersion Include="EFCore.BulkExtensions.Sqlite" Version="[9.0.2,10)" />
<PackageVersion Include="Elastic.Clients.Elasticsearch" Version="9.3.0" />
<PackageVersion Include="EntityFrameworkProfiler.Appender" Version="6.0.6049" />
<PackageVersion Include="EntityFrameworkProfiler.Appender" Version="6.0.6053" />
<PackageVersion Include="FluentValidation" Version="12.1.1" />
<PackageVersion Include="FluentValidation.AspNetCore" Version="11.3.1" />
<PackageVersion Include="Flurl" Version="4.0.0" />
@@ -79,7 +79,7 @@
<PackageVersion Include="RichTextKit.Stbear" Version="0.4.167.3" />
<PackageVersion Include="Roslynator.Analyzers" Version="4.15.0" />
<PackageVersion Include="Scalar.AspNetCore" Version="2.12.32" />
<PackageVersion Include="Scriban.Signed" Version="6.5.2" />
<PackageVersion Include="Scriban.Signed" Version="7.2.5" />
<PackageVersion Include="Serilog" Version="4.3.0" />
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageVersion Include="Serilog.Extensions.Hosting" Version="10.0.0" />
@@ -0,0 +1,13 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Artwork;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Artworks;
/// <summary>
/// Validates and stores an uploaded image as channel logo or watermark artwork,
/// landing it in the same on-disk cache the Blazor UI uses (via <c>IImageCache</c>),
/// so the returned path is equivalent to a Blazor-uploaded image.
/// </summary>
public record UploadArtwork(Stream Stream, string ContentType, ArtworkKind ArtworkKind)
: IRequest<Either<BaseError, ArtworkUploadResponseModel>>;
@@ -0,0 +1,53 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Artwork;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Images;
namespace ErsatzTV.Application.Artworks;
public class UploadArtworkHandler : IRequestHandler<UploadArtwork, Either<BaseError, ArtworkUploadResponseModel>>
{
// png/jpeg/gif/webp are all decoded by SkiaSharp and read by FFmpeg, matching the
// formats the Blazor logo/watermark upload already accepts. Format expansion is ersatztv#66.
private static readonly System.Collections.Generic.HashSet<string> AcceptedContentTypes = new(StringComparer.OrdinalIgnoreCase)
{
"image/png",
"image/jpeg",
"image/gif",
"image/webp"
};
private readonly IImageCache _imageCache;
public UploadArtworkHandler(IImageCache imageCache) => _imageCache = imageCache;
public async Task<Either<BaseError, ArtworkUploadResponseModel>> Handle(
UploadArtwork request,
CancellationToken cancellationToken)
{
string contentType = (request.ContentType ?? string.Empty).Trim();
if (!AcceptedContentTypes.Contains(contentType))
{
return BaseError.New(
$"Unsupported image content type '{contentType}'; supported types are: {string.Join(", ", AcceptedContentTypes)}");
}
Either<BaseError, string> maybeFileName = await _imageCache.SaveArtworkToCache(
request.Stream,
request.ArtworkKind);
return maybeFileName.Map(fileName => new ArtworkUploadResponseModel(
BuildPath(request.ArtworkKind, fileName),
contentType));
}
// Mirror the on-disk conventions the Blazor editors use so the returned path is a drop-in
// for ArtworkContentTypeModel.Path: channel logos are addressed as "iptv/logos/{file}"
// (see ChannelEditor.UploadLogo), watermarks by the bare cache file name (see WatermarkEditor).
private static string BuildPath(ArtworkKind artworkKind, string fileName) =>
artworkKind switch
{
ArtworkKind.Logo => $"iptv/logos/{fileName}",
_ => fileName
};
}
@@ -0,0 +1,24 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
namespace ErsatzTV.Application.ChannelTemplates;
internal static class ChannelTemplateDefault
{
public static async Task<int?> GetDefaultTemplateId(
IConfigElementRepository configElementRepository,
CancellationToken cancellationToken)
{
Option<int> maybeDefault =
await configElementRepository.GetValue<int>(
ConfigElementKey.ChannelTemplatesDefaultTemplateId,
cancellationToken);
int? result = null;
foreach (int id in maybeDefault)
{
result = id;
}
return result;
}
}
@@ -0,0 +1,38 @@
using ErsatzTV.Core.Api.ChannelTemplates;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.ChannelTemplates;
public static class ChannelTemplateMapper
{
public static ChannelTemplateResponseModel ProjectToResponseModel(ChannelTemplate template, int? defaultTemplateId) =>
new(
template.Id,
template.Name,
template.Description,
template.IsSystem,
defaultTemplateId == template.Id,
template.FFmpegProfileId,
template.WatermarkId,
template.FallbackFillerId,
template.PreRollFillerId,
template.MidRollFillerId,
template.PostRollFillerId,
template.StreamSelectorMode,
template.StreamSelector,
template.PreferredAudioLanguageCode,
template.PreferredAudioTitle,
template.PlayoutSource,
template.PlayoutMode,
template.StreamingMode,
template.PreferredSubtitleLanguageCode,
template.SubtitleMode,
template.MusicVideoCreditsMode,
template.MusicVideoCreditsTemplate,
template.SongVideoMode,
template.TranscodeMode,
template.IdleBehavior,
template.ShuffleScheduleItems,
template.RandomStartPoint,
template.FixedStartTimeBehavior);
}
@@ -0,0 +1,155 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.ChannelTemplates;
public abstract record ChannelTemplateCommandBase(
string Name,
string Description,
int FFmpegProfileId,
int? WatermarkId,
int? FallbackFillerId,
int? PreRollFillerId,
int? MidRollFillerId,
int? PostRollFillerId,
ChannelStreamSelectorMode StreamSelectorMode,
string StreamSelector,
string PreferredAudioLanguageCode,
string PreferredAudioTitle,
ChannelPlayoutSource PlayoutSource,
ChannelPlayoutMode PlayoutMode,
StreamingMode StreamingMode,
string PreferredSubtitleLanguageCode,
ChannelSubtitleMode SubtitleMode,
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
string MusicVideoCreditsTemplate,
ChannelSongVideoMode SongVideoMode,
ChannelTranscodeMode TranscodeMode,
ChannelIdleBehavior IdleBehavior,
bool ShuffleScheduleItems,
bool RandomStartPoint,
FixedStartTimeBehavior FixedStartTimeBehavior)
{
internal static async Task<Option<BaseError>> ValidateCommon(
TvContext dbContext,
ChannelTemplateCommandBase request,
int? existingTemplateId,
CancellationToken cancellationToken)
{
string name = NormalizeName(request.Name);
if (string.IsNullOrWhiteSpace(name))
{
return BaseError.New("Name is required.");
}
if (name.Length > 50)
{
return BaseError.New("Name must be 50 characters or less.");
}
if (request.Description?.Length > 500)
{
return BaseError.New("Description must be 500 characters or less.");
}
bool duplicateName = await dbContext.ChannelTemplates
.AnyAsync(t => t.Id != existingTemplateId && t.Name == name, cancellationToken);
if (duplicateName)
{
return BaseError.New("Channel template name must be unique.");
}
bool ffmpegProfileExists = await dbContext.FFmpegProfiles
.AnyAsync(p => p.Id == request.FFmpegProfileId, cancellationToken);
if (!ffmpegProfileExists)
{
return new NotFoundError($"FFmpegProfile {request.FFmpegProfileId} does not exist.");
}
foreach (int watermarkId in Optional(request.WatermarkId))
{
bool watermarkExists = await dbContext.ChannelWatermarks
.AnyAsync(w => w.Id == watermarkId, cancellationToken);
if (!watermarkExists)
{
return new NotFoundError($"Watermark {watermarkId} does not exist.");
}
}
Option<BaseError> maybeFillerError =
await FillerMustExist(dbContext, request.FallbackFillerId, FillerKind.Fallback, cancellationToken);
if (maybeFillerError.IsSome)
{
return maybeFillerError;
}
maybeFillerError = await FillerMustExist(dbContext, request.PreRollFillerId, FillerKind.PreRoll, cancellationToken);
if (maybeFillerError.IsSome)
{
return maybeFillerError;
}
maybeFillerError = await FillerMustExist(dbContext, request.MidRollFillerId, FillerKind.MidRoll, cancellationToken);
if (maybeFillerError.IsSome)
{
return maybeFillerError;
}
return await FillerMustExist(dbContext, request.PostRollFillerId, FillerKind.PostRoll, cancellationToken);
}
internal void ApplyTo(ChannelTemplate template)
{
template.Name = NormalizeName(Name);
template.Description = Description ?? string.Empty;
template.FFmpegProfileId = FFmpegProfileId;
template.WatermarkId = WatermarkId;
template.FallbackFillerId = FallbackFillerId;
template.PreRollFillerId = PreRollFillerId;
template.MidRollFillerId = MidRollFillerId;
template.PostRollFillerId = PostRollFillerId;
template.StreamSelectorMode = StreamSelectorMode;
template.StreamSelector = StreamSelector ?? string.Empty;
template.PreferredAudioLanguageCode = PreferredAudioLanguageCode ?? string.Empty;
template.PreferredAudioTitle = PreferredAudioTitle ?? string.Empty;
template.PlayoutSource = PlayoutSource;
template.PlayoutMode = PlayoutMode;
template.StreamingMode = StreamingMode;
template.PreferredSubtitleLanguageCode = PreferredSubtitleLanguageCode ?? string.Empty;
template.SubtitleMode = SubtitleMode;
template.MusicVideoCreditsMode = MusicVideoCreditsMode;
template.MusicVideoCreditsTemplate = MusicVideoCreditsTemplate ?? string.Empty;
template.SongVideoMode = SongVideoMode;
template.TranscodeMode = TranscodeMode;
template.IdleBehavior = IdleBehavior;
template.ShuffleScheduleItems = ShuffleScheduleItems;
template.RandomStartPoint = RandomStartPoint;
template.FixedStartTimeBehavior = FixedStartTimeBehavior;
}
internal static string NormalizeName(string name) => (name ?? string.Empty).Trim();
private static async Task<Option<BaseError>> FillerMustExist(
TvContext dbContext,
int? fillerPresetId,
FillerKind fillerKind,
CancellationToken cancellationToken)
{
foreach (int id in Optional(fillerPresetId))
{
bool exists = await dbContext.FillerPresets
.AnyAsync(f => f.Id == id && f.FillerKind == fillerKind, cancellationToken);
if (!exists)
{
return new NotFoundError($"{fillerKind} filler {id} does not exist.");
}
}
return Option<BaseError>.None;
}
}
@@ -0,0 +1,60 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Api.ChannelTemplates;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Application.ChannelTemplates;
public record CreateChannelTemplate(
string Name,
string Description,
int FFmpegProfileId,
int? WatermarkId,
int? FallbackFillerId,
int? PreRollFillerId,
int? MidRollFillerId,
int? PostRollFillerId,
ChannelStreamSelectorMode StreamSelectorMode,
string StreamSelector,
string PreferredAudioLanguageCode,
string PreferredAudioTitle,
ChannelPlayoutSource PlayoutSource,
ChannelPlayoutMode PlayoutMode,
StreamingMode StreamingMode,
string PreferredSubtitleLanguageCode,
ChannelSubtitleMode SubtitleMode,
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
string MusicVideoCreditsTemplate,
ChannelSongVideoMode SongVideoMode,
ChannelTranscodeMode TranscodeMode,
ChannelIdleBehavior IdleBehavior,
bool ShuffleScheduleItems,
bool RandomStartPoint,
FixedStartTimeBehavior FixedStartTimeBehavior)
: ChannelTemplateCommandBase(
Name,
Description,
FFmpegProfileId,
WatermarkId,
FallbackFillerId,
PreRollFillerId,
MidRollFillerId,
PostRollFillerId,
StreamSelectorMode,
StreamSelector,
PreferredAudioLanguageCode,
PreferredAudioTitle,
PlayoutSource,
PlayoutMode,
StreamingMode,
PreferredSubtitleLanguageCode,
SubtitleMode,
MusicVideoCreditsMode,
MusicVideoCreditsTemplate,
SongVideoMode,
TranscodeMode,
IdleBehavior,
ShuffleScheduleItems,
RandomStartPoint,
FixedStartTimeBehavior),
IRequest<Either<BaseError, ChannelTemplateResponseModel>>;
@@ -0,0 +1,36 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Api.ChannelTemplates;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.ChannelTemplates;
public class CreateChannelTemplateHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository)
: IRequestHandler<CreateChannelTemplate, Either<BaseError, ChannelTemplateResponseModel>>
{
public async Task<Either<BaseError, ChannelTemplateResponseModel>> Handle(
CreateChannelTemplate request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<BaseError> maybeError =
await ChannelTemplateCommandBase.ValidateCommon(dbContext, request, null, cancellationToken);
foreach (BaseError error in maybeError)
{
return error;
}
var template = new ChannelTemplate();
request.ApplyTo(template);
await dbContext.ChannelTemplates.AddAsync(template, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
int? defaultTemplateId =
await ChannelTemplateDefault.GetDefaultTemplateId(configElementRepository, cancellationToken);
return ChannelTemplateMapper.ProjectToResponseModel(template, defaultTemplateId);
}
}
@@ -0,0 +1,5 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.ChannelTemplates;
public record DeleteChannelTemplate(int ChannelTemplateId) : IRequest<Either<BaseError, Unit>>;
@@ -0,0 +1,42 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.ChannelTemplates;
public class DeleteChannelTemplateHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository)
: IRequestHandler<DeleteChannelTemplate, Either<BaseError, Unit>>
{
public async Task<Either<BaseError, Unit>> Handle(DeleteChannelTemplate request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<ChannelTemplate> maybeTemplate = await dbContext.ChannelTemplates
.SelectOneAsync(t => t.Id, t => t.Id == request.ChannelTemplateId, cancellationToken);
foreach (ChannelTemplate template in maybeTemplate)
{
if (template.IsSystem)
{
return BaseError.New("System templates cannot be deleted.");
}
int? defaultTemplateId =
await ChannelTemplateDefault.GetDefaultTemplateId(configElementRepository, cancellationToken);
if (defaultTemplateId == template.Id)
{
return BaseError.New("Default channel template cannot be deleted.");
}
dbContext.ChannelTemplates.Remove(template);
await dbContext.SaveChangesAsync(cancellationToken);
return Unit.Default;
}
return new NotFoundError($"ChannelTemplate {request.ChannelTemplateId} does not exist.");
}
}
@@ -0,0 +1,6 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Api.ChannelTemplates;
namespace ErsatzTV.Application.ChannelTemplates;
public record SetDefaultChannelTemplate(int ChannelTemplateId) : IRequest<Either<BaseError, ChannelTemplateResponseModel>>;
@@ -0,0 +1,36 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Api.ChannelTemplates;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.ChannelTemplates;
public class SetDefaultChannelTemplateHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository)
: IRequestHandler<SetDefaultChannelTemplate, Either<BaseError, ChannelTemplateResponseModel>>
{
public async Task<Either<BaseError, ChannelTemplateResponseModel>> Handle(
SetDefaultChannelTemplate request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<ChannelTemplate> maybeTemplate = await dbContext.ChannelTemplates
.AsNoTracking()
.SelectOneAsync(t => t.Id, t => t.Id == request.ChannelTemplateId, cancellationToken);
foreach (ChannelTemplate template in maybeTemplate)
{
await configElementRepository.Upsert(
ConfigElementKey.ChannelTemplatesDefaultTemplateId,
template.Id,
cancellationToken);
return ChannelTemplateMapper.ProjectToResponseModel(template, template.Id);
}
return new NotFoundError($"ChannelTemplate {request.ChannelTemplateId} does not exist.");
}
}
@@ -0,0 +1,61 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Api.ChannelTemplates;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Application.ChannelTemplates;
public record UpdateChannelTemplate(
int ChannelTemplateId,
string Name,
string Description,
int FFmpegProfileId,
int? WatermarkId,
int? FallbackFillerId,
int? PreRollFillerId,
int? MidRollFillerId,
int? PostRollFillerId,
ChannelStreamSelectorMode StreamSelectorMode,
string StreamSelector,
string PreferredAudioLanguageCode,
string PreferredAudioTitle,
ChannelPlayoutSource PlayoutSource,
ChannelPlayoutMode PlayoutMode,
StreamingMode StreamingMode,
string PreferredSubtitleLanguageCode,
ChannelSubtitleMode SubtitleMode,
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
string MusicVideoCreditsTemplate,
ChannelSongVideoMode SongVideoMode,
ChannelTranscodeMode TranscodeMode,
ChannelIdleBehavior IdleBehavior,
bool ShuffleScheduleItems,
bool RandomStartPoint,
FixedStartTimeBehavior FixedStartTimeBehavior)
: ChannelTemplateCommandBase(
Name,
Description,
FFmpegProfileId,
WatermarkId,
FallbackFillerId,
PreRollFillerId,
MidRollFillerId,
PostRollFillerId,
StreamSelectorMode,
StreamSelector,
PreferredAudioLanguageCode,
PreferredAudioTitle,
PlayoutSource,
PlayoutMode,
StreamingMode,
PreferredSubtitleLanguageCode,
SubtitleMode,
MusicVideoCreditsMode,
MusicVideoCreditsTemplate,
SongVideoMode,
TranscodeMode,
IdleBehavior,
ShuffleScheduleItems,
RandomStartPoint,
FixedStartTimeBehavior),
IRequest<Either<BaseError, ChannelTemplateResponseModel>>;
@@ -0,0 +1,51 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Api.ChannelTemplates;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.ChannelTemplates;
public class UpdateChannelTemplateHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository)
: IRequestHandler<UpdateChannelTemplate, Either<BaseError, ChannelTemplateResponseModel>>
{
public async Task<Either<BaseError, ChannelTemplateResponseModel>> Handle(
UpdateChannelTemplate request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<ChannelTemplate> maybeTemplate = await dbContext.ChannelTemplates
.SelectOneAsync(t => t.Id, t => t.Id == request.ChannelTemplateId, cancellationToken);
foreach (ChannelTemplate template in maybeTemplate)
{
if (template.IsSystem)
{
return BaseError.New("System templates cannot be updated.");
}
Option<BaseError> maybeError =
await ChannelTemplateCommandBase.ValidateCommon(
dbContext,
request,
request.ChannelTemplateId,
cancellationToken);
foreach (BaseError error in maybeError)
{
return error;
}
request.ApplyTo(template);
await dbContext.SaveChangesAsync(cancellationToken);
int? defaultTemplateId =
await ChannelTemplateDefault.GetDefaultTemplateId(configElementRepository, cancellationToken);
return ChannelTemplateMapper.ProjectToResponseModel(template, defaultTemplateId);
}
return new NotFoundError($"ChannelTemplate {request.ChannelTemplateId} does not exist.");
}
}
@@ -0,0 +1,5 @@
using ErsatzTV.Core.Api.ChannelTemplates;
namespace ErsatzTV.Application.ChannelTemplates;
public record GetAllChannelTemplates : IRequest<List<ChannelTemplateResponseModel>>;
@@ -0,0 +1,28 @@
using ErsatzTV.Core.Api.ChannelTemplates;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.ChannelTemplates;
public class GetAllChannelTemplatesHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository)
: IRequestHandler<GetAllChannelTemplates, List<ChannelTemplateResponseModel>>
{
public async Task<List<ChannelTemplateResponseModel>> Handle(
GetAllChannelTemplates request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
int? defaultTemplateId =
await ChannelTemplateDefault.GetDefaultTemplateId(configElementRepository, cancellationToken);
return await dbContext.ChannelTemplates
.AsNoTracking()
.OrderBy(t => t.IsSystem ? 0 : 1)
.ThenBy(t => t.Name)
.Select(t => ChannelTemplateMapper.ProjectToResponseModel(t, defaultTemplateId))
.ToListAsync(cancellationToken);
}
}
@@ -0,0 +1,5 @@
using ErsatzTV.Core.Api.ChannelTemplates;
namespace ErsatzTV.Application.ChannelTemplates;
public record GetChannelTemplateById(int ChannelTemplateId) : IRequest<Option<ChannelTemplateResponseModel>>;
@@ -0,0 +1,27 @@
using ErsatzTV.Core.Api.ChannelTemplates;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.ChannelTemplates;
public class GetChannelTemplateByIdHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository)
: IRequestHandler<GetChannelTemplateById, Option<ChannelTemplateResponseModel>>
{
public async Task<Option<ChannelTemplateResponseModel>> Handle(
GetChannelTemplateById request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
int? defaultTemplateId =
await ChannelTemplateDefault.GetDefaultTemplateId(configElementRepository, cancellationToken);
Option<ChannelTemplate> maybeTemplate = await dbContext.ChannelTemplates
.AsNoTracking()
.SelectOneAsync(t => t.Id, t => t.Id == request.ChannelTemplateId, cancellationToken);
return maybeTemplate.Map(t => ChannelTemplateMapper.ProjectToResponseModel(t, defaultTemplateId));
}
}
@@ -0,0 +1,5 @@
using ErsatzTV.Core.Api.ChannelTemplates;
namespace ErsatzTV.Application.ChannelTemplates;
public record GetDefaultChannelTemplate : IRequest<Option<ChannelTemplateResponseModel>>;
@@ -0,0 +1,40 @@
using ErsatzTV.Core.Api.ChannelTemplates;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.ChannelTemplates;
public class GetDefaultChannelTemplateHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository)
: IRequestHandler<GetDefaultChannelTemplate, Option<ChannelTemplateResponseModel>>
{
public async Task<Option<ChannelTemplateResponseModel>> Handle(
GetDefaultChannelTemplate request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
int? defaultTemplateId =
await ChannelTemplateDefault.GetDefaultTemplateId(configElementRepository, cancellationToken);
foreach (int id in Optional(defaultTemplateId))
{
Option<ChannelTemplate> maybeConfigured = await dbContext.ChannelTemplates
.AsNoTracking()
.SelectOneAsync(t => t.Id, t => t.Id == id, cancellationToken);
foreach (ChannelTemplate template in maybeConfigured)
{
return ChannelTemplateMapper.ProjectToResponseModel(template, id);
}
}
ChannelTemplate fallback = await dbContext.ChannelTemplates
.AsNoTracking()
.Where(t => t.IsSystem)
.OrderBy(t => t.Name)
.FirstOrDefaultAsync(cancellationToken);
return Optional(fallback).Map(t => ChannelTemplateMapper.ProjectToResponseModel(t, t.Id));
}
}
@@ -0,0 +1,71 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Channels;
/// <summary>
/// Shared programme-metadata projection for guide output. Both the XMLTV cache builder
/// (<see cref="RefreshChannelDataHandler" />) and the JSON guide query
/// (<see cref="GetChannelGuideDataHandler" />) resolve the display title/subtitle/category from a
/// <see cref="PlayoutItem" /> here so the two representations stay consistent.
/// </summary>
public static class ChannelGuideMetadata
{
public static string GetTitle(PlayoutItem playoutItem)
{
if (!string.IsNullOrWhiteSpace(playoutItem.CustomTitle))
{
return playoutItem.CustomTitle;
}
return playoutItem.MediaItem switch
{
Movie m => m.MovieMetadata.HeadOrNone().Map(mm => mm.Title ?? string.Empty)
.IfNone("[unknown movie]"),
Episode e => e.Season.Show.ShowMetadata.HeadOrNone().Map(em => em.Title ?? string.Empty)
.IfNone("[unknown show]"),
MusicVideo mv => mv.Artist.ArtistMetadata.HeadOrNone().Map(am => am.Title ?? string.Empty)
.IfNone("[unknown artist]"),
OtherVideo ov => ov.OtherVideoMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty)
.IfNone("[unknown video]"),
RemoteStream rs => rs.RemoteStreamMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty)
.IfNone("[unknown remote stream]"),
_ => "[unknown]"
};
}
public static string GetSubtitle(PlayoutItem playoutItem)
{
if (!string.IsNullOrWhiteSpace(playoutItem.CustomTitle))
{
return string.Empty;
}
return playoutItem.MediaItem switch
{
Episode e => e.EpisodeMetadata.HeadOrNone().Match(
em => em.Title ?? string.Empty,
() => string.Empty),
MusicVideo mv => mv.MusicVideoMetadata.HeadOrNone().Match(
mvm => mvm.Title ?? string.Empty,
() => string.Empty),
Song s => s.SongMetadata.HeadOrNone().Match(
mvm => mvm.Title ?? string.Empty,
() => string.Empty),
_ => string.Empty
};
}
/// <summary>
/// The primary guide category, mirroring the fixed <c>&lt;category&gt;</c> the XMLTV templates
/// emit per media kind (Movie / Series / Music). Media kinds without a fixed category return null.
/// </summary>
public static string GetCategory(PlayoutItem playoutItem) =>
playoutItem.MediaItem switch
{
Movie => "Movie",
Episode => "Series",
MusicVideo => "Music",
Song => "Music",
_ => null
};
}
@@ -0,0 +1,164 @@
using ErsatzTV.Application.Configuration;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
namespace ErsatzTV.Application.Channels;
/// <summary>
/// A single guide programme resolved from one or more <see cref="PlayoutItem" />s: the
/// <see cref="DisplayItem" /> whose metadata is shown, plus the coalesced <see cref="Start" />/
/// <see cref="Stop" /> window and whether the originating item carried a custom title.
/// </summary>
public readonly record struct ChannelGuideEntry(
PlayoutItem DisplayItem,
DateTimeOffset Start,
DateTimeOffset Stop,
bool HasCustomTitle);
/// <summary>
/// Shared guide-group / filler-merge projection. This is the single source of truth for turning a
/// channel's sorted <see cref="PlayoutItem" />s into guide programmes; both the XMLTV cache builder
/// (<see cref="RefreshChannelDataHandler" />) and the JSON guide query
/// (<see cref="GetChannelGuideDataHandler" />) consume it so the two representations cannot drift.
/// The XMLTV path formats <see cref="ChannelGuideEntry.Start" />/<see cref="ChannelGuideEntry.Stop" />
/// into the XMLTV timestamp strings; the JSON path returns them (and the display item's
/// <see cref="FillerKind" />) directly and lets the UI decide how to render filler.
/// </summary>
public static class ChannelGuideProjector
{
public static IEnumerable<ChannelGuideEntry> Project(
PlayoutScheduleKind scheduleKind,
IReadOnlyList<PlayoutItem> sorted,
XmltvTimeZone timeZone,
XmltvBlockBehavior blockBehavior) =>
scheduleKind switch
{
PlayoutScheduleKind.Block => ProjectBlock(sorted, timeZone, blockBehavior),
_ => ProjectFlood(sorted, timeZone)
};
// Classic / Sequential / Scripted / ExternalJson: skip leading non-preroll filler, then coalesce
// each guide group (following filler) into a single programme using the display item's GuideFinish
// override when present.
private static IEnumerable<ChannelGuideEntry> ProjectFlood(
IReadOnlyList<PlayoutItem> sorted,
XmltvTimeZone timeZone)
{
// skip all filler that isn't pre-roll
var i = 0;
while (i < sorted.Count && sorted[i].FillerKind != FillerKind.None &&
sorted[i].FillerKind != FillerKind.PreRoll)
{
i++;
}
while (i < sorted.Count)
{
PlayoutItem startItem = sorted[i];
int j = i;
while (sorted[j].FillerKind != FillerKind.None && j + 1 < sorted.Count)
{
j++;
}
PlayoutItem displayItem = sorted[j];
bool hasCustomTitle = !string.IsNullOrWhiteSpace(startItem.CustomTitle);
int finishIndex = j;
while (finishIndex + 1 < sorted.Count && (sorted[finishIndex + 1].GuideGroup == startItem.GuideGroup
|| sorted[finishIndex + 1].FillerKind is FillerKind.GuideMode
or FillerKind.PostRoll or FillerKind.Tail
or FillerKind.Fallback or FillerKind.DecoDefault))
{
finishIndex++;
}
PlayoutItem finishItem = sorted[finishIndex];
i = finishIndex;
DateTimeOffset startTime = timeZone switch
{
XmltvTimeZone.Utc => new DateTimeOffset(startItem.Start, TimeSpan.Zero),
_ => startItem.StartOffset
};
DateTimeOffset stopTime = (timeZone, displayItem.GuideFinishOffset.HasValue) switch
{
(XmltvTimeZone.Utc, true) => new DateTimeOffset(displayItem.GuideFinish!.Value, TimeSpan.Zero),
(XmltvTimeZone.Utc, false) => new DateTimeOffset(finishItem.Finish, TimeSpan.Zero),
(_, true) => displayItem.GuideFinishOffset!.Value,
(_, false) => finishItem.FinishOffset
};
yield return new ChannelGuideEntry(displayItem, startTime, stopTime, hasCustomTitle);
i++;
}
}
// Block: group by guide window, drop filler entirely, then either use the items' actual times or
// split the group window evenly across the non-filler items.
private static IEnumerable<ChannelGuideEntry> ProjectBlock(
IReadOnlyList<PlayoutItem> sorted,
XmltvTimeZone timeZone,
XmltvBlockBehavior blockBehavior)
{
var groups = sorted.GroupBy(s => new { s.GuideStart, s.GuideFinish, s.GuideGroup });
foreach (var group in groups)
{
var itemsToInclude = group.Filter(g => g.FillerKind is FillerKind.None).ToList();
if (itemsToInclude.Count == 0)
{
continue;
}
switch (blockBehavior)
{
case XmltvBlockBehavior.UseActualTimes:
foreach (PlayoutItem item in itemsToInclude)
{
DateTimeOffset actualStart = timeZone switch
{
XmltvTimeZone.Utc => new DateTimeOffset(item.Start, TimeSpan.Zero),
_ => new DateTimeOffset(item.Start, TimeSpan.Zero).ToLocalTime()
};
DateTimeOffset actualFinish = timeZone switch
{
XmltvTimeZone.Utc => new DateTimeOffset(item.Finish, TimeSpan.Zero),
_ => new DateTimeOffset(item.Finish, TimeSpan.Zero).ToLocalTime()
};
yield return new ChannelGuideEntry(item, actualStart, actualFinish, false);
}
break;
case XmltvBlockBehavior.SplitTimeEvenly:
default:
DateTime groupStart = group.Key.GuideStart!.Value;
DateTime groupFinish = group.Key.GuideFinish!.Value;
TimeSpan groupDuration = groupFinish - groupStart;
TimeSpan perItem = groupDuration / itemsToInclude.Count;
DateTimeOffset currentStart = timeZone switch
{
XmltvTimeZone.Utc => new DateTimeOffset(groupStart, TimeSpan.Zero),
_ => new DateTimeOffset(groupStart, TimeSpan.Zero).ToLocalTime()
};
DateTimeOffset currentFinish = currentStart + perItem;
foreach (PlayoutItem item in itemsToInclude)
{
yield return new ChannelGuideEntry(item, currentStart, currentFinish, false);
currentStart = currentFinish;
currentFinish += perItem;
}
break;
}
}
}
}
@@ -0,0 +1,5 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Channels;
public record BulkDeleteChannels(IReadOnlyList<int> ChannelIds) : IRequest<Either<BaseError, Unit>>;
@@ -0,0 +1,62 @@
using System.IO.Abstractions;
using System.Threading.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Channel = ErsatzTV.Core.Domain.Channel;
namespace ErsatzTV.Application.Channels;
public class BulkDeleteChannelsHandler(
IDbContextFactory<TvContext> dbContextFactory,
ChannelWriter<IBackgroundServiceRequest> workerChannel,
IFileSystem fileSystem,
ISearchTargets searchTargets)
: IRequestHandler<BulkDeleteChannels, Either<BaseError, Unit>>
{
public async Task<Either<BaseError, Unit>> Handle(
BulkDeleteChannels request,
CancellationToken cancellationToken)
{
if (request.ChannelIds.Count == 0)
{
return Left<BaseError, Unit>(BaseError.New("At least one channel id is required"));
}
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
List<int> channelIds = request.ChannelIds.Distinct().ToList();
List<Channel> channels = await dbContext.Channels
.Where(c => channelIds.Contains(c.Id))
.ToListAsync(cancellationToken);
if (channels.Count != channelIds.Count)
{
var found = channels.Select(c => c.Id).ToHashSet();
int missingId = channelIds.First(id => !found.Contains(id));
return Left<BaseError, Unit>(new NotFoundError($"Channel {missingId} does not exist."));
}
dbContext.Channels.RemoveRange(channels);
await dbContext.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
searchTargets.SearchTargetsChanged();
foreach (Channel channel in channels)
{
string cacheFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{channel.Number}.xml");
if (fileSystem.File.Exists(cacheFile))
{
fileSystem.File.Delete(cacheFile);
}
}
await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
return Right<BaseError, Unit>(Unit.Default);
}
}
@@ -0,0 +1,6 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Channels;
public record BulkMoveChannelsToGroup(IReadOnlyList<int> ChannelIds, string Group)
: IRequest<Either<BaseError, Unit>>;
@@ -0,0 +1,61 @@
using System.Threading.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.Channels.ChannelValidations;
using Channel = ErsatzTV.Core.Domain.Channel;
namespace ErsatzTV.Application.Channels;
public class BulkMoveChannelsToGroupHandler(
IDbContextFactory<TvContext> dbContextFactory,
ChannelWriter<IBackgroundServiceRequest> workerChannel,
ISearchTargets searchTargets)
: IRequestHandler<BulkMoveChannelsToGroup, Either<BaseError, Unit>>
{
public async Task<Either<BaseError, Unit>> Handle(
BulkMoveChannelsToGroup request,
CancellationToken cancellationToken)
{
if (request.ChannelIds.Count == 0)
{
return Left<BaseError, Unit>(BaseError.New("At least one channel id is required"));
}
Validation<BaseError, string> groupValidation = ValidateGroup(request.Group);
if (groupValidation.IsFail)
{
return Left<BaseError, Unit>(groupValidation.FailToSeq().Head());
}
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
List<int> channelIds = request.ChannelIds.Distinct().ToList();
List<Channel> channels = await dbContext.Channels
.Where(c => channelIds.Contains(c.Id))
.ToListAsync(cancellationToken);
if (channels.Count != channelIds.Count)
{
var found = channels.Select(c => c.Id).ToHashSet();
int missingId = channelIds.First(id => !found.Contains(id));
return Left<BaseError, Unit>(new NotFoundError($"Channel {missingId} does not exist."));
}
foreach (Channel channel in channels)
{
channel.Group = request.Group;
}
await dbContext.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
searchTargets.SearchTargetsChanged();
await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
return Right<BaseError, Unit>(Unit.Default);
}
}
@@ -0,0 +1,56 @@
using ErsatzTV.Application.Artworks;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Application.Channels;
public record CreateChannelFromLineup(
string Name,
string Number,
string Group,
string Categories,
ArtworkContentTypeModel Logo,
bool IsEnabled,
bool ShowInEpg,
int TemplateId,
CreateChannelFromLineupAdvancedOptions Advanced,
List<CreateChannelFromLineupItem> Lineup) : IRequest<Either<BaseError, CreateChannelFromLineupResponseModel>>;
public record CreateChannelFromLineupAdvancedOptions(
PlaybackOrder? PlaybackOrder = null,
int? FFmpegProfileId = null,
int? WatermarkId = null,
int? FallbackFillerId = null,
int? PreRollFillerId = null,
int? MidRollFillerId = null,
int? PostRollFillerId = null,
ChannelStreamSelectorMode? StreamSelectorMode = null,
string StreamSelector = null,
string PreferredAudioLanguageCode = null,
string PreferredAudioTitle = null,
ChannelPlayoutSource? PlayoutSource = null,
ChannelPlayoutMode? PlayoutMode = null,
StreamingMode? StreamingMode = null,
string PreferredSubtitleLanguageCode = null,
ChannelSubtitleMode? SubtitleMode = null,
ChannelMusicVideoCreditsMode? MusicVideoCreditsMode = null,
string MusicVideoCreditsTemplate = null,
ChannelSongVideoMode? SongVideoMode = null,
ChannelTranscodeMode? TranscodeMode = null,
ChannelIdleBehavior? IdleBehavior = null,
bool? ShuffleScheduleItems = null,
bool? RandomStartPoint = null,
FixedStartTimeBehavior? FixedStartTimeBehavior = null);
public record CreateChannelFromLineupItem(
LibraryBrowseMediaType MediaType,
CollectionType CollectionType,
int? CollectionId,
int? MultiCollectionId,
int? SmartCollectionId,
int? RerunCollectionId,
int? MediaItemId,
int? PlaylistId);
@@ -0,0 +1,754 @@
using System.Globalization;
using System.Text.RegularExpressions;
using System.Threading.Channels;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Channel = ErsatzTV.Core.Domain.Channel;
namespace ErsatzTV.Application.Channels;
public class CreateChannelFromLineupHandler(
ChannelWriter<IBackgroundServiceRequest> workerChannel,
IDbContextFactory<TvContext> dbContextFactory,
ISearchTargets searchTargets,
ILogger<CreateChannelFromLineupHandler> logger)
: IRequestHandler<CreateChannelFromLineup, Either<BaseError, CreateChannelFromLineupResponseModel>>
{
// The single system playlist group that holds every generated channel-lineup playlist.
// Matches the Trakt "Trakt Lists" precedent (DbInitializer + delete guards on IsSystem).
private const string SystemPlaylistGroupName = "Channel Lineups";
public async Task<Either<BaseError, CreateChannelFromLineupResponseModel>> Handle(
CreateChannelFromLineup request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Either<BaseError, PreparedCreate> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Match(
Left: error => Task.FromResult<Either<BaseError, CreateChannelFromLineupResponseModel>>(error),
Right: prepared => PersistAndDispatch(dbContext, prepared, cancellationToken));
}
private async Task<Either<BaseError, CreateChannelFromLineupResponseModel>> PersistAndDispatch(
TvContext dbContext,
PreparedCreate prepared,
CancellationToken cancellationToken)
{
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
try
{
dbContext.Channels.Add(prepared.Channel);
if (prepared.Playlist is not null)
{
dbContext.Playlists.Add(prepared.Playlist);
}
dbContext.ProgramSchedules.Add(prepared.ProgramSchedule);
dbContext.Playouts.Add(prepared.Playout);
await dbContext.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
}
catch (DbUpdateException ex)
{
await transaction.RollbackAsync(cancellationToken);
logger.LogError(ex, "Failed to persist channel created from lineup");
return BaseError.New("Unable to create channel from lineup");
}
searchTargets.SearchTargetsChanged();
await workerChannel.WriteAsync(
new BuildPlayout(prepared.Playout.Id, PlayoutBuildMode.Reset),
cancellationToken);
// Mirror CreateClassicPlayoutHandler: on-demand playouts must be time-shifted to "now" after build.
if (prepared.Channel.PlayoutMode is ChannelPlayoutMode.OnDemand)
{
await workerChannel.WriteAsync(
new TimeShiftOnDemandPlayout(prepared.Playout.Id, DateTimeOffset.Now, false),
cancellationToken);
}
await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
return new CreateChannelFromLineupResponseModel(
prepared.Channel.Id,
prepared.Playlist?.Id,
prepared.ProgramSchedule.Id,
prepared.Playout.Id);
}
private static async Task<Either<BaseError, PreparedCreate>> Validate(
TvContext dbContext,
CreateChannelFromLineup request,
CancellationToken cancellationToken)
{
string name = (request.Name ?? string.Empty).Trim();
string number = (request.Number ?? string.Empty).Trim();
string group = (request.Group ?? string.Empty).Trim();
string categories = (request.Categories ?? string.Empty).Trim();
CreateChannelFromLineupAdvancedOptions advanced = request.Advanced ?? new CreateChannelFromLineupAdvancedOptions();
if (string.IsNullOrWhiteSpace(name))
{
return BaseError.New("Channel name is required");
}
if (name.Length > 50)
{
return BaseError.New("Channel name must be 50 characters or fewer");
}
if (string.IsNullOrWhiteSpace(group))
{
return BaseError.New("Channel group is required");
}
if (!Regex.IsMatch(number, Channel.NumberValidator))
{
return BaseError.New("Invalid channel number; two decimals are allowed for subchannels");
}
if (await dbContext.Channels.AnyAsync(c => c.Number == number, cancellationToken))
{
return BaseError.New("Channel number must be unique");
}
if (!request.IsEnabled && request.ShowInEpg)
{
return BaseError.New("Disabled channels cannot be shown in EPG");
}
if (!string.IsNullOrWhiteSpace(request.Logo?.Path) &&
Uri.TryCreate(request.Logo.Path, UriKind.Absolute, out _) &&
!Artwork.IsExternalUrl(request.Logo.Path))
{
return BaseError.New("External logo url is invalid");
}
if (request.Lineup is null || request.Lineup.Count == 0)
{
return BaseError.New("Lineup must contain at least one item");
}
ChannelTemplate template = await dbContext.ChannelTemplates
.AsNoTracking()
.SingleOrDefaultAsync(t => t.Id == request.TemplateId, cancellationToken);
if (template is null)
{
return new NotFoundError($"Channel template {request.TemplateId} does not exist.");
}
int ffmpegProfileId = advanced.FFmpegProfileId ?? template.FFmpegProfileId;
int? fallbackFillerId = advanced.FallbackFillerId ?? template.FallbackFillerId;
int? preRollFillerId = advanced.PreRollFillerId ?? template.PreRollFillerId;
int? midRollFillerId = advanced.MidRollFillerId ?? template.MidRollFillerId;
int? postRollFillerId = advanced.PostRollFillerId ?? template.PostRollFillerId;
PlaybackOrder playbackOrder = advanced.PlaybackOrder ?? PlaybackOrder.Chronological;
ChannelPlayoutSource playoutSource = advanced.PlayoutSource ?? template.PlayoutSource;
// Mirror channels need special MirrorSourceChannelId plumbing (see CreateChannelHandler);
// this endpoint only builds generated playouts.
if (playoutSource is ChannelPlayoutSource.Mirror)
{
return BaseError.New("Mirror playout source is not supported by this endpoint");
}
Either<BaseError, Unit> referenceValidation = await ValidateReferences(
dbContext,
advanced,
template,
cancellationToken);
foreach (BaseError error in referenceValidation.LeftToSeq())
{
return error;
}
// Normalize + validate every lineup entry once, so validation and build see the same data.
var normalized = new List<NormalizedLineupItem>();
for (int i = 0; i < request.Lineup.Count; i++)
{
Either<BaseError, NormalizedLineupItem> itemValidation =
await NormalizeLineupItem(dbContext, request.Lineup[i], i, cancellationToken);
foreach (BaseError error in itemValidation.LeftToSeq())
{
return error;
}
foreach (NormalizedLineupItem item in itemValidation.RightToSeq())
{
normalized.Add(item);
}
}
bool multiItem = normalized.Count >= 2;
// MultiCollection entries only support Shuffle / ShuffleInOrder (mirrors PlayoutModeMustBeValid).
if (normalized.Any(i => i.CollectionType is CollectionType.MultiCollection) &&
playbackOrder is not (PlaybackOrder.Shuffle or PlaybackOrder.ShuffleInOrder))
{
return BaseError.New($"Invalid playback order for multi collection: '{playbackOrder}'");
}
if (multiItem)
{
// The generated playlist cannot express rerun collections or nested playlists
// (PlaylistItem + CollectionKey.ForPlaylistItem lack those fields).
for (int i = 0; i < normalized.Count; i++)
{
if (normalized[i].CollectionType is CollectionType.RerunFirstRun or CollectionType.Playlist)
{
return BaseError.New(
$"lineup[{normalized[i].Index}]: rerun collections and playlists are only " +
"supported as a single-item lineup");
}
}
}
Channel channel = BuildChannel(
request,
template,
advanced,
name,
number,
group,
categories,
ffmpegProfileId,
fallbackFillerId);
string scheduleName = await DeCollideName(
GeneratedName(number, name, "Schedule"),
(candidate, ct) => dbContext.ProgramSchedules.AnyAsync(ps => ps.Name == candidate, ct),
cancellationToken);
ProgramSchedule schedule = BuildProgramSchedule(scheduleName, template, advanced);
Playlist playlist = null;
ProgramScheduleItemFlood floodItem = BuildFloodBase(
playbackOrder,
advanced,
template,
fallbackFillerId,
preRollFillerId,
midRollFillerId,
postRollFillerId);
if (multiItem)
{
playlist = await BuildPlaylist(dbContext, number, name, normalized, playbackOrder, cancellationToken);
floodItem.CollectionType = CollectionType.Playlist;
floodItem.Playlist = playlist;
}
else
{
NormalizedLineupItem only = normalized[0];
floodItem.CollectionType = only.CollectionType;
floodItem.CollectionId = only.CollectionId;
floodItem.MultiCollectionId = only.MultiCollectionId;
floodItem.SmartCollectionId = only.SmartCollectionId;
floodItem.RerunCollectionId = only.RerunCollectionId;
floodItem.MediaItemId = only.MediaItemId;
floodItem.PlaylistId = only.PlaylistId;
}
schedule.Items = [floodItem];
var playout = new Playout
{
Channel = channel,
ProgramSchedule = schedule,
ScheduleKind = PlayoutScheduleKind.Classic
};
return new PreparedCreate(channel, playlist, schedule, playout);
}
private static async Task<Playlist> BuildPlaylist(
TvContext dbContext,
string channelNumber,
string channelName,
List<NormalizedLineupItem> lineup,
PlaybackOrder playbackOrder,
CancellationToken cancellationToken)
{
// Reuse the single system playlist group, creating it on first use.
PlaylistGroup playlistGroup = await dbContext.PlaylistGroups
.FirstOrDefaultAsync(pg => pg.IsSystem && pg.Name == SystemPlaylistGroupName, cancellationToken);
playlistGroup ??= new PlaylistGroup { Name = SystemPlaylistGroupName, IsSystem = true };
string playlistName = await DeCollideName(
GeneratedName(channelNumber, channelName, "Lineup"),
(candidate, ct) => dbContext.Playlists.AnyAsync(
p => p.PlaylistGroupId == playlistGroup.Id && p.Name == candidate,
ct),
cancellationToken);
var playlist = new Playlist
{
Name = playlistName,
IsSystem = true,
PlaylistGroup = playlistGroup,
PlaylistGroupId = playlistGroup.Id,
Items = []
};
int index = 1;
foreach (NormalizedLineupItem item in lineup)
{
playlist.Items.Add(new PlaylistItem
{
Playlist = playlist,
Index = index++,
CollectionType = item.CollectionType,
CollectionId = item.CollectionId,
MultiCollectionId = item.MultiCollectionId,
SmartCollectionId = item.SmartCollectionId,
MediaItemId = item.MediaItemId,
PlaybackOrder = playbackOrder,
// Play every item in each entry before advancing so lineup order is honored
// (PlaylistEnumerator round-robins one-per-entry unless PlayAll/Count).
PlayAll = true,
IncludeInProgramGuide = true
});
}
return playlist;
}
private static Channel BuildChannel(
CreateChannelFromLineup request,
ChannelTemplate template,
CreateChannelFromLineupAdvancedOptions advanced,
string name,
string number,
string group,
string categories,
int ffmpegProfileId,
int? fallbackFillerId)
{
var artwork = new List<Artwork>();
if (!string.IsNullOrWhiteSpace(request.Logo?.Path))
{
string logo = request.Logo.Path;
if (logo.StartsWith("iptv/logos/", StringComparison.Ordinal))
{
logo = logo.Replace("iptv/logos/", string.Empty);
}
artwork.Add(new Artwork
{
Path = logo,
ArtworkKind = ArtworkKind.Logo,
OriginalContentType = !string.IsNullOrEmpty(request.Logo.ContentType) ? request.Logo.ContentType : null,
DateAdded = DateTime.UtcNow,
DateUpdated = DateTime.UtcNow
});
}
return new Channel(Guid.NewGuid())
{
Name = name,
Number = number,
SortNumber = double.Parse(number, CultureInfo.InvariantCulture),
Group = group,
Categories = categories,
FFmpegProfileId = ffmpegProfileId,
SlugSeconds = null,
PlayoutSource = advanced.PlayoutSource ?? template.PlayoutSource,
PlayoutMode = advanced.PlayoutMode ?? template.PlayoutMode,
StreamingMode = advanced.StreamingMode ?? template.StreamingMode,
WatermarkId = advanced.WatermarkId ?? template.WatermarkId,
FallbackFillerId = fallbackFillerId,
Artwork = artwork,
StreamSelectorMode = advanced.StreamSelectorMode ?? template.StreamSelectorMode,
StreamSelector = advanced.StreamSelector ?? template.StreamSelector ?? string.Empty,
PreferredAudioLanguageCode =
advanced.PreferredAudioLanguageCode ?? template.PreferredAudioLanguageCode ?? string.Empty,
PreferredAudioTitle = advanced.PreferredAudioTitle ?? template.PreferredAudioTitle ?? string.Empty,
PreferredSubtitleLanguageCode =
advanced.PreferredSubtitleLanguageCode ?? template.PreferredSubtitleLanguageCode ?? string.Empty,
SubtitleMode = advanced.SubtitleMode ?? template.SubtitleMode,
MusicVideoCreditsMode = advanced.MusicVideoCreditsMode ?? template.MusicVideoCreditsMode,
MusicVideoCreditsTemplate =
advanced.MusicVideoCreditsTemplate ?? template.MusicVideoCreditsTemplate ?? string.Empty,
SongVideoMode = advanced.SongVideoMode ?? template.SongVideoMode,
TranscodeMode = advanced.TranscodeMode ?? template.TranscodeMode,
IdleBehavior = advanced.IdleBehavior ?? template.IdleBehavior,
IsEnabled = request.IsEnabled,
ShowInEpg = request.IsEnabled && request.ShowInEpg
};
}
private static ProgramSchedule BuildProgramSchedule(
string scheduleName,
ChannelTemplate template,
CreateChannelFromLineupAdvancedOptions advanced) =>
new()
{
Name = scheduleName,
KeepMultiPartEpisodesTogether = true,
TreatCollectionsAsShows = true,
ShuffleScheduleItems = advanced.ShuffleScheduleItems ?? template.ShuffleScheduleItems,
RandomStartPoint = advanced.RandomStartPoint ?? template.RandomStartPoint,
FixedStartTimeBehavior = advanced.FixedStartTimeBehavior ?? template.FixedStartTimeBehavior,
Items = []
};
private static ProgramScheduleItemFlood BuildFloodBase(
PlaybackOrder playbackOrder,
CreateChannelFromLineupAdvancedOptions advanced,
ChannelTemplate template,
int? fallbackFillerId,
int? preRollFillerId,
int? midRollFillerId,
int? postRollFillerId) =>
new()
{
Index = 1,
PlaybackOrder = playbackOrder,
GuideMode = GuideMode.Normal,
CustomTitle = string.Empty,
SearchTitle = string.Empty,
SearchQuery = string.Empty,
PreRollFillerId = preRollFillerId,
MidRollFillerId = midRollFillerId,
PostRollFillerId = postRollFillerId,
FallbackFillerId = fallbackFillerId,
PreferredAudioLanguageCode =
advanced.PreferredAudioLanguageCode ?? template.PreferredAudioLanguageCode ?? string.Empty,
PreferredAudioTitle = advanced.PreferredAudioTitle ?? template.PreferredAudioTitle ?? string.Empty,
PreferredSubtitleLanguageCode =
advanced.PreferredSubtitleLanguageCode ?? template.PreferredSubtitleLanguageCode ?? string.Empty,
SubtitleMode = advanced.SubtitleMode ?? template.SubtitleMode
};
private static string GeneratedName(string channelNumber, string channelName, string suffix)
{
string prefix = $"{channelNumber} {channelName}".Trim();
int maxPrefixLength = Math.Max(0, 50 - suffix.Length - 1);
if (prefix.Length > maxPrefixLength)
{
prefix = prefix[..maxPrefixLength].TrimEnd();
}
return $"{prefix} {suffix}".Trim();
}
// De-collide a generated (already <= 50 char) name against a unique index by appending " 2", " 3", ...
// rather than leaking a raw UNIQUE-constraint failure.
private static async Task<string> DeCollideName(
string baseName,
Func<string, CancellationToken, Task<bool>> exists,
CancellationToken cancellationToken)
{
if (!await exists(baseName, cancellationToken))
{
return baseName;
}
for (int n = 2; ; n++)
{
string suffix = $" {n}";
string candidate = baseName.Length + suffix.Length > 50
? baseName[..(50 - suffix.Length)].TrimEnd() + suffix
: baseName + suffix;
if (!await exists(candidate, cancellationToken))
{
return candidate;
}
}
}
private static async Task<Either<BaseError, Unit>> ValidateReferences(
TvContext dbContext,
CreateChannelFromLineupAdvancedOptions advanced,
ChannelTemplate template,
CancellationToken cancellationToken)
{
int ffmpegProfileId = advanced.FFmpegProfileId ?? template.FFmpegProfileId;
if (!await dbContext.FFmpegProfiles.AnyAsync(p => p.Id == ffmpegProfileId, cancellationToken))
{
return new NotFoundError($"FFmpegProfile {ffmpegProfileId} does not exist.");
}
Either<BaseError, Unit> channelReferences = await ValidateChannelReferences(
dbContext,
advanced.WatermarkId ?? template.WatermarkId,
advanced.FallbackFillerId ?? template.FallbackFillerId,
cancellationToken);
foreach (BaseError error in channelReferences.LeftToSeq())
{
return error;
}
Either<BaseError, Unit> itemFillers = await ValidateItemFillers(
dbContext,
advanced.PreRollFillerId ?? template.PreRollFillerId,
advanced.MidRollFillerId ?? template.MidRollFillerId,
advanced.PostRollFillerId ?? template.PostRollFillerId,
cancellationToken);
foreach (BaseError error in itemFillers.LeftToSeq())
{
return error;
}
return Unit.Default;
}
private static async Task<Either<BaseError, Unit>> ValidateChannelReferences(
TvContext dbContext,
int? watermarkId,
int? fallbackFillerId,
CancellationToken cancellationToken)
{
if (watermarkId.HasValue &&
!await dbContext.ChannelWatermarks.AnyAsync(w => w.Id == watermarkId.Value, cancellationToken))
{
return new NotFoundError($"Watermark {watermarkId.Value} does not exist.");
}
if (fallbackFillerId.HasValue && !await dbContext.FillerPresets.AnyAsync(
fp => fp.Id == fallbackFillerId.Value && fp.FillerKind == FillerKind.Fallback,
cancellationToken))
{
return new NotFoundError($"Fallback filler {fallbackFillerId.Value} does not exist.");
}
return Unit.Default;
}
private static async Task<Either<BaseError, Unit>> ValidateItemFillers(
TvContext dbContext,
int? preRollFillerId,
int? midRollFillerId,
int? postRollFillerId,
CancellationToken cancellationToken)
{
if (preRollFillerId.HasValue && !await FillerExists(dbContext, preRollFillerId.Value, FillerKind.PreRoll, cancellationToken))
{
return new NotFoundError($"Pre-roll filler {preRollFillerId.Value} does not exist.");
}
if (midRollFillerId.HasValue && !await FillerExists(dbContext, midRollFillerId.Value, FillerKind.MidRoll, cancellationToken))
{
return new NotFoundError($"Mid-roll filler {midRollFillerId.Value} does not exist.");
}
if (postRollFillerId.HasValue && !await FillerExists(dbContext, postRollFillerId.Value, FillerKind.PostRoll, cancellationToken))
{
return new NotFoundError($"Post-roll filler {postRollFillerId.Value} does not exist.");
}
return Unit.Default;
}
private static Task<bool> FillerExists(
TvContext dbContext,
int id,
FillerKind fillerKind,
CancellationToken cancellationToken) =>
dbContext.FillerPresets.AnyAsync(fp => fp.Id == id && fp.FillerKind == fillerKind, cancellationToken);
private static async Task<Either<BaseError, NormalizedLineupItem>> NormalizeLineupItem(
TvContext dbContext,
CreateChannelFromLineupItem item,
int index,
CancellationToken cancellationToken)
{
int providedIds = new int?[]
{
item.CollectionId,
item.MultiCollectionId,
item.SmartCollectionId,
item.RerunCollectionId,
item.MediaItemId,
item.PlaylistId
}.Count(id => id.HasValue);
if (providedIds != 1)
{
return BaseError.New($"lineup[{index}] must provide exactly one typed id.");
}
switch (item.MediaType)
{
case LibraryBrowseMediaType.Movie:
return await NormalizeMediaItem(
dbContext.Movies, item, CollectionType.Movie, index, "Movie", cancellationToken);
case LibraryBrowseMediaType.TelevisionShow:
return await NormalizeMediaItem(
dbContext.Shows, item, CollectionType.TelevisionShow, index, "TelevisionShow", cancellationToken);
case LibraryBrowseMediaType.TelevisionSeason:
return await NormalizeMediaItem(
dbContext.Seasons, item, CollectionType.TelevisionSeason, index, "TelevisionSeason", cancellationToken);
case LibraryBrowseMediaType.Artist:
return await NormalizeMediaItem(
dbContext.Artists, item, CollectionType.Artist, index, "Artist", cancellationToken);
case LibraryBrowseMediaType.Collection:
if (item.CollectionType != CollectionType.Collection)
{
return Mismatch(index, item);
}
if (!item.CollectionId.HasValue)
{
return WrongId(index, item.MediaType, "collectionId");
}
return await ExistsThen(
dbContext.Collections,
item.CollectionId.Value,
$"lineup[{index}] Collection",
new NormalizedLineupItem(index, CollectionType.Collection, CollectionId: item.CollectionId),
cancellationToken);
case LibraryBrowseMediaType.SmartCollection:
if (item.CollectionType != CollectionType.SmartCollection)
{
return Mismatch(index, item);
}
if (!item.SmartCollectionId.HasValue)
{
return WrongId(index, item.MediaType, "smartCollectionId");
}
return await ExistsThen(
dbContext.SmartCollections,
item.SmartCollectionId.Value,
$"lineup[{index}] SmartCollection",
new NormalizedLineupItem(index, CollectionType.SmartCollection, SmartCollectionId: item.SmartCollectionId),
cancellationToken);
case LibraryBrowseMediaType.MultiCollection:
if (item.CollectionType != CollectionType.MultiCollection)
{
return Mismatch(index, item);
}
if (!item.MultiCollectionId.HasValue)
{
return WrongId(index, item.MediaType, "multiCollectionId");
}
return await ExistsThen(
dbContext.MultiCollections,
item.MultiCollectionId.Value,
$"lineup[{index}] MultiCollection",
new NormalizedLineupItem(index, CollectionType.MultiCollection, MultiCollectionId: item.MultiCollectionId),
cancellationToken);
case LibraryBrowseMediaType.RerunCollection:
if (item.CollectionType != CollectionType.RerunFirstRun)
{
return Mismatch(index, item);
}
if (!item.RerunCollectionId.HasValue)
{
return WrongId(index, item.MediaType, "rerunCollectionId");
}
return await ExistsThen(
dbContext.RerunCollections,
item.RerunCollectionId.Value,
$"lineup[{index}] RerunCollection",
new NormalizedLineupItem(index, CollectionType.RerunFirstRun, RerunCollectionId: item.RerunCollectionId),
cancellationToken);
case LibraryBrowseMediaType.Playlist:
if (item.CollectionType != CollectionType.Playlist)
{
return Mismatch(index, item);
}
if (!item.PlaylistId.HasValue)
{
return WrongId(index, item.MediaType, "playlistId");
}
return await ExistsThen(
dbContext.Playlists,
item.PlaylistId.Value,
$"lineup[{index}] Playlist",
new NormalizedLineupItem(index, CollectionType.Playlist, PlaylistId: item.PlaylistId),
cancellationToken);
default:
return BaseError.New($"lineup[{index}] has an unsupported media type '{item.MediaType}'.");
}
}
private static async Task<Either<BaseError, NormalizedLineupItem>> NormalizeMediaItem<TEntity>(
DbSet<TEntity> set,
CreateChannelFromLineupItem item,
CollectionType expectedType,
int index,
string label,
CancellationToken cancellationToken)
where TEntity : class
{
if (item.CollectionType != expectedType)
{
return Mismatch(index, item);
}
if (!item.MediaItemId.HasValue)
{
return WrongId(index, item.MediaType, "mediaItemId");
}
return await ExistsThen(
set,
item.MediaItemId.Value,
$"lineup[{index}] {label}",
new NormalizedLineupItem(index, expectedType, MediaItemId: item.MediaItemId),
cancellationToken);
}
private static BaseError Mismatch(int index, CreateChannelFromLineupItem item) =>
BaseError.New(
$"lineup[{index}]: media type '{item.MediaType}' does not match collection type '{item.CollectionType}'.");
private static BaseError WrongId(int index, LibraryBrowseMediaType mediaType, string expectedField) =>
BaseError.New($"lineup[{index}]: media type '{mediaType}' requires a {expectedField}.");
private static async Task<Either<BaseError, NormalizedLineupItem>> ExistsThen<TEntity>(
DbSet<TEntity> set,
int id,
string label,
NormalizedLineupItem normalized,
CancellationToken cancellationToken)
where TEntity : class
{
bool exists = await set.AsNoTracking()
.AnyAsync(e => EF.Property<int>(e, "Id") == id, cancellationToken);
return exists
? normalized
: new NotFoundError($"{label} {id} does not exist.");
}
private sealed record NormalizedLineupItem(
int Index,
CollectionType CollectionType,
int? CollectionId = null,
int? MultiCollectionId = null,
int? SmartCollectionId = null,
int? RerunCollectionId = null,
int? MediaItemId = null,
int? PlaylistId = null);
private sealed record PreparedCreate(
Channel Channel,
Playlist Playlist,
ProgramSchedule ProgramSchedule,
Playout Playout);
}
@@ -129,89 +129,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
List<Playout> playouts = await dbContext.Playouts
.AsNoTracking()
.Filter(pi => pi.Channel.Number == (mirrorChannelNumber ?? request.ChannelNumber))
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Episode).EpisodeMetadata)
.ThenInclude(em => em.Guids)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Episode).EpisodeMetadata)
.ThenInclude(em => em.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Episode).Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Episode).Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Genres)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Episode).Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Guids)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Movie).MovieMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Movie).MovieMetadata)
.ThenInclude(mm => mm.Genres)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Movie).MovieMetadata)
.ThenInclude(mm => mm.Guids)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mvm => mvm.Genres)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mvm => mvm.Studios)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mvm => mvm.Directors)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mvm => mvm.Artists)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).Artist)
.ThenInclude(a => a.ArtistMetadata)
.ThenInclude(am => am.Genres)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as OtherVideo).OtherVideoMetadata)
.ThenInclude(vm => vm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as RemoteStream).RemoteStreamMetadata)
.ThenInclude(vm => vm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Song).SongMetadata)
.ThenInclude(vm => vm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Song).SongMetadata)
.ThenInclude(sm => sm.Genres)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Song).SongMetadata)
.ThenInclude(sm => sm.Studios)
.IncludeGuideMetadata()
.AsSplitQuery()
.ToListAsync(cancellationToken);
@@ -244,8 +162,9 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
item.Finish += playoutOffset;
}
await WritePlayoutXml(
await WriteScheduleXml(
request,
playout.ScheduleKind,
floodSorted,
templateContext,
movieTemplate,
@@ -270,8 +189,9 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
item.Finish += playoutOffset;
}
await WriteBlockPlayoutXml(
await WriteScheduleXml(
request,
playout.ScheduleKind,
blockSorted,
templateContext,
movieTemplate,
@@ -294,8 +214,9 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
item.Finish += playoutOffset;
}
await WritePlayoutXml(
await WriteScheduleXml(
request,
playout.ScheduleKind,
externalJsonSorted,
templateContext,
movieTemplate,
@@ -324,100 +245,9 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
}
}
private async Task WritePlayoutXml(
RefreshChannelData request,
List<PlayoutItem> sorted,
XmlTemplateContext templateContext,
Template movieTemplate,
Template episodeTemplate,
Template musicVideoTemplate,
Template songTemplate,
Template otherVideoTemplate,
Template remoteStreamTemplate,
XmlMinifier minifier,
XmlWriter xml,
CancellationToken cancellationToken)
{
XmltvTimeZone xmltvTimeZone = await _configElementRepository
.GetValue<XmltvTimeZone>(ConfigElementKey.XmltvTimeZone, cancellationToken)
.IfNoneAsync(XmltvTimeZone.Local);
// skip all filler that isn't pre-roll
var i = 0;
while (i < sorted.Count && sorted[i].FillerKind != FillerKind.None &&
sorted[i].FillerKind != FillerKind.PreRoll)
{
i++;
}
while (i < sorted.Count)
{
PlayoutItem startItem = sorted[i];
int j = i;
while (sorted[j].FillerKind != FillerKind.None && j + 1 < sorted.Count)
{
j++;
}
PlayoutItem displayItem = sorted[j];
bool hasCustomTitle = !string.IsNullOrWhiteSpace(startItem.CustomTitle);
int finishIndex = j;
while (finishIndex + 1 < sorted.Count && (sorted[finishIndex + 1].GuideGroup == startItem.GuideGroup
|| sorted[finishIndex + 1].FillerKind is FillerKind.GuideMode
or FillerKind.PostRoll or FillerKind.Tail
or FillerKind.Fallback or FillerKind.DecoDefault))
{
finishIndex++;
}
PlayoutItem finishItem = sorted[finishIndex];
i = finishIndex;
DateTimeOffset startTime = xmltvTimeZone switch
{
XmltvTimeZone.Utc => new DateTimeOffset(startItem.Start, TimeSpan.Zero),
_ => startItem.StartOffset
};
DateTimeOffset stopTime = (xmltvTimeZone, displayItem.GuideFinishOffset.HasValue) switch
{
(XmltvTimeZone.Utc, true) => new DateTimeOffset(displayItem.GuideFinish!.Value, TimeSpan.Zero),
(XmltvTimeZone.Utc, false) => new DateTimeOffset(finishItem.Finish, TimeSpan.Zero),
(_, true) => displayItem.GuideFinishOffset!.Value,
(_, false) => finishItem.FinishOffset
};
string start = startTime
.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
.Replace(":", string.Empty);
string stop = stopTime
.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
.Replace(":", string.Empty);
await WriteItemToXml(
request,
displayItem,
start,
stop,
hasCustomTitle,
templateContext,
movieTemplate,
episodeTemplate,
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
remoteStreamTemplate,
minifier,
xml);
i++;
}
}
private async Task WriteBlockPlayoutXml(
private async Task WriteScheduleXml(
RefreshChannelData request,
PlayoutScheduleKind scheduleKind,
List<PlayoutItem> sorted,
XmlTemplateContext templateContext,
Template movieTemplate,
@@ -438,98 +268,36 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
.GetValue<XmltvBlockBehavior>(ConfigElementKey.XmltvBlockBehavior, cancellationToken)
.IfNoneAsync(XmltvBlockBehavior.SplitTimeEvenly);
var groups = sorted.GroupBy(s => new { s.GuideStart, s.GuideFinish, s.GuideGroup });
foreach (var group in groups)
// guide-group / filler-merge logic is shared with the JSON guide query so the two cannot drift
foreach (ChannelGuideEntry entry in ChannelGuideProjector.Project(
scheduleKind,
sorted,
xmltvTimeZone,
xmltvBlockBehavior))
{
var itemsToInclude = group.Filter(g => g.FillerKind is FillerKind.None).ToList();
if (itemsToInclude.Count == 0)
{
continue;
}
string start = entry.Start
.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
.Replace(":", string.Empty);
switch (xmltvBlockBehavior)
{
case XmltvBlockBehavior.UseActualTimes:
foreach (PlayoutItem item in itemsToInclude)
{
DateTimeOffset actualStart = xmltvTimeZone switch
{
XmltvTimeZone.Utc => new DateTimeOffset(item.Start, TimeSpan.Zero),
_ => new DateTimeOffset(item.Start, TimeSpan.Zero).ToLocalTime()
};
string stop = entry.Stop
.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
.Replace(":", string.Empty);
DateTimeOffset actualFinish = xmltvTimeZone switch
{
XmltvTimeZone.Utc => new DateTimeOffset(item.Finish, TimeSpan.Zero),
_ => new DateTimeOffset(item.Finish, TimeSpan.Zero).ToLocalTime()
};
string start = actualStart.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
.Replace(":", string.Empty);
string stop = actualFinish.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
.Replace(":", string.Empty);
await WriteItemToXml(
request,
item,
start,
stop,
false,
templateContext,
movieTemplate,
episodeTemplate,
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
remoteStreamTemplate,
minifier,
xml);
}
break;
case XmltvBlockBehavior.SplitTimeEvenly:
default:
DateTime groupStart = group.Key.GuideStart!.Value;
DateTime groupFinish = group.Key.GuideFinish!.Value;
TimeSpan groupDuration = groupFinish - groupStart;
TimeSpan perItem = groupDuration / itemsToInclude.Count;
DateTimeOffset currentStart = xmltvTimeZone switch
{
XmltvTimeZone.Utc => new DateTimeOffset(groupStart, TimeSpan.Zero),
_ => new DateTimeOffset(groupStart, TimeSpan.Zero).ToLocalTime()
};
DateTimeOffset currentFinish = currentStart + perItem;
foreach (PlayoutItem item in itemsToInclude)
{
string start = currentStart.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
.Replace(":", string.Empty);
string stop = currentFinish.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
.Replace(":", string.Empty);
await WriteItemToXml(
request,
item,
start,
stop,
false,
templateContext,
movieTemplate,
episodeTemplate,
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
remoteStreamTemplate,
minifier,
xml);
currentStart = currentFinish;
currentFinish += perItem;
}
break;
}
await WriteItemToXml(
request,
entry.DisplayItem,
start,
stop,
entry.HasCustomTitle,
templateContext,
movieTemplate,
episodeTemplate,
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
remoteStreamTemplate,
minifier,
xml);
}
}
@@ -549,8 +317,8 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
XmlMinifier minifier,
XmlWriter xml)
{
string title = GetTitle(displayItem);
string subtitle = GetSubtitle(displayItem);
string title = ChannelGuideMetadata.GetTitle(displayItem);
string subtitle = ChannelGuideMetadata.GetSubtitle(displayItem);
Option<string> maybeTemplateOutput = displayItem.MediaItem switch
{
@@ -1117,51 +885,6 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
return artworkPath;
}
private static string GetTitle(PlayoutItem playoutItem)
{
if (!string.IsNullOrWhiteSpace(playoutItem.CustomTitle))
{
return playoutItem.CustomTitle;
}
return playoutItem.MediaItem switch
{
Movie m => m.MovieMetadata.HeadOrNone().Map(mm => mm.Title ?? string.Empty)
.IfNone("[unknown movie]"),
Episode e => e.Season.Show.ShowMetadata.HeadOrNone().Map(em => em.Title ?? string.Empty)
.IfNone("[unknown show]"),
MusicVideo mv => mv.Artist.ArtistMetadata.HeadOrNone().Map(am => am.Title ?? string.Empty)
.IfNone("[unknown artist]"),
OtherVideo ov => ov.OtherVideoMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty)
.IfNone("[unknown video]"),
RemoteStream rs => rs.RemoteStreamMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty)
.IfNone("[unknown remote stream]"),
_ => "[unknown]"
};
}
private static string GetSubtitle(PlayoutItem playoutItem)
{
if (!string.IsNullOrWhiteSpace(playoutItem.CustomTitle))
{
return string.Empty;
}
return playoutItem.MediaItem switch
{
Episode e => e.EpisodeMetadata.HeadOrNone().Match(
em => em.Title ?? string.Empty,
() => string.Empty),
MusicVideo mv => mv.MusicVideoMetadata.HeadOrNone().Match(
mvm => mvm.Title ?? string.Empty,
() => string.Empty),
Song s => s.SongMetadata.HeadOrNone().Match(
mvm => mvm.Title ?? string.Empty,
() => string.Empty),
_ => string.Empty
};
}
private static string GetPrioritizedArtworkPath(Metadata metadata)
{
Option<string> maybeArtwork = Optional(metadata.Artwork).Flatten()
@@ -1,6 +1,8 @@
using System.Globalization;
using System.Text.RegularExpressions;
using System.Threading.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Errors;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Channel = ErsatzTV.Core.Domain.Channel;
@@ -14,18 +16,41 @@ public class UpdateChannelNumbersHandler(
{
public async Task<Option<BaseError>> Handle(UpdateChannelNumbers request, CancellationToken cancellationToken)
{
Option<BaseError> validationError = ValidateRequest(request);
if (validationError.IsSome)
{
return validationError;
}
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
try
{
var numberUpdates = request.Channels.ToDictionary(c => c.Id, c => c.Number);
var channelIds = numberUpdates.Keys;
List<int> channelIds = numberUpdates.Keys.ToList();
List<Channel> channelsToUpdate = await dbContext.Channels
.Where(c => channelIds.Contains(c.Id))
.ToListAsync(cancellationToken);
if (channelsToUpdate.Count != channelIds.Count)
{
var found = channelsToUpdate.Select(c => c.Id).ToHashSet();
int missingId = channelIds.First(id => !found.Contains(id));
return new NotFoundError($"Channel {missingId} does not exist.");
}
List<string> requestedNumbers = numberUpdates.Values.ToList();
bool numberConflict = await dbContext.Channels
.AnyAsync(
c => requestedNumbers.Contains(c.Number) && !channelIds.Contains(c.Id),
cancellationToken);
if (numberConflict)
{
return BaseError.New("Channel number must be unique");
}
// give every channel a non-conflicting number
foreach (var channel in channelsToUpdate)
{
@@ -69,4 +94,32 @@ public class UpdateChannelNumbersHandler(
return BaseError.New("Failed to update channel numbers: " + ex.Message);
}
}
private static Option<BaseError> ValidateRequest(UpdateChannelNumbers request)
{
if (request.Channels.Count == 0)
{
return BaseError.New("At least one channel is required");
}
if (request.Channels.Select(c => c.Id).Distinct().Count() != request.Channels.Count)
{
return BaseError.New("Channel ids must be unique");
}
if (request.Channels.Select(c => c.Number).Distinct(StringComparer.Ordinal).Count() != request.Channels.Count)
{
return BaseError.New("Channel number must be unique");
}
foreach (ChannelSortViewModel channel in request.Channels)
{
if (!Regex.IsMatch(channel.Number, Channel.NumberValidator))
{
return BaseError.New("Invalid channel number; two decimals are allowed for subchannels");
}
}
return Option<BaseError>.None;
}
}
+6 -1
View File
@@ -42,10 +42,15 @@ internal static class Mapper
new(
channel.Id,
channel.Number,
channel.SortNumber,
channel.Name,
channel.Group,
channel.Categories,
channel.FFmpegProfile.Name,
channel.PreferredAudioLanguageCode,
GetStreamingMode(channel));
GetStreamingMode(channel),
channel.IsEnabled,
channel.ShowInEpg);
internal static ResolutionViewModel ProjectToViewModel(Resolution resolution) =>
new(resolution.Height, resolution.Width);
@@ -0,0 +1,10 @@
using ErsatzTV.Core.Api.Channels;
namespace ErsatzTV.Application.Channels;
/// <summary>
/// JSON channel-guide query for the EPG grid. <paramref name="Start" /> defaults to now and
/// <paramref name="End" /> defaults to now + the configured XmltvDaysToBuild window.
/// </summary>
public record GetChannelGuideData(DateTimeOffset? Start, DateTimeOffset? End)
: IRequest<ChannelGuideResponseModel>;
@@ -0,0 +1,145 @@
using System.Globalization;
using ErsatzTV.Application.Configuration;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Channels;
/// <summary>
/// Builds the JSON channel guide directly from <see cref="Playout" /> items, using the shared
/// <see cref="ChannelGuideProjector" /> guide-group/filler-merge logic (the same logic the XMLTV
/// cache builder uses) so the two representations cannot drift. Only channels with
/// <see cref="Channel.ShowInEpg" /> are included, mirroring <c>GetChannelGuideHandler</c>.
/// Unlike XMLTV, filler programmes are returned (with their <see cref="Core.Domain.Filler.FillerKind" />)
/// so the UI can decide how to render them.
/// </summary>
public class GetChannelGuideDataHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository)
: IRequestHandler<GetChannelGuideData, ChannelGuideResponseModel>
{
public async Task<ChannelGuideResponseModel> Handle(
GetChannelGuideData request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
int daysToBuild = await configElementRepository
.GetValue<int>(ConfigElementKey.XmltvDaysToBuild, cancellationToken)
.IfNoneAsync(2);
XmltvTimeZone xmltvTimeZone = await configElementRepository
.GetValue<XmltvTimeZone>(ConfigElementKey.XmltvTimeZone, cancellationToken)
.IfNoneAsync(XmltvTimeZone.Local);
XmltvBlockBehavior xmltvBlockBehavior = await configElementRepository
.GetValue<XmltvBlockBehavior>(ConfigElementKey.XmltvBlockBehavior, cancellationToken)
.IfNoneAsync(XmltvBlockBehavior.SplitTimeEvenly);
DateTimeOffset start = request.Start ?? DateTimeOffset.UtcNow;
DateTimeOffset end = request.End ?? start.AddDays(daysToBuild);
// Visible channels only (mirror GetChannelGuideHandler's ShowInEpg == false skip).
List<Channel> channels = await dbContext.Channels
.AsNoTracking()
.Where(c => c.ShowInEpg)
.Include(c => c.MirrorSourceChannel)
.ToListAsync(cancellationToken);
// Order channels by their decimal channel number so "2" precedes "10", matching
// ChannelGuide.ToXml (which orders XMLTV channels by decimal.Parse of the number).
channels = channels
.OrderBy(c => decimal.Parse(c.Number, CultureInfo.InvariantCulture))
.ToList();
var responseChannels = new List<ChannelGuideChannelResponseModel>();
foreach (Channel channel in channels)
{
bool isMirror = channel.PlayoutSource == ChannelPlayoutSource.Mirror
&& channel.MirrorSourceChannel is not null;
string sourceChannelNumber = isMirror ? channel.MirrorSourceChannel.Number : channel.Number;
TimeSpan playoutOffset = isMirror ? channel.PlayoutOffset ?? TimeSpan.Zero : TimeSpan.Zero;
List<Playout> playouts = await dbContext.Playouts
.AsNoTracking()
.Filter(p => p.Channel.Number == sourceChannelNumber)
.IncludeGuideMetadata()
.AsSplitQuery()
.ToListAsync(cancellationToken);
var programmes = new List<ChannelGuideProgrammeResponseModel>();
foreach (Playout playout in playouts)
{
// ExternalJson playouts materialize items from a file rather than Playout.Items; they are
// out of scope for the JSON guide (see issue #102 notes).
if (playout.ScheduleKind is PlayoutScheduleKind.ExternalJson)
{
continue;
}
// Filter to the window (on the pre-offset time, mirroring the XMLTV builder) then apply the
// mirror playout offset without mutating the loaded (shared, AsNoTracking) entities.
List<PlayoutItem> sorted = playout.Items
.OrderBy(pi => pi.Start)
.Filter(pi => pi.StartOffset <= end)
.Select(pi => playoutOffset == TimeSpan.Zero ? pi : WithPlayoutOffset(pi, playoutOffset))
.ToList();
foreach (ChannelGuideEntry entry in ChannelGuideProjector.Project(
playout.ScheduleKind,
sorted,
xmltvTimeZone,
xmltvBlockBehavior))
{
// drop programmes that finish before the requested window starts
if (entry.Stop <= start)
{
continue;
}
string subtitle = ChannelGuideMetadata.GetSubtitle(entry.DisplayItem);
programmes.Add(
new ChannelGuideProgrammeResponseModel(
entry.Start,
entry.Stop,
ChannelGuideMetadata.GetTitle(entry.DisplayItem),
string.IsNullOrWhiteSpace(subtitle) ? null : subtitle,
ChannelGuideMetadata.GetCategory(entry.DisplayItem),
entry.DisplayItem.FillerKind));
}
}
responseChannels.Add(
new ChannelGuideChannelResponseModel(
channel.Number,
channel.Name,
programmes.OrderBy(p => p.Start).ToList()));
}
return new ChannelGuideResponseModel(start, end, responseChannels);
}
// Copy (don't mutate) the loaded PlayoutItem when shifting by the mirror playout offset. The loaded
// entities are AsNoTracking and shared; mutating them in place would corrupt the guide projection.
// Mirrors the XMLTV builder, which shifts only Start/Finish (not the Guide* window).
private static PlayoutItem WithPlayoutOffset(PlayoutItem item, TimeSpan offset) =>
new()
{
MediaItem = item.MediaItem,
Start = item.Start + offset,
Finish = item.Finish + offset,
GuideStart = item.GuideStart,
GuideFinish = item.GuideFinish,
GuideGroup = item.GuideGroup,
FillerKind = item.FillerKind,
CustomTitle = item.CustomTitle
};
}
@@ -0,0 +1,5 @@
using ErsatzTV.Core.Api.Channels;
namespace ErsatzTV.Application.Channels;
public record GetChannelStatesForApi(DateTime Now) : IRequest<List<ChannelStateResponseModel>>;
@@ -0,0 +1,151 @@
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using PlayoutMapper = ErsatzTV.Application.Playouts.Mapper;
namespace ErsatzTV.Application.Channels;
public class GetChannelStatesForApiHandler(
IDbContextFactory<TvContext> dbContextFactory,
IFFmpegSegmenterService ffmpegSegmenterService)
: IRequestHandler<GetChannelStatesForApi, List<ChannelStateResponseModel>>
{
// a guide entry (program + surrounding filler) never spans anywhere near a day; the time
// bound also protects against GuideGroup values recycling (mod 10000) elsewhere in a playout
private static readonly TimeSpan GuideEntryBound = TimeSpan.FromDays(1);
public async Task<List<ChannelStateResponseModel>> Handle(
GetChannelStatesForApi request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
List<Channel> channels = await dbContext.Channels
.AsNoTracking()
.OrderBy(c => c.SortNumber)
.ThenBy(c => c.Number)
.ToListAsync(cancellationToken);
if (channels.Count == 0)
{
return [];
}
Dictionary<int, (int SourceChannelId, TimeSpan Offset, DateTime LookupTime)> channelLookup = channels
.ToDictionary(
c => c.Id,
c =>
{
TimeSpan offset = c.PlayoutOffset ?? TimeSpan.Zero;
return (c.MirrorSourceChannelId ?? c.Id, offset, request.Now - offset);
});
// one covering-item query per distinct lookup time (i.e. per distinct playout offset,
// typically just one) with a cheap projection; metadata is hydrated in a second query
// for only the covering items' guide groups
var coveringBySourceAndTime = new Dictionary<(int SourceChannelId, DateTime LookupTime), CoveringItem>();
foreach (IGrouping<DateTime, (int SourceChannelId, TimeSpan Offset, DateTime LookupTime)> timeGroup in
channelLookup.Values.GroupBy(v => v.LookupTime))
{
DateTime lookupTime = timeGroup.Key;
int[] sourceChannelIds = timeGroup.Map(v => v.SourceChannelId).Distinct().ToArray();
List<CoveringItem> covering = await dbContext.PlayoutItems
.AsNoTracking()
.Where(pi => sourceChannelIds.Contains(pi.Playout.ChannelId))
.Where(pi => pi.Start <= lookupTime && pi.Finish > lookupTime)
.Select(pi => new CoveringItem(pi.Id, pi.PlayoutId, pi.Playout.ChannelId, pi.GuideGroup, pi.Start))
.ToListAsync(cancellationToken);
foreach (CoveringItem item in covering.OrderBy(ci => ci.Start))
{
coveringBySourceAndTime.TryAdd((item.ChannelId, lookupTime), item);
}
}
Dictionary<(int PlayoutId, int GuideGroup), List<PlayoutItem>> itemsByGuideEntry = [];
if (coveringBySourceAndTime.Count > 0)
{
int[] playoutIds = coveringBySourceAndTime.Values.Map(ci => ci.PlayoutId).Distinct().ToArray();
int[] guideGroups = coveringBySourceAndTime.Values.Map(ci => ci.GuideGroup).Distinct().ToArray();
DateTime windowStart = channelLookup.Values.Min(v => v.LookupTime) - GuideEntryBound;
DateTime windowFinish = channelLookup.Values.Max(v => v.LookupTime) + GuideEntryBound;
List<PlayoutItem> guideEntryItems = await dbContext.PlayoutItems
.AsNoTracking()
.Include(pi => pi.MediaItem)
.ThenInclude(mi => (mi as Episode).EpisodeMetadata)
.Include(pi => pi.MediaItem)
.ThenInclude(mi => (mi as Episode).Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.Include(pi => pi.MediaItem)
.ThenInclude(mi => (mi as Movie).MovieMetadata)
.Include(pi => pi.MediaItem)
.ThenInclude(mi => (mi as MusicVideo).MusicVideoMetadata)
.Include(pi => pi.MediaItem)
.ThenInclude(mi => (mi as MusicVideo).Artist)
.ThenInclude(a => a.ArtistMetadata)
.Include(pi => pi.MediaItem)
.ThenInclude(mi => (mi as OtherVideo).OtherVideoMetadata)
.Include(pi => pi.MediaItem)
.ThenInclude(mi => (mi as Song).SongMetadata)
.Include(pi => pi.MediaItem)
.ThenInclude(mi => (mi as Image).ImageMetadata)
.Include(pi => pi.MediaItem)
.ThenInclude(mi => (mi as RemoteStream).RemoteStreamMetadata)
.Where(pi => playoutIds.Contains(pi.PlayoutId) && guideGroups.Contains(pi.GuideGroup))
.Where(pi => pi.Start <= windowFinish && pi.Finish > windowStart)
.ToListAsync(cancellationToken);
itemsByGuideEntry = guideEntryItems
.GroupBy(pi => (pi.PlayoutId, pi.GuideGroup))
.ToDictionary(g => g.Key, g => g.OrderBy(pi => pi.Start).ToList());
}
return channels
.Map(channel =>
{
(int sourceChannelId, TimeSpan offset, DateTime lookupTime) = channelLookup[channel.Id];
ChannelNowPlayingResponseModel nowPlaying = null;
if (coveringBySourceAndTime.TryGetValue((sourceChannelId, lookupTime), out CoveringItem covering) &&
itemsByGuideEntry.TryGetValue(
(covering.PlayoutId, covering.GuideGroup),
out List<PlayoutItem> guideEntry))
{
// like the XMLTV guide, surface the program rather than its filler: use the
// covering item itself when it is a program part, otherwise the guide entry's
// first program item; an entry with no program item (e.g. offline fallback
// filler) stays null
PlayoutItem coveringItem = guideEntry.Find(pi => pi.Id == covering.Id);
PlayoutItem displayItem = coveringItem?.FillerKind == FillerKind.None
? coveringItem
: guideEntry.Find(pi => pi.FillerKind == FillerKind.None);
if (displayItem is not null)
{
DateTime start = guideEntry[0].Start;
DateTime finish = displayItem.GuideFinish ?? guideEntry.Max(pi => pi.Finish);
nowPlaying = new ChannelNowPlayingResponseModel(
PlayoutMapper.GetDisplayTitle(displayItem.MediaItem, Optional(displayItem.ChapterTitle)),
new DateTimeOffset(start + offset, TimeSpan.Zero),
new DateTimeOffset(finish + offset, TimeSpan.Zero));
}
}
return new ChannelStateResponseModel(
channel.Id,
channel.Number,
ffmpegSegmenterService.IsActive(channel.Number),
nowPlaying);
})
.ToList();
}
private sealed record CoveringItem(int Id, int PlayoutId, int ChannelId, int GuideGroup, DateTime Start);
}
@@ -52,13 +52,17 @@ internal static class Mapper
ffmpegProfile.Id,
ffmpegProfile.Name,
ffmpegProfile.ThreadCount,
ffmpegProfile.NormalizeAudio,
ffmpegProfile.NormalizeVideo,
ffmpegProfile.HardwareAcceleration,
ffmpegProfile.VaapiDisplay,
ffmpegProfile.VaapiDriver,
ffmpegProfile.VaapiDevice,
ffmpegProfile.QsvExtraHardwareFrames,
ffmpegProfile.ResolutionId,
ffmpegProfile.Resolution.Name,
ffmpegProfile.ScalingBehavior,
ffmpegProfile.PadMode,
ffmpegProfile.VideoFormat,
ffmpegProfile.VideoProfile,
ffmpegProfile.VideoPreset,
@@ -71,8 +75,10 @@ internal static class Mapper
ffmpegProfile.AudioBitrate,
ffmpegProfile.AudioBufferSize,
ffmpegProfile.NormalizeLoudnessMode,
ffmpegProfile.TargetLoudness,
ffmpegProfile.AudioChannels,
ffmpegProfile.AudioSampleRate,
ffmpegProfile.NormalizeFramerate,
ffmpegProfile.DeinterlaceVideo);
ffmpegProfile.NormalizeColors,
ffmpegProfile.DeinterlaceVideo == true);
}
@@ -20,4 +20,6 @@ public record CreateFillerPreset(
int? PlaylistId,
string Expression,
bool UseChaptersAsMediaItems
) : IRequest<Either<BaseError, Unit>>;
) : IRequest<Either<BaseError, CreateFillerPresetResult>>;
public record CreateFillerPresetResult(int FillerPresetId) : EntityIdResult(FillerPresetId);
@@ -6,23 +6,25 @@ using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Filler;
public class CreateFillerPresetHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<CreateFillerPreset, Either<BaseError, Unit>>
: IRequestHandler<CreateFillerPreset, Either<BaseError, CreateFillerPresetResult>>
{
public async Task<Either<BaseError, Unit>> Handle(CreateFillerPreset request, CancellationToken cancellationToken)
public async Task<Either<BaseError, CreateFillerPresetResult>> Handle(
CreateFillerPreset request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, FillerPreset> validation = await Validate(dbContext, request);
return await validation.Apply(fp => Persist(dbContext, fp, cancellationToken));
}
private static async Task<Unit> Persist(
private static async Task<CreateFillerPresetResult> Persist(
TvContext dbContext,
FillerPreset fillerPreset,
CancellationToken cancellationToken)
{
await dbContext.FillerPresets.AddAsync(fillerPreset, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
return Unit.Default;
return new CreateFillerPresetResult(fillerPreset.Id);
}
private static Task<Validation<BaseError, FillerPreset>> Validate(
@@ -1,5 +1,6 @@
using ErsatzTV.Core;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Errors;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
@@ -18,8 +19,14 @@ public class DeleteFillerPresetHandler : IRequestHandler<DeleteFillerPreset, Eit
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, FillerPreset> validation = await FillerPresetMustExist(dbContext, request, cancellationToken);
return await validation.Apply(ps => DoDeletion(dbContext, ps));
Option<FillerPreset> maybeFillerPreset = await FillerPresetMustExist(dbContext, request, cancellationToken);
// must-exist maps to a NotFoundError Either directly (not via Validation, which
// aggregates errors and loses the subtype the API layer maps to 404)
return await maybeFillerPreset.Match(
Some: fillerPreset => DoDeletion(dbContext, fillerPreset).Map(Right<BaseError, Unit>),
None: () => Task.FromResult<Either<BaseError, Unit>>(
new NotFoundError($"FillerPreset {request.FillerPresetId} does not exist.")));
}
private static Task<Unit> DoDeletion(TvContext dbContext, FillerPreset fillerPreset)
@@ -28,11 +35,10 @@ public class DeleteFillerPresetHandler : IRequestHandler<DeleteFillerPreset, Eit
return dbContext.SaveChangesAsync().ToUnit();
}
private static Task<Validation<BaseError, FillerPreset>> FillerPresetMustExist(
private static Task<Option<FillerPreset>> FillerPresetMustExist(
TvContext dbContext,
DeleteFillerPreset request,
CancellationToken cancellationToken) =>
dbContext.FillerPresets
.SelectOneAsync(fp => fp.Id, ps => ps.Id == request.FillerPresetId, cancellationToken)
.Map(o => o.ToValidation<BaseError>($"FillerPreset {request.FillerPresetId} does not exist."));
.SelectOneAsync(fp => fp.Id, ps => ps.Id == request.FillerPresetId, cancellationToken);
}
@@ -1,5 +1,6 @@
using ErsatzTV.Core;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Errors;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
@@ -12,8 +13,19 @@ public class UpdateFillerPresetHandler(IDbContextFactory<TvContext> dbContextFac
public async Task<Either<BaseError, Unit>> Handle(UpdateFillerPreset request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, FillerPreset> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Apply(ps => ApplyUpdateRequest(dbContext, ps, request, cancellationToken));
Option<FillerPreset> maybeFillerPreset = await FillerPresetMustExist(dbContext, request, cancellationToken);
// must-exist maps to a NotFoundError Either directly (not via Validation, which
// aggregates errors and loses the subtype the API layer maps to 404)
return await maybeFillerPreset.Match(
Some: async fillerPreset =>
{
Validation<BaseError, string> validation = await ValidateName(dbContext, request);
return await validation.Apply((string _) =>
ApplyUpdateRequest(dbContext, fillerPreset, request, cancellationToken));
},
None: () => Task.FromResult<Either<BaseError, Unit>>(
new NotFoundError($"FillerPreset {request.Id} does not exist.")));
}
private static async Task<Unit> ApplyUpdateRequest(
@@ -44,20 +56,12 @@ public class UpdateFillerPresetHandler(IDbContextFactory<TvContext> dbContextFac
return Unit.Default;
}
private static async Task<Validation<BaseError, FillerPreset>> Validate(
TvContext dbContext,
UpdateFillerPreset request,
CancellationToken cancellationToken) =>
(await FillerPresetMustExist(dbContext, request, cancellationToken), await ValidateName(dbContext, request))
.Apply((collectionToUpdate, _) => collectionToUpdate);
private static Task<Validation<BaseError, FillerPreset>> FillerPresetMustExist(
private static Task<Option<FillerPreset>> FillerPresetMustExist(
TvContext dbContext,
UpdateFillerPreset request,
CancellationToken cancellationToken) =>
dbContext.FillerPresets
.SelectOneAsync(ps => ps.Id, ps => ps.Id == request.Id, cancellationToken)
.Map(o => o.ToValidation<BaseError>("Filler preset does not exist"));
.SelectOneAsync(ps => ps.Id, ps => ps.Id == request.Id, cancellationToken);
private static async Task<Validation<BaseError, string>> ValidateName(
TvContext dbContext,
+24 -1
View File
@@ -1,9 +1,32 @@
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Api.Filler;
using ErsatzTV.Core.Domain.Filler;
namespace ErsatzTV.Application.Filler;
internal static class Mapper
{
internal static FillerPresetResponseModel ProjectToResponseModel(FillerPreset fillerPreset) =>
new(fillerPreset.Id, fillerPreset.Name);
internal static FillerPresetFullResponseModel ProjectToFullResponseModel(FillerPreset fillerPreset) =>
new(
fillerPreset.Id,
fillerPreset.Name,
fillerPreset.FillerKind,
fillerPreset.FillerMode,
fillerPreset.Duration,
fillerPreset.Count,
fillerPreset.PadToNearestMinute,
fillerPreset.AllowWatermarks,
fillerPreset.CollectionType,
fillerPreset.CollectionId,
fillerPreset.MediaItemId,
fillerPreset.MultiCollectionId,
fillerPreset.SmartCollectionId,
fillerPreset.PlaylistId,
fillerPreset.Expression,
fillerPreset.UseChaptersAsMediaItems);
internal static FillerPresetViewModel ProjectToViewModel(FillerPreset fillerPreset) =>
new(
fillerPreset.Id,
@@ -0,0 +1,5 @@
using ErsatzTV.Core.Api.Filler;
namespace ErsatzTV.Application.Filler;
public record GetAllFillerPresetsForApi : IRequest<List<FillerPresetResponseModel>>;
@@ -0,0 +1,22 @@
using ErsatzTV.Core.Api.Filler;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.Filler.Mapper;
namespace ErsatzTV.Application.Filler;
public class GetAllFillerPresetsForApiHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetAllFillerPresetsForApi, List<FillerPresetResponseModel>>
{
public async Task<List<FillerPresetResponseModel>> Handle(
GetAllFillerPresetsForApi request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
List<FillerPreset> fillerPresets = await dbContext.FillerPresets
.AsNoTracking()
.ToListAsync(cancellationToken);
return fillerPresets.Map(ProjectToResponseModel).ToList();
}
}
@@ -0,0 +1,5 @@
using ErsatzTV.Core.Api.Filler;
namespace ErsatzTV.Application.Filler;
public record GetFillerPresetByIdForApi(int Id) : IRequest<Option<FillerPresetFullResponseModel>>;
@@ -0,0 +1,22 @@
using ErsatzTV.Core.Api.Filler;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.Filler.Mapper;
namespace ErsatzTV.Application.Filler;
public class GetFillerPresetByIdForApiHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetFillerPresetByIdForApi, Option<FillerPresetFullResponseModel>>
{
public async Task<Option<FillerPresetFullResponseModel>> Handle(
GetFillerPresetByIdForApi request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.FillerPresets
.AsNoTracking()
.SelectOneAsync(fp => fp.Id, fp => fp.Id == request.Id, cancellationToken)
.MapT(ProjectToFullResponseModel);
}
}
@@ -0,0 +1,5 @@
using ErsatzTV.Core.Api.Graphics;
namespace ErsatzTV.Application.Graphics;
public record GetAllGraphicsElementsForApi : IRequest<List<GraphicsElementResponseModel>>;
@@ -0,0 +1,27 @@
using ErsatzTV.Core.Api.Graphics;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.Graphics.Mapper;
namespace ErsatzTV.Application.Graphics;
public class GetAllGraphicsElementsForApiHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetAllGraphicsElementsForApi, List<GraphicsElementResponseModel>>
{
public async Task<List<GraphicsElementResponseModel>> Handle(
GetAllGraphicsElementsForApi request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
List<GraphicsElement> graphicsElements = await dbContext.GraphicsElements
.AsNoTracking()
.ToListAsync(cancellationToken);
return graphicsElements
.Map(ProjectToViewModel)
.OrderBy(e => e.Name == e.FileName)
.ThenBy(e => e.Name)
.Select(vm => new GraphicsElementResponseModel(vm.Id, vm.Name))
.ToList();
}
}
+24
View File
@@ -0,0 +1,24 @@
using ErsatzTV.Core.Api.Health;
using ErsatzTV.Core.Health;
namespace ErsatzTV.Application.Health;
internal static class Mapper
{
internal static HealthCheckResponseModel ProjectToResponseModel(HealthCheckResult result) =>
new(
result.Title,
GetStatus(result.Status),
result.Message,
result.Link.MatchUnsafe(l => l.Link, () => null));
private static string GetStatus(HealthCheckStatus status) =>
status switch
{
HealthCheckStatus.Pass => "pass",
HealthCheckStatus.Fail => "fail",
HealthCheckStatus.Warning => "warn",
HealthCheckStatus.Info => "info",
_ => throw new ArgumentOutOfRangeException(nameof(status), status, null)
};
}
@@ -0,0 +1,5 @@
using ErsatzTV.Core.Api.Health;
namespace ErsatzTV.Application.Health;
public record GetAllHealthCheckResultsForApi : IRequest<List<HealthCheckResponseModel>>;
@@ -0,0 +1,32 @@
using ErsatzTV.Core.Api.Health;
using ErsatzTV.Core.Health;
using static ErsatzTV.Application.Health.Mapper;
namespace ErsatzTV.Application.Health;
public class GetAllHealthCheckResultsForApiHandler
: IRequestHandler<GetAllHealthCheckResultsForApi, List<HealthCheckResponseModel>>
{
private readonly IHealthCheckService _healthCheckService;
public GetAllHealthCheckResultsForApiHandler(IHealthCheckService healthCheckService) =>
_healthCheckService = healthCheckService;
public async Task<List<HealthCheckResponseModel>> Handle(
GetAllHealthCheckResultsForApi request,
CancellationToken cancellationToken)
{
try
{
List<HealthCheckResult> results = await _healthCheckService.PerformHealthChecks(cancellationToken);
return results
.Filter(r => r.Status != HealthCheckStatus.NotApplicable)
.Map(ProjectToResponseModel)
.ToList();
}
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
{
return [];
}
}
}
@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Images;
public record ImageFolderExists(int LibraryFolderId) : IRequest<bool>;
@@ -0,0 +1,21 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Images;
public class ImageFolderExistsHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<ImageFolderExists, bool>
{
public async Task<bool> Handle(ImageFolderExists request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.LibraryFolders
.AsNoTracking()
.AnyAsync(
lf => lf.Id == request.LibraryFolderId
&& lf.LibraryPath.Library.MediaKind == LibraryMediaKind.Images,
cancellationToken);
}
}
@@ -0,0 +1,5 @@
using ErsatzTV.Core.Api.Libraries;
namespace ErsatzTV.Application.Libraries;
public record GetLibraryScanStatus : IRequest<List<LibraryScanStatusResponseModel>>;
@@ -0,0 +1,20 @@
using ErsatzTV.Core.Api.Libraries;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Metadata;
namespace ErsatzTV.Application.Libraries;
public class GetLibraryScanStatusHandler(IScannerProxyService scannerProxyService)
: IRequestHandler<GetLibraryScanStatus, List<LibraryScanStatusResponseModel>>
{
public Task<List<LibraryScanStatusResponseModel>> Handle(
GetLibraryScanStatus request,
CancellationToken cancellationToken)
{
List<LibraryScanStatusResponseModel> result = scannerProxyService.GetActiveScans()
.Select(scan => new LibraryScanStatusResponseModel(scan.LibraryId, scan.Progress))
.ToList();
return Task.FromResult(result);
}
}
@@ -0,0 +1,620 @@
using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Jellyfin;
using ErsatzTV.Infrastructure.Data;
using Flurl;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.LibraryBrowse;
// Shared MediaItem -> LibraryBrowseItemResponseModel projection used by both the library-browse
// search handler and the collection-items handler (#155). Keeping the per-kind hydration and the
// rooted-artwork logic in one place avoids duplicating the Blazor-vs-SPA artwork rooting rules
// (see the Artwork helper below and docs/api-conventions.md §4).
internal static class LibraryBrowseItemMapper
{
// Hydrates an arbitrary set of media item ids (any kinds mixed) into response models. MediaItem
// ids are globally unique across kinds, so passing the full id list to every per-kind query is
// safe: each query only matches its own kind. Callers order/page the result themselves.
public static async Task<List<LibraryBrowseItemResponseModel>> HydrateMediaItemsByIds(
TvContext dbContext,
IReadOnlyList<int> ids,
CancellationToken cancellationToken)
{
if (ids.Count == 0)
{
return [];
}
var idList = ids.Distinct().ToList();
var results = new List<LibraryBrowseItemResponseModel>();
results.AddRange(await GetMovies(dbContext, idList, cancellationToken));
results.AddRange(await GetShows(dbContext, idList, cancellationToken));
results.AddRange(await GetSeasons(dbContext, idList, cancellationToken));
results.AddRange(await GetArtists(dbContext, idList, cancellationToken));
results.AddRange(await GetEpisodes(dbContext, idList, cancellationToken));
results.AddRange(await GetMusicVideos(dbContext, idList, cancellationToken));
results.AddRange(await GetSongs(dbContext, idList, cancellationToken));
results.AddRange(await GetOtherVideos(dbContext, idList, cancellationToken));
results.AddRange(await GetImages(dbContext, idList, cancellationToken));
results.AddRange(await GetRemoteStreams(dbContext, idList, cancellationToken));
return results;
}
public static async Task<List<LibraryBrowseItemResponseModel>> GetMovies(
TvContext dbContext,
List<int> ids,
CancellationToken cancellationToken)
{
if (ids.Count == 0)
{
return [];
}
return await dbContext.MovieMetadata
.AsNoTracking()
.Where(mm => ids.Contains(mm.MovieId))
.Include(mm => mm.Artwork)
.Include(mm => mm.Movie)
.ThenInclude(m => m.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(mm => mm.Movie)
.ThenInclude(m => m.MediaVersions)
.ToListAsync(cancellationToken)
.Map(list => list
.GroupBy(mm => mm.MovieId)
.Select(g => g.OrderBy(mm => mm.Id).First())
.Map(mm => new LibraryBrowseItemResponseModel(
mm.MovieId,
LibraryBrowseMediaType.Movie,
mm.Title ?? string.Empty,
mm.Movie.LibraryPath.LibraryId,
mm.Movie.LibraryPath.Library.Name,
Artwork(mm, ArtworkKind.Poster),
BestDuration(mm.Movie.MediaVersions),
1,
null,
CollectionType.Movie,
null,
null,
null,
null,
mm.MovieId,
null)).ToList());
}
public static async Task<List<LibraryBrowseItemResponseModel>> GetShows(
TvContext dbContext,
List<int> ids,
CancellationToken cancellationToken)
{
if (ids.Count == 0)
{
return [];
}
Dictionary<int, int> counts = await dbContext.Episodes
.AsNoTracking()
.Where(e => ids.Contains(e.Season.ShowId))
.GroupBy(e => e.Season.ShowId)
.Select(g => new { ShowId = g.Key, Count = g.Count() })
.ToDictionaryAsync(g => g.ShowId, g => g.Count, cancellationToken);
return await dbContext.ShowMetadata
.AsNoTracking()
.Where(sm => ids.Contains(sm.ShowId))
.Include(sm => sm.Artwork)
.Include(sm => sm.Show)
.ThenInclude(s => s.LibraryPath)
.ThenInclude(lp => lp.Library)
.ToListAsync(cancellationToken)
.Map(list => list
.GroupBy(sm => sm.ShowId)
.Select(g => g.OrderBy(sm => sm.Id).First())
.Map(sm => new LibraryBrowseItemResponseModel(
sm.ShowId,
LibraryBrowseMediaType.TelevisionShow,
sm.Title ?? string.Empty,
sm.Show.LibraryPath.LibraryId,
sm.Show.LibraryPath.Library.Name,
Artwork(sm, ArtworkKind.Poster),
null,
counts.TryGetValue(sm.ShowId, out int count) ? count : 0,
null,
CollectionType.TelevisionShow,
null,
null,
null,
null,
sm.ShowId,
null)).ToList());
}
public static async Task<List<LibraryBrowseItemResponseModel>> GetSeasons(
TvContext dbContext,
List<int> ids,
CancellationToken cancellationToken)
{
if (ids.Count == 0)
{
return [];
}
Dictionary<int, int> counts = await dbContext.Episodes
.AsNoTracking()
.Where(e => ids.Contains(e.SeasonId))
.GroupBy(e => e.SeasonId)
.Select(g => new { SeasonId = g.Key, Count = g.Count() })
.ToDictionaryAsync(g => g.SeasonId, g => g.Count, cancellationToken);
return await dbContext.SeasonMetadata
.AsNoTracking()
.Where(sm => ids.Contains(sm.SeasonId))
.Include(sm => sm.Artwork)
.Include(sm => sm.Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(shm => shm.Artwork)
.Include(sm => sm.Season)
.ThenInclude(s => s.LibraryPath)
.ThenInclude(lp => lp.Library)
.ToListAsync(cancellationToken)
.Map(list => list
.GroupBy(sm => sm.SeasonId)
.Select(g => g.OrderBy(sm => sm.Id).First())
.Map(sm => new LibraryBrowseItemResponseModel(
sm.SeasonId,
LibraryBrowseMediaType.TelevisionSeason,
SeasonTitle(sm),
sm.Season.LibraryPath.LibraryId,
sm.Season.LibraryPath.Library.Name,
SeasonArtwork(sm),
null,
counts.TryGetValue(sm.SeasonId, out int count) ? count : 0,
null,
CollectionType.TelevisionSeason,
null,
null,
null,
null,
sm.SeasonId,
null)).ToList());
}
public static async Task<List<LibraryBrowseItemResponseModel>> GetArtists(
TvContext dbContext,
List<int> ids,
CancellationToken cancellationToken)
{
if (ids.Count == 0)
{
return [];
}
Dictionary<int, int> counts = await dbContext.MusicVideos
.AsNoTracking()
.Where(mv => ids.Contains(mv.ArtistId))
.GroupBy(mv => mv.ArtistId)
.Select(g => new { ArtistId = g.Key, Count = g.Count() })
.ToDictionaryAsync(g => g.ArtistId, g => g.Count, cancellationToken);
return await dbContext.ArtistMetadata
.AsNoTracking()
.Where(am => ids.Contains(am.ArtistId))
.Include(am => am.Artwork)
.Include(am => am.Artist)
.ThenInclude(a => a.LibraryPath)
.ThenInclude(lp => lp.Library)
.ToListAsync(cancellationToken)
.Map(list => list
.GroupBy(am => am.ArtistId)
.Select(g => g.OrderBy(am => am.Id).First())
.Map(am => new LibraryBrowseItemResponseModel(
am.ArtistId,
LibraryBrowseMediaType.Artist,
am.Title ?? string.Empty,
am.Artist.LibraryPath.LibraryId,
am.Artist.LibraryPath.Library.Name,
Artwork(am, ArtworkKind.Thumbnail),
null,
counts.TryGetValue(am.ArtistId, out int count) ? count : 0,
null,
CollectionType.Artist,
null,
null,
null,
null,
am.ArtistId,
null)).ToList());
}
public static async Task<List<LibraryBrowseItemResponseModel>> GetEpisodes(
TvContext dbContext,
List<int> ids,
CancellationToken cancellationToken)
{
if (ids.Count == 0)
{
return [];
}
return await dbContext.EpisodeMetadata
.AsNoTracking()
.Where(em => ids.Contains(em.EpisodeId))
.Include(em => em.Artwork)
.Include(em => em.Episode)
.ThenInclude(e => e.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(em => em.Episode)
.ThenInclude(e => e.MediaVersions)
.Include(em => em.Episode)
.ThenInclude(e => e.Season)
.ThenInclude(s => s.Show)
.ThenInclude(sh => sh.ShowMetadata)
.ToListAsync(cancellationToken)
.Map(list => list
.GroupBy(em => em.EpisodeId)
.Select(g => g.OrderBy(em => em.Id).First())
.Map(em => new LibraryBrowseItemResponseModel(
em.EpisodeId,
LibraryBrowseMediaType.Episode,
em.Title ?? string.Empty,
em.Episode.LibraryPath.LibraryId,
em.Episode.LibraryPath.Library.Name,
ArtworkWithFallback(em, ArtworkKind.Thumbnail, ArtworkKind.Poster),
BestDuration(em.Episode.MediaVersions),
1,
null,
CollectionType.Episode,
null,
null,
null,
null,
em.EpisodeId,
null,
EpisodeSubtitle(em))).ToList());
}
public static async Task<List<LibraryBrowseItemResponseModel>> GetMusicVideos(
TvContext dbContext,
List<int> ids,
CancellationToken cancellationToken)
{
if (ids.Count == 0)
{
return [];
}
return await dbContext.MusicVideoMetadata
.AsNoTracking()
.Where(mvm => ids.Contains(mvm.MusicVideoId))
.Include(mvm => mvm.Artwork)
.Include(mvm => mvm.MusicVideo)
.ThenInclude(mv => mv.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(mvm => mvm.MusicVideo)
.ThenInclude(mv => mv.MediaVersions)
.Include(mvm => mvm.MusicVideo)
.ThenInclude(mv => mv.Artist)
.ThenInclude(a => a.ArtistMetadata)
.ToListAsync(cancellationToken)
.Map(list => list
.GroupBy(mvm => mvm.MusicVideoId)
.Select(g => g.OrderBy(mvm => mvm.Id).First())
.Map(mvm => new LibraryBrowseItemResponseModel(
mvm.MusicVideoId,
LibraryBrowseMediaType.MusicVideo,
mvm.Title ?? string.Empty,
mvm.MusicVideo.LibraryPath.LibraryId,
mvm.MusicVideo.LibraryPath.Library.Name,
ArtworkWithFallback(mvm, ArtworkKind.Thumbnail, ArtworkKind.Poster),
BestDuration(mvm.MusicVideo.MediaVersions),
1,
null,
CollectionType.MusicVideo,
null,
null,
null,
null,
mvm.MusicVideoId,
null,
MusicVideoSubtitle(mvm))).ToList());
}
public static async Task<List<LibraryBrowseItemResponseModel>> GetSongs(
TvContext dbContext,
List<int> ids,
CancellationToken cancellationToken)
{
if (ids.Count == 0)
{
return [];
}
return await dbContext.SongMetadata
.AsNoTracking()
.Where(sm => ids.Contains(sm.SongId))
.Include(sm => sm.Artwork)
.Include(sm => sm.Song)
.ThenInclude(s => s.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(sm => sm.Song)
.ThenInclude(s => s.MediaVersions)
.ToListAsync(cancellationToken)
.Map(list => list
.GroupBy(sm => sm.SongId)
.Select(g => g.OrderBy(sm => sm.Id).First())
.Map(sm => new LibraryBrowseItemResponseModel(
sm.SongId,
LibraryBrowseMediaType.Song,
sm.Title ?? string.Empty,
sm.Song.LibraryPath.LibraryId,
sm.Song.LibraryPath.Library.Name,
ArtworkWithFallback(sm, ArtworkKind.Thumbnail, ArtworkKind.Poster),
BestDuration(sm.Song.MediaVersions),
1,
null,
CollectionType.Song,
null,
null,
null,
null,
sm.SongId,
null,
SongSubtitle(sm))).ToList());
}
public static async Task<List<LibraryBrowseItemResponseModel>> GetOtherVideos(
TvContext dbContext,
List<int> ids,
CancellationToken cancellationToken)
{
if (ids.Count == 0)
{
return [];
}
return await dbContext.OtherVideoMetadata
.AsNoTracking()
.Where(ovm => ids.Contains(ovm.OtherVideoId))
.Include(ovm => ovm.Artwork)
.Include(ovm => ovm.OtherVideo)
.ThenInclude(ov => ov.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(ovm => ovm.OtherVideo)
.ThenInclude(ov => ov.MediaVersions)
.ToListAsync(cancellationToken)
.Map(list => list
.GroupBy(ovm => ovm.OtherVideoId)
.Select(g => g.OrderBy(ovm => ovm.Id).First())
.Map(ovm => new LibraryBrowseItemResponseModel(
ovm.OtherVideoId,
LibraryBrowseMediaType.OtherVideo,
ovm.Title ?? string.Empty,
ovm.OtherVideo.LibraryPath.LibraryId,
ovm.OtherVideo.LibraryPath.Library.Name,
ArtworkWithFallback(ovm, ArtworkKind.Thumbnail, ArtworkKind.Poster),
BestDuration(ovm.OtherVideo.MediaVersions),
1,
null,
CollectionType.OtherVideo,
null,
null,
null,
null,
ovm.OtherVideoId,
null,
string.IsNullOrWhiteSpace(ovm.OriginalTitle) ? null : ovm.OriginalTitle)).ToList());
}
public static async Task<List<LibraryBrowseItemResponseModel>> GetImages(
TvContext dbContext,
List<int> ids,
CancellationToken cancellationToken)
{
if (ids.Count == 0)
{
return [];
}
return await dbContext.ImageMetadata
.AsNoTracking()
.Where(im => ids.Contains(im.ImageId))
.Include(im => im.Artwork)
.Include(im => im.Image)
.ThenInclude(i => i.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(im => im.Image)
.ThenInclude(i => i.MediaVersions)
.ToListAsync(cancellationToken)
.Map(list => list
.GroupBy(im => im.ImageId)
.Select(g => g.OrderBy(im => im.Id).First())
.Map(im => new LibraryBrowseItemResponseModel(
im.ImageId,
LibraryBrowseMediaType.Image,
im.Title ?? string.Empty,
im.Image.LibraryPath.LibraryId,
im.Image.LibraryPath.Library.Name,
ArtworkWithFallback(im, ArtworkKind.Poster, ArtworkKind.Thumbnail),
BestDuration(im.Image.MediaVersions),
1,
null,
CollectionType.Image,
null,
null,
null,
null,
im.ImageId,
null,
string.IsNullOrWhiteSpace(im.OriginalTitle) ? null : im.OriginalTitle)).ToList());
}
public static async Task<List<LibraryBrowseItemResponseModel>> GetRemoteStreams(
TvContext dbContext,
List<int> ids,
CancellationToken cancellationToken)
{
if (ids.Count == 0)
{
return [];
}
return await dbContext.RemoteStreamMetadata
.AsNoTracking()
.Where(rsm => ids.Contains(rsm.RemoteStreamId))
.Include(rsm => rsm.Artwork)
.Include(rsm => rsm.RemoteStream)
.ThenInclude(rs => rs.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(rsm => rsm.RemoteStream)
.ThenInclude(rs => rs.MediaVersions)
.ToListAsync(cancellationToken)
.Map(list => list
.GroupBy(rsm => rsm.RemoteStreamId)
.Select(g => g.OrderBy(rsm => rsm.Id).First())
.Map(rsm => new LibraryBrowseItemResponseModel(
rsm.RemoteStreamId,
LibraryBrowseMediaType.RemoteStream,
rsm.Title ?? string.Empty,
rsm.RemoteStream.LibraryPath.LibraryId,
rsm.RemoteStream.LibraryPath.Library.Name,
ArtworkWithFallback(rsm, ArtworkKind.Thumbnail, ArtworkKind.Poster),
BestDuration(rsm.RemoteStream.MediaVersions),
1,
null,
CollectionType.RemoteStream,
null,
null,
null,
null,
rsm.RemoteStreamId,
null,
string.IsNullOrWhiteSpace(rsm.OriginalTitle) ? null : rsm.OriginalTitle)).ToList());
}
public static TimeSpan? BestDuration(IEnumerable<MediaVersion> versions)
{
TimeSpan duration = versions
.Select(v => v.Duration)
.Where(d => d > TimeSpan.Zero)
.DefaultIfEmpty()
.Max();
return duration > TimeSpan.Zero ? duration : null;
}
// Returns a rooted, directly-usable artwork URL for the SPA's <img src>. Blazor pages rely on
// GetPosterUrl to prefix "artwork/posters/" and resolve relative to <base href="/">, but the SPA
// renders the value raw from under /app/, so the API must root the URL itself (issue #180).
public static string Artwork(Metadata metadata, ArtworkKind artworkKind)
{
string artwork = Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == artworkKind))
.Match(a => a.Path, string.Empty);
if (string.IsNullOrWhiteSpace(artwork))
{
return string.Empty;
}
// Absolute URLs are already usable as-is (matches Blazor's GetPosterUrl guard).
if (artwork.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
artwork.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
return artwork;
}
string folder = artworkKind is ArtworkKind.Thumbnail ? "thumbnails" : "posters";
if (artwork.StartsWith("jellyfin://", StringComparison.OrdinalIgnoreCase))
{
Url url = JellyfinUrl.RelativeProxyForArtwork(artwork);
if (artworkKind is ArtworkKind.Poster or ArtworkKind.Thumbnail)
{
url.SetQueryParam("fillHeight", 440);
}
return $"/artwork/{folder}/{url}";
}
if (artwork.StartsWith("emby://", StringComparison.OrdinalIgnoreCase))
{
Url url = EmbyUrl.RelativeProxyForArtwork(artwork);
if (artworkKind is ArtworkKind.Poster or ArtworkKind.Thumbnail)
{
url.SetQueryParam("maxHeight", 440);
}
return $"/artwork/{folder}/{url}";
}
return $"/artwork/{folder}/{artwork}";
}
private static string ArtworkWithFallback(Metadata metadata, ArtworkKind primary, ArtworkKind fallback)
{
string artwork = Artwork(metadata, primary);
return string.IsNullOrWhiteSpace(artwork) ? Artwork(metadata, fallback) : artwork;
}
private static string SeasonTitle(SeasonMetadata metadata)
{
string showTitle = metadata.Season.Show.ShowMetadata.HeadOrNone()
.Map(sm => sm.Title ?? string.Empty)
.IfNone(string.Empty);
string seasonTitle = metadata.Season.SeasonNumber == 0
? "Specials"
: $"Season {metadata.Season.SeasonNumber}";
return string.IsNullOrWhiteSpace(showTitle) ? seasonTitle : $"{showTitle} - {seasonTitle}";
}
// Seasons often have no poster of their own; fall back to the parent show's poster (issue #180).
private static string SeasonArtwork(SeasonMetadata metadata)
{
string artwork = Artwork(metadata, ArtworkKind.Poster);
if (!string.IsNullOrWhiteSpace(artwork))
{
return artwork;
}
return metadata.Season.Show.ShowMetadata.HeadOrNone()
.Match(sm => Artwork(sm, ArtworkKind.Poster), string.Empty);
}
private static string EpisodeSubtitle(EpisodeMetadata metadata)
{
string showTitle = metadata.Episode.Season.Show.ShowMetadata.HeadOrNone()
.Map(sm => sm.Title ?? string.Empty)
.IfNone(string.Empty);
int seasonNumber = metadata.Episode.Season.SeasonNumber;
string suffix = $"S{seasonNumber}E{metadata.EpisodeNumber}";
return string.IsNullOrWhiteSpace(showTitle) ? suffix : $"{showTitle} - {suffix}";
}
private static string MusicVideoSubtitle(MusicVideoMetadata metadata)
{
string artist = metadata.MusicVideo.Artist.ArtistMetadata.HeadOrNone()
.Map(am => am.Title ?? string.Empty)
.IfNone(string.Empty);
string album = string.IsNullOrWhiteSpace(metadata.Album) ? string.Empty : metadata.Album;
if (!string.IsNullOrWhiteSpace(artist) && !string.IsNullOrWhiteSpace(album))
{
return $"{artist} - {album}";
}
return string.IsNullOrWhiteSpace(artist) ? album : artist;
}
private static string SongSubtitle(SongMetadata metadata)
{
string artists = string.Join(", ", metadata.Artists ?? []);
string album = string.IsNullOrWhiteSpace(metadata.Album) ? string.Empty : metadata.Album;
if (!string.IsNullOrWhiteSpace(artists) && !string.IsNullOrWhiteSpace(album))
{
return $"{artists} - {album}";
}
return string.IsNullOrWhiteSpace(artists) ? album : artists;
}
}
@@ -0,0 +1,11 @@
using ErsatzTV.Core.Api.LibraryBrowse;
namespace ErsatzTV.Application.LibraryBrowse;
public record GetLibraryBrowseItems(
string Query,
int? LibraryId,
LibraryBrowseMediaType? MediaType,
int PageNum,
int PageSize,
int? ParentId = null) : IRequest<PagedLibraryBrowseItemsResponseModel>;
@@ -0,0 +1,904 @@
using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Search;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.LibraryBrowse;
public class GetLibraryBrowseItemsHandler(
ISearchIndex searchIndex,
IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetLibraryBrowseItems, PagedLibraryBrowseItemsResponseModel>
{
private const string LibraryIdField = "library_id";
public async Task<PagedLibraryBrowseItemsResponseModel> Handle(
GetLibraryBrowseItems request,
CancellationToken cancellationToken)
{
// Drill-in for detail pages: read a parent's children directly (bypassing Lucene) so the SPA can
// expand a show into its seasons (#180), a season into its episodes, or an artist into its music
// videos (#141/#161). Each reads in the natural display order for that kind.
if (request.ParentId.HasValue)
{
switch (request.MediaType)
{
case LibraryBrowseMediaType.TelevisionSeason:
{
await using TvContext seasonContext =
await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await BrowseSeasonsForShow(seasonContext, request, cancellationToken);
}
case LibraryBrowseMediaType.Episode:
{
await using TvContext episodeContext =
await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await BrowseEpisodesForSeason(episodeContext, request, cancellationToken);
}
case LibraryBrowseMediaType.MusicVideo:
{
await using TvContext musicVideoContext =
await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await BrowseMusicVideosForArtist(musicVideoContext, request, cancellationToken);
}
}
}
int offset = request.PageNum * request.PageSize;
SearchResult mediaResult = await SearchMedia(request, offset, request.PageSize, cancellationToken);
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
List<LibraryBrowseItemResponseModel> mediaItems = await HydrateMediaItems(
dbContext,
mediaResult.Items,
cancellationToken);
int collectionTotal = await CountCollections(dbContext, request, cancellationToken);
// Lucene total count can briefly include stale media ids until the async rescan catches up; collection
// paging may drift in that window, matching the staleness behavior accepted by the existing search UI.
int collectionSkip = Math.Max(0, offset - mediaResult.TotalCount);
int collectionTake = request.PageSize - mediaItems.Count;
List<LibraryBrowseItemResponseModel> collectionItems = collectionTake > 0
? await GetCollectionItems(dbContext, request, collectionSkip, collectionTake, cancellationToken)
: [];
return new PagedLibraryBrowseItemsResponseModel(
mediaResult.TotalCount + collectionTotal,
mediaItems.Concat(collectionItems).ToList());
}
private async Task<SearchResult> SearchMedia(
GetLibraryBrowseItems request,
int offset,
int pageSize,
CancellationToken cancellationToken)
{
List<string> typeFilters = MediaTypesFor(request.MediaType).ToList();
if (typeFilters.Count == 0)
{
return new SearchResult([], 0);
}
var clauses = new List<string>
{
typeFilters.Count == 1
? $"type:{typeFilters[0]}"
: $"({string.Join(" OR ", typeFilters.Map(t => $"type:{t}"))})"
};
if (request.LibraryId.HasValue)
{
clauses.Add($"{LibraryIdField}:{request.LibraryId.Value}");
}
if (!string.IsNullOrWhiteSpace(request.Query))
{
clauses.Add($"({request.Query})");
}
return await searchIndex.Search(
string.Join(" AND ", clauses),
string.Empty,
offset,
pageSize,
[LuceneSearchIndex.TitleAndYearSearchField],
cancellationToken);
}
private static IEnumerable<string> MediaTypesFor(LibraryBrowseMediaType? mediaType) =>
mediaType switch
{
LibraryBrowseMediaType.Movie => [LuceneSearchIndex.MovieType],
LibraryBrowseMediaType.TelevisionShow => [LuceneSearchIndex.ShowType],
LibraryBrowseMediaType.TelevisionSeason => [LuceneSearchIndex.SeasonType],
LibraryBrowseMediaType.Artist => [LuceneSearchIndex.ArtistType],
LibraryBrowseMediaType.Episode => [LuceneSearchIndex.EpisodeType],
LibraryBrowseMediaType.MusicVideo => [LuceneSearchIndex.MusicVideoType],
LibraryBrowseMediaType.Song => [LuceneSearchIndex.SongType],
LibraryBrowseMediaType.OtherVideo => [LuceneSearchIndex.OtherVideoType],
LibraryBrowseMediaType.Image => [LuceneSearchIndex.ImageType],
LibraryBrowseMediaType.RemoteStream => [LuceneSearchIndex.RemoteStreamType],
null =>
[
LuceneSearchIndex.MovieType,
LuceneSearchIndex.ShowType,
LuceneSearchIndex.SeasonType,
LuceneSearchIndex.ArtistType,
LuceneSearchIndex.EpisodeType,
LuceneSearchIndex.MusicVideoType,
LuceneSearchIndex.SongType,
LuceneSearchIndex.OtherVideoType,
LuceneSearchIndex.ImageType,
LuceneSearchIndex.RemoteStreamType
],
_ => []
};
private static async Task<List<LibraryBrowseItemResponseModel>> HydrateMediaItems(
TvContext dbContext,
List<SearchItem> searchItems,
CancellationToken cancellationToken)
{
if (searchItems.Count == 0)
{
return [];
}
List<int> movieIds = searchItems.Where(i => i.Type == LuceneSearchIndex.MovieType).Select(i => i.Id).ToList();
List<int> showIds = searchItems.Where(i => i.Type == LuceneSearchIndex.ShowType).Select(i => i.Id).ToList();
List<int> seasonIds = searchItems.Where(i => i.Type == LuceneSearchIndex.SeasonType).Select(i => i.Id).ToList();
List<int> artistIds = searchItems.Where(i => i.Type == LuceneSearchIndex.ArtistType).Select(i => i.Id).ToList();
List<int> episodeIds = searchItems.Where(i => i.Type == LuceneSearchIndex.EpisodeType).Select(i => i.Id).ToList();
List<int> musicVideoIds =
searchItems.Where(i => i.Type == LuceneSearchIndex.MusicVideoType).Select(i => i.Id).ToList();
List<int> songIds = searchItems.Where(i => i.Type == LuceneSearchIndex.SongType).Select(i => i.Id).ToList();
List<int> otherVideoIds =
searchItems.Where(i => i.Type == LuceneSearchIndex.OtherVideoType).Select(i => i.Id).ToList();
List<int> imageIds = searchItems.Where(i => i.Type == LuceneSearchIndex.ImageType).Select(i => i.Id).ToList();
List<int> remoteStreamIds =
searchItems.Where(i => i.Type == LuceneSearchIndex.RemoteStreamType).Select(i => i.Id).ToList();
Dictionary<(string Type, int Id), LibraryBrowseItemResponseModel> hydrated = [];
foreach (LibraryBrowseItemResponseModel item in
await LibraryBrowseItemMapper.GetMovies(dbContext, movieIds, cancellationToken))
{
hydrated[(LuceneSearchIndex.MovieType, item.Id)] = item;
}
foreach (LibraryBrowseItemResponseModel item in
await LibraryBrowseItemMapper.GetShows(dbContext, showIds, cancellationToken))
{
hydrated[(LuceneSearchIndex.ShowType, item.Id)] = item;
}
foreach (LibraryBrowseItemResponseModel item in
await LibraryBrowseItemMapper.GetSeasons(dbContext, seasonIds, cancellationToken))
{
hydrated[(LuceneSearchIndex.SeasonType, item.Id)] = item;
}
foreach (LibraryBrowseItemResponseModel item in
await LibraryBrowseItemMapper.GetArtists(dbContext, artistIds, cancellationToken))
{
hydrated[(LuceneSearchIndex.ArtistType, item.Id)] = item;
}
foreach (LibraryBrowseItemResponseModel item in
await LibraryBrowseItemMapper.GetEpisodes(dbContext, episodeIds, cancellationToken))
{
hydrated[(LuceneSearchIndex.EpisodeType, item.Id)] = item;
}
foreach (LibraryBrowseItemResponseModel item in
await LibraryBrowseItemMapper.GetMusicVideos(dbContext, musicVideoIds, cancellationToken))
{
hydrated[(LuceneSearchIndex.MusicVideoType, item.Id)] = item;
}
foreach (LibraryBrowseItemResponseModel item in
await LibraryBrowseItemMapper.GetSongs(dbContext, songIds, cancellationToken))
{
hydrated[(LuceneSearchIndex.SongType, item.Id)] = item;
}
foreach (LibraryBrowseItemResponseModel item in
await LibraryBrowseItemMapper.GetOtherVideos(dbContext, otherVideoIds, cancellationToken))
{
hydrated[(LuceneSearchIndex.OtherVideoType, item.Id)] = item;
}
foreach (LibraryBrowseItemResponseModel item in
await LibraryBrowseItemMapper.GetImages(dbContext, imageIds, cancellationToken))
{
hydrated[(LuceneSearchIndex.ImageType, item.Id)] = item;
}
foreach (LibraryBrowseItemResponseModel item in
await LibraryBrowseItemMapper.GetRemoteStreams(dbContext, remoteStreamIds, cancellationToken))
{
hydrated[(LuceneSearchIndex.RemoteStreamType, item.Id)] = item;
}
return searchItems
.Where(i => hydrated.ContainsKey((i.Type, i.Id)))
.Select(i => hydrated[(i.Type, i.Id)])
.ToList();
}
private static async Task<PagedLibraryBrowseItemsResponseModel> BrowseSeasonsForShow(
TvContext dbContext,
GetLibraryBrowseItems request,
CancellationToken cancellationToken)
{
List<int> allSeasonIds = await dbContext.Seasons
.AsNoTracking()
.Where(s => s.ShowId == request.ParentId.Value)
.OrderBy(s => s.SeasonNumber)
.Select(s => s.Id)
.ToListAsync(cancellationToken);
int total = allSeasonIds.Count;
List<int> pageIds = allSeasonIds
.Skip(request.PageNum * request.PageSize)
.Take(request.PageSize)
.ToList();
List<LibraryBrowseItemResponseModel> seasons =
await LibraryBrowseItemMapper.GetSeasons(dbContext, pageIds, cancellationToken);
// GetSeasons groups by season id, so restore the requested season-number order.
Dictionary<int, LibraryBrowseItemResponseModel> byId = seasons.ToDictionary(s => s.Id);
List<LibraryBrowseItemResponseModel> ordered = pageIds
.Where(byId.ContainsKey)
.Select(id => byId[id])
.ToList();
return new PagedLibraryBrowseItemsResponseModel(total, ordered);
}
// Drill-in: episodes of a specific season, in episode-number order (#141/#161).
private static async Task<PagedLibraryBrowseItemsResponseModel> BrowseEpisodesForSeason(
TvContext dbContext,
GetLibraryBrowseItems request,
CancellationToken cancellationToken)
{
List<int> allEpisodeIds = await dbContext.EpisodeMetadata
.AsNoTracking()
.Where(em => em.Episode.SeasonId == request.ParentId.Value)
.OrderBy(em => em.EpisodeNumber)
.ThenBy(em => em.EpisodeId)
.Select(em => em.EpisodeId)
.ToListAsync(cancellationToken);
// Distinct preserves order (LINQ-to-Objects) for episodes with multiple metadata rows.
allEpisodeIds = allEpisodeIds.Distinct().ToList();
int total = allEpisodeIds.Count;
List<int> pageIds = allEpisodeIds
.Skip(request.PageNum * request.PageSize)
.Take(request.PageSize)
.ToList();
List<LibraryBrowseItemResponseModel> episodes =
await LibraryBrowseItemMapper.GetEpisodes(dbContext, pageIds, cancellationToken);
Dictionary<int, LibraryBrowseItemResponseModel> byId = episodes.ToDictionary(e => e.Id);
List<LibraryBrowseItemResponseModel> ordered = pageIds
.Where(byId.ContainsKey)
.Select(id => byId[id])
.ToList();
return new PagedLibraryBrowseItemsResponseModel(total, ordered);
}
// Drill-in: music videos of a specific artist, in album/track/title order (#141/#161).
private static async Task<PagedLibraryBrowseItemsResponseModel> BrowseMusicVideosForArtist(
TvContext dbContext,
GetLibraryBrowseItems request,
CancellationToken cancellationToken)
{
List<int> allMusicVideoIds = await dbContext.MusicVideoMetadata
.AsNoTracking()
.Where(mvm => mvm.MusicVideo.ArtistId == request.ParentId.Value)
.OrderBy(mvm => mvm.Album)
.ThenBy(mvm => mvm.Track)
.ThenBy(mvm => mvm.Title)
.ThenBy(mvm => mvm.MusicVideoId)
.Select(mvm => mvm.MusicVideoId)
.ToListAsync(cancellationToken);
allMusicVideoIds = allMusicVideoIds.Distinct().ToList();
int total = allMusicVideoIds.Count;
List<int> pageIds = allMusicVideoIds
.Skip(request.PageNum * request.PageSize)
.Take(request.PageSize)
.ToList();
List<LibraryBrowseItemResponseModel> musicVideos =
await LibraryBrowseItemMapper.GetMusicVideos(dbContext, pageIds, cancellationToken);
Dictionary<int, LibraryBrowseItemResponseModel> byId = musicVideos.ToDictionary(mv => mv.Id);
List<LibraryBrowseItemResponseModel> ordered = pageIds
.Where(byId.ContainsKey)
.Select(id => byId[id])
.ToList();
return new PagedLibraryBrowseItemsResponseModel(total, ordered);
}
private static async Task<int> CountCollections(
TvContext dbContext,
GetLibraryBrowseItems request,
CancellationToken cancellationToken)
{
int count = 0;
if (ShouldInclude(request.MediaType, LibraryBrowseMediaType.Collection))
{
count += await FilterCollections(dbContext.Collections.AsNoTracking(), request)
.CountAsync(cancellationToken);
}
if (!request.LibraryId.HasValue && ShouldInclude(request.MediaType, LibraryBrowseMediaType.SmartCollection))
{
count += await FilterByName(dbContext.SmartCollections.AsNoTracking(), request.Query)
.CountAsync(cancellationToken);
}
if (!request.LibraryId.HasValue && ShouldInclude(request.MediaType, LibraryBrowseMediaType.MultiCollection))
{
count += await FilterByName(dbContext.MultiCollections.AsNoTracking(), request.Query)
.CountAsync(cancellationToken);
}
if (!request.LibraryId.HasValue && ShouldInclude(request.MediaType, LibraryBrowseMediaType.RerunCollection))
{
count += await FilterByName(dbContext.RerunCollections.AsNoTracking(), request.Query)
.CountAsync(cancellationToken);
}
if (!request.LibraryId.HasValue && ShouldInclude(request.MediaType, LibraryBrowseMediaType.Playlist))
{
count += await FilterByName(dbContext.Playlists.AsNoTracking(), request.Query)
.CountAsync(cancellationToken);
}
return count;
}
private static async Task<List<LibraryBrowseItemResponseModel>> GetCollectionItems(
TvContext dbContext,
GetLibraryBrowseItems request,
int skip,
int take,
CancellationToken cancellationToken)
{
var results = new List<LibraryBrowseItemResponseModel>();
int remainingSkip = skip;
if (ShouldInclude(request.MediaType, LibraryBrowseMediaType.Collection))
{
IQueryable<Collection> collectionQuery = FilterCollections(dbContext.Collections.AsNoTracking(), request);
int count = await collectionQuery.CountAsync(cancellationToken);
int pageSkip = Math.Min(remainingSkip, count);
remainingSkip -= pageSkip;
List<Collection> collections = await collectionQuery
.OrderBy(c => c.Name)
.Skip(pageSkip)
.Take(take)
.ToListAsync(cancellationToken);
List<int> collectionIds = collections.Map(c => c.Id).ToList();
Dictionary<int, int> itemCounts = await dbContext.CollectionItems
.AsNoTracking()
.Where(ci => collectionIds.Contains(ci.CollectionId))
.GroupBy(ci => ci.CollectionId)
.Select(g => new { CollectionId = g.Key, Count = g.Count() })
.ToDictionaryAsync(g => g.CollectionId, g => g.Count, cancellationToken);
Dictionary<int, TimeSpan?> durations = await GetManualCollectionDurations(
dbContext,
collectionIds,
cancellationToken);
Dictionary<int, string> artwork = await GetManualCollectionArtwork(dbContext, collectionIds, cancellationToken);
results.AddRange(collections.Map(c => new LibraryBrowseItemResponseModel(
c.Id,
LibraryBrowseMediaType.Collection,
c.Name,
null,
null,
artwork.TryGetValue(c.Id, out string poster) ? poster : string.Empty,
durations.TryGetValue(c.Id, out TimeSpan? duration) ? duration : null,
itemCounts.TryGetValue(c.Id, out int itemCount) ? itemCount : 0,
"Manual",
CollectionType.Collection,
c.Id,
null,
null,
null,
null,
null)));
}
if (results.Count >= take || request.LibraryId.HasValue)
{
return results.Take(take).ToList();
}
remainingSkip = await AppendSmartCollections(dbContext, request, results, remainingSkip, take, cancellationToken);
remainingSkip = await AppendMultiCollections(dbContext, request, results, remainingSkip, take, cancellationToken);
remainingSkip = await AppendRerunCollections(dbContext, request, results, remainingSkip, take, cancellationToken);
await AppendPlaylists(dbContext, request, results, remainingSkip, take, cancellationToken);
return results.Take(take).ToList();
}
private static async Task<int> AppendSmartCollections(
TvContext dbContext,
GetLibraryBrowseItems request,
List<LibraryBrowseItemResponseModel> results,
int skip,
int take,
CancellationToken cancellationToken)
{
if (!ShouldInclude(request.MediaType, LibraryBrowseMediaType.SmartCollection) || results.Count >= take)
{
return skip;
}
IQueryable<SmartCollection> query = FilterByName(dbContext.SmartCollections.AsNoTracking(), request.Query);
int count = await query.CountAsync(cancellationToken);
int pageSkip = Math.Min(skip, count);
List<SmartCollection> page = await query
.OrderBy(c => c.Name)
.Skip(pageSkip)
.Take(take - results.Count)
.ToListAsync(cancellationToken);
results.AddRange(page.Map(c => new LibraryBrowseItemResponseModel(
c.Id,
LibraryBrowseMediaType.SmartCollection,
c.Name,
null,
null,
string.Empty,
null,
null,
"Smart",
CollectionType.SmartCollection,
null,
null,
c.Id,
null,
null,
null)));
return skip - pageSkip;
}
private static async Task<int> AppendMultiCollections(
TvContext dbContext,
GetLibraryBrowseItems request,
List<LibraryBrowseItemResponseModel> results,
int skip,
int take,
CancellationToken cancellationToken)
{
if (!ShouldInclude(request.MediaType, LibraryBrowseMediaType.MultiCollection) || results.Count >= take)
{
return skip;
}
IQueryable<MultiCollection> query = FilterByName(dbContext.MultiCollections.AsNoTracking(), request.Query);
int count = await query.CountAsync(cancellationToken);
int pageSkip = Math.Min(skip, count);
List<MultiCollection> page = await query
.OrderBy(c => c.Name)
.Skip(pageSkip)
.Take(take - results.Count)
.ToListAsync(cancellationToken);
List<int> ids = page.Map(c => c.Id).ToList();
Dictionary<int, int> collectionCounts = await dbContext.Set<MultiCollectionItem>()
.AsNoTracking()
.Where(i => ids.Contains(i.MultiCollectionId))
.GroupBy(i => i.MultiCollectionId)
.Select(g => new { MultiCollectionId = g.Key, Count = g.Count() })
.ToDictionaryAsync(g => g.MultiCollectionId, g => g.Count, cancellationToken);
Dictionary<int, int> smartCollectionCounts = await dbContext.Set<MultiCollectionSmartItem>()
.AsNoTracking()
.Where(i => ids.Contains(i.MultiCollectionId))
.GroupBy(i => i.MultiCollectionId)
.Select(g => new { MultiCollectionId = g.Key, Count = g.Count() })
.ToDictionaryAsync(g => g.MultiCollectionId, g => g.Count, cancellationToken);
results.AddRange(page.Map(c => new LibraryBrowseItemResponseModel(
c.Id,
LibraryBrowseMediaType.MultiCollection,
c.Name,
null,
null,
string.Empty,
null,
(collectionCounts.TryGetValue(c.Id, out int collectionCount) ? collectionCount : 0) +
(smartCollectionCounts.TryGetValue(c.Id, out int smartCollectionCount) ? smartCollectionCount : 0),
"Multi",
CollectionType.MultiCollection,
null,
c.Id,
null,
null,
null,
null)));
return skip - pageSkip;
}
private static async Task<int> AppendRerunCollections(
TvContext dbContext,
GetLibraryBrowseItems request,
List<LibraryBrowseItemResponseModel> results,
int skip,
int take,
CancellationToken cancellationToken)
{
if (!ShouldInclude(request.MediaType, LibraryBrowseMediaType.RerunCollection) || results.Count >= take)
{
return skip;
}
IQueryable<RerunCollection> query = FilterByName(dbContext.RerunCollections.AsNoTracking(), request.Query);
int count = await query.CountAsync(cancellationToken);
int pageSkip = Math.Min(skip, count);
List<RerunCollection> page = await query
.OrderBy(c => c.Name)
.Skip(pageSkip)
.Take(take - results.Count)
.ToListAsync(cancellationToken);
results.AddRange(page.Map(c => new LibraryBrowseItemResponseModel(
c.Id,
LibraryBrowseMediaType.RerunCollection,
c.Name,
null,
null,
string.Empty,
null,
null,
"Rerun",
// RerunFirstRun addresses the first-run side; the SPA can substitute RerunRerun for rerun schedule items.
CollectionType.RerunFirstRun,
null,
null,
null,
c.Id,
null,
null)));
return skip - pageSkip;
}
private static async Task AppendPlaylists(
TvContext dbContext,
GetLibraryBrowseItems request,
List<LibraryBrowseItemResponseModel> results,
int skip,
int take,
CancellationToken cancellationToken)
{
if (!ShouldInclude(request.MediaType, LibraryBrowseMediaType.Playlist) || results.Count >= take)
{
return;
}
IQueryable<Playlist> query = FilterByName(dbContext.Playlists.AsNoTracking(), request.Query);
int count = await query.CountAsync(cancellationToken);
int pageSkip = Math.Min(skip, count);
List<Playlist> page = await query
.OrderBy(c => c.Name)
.Skip(pageSkip)
.Take(take - results.Count)
.ToListAsync(cancellationToken);
List<int> ids = page.Map(c => c.Id).ToList();
Dictionary<int, int> itemCounts = await dbContext.PlaylistItems
.AsNoTracking()
.Where(i => ids.Contains(i.PlaylistId))
.GroupBy(i => i.PlaylistId)
.Select(g => new { PlaylistId = g.Key, Count = g.Count() })
.ToDictionaryAsync(g => g.PlaylistId, g => g.Count, cancellationToken);
results.AddRange(page.Map(c => new LibraryBrowseItemResponseModel(
c.Id,
LibraryBrowseMediaType.Playlist,
c.Name,
null,
null,
string.Empty,
null,
itemCounts.TryGetValue(c.Id, out int itemCount) ? itemCount : 0,
null,
CollectionType.Playlist,
null,
null,
null,
null,
null,
c.Id)));
}
private static IQueryable<Collection> FilterCollections(
IQueryable<Collection> query,
GetLibraryBrowseItems request)
{
query = FilterByName(query, request.Query);
if (request.LibraryId.HasValue)
{
query = query.Where(c => c.MediaItems.Any(mi => mi.LibraryPath.LibraryId == request.LibraryId.Value));
}
return query;
}
private static IQueryable<T> FilterByName<T>(IQueryable<T> query, string searchQuery)
where T : class
{
if (string.IsNullOrWhiteSpace(searchQuery))
{
return query;
}
return query.Where(c => EF.Functions.Like(EF.Property<string>(c, "Name"), $"%{EscapeLike(searchQuery)}%", "\\"));
}
private static bool ShouldInclude(LibraryBrowseMediaType? requestType, LibraryBrowseMediaType itemType) =>
requestType is null || requestType == itemType;
private static async Task<Dictionary<int, TimeSpan?>> GetManualCollectionDurations(
TvContext dbContext,
List<int> collectionIds,
CancellationToken cancellationToken)
{
List<CollectionMediaItem> items = await GetCollectionMediaItems(dbContext, collectionIds, cancellationToken);
List<int> mediaItemIds = items.Map(i => i.MediaItemId).Distinct().ToList();
Dictionary<int, TimeSpan> mediaItemDurations = [];
foreach (Movie movie in await dbContext.Movies
.AsNoTracking()
.Where(m => mediaItemIds.Contains(m.Id))
.Include(m => m.MediaVersions)
.ToListAsync(cancellationToken))
{
TimeSpan? duration = LibraryBrowseItemMapper.BestDuration(movie.MediaVersions);
if (duration.HasValue)
{
mediaItemDurations[movie.Id] = duration.Value;
}
}
foreach (Episode episode in await dbContext.Episodes
.AsNoTracking()
.Where(e => mediaItemIds.Contains(e.Id))
.Include(e => e.MediaVersions)
.ToListAsync(cancellationToken))
{
TimeSpan? duration = LibraryBrowseItemMapper.BestDuration(episode.MediaVersions);
if (duration.HasValue)
{
mediaItemDurations[episode.Id] = duration.Value;
}
}
foreach (MusicVideo musicVideo in await dbContext.MusicVideos
.AsNoTracking()
.Where(mv => mediaItemIds.Contains(mv.Id))
.Include(mv => mv.MediaVersions)
.ToListAsync(cancellationToken))
{
TimeSpan? duration = LibraryBrowseItemMapper.BestDuration(musicVideo.MediaVersions);
if (duration.HasValue)
{
mediaItemDurations[musicVideo.Id] = duration.Value;
}
}
foreach (OtherVideo otherVideo in await dbContext.OtherVideos
.AsNoTracking()
.Where(ov => mediaItemIds.Contains(ov.Id))
.Include(ov => ov.MediaVersions)
.ToListAsync(cancellationToken))
{
TimeSpan? duration = LibraryBrowseItemMapper.BestDuration(otherVideo.MediaVersions);
if (duration.HasValue)
{
mediaItemDurations[otherVideo.Id] = duration.Value;
}
}
foreach (Song song in await dbContext.Songs
.AsNoTracking()
.Where(s => mediaItemIds.Contains(s.Id))
.Include(s => s.MediaVersions)
.ToListAsync(cancellationToken))
{
TimeSpan? duration = LibraryBrowseItemMapper.BestDuration(song.MediaVersions);
if (duration.HasValue)
{
mediaItemDurations[song.Id] = duration.Value;
}
}
foreach (Image image in await dbContext.Images
.AsNoTracking()
.Where(i => mediaItemIds.Contains(i.Id))
.Include(i => i.MediaVersions)
.ToListAsync(cancellationToken))
{
TimeSpan? duration = LibraryBrowseItemMapper.BestDuration(image.MediaVersions);
if (duration.HasValue)
{
mediaItemDurations[image.Id] = duration.Value;
}
}
foreach (RemoteStream remoteStream in await dbContext.RemoteStreams
.AsNoTracking()
.Where(rs => mediaItemIds.Contains(rs.Id))
.Include(rs => rs.MediaVersions)
.ToListAsync(cancellationToken))
{
TimeSpan? duration = LibraryBrowseItemMapper.BestDuration(remoteStream.MediaVersions);
if (duration.HasValue)
{
mediaItemDurations[remoteStream.Id] = duration.Value;
}
}
return items
.Where(i => mediaItemDurations.ContainsKey(i.MediaItemId))
.GroupBy(i => i.CollectionId)
.ToDictionary(
g => g.Key,
g => (TimeSpan?)g.Aggregate(TimeSpan.Zero, (sum, item) => sum + mediaItemDurations[item.MediaItemId]));
}
private static async Task<Dictionary<int, string>> GetManualCollectionArtwork(
TvContext dbContext,
List<int> collectionIds,
CancellationToken cancellationToken)
{
List<CollectionMediaItem> items = await GetCollectionMediaItems(dbContext, collectionIds, cancellationToken);
List<int> mediaItemIds = items.Map(i => i.MediaItemId).Distinct().ToList();
Dictionary<int, string> mediaItemArtwork = [];
foreach (MovieMetadata metadata in await dbContext.MovieMetadata
.AsNoTracking()
.Where(mm => mediaItemIds.Contains(mm.MovieId))
.Include(mm => mm.Artwork)
.OrderBy(mm => mm.Id)
.ToListAsync(cancellationToken))
{
string poster = LibraryBrowseItemMapper.Artwork(metadata, ArtworkKind.Poster);
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.MovieId))
{
mediaItemArtwork[metadata.MovieId] = poster;
}
}
foreach (ShowMetadata metadata in await dbContext.ShowMetadata
.AsNoTracking()
.Where(sm => mediaItemIds.Contains(sm.ShowId))
.Include(sm => sm.Artwork)
.OrderBy(sm => sm.Id)
.ToListAsync(cancellationToken))
{
string poster = LibraryBrowseItemMapper.Artwork(metadata, ArtworkKind.Poster);
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.ShowId))
{
mediaItemArtwork[metadata.ShowId] = poster;
}
}
foreach (SeasonMetadata metadata in await dbContext.SeasonMetadata
.AsNoTracking()
.Where(sm => mediaItemIds.Contains(sm.SeasonId))
.Include(sm => sm.Artwork)
.OrderBy(sm => sm.Id)
.ToListAsync(cancellationToken))
{
string poster = LibraryBrowseItemMapper.Artwork(metadata, ArtworkKind.Poster);
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.SeasonId))
{
mediaItemArtwork[metadata.SeasonId] = poster;
}
}
foreach (OtherVideoMetadata metadata in await dbContext.OtherVideoMetadata
.AsNoTracking()
.Where(ovm => mediaItemIds.Contains(ovm.OtherVideoId))
.Include(ovm => ovm.Artwork)
.OrderBy(ovm => ovm.Id)
.ToListAsync(cancellationToken))
{
string poster = LibraryBrowseItemMapper.Artwork(metadata, ArtworkKind.Poster);
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.OtherVideoId))
{
mediaItemArtwork[metadata.OtherVideoId] = poster;
}
}
foreach (SongMetadata metadata in await dbContext.SongMetadata
.AsNoTracking()
.Where(sm => mediaItemIds.Contains(sm.SongId))
.Include(sm => sm.Artwork)
.OrderBy(sm => sm.Id)
.ToListAsync(cancellationToken))
{
string poster = LibraryBrowseItemMapper.Artwork(metadata, ArtworkKind.Poster);
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.SongId))
{
mediaItemArtwork[metadata.SongId] = poster;
}
}
foreach (ImageMetadata metadata in await dbContext.ImageMetadata
.AsNoTracking()
.Where(im => mediaItemIds.Contains(im.ImageId))
.Include(im => im.Artwork)
.OrderBy(im => im.Id)
.ToListAsync(cancellationToken))
{
string poster = LibraryBrowseItemMapper.Artwork(metadata, ArtworkKind.Poster);
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.ImageId))
{
mediaItemArtwork[metadata.ImageId] = poster;
}
}
foreach (RemoteStreamMetadata metadata in await dbContext.RemoteStreamMetadata
.AsNoTracking()
.Where(rsm => mediaItemIds.Contains(rsm.RemoteStreamId))
.Include(rsm => rsm.Artwork)
.OrderBy(rsm => rsm.Id)
.ToListAsync(cancellationToken))
{
string poster = LibraryBrowseItemMapper.Artwork(metadata, ArtworkKind.Poster);
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.RemoteStreamId))
{
mediaItemArtwork[metadata.RemoteStreamId] = poster;
}
}
return items
.Where(i => mediaItemArtwork.ContainsKey(i.MediaItemId))
.GroupBy(i => i.CollectionId)
.ToDictionary(
g => g.Key,
g => mediaItemArtwork[g.OrderBy(i => i.CustomIndex ?? int.MaxValue).ThenBy(i => i.MediaItemId).First().MediaItemId]);
}
private static async Task<List<CollectionMediaItem>> GetCollectionMediaItems(
TvContext dbContext,
List<int> collectionIds,
CancellationToken cancellationToken) =>
await dbContext.CollectionItems
.AsNoTracking()
.Where(ci => collectionIds.Contains(ci.CollectionId))
.Select(ci => new CollectionMediaItem(ci.CollectionId, ci.MediaItemId, ci.CustomIndex))
.ToListAsync(cancellationToken);
private static string EscapeLike(string searchQuery) =>
searchQuery
.Replace("\\", "\\\\", StringComparison.Ordinal)
.Replace("%", "\\%", StringComparison.Ordinal)
.Replace("_", "\\_", StringComparison.Ordinal);
private sealed record CollectionMediaItem(int CollectionId, int MediaItemId, int? CustomIndex);
}
@@ -0,0 +1,7 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Api.LibraryBrowse;
namespace ErsatzTV.Application.MediaCollections;
public record GetCollectionItems(int Id, int PageNum, int PageSize)
: IRequest<Either<BaseError, PagedLibraryBrowseItemsResponseModel>>;
@@ -0,0 +1,56 @@
using ErsatzTV.Application.LibraryBrowse;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Errors;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.MediaCollections;
public class GetCollectionItemsHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetCollectionItems, Either<BaseError, PagedLibraryBrowseItemsResponseModel>>
{
public async Task<Either<BaseError, PagedLibraryBrowseItemsResponseModel>> Handle(
GetCollectionItems request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
bool exists = await dbContext.Collections
.AsNoTracking()
.AnyAsync(c => c.Id == request.Id, cancellationToken);
if (!exists)
{
return new NotFoundError($"Collection {request.Id} does not exist.");
}
// The collection graph is bounded, so load every member id and hydrate them in one shared
// pass (LibraryBrowseItemMapper), then order + page in-memory. Mixed media kinds are supported
// because MediaItem ids are globally unique across kinds.
List<int> mediaItemIds = await dbContext.CollectionItems
.AsNoTracking()
.Where(ci => ci.CollectionId == request.Id)
.Select(ci => ci.MediaItemId)
.ToListAsync(cancellationToken);
List<LibraryBrowseItemResponseModel> all =
await LibraryBrowseItemMapper.HydrateMediaItemsByIds(dbContext, mediaItemIds, cancellationToken);
// Stable title ordering mirrors the library-browse handler (which orders its rows by name),
// giving the SPA a deterministic, browsable list independent of collection insertion order.
List<LibraryBrowseItemResponseModel> ordered = all
.OrderBy(i => i.Title, StringComparer.OrdinalIgnoreCase)
.ThenBy(i => i.Id)
.ToList();
int pageNum = Math.Max(0, request.PageNum);
int pageSize = Math.Clamp(request.PageSize, 1, 100);
List<LibraryBrowseItemResponseModel> page = ordered
.Skip(pageNum * pageSize)
.Take(pageSize)
.ToList();
return new PagedLibraryBrowseItemsResponseModel(ordered.Count, page);
}
}
@@ -0,0 +1,5 @@
using ErsatzTV.Core.Api.MediaSources;
namespace ErsatzTV.Application.MediaSources;
public record GetAllMediaSourcesForApi : IRequest<List<MediaSourceResponseModel>>;
@@ -0,0 +1,146 @@
#nullable enable
using ErsatzTV.Core.Api.MediaSources;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.MediaSources;
public class GetAllMediaSourcesForApiHandler(
IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetAllMediaSourcesForApi, List<MediaSourceResponseModel>>
{
public async Task<List<MediaSourceResponseModel>> Handle(
GetAllMediaSourcesForApi request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
List<MediaSource> mediaSources = await dbContext.MediaSources
.AsNoTracking()
.Include(s => s.Libraries)
.ThenInclude(l => l.Paths)
.ToListAsync(cancellationToken);
Dictionary<int, int> itemCountsByLibrary = await GetItemCountsByLibrary(dbContext, cancellationToken);
Dictionary<int, string> addressByMediaSourceId = await GetConnectionAddresses(dbContext, cancellationToken);
var result = new List<MediaSourceResponseModel>();
foreach (MediaSource mediaSource in mediaSources)
{
List<MediaSourceLibraryResponseModel> libraryModels = mediaSource.Libraries
.Filter(ShouldIncludeLibrary)
.OrderBy(l => l.MediaKind)
.ThenBy(l => l.Name)
.Map(l => new MediaSourceLibraryResponseModel(
l.Id,
l.Name,
l.MediaKind,
l.LastScan,
itemCountsByLibrary.TryGetValue(l.Id, out int count) ? count : 0))
.ToList();
string? address = addressByMediaSourceId.TryGetValue(mediaSource.Id, out string? a) ? a : null;
result.Add(
new MediaSourceResponseModel(
mediaSource.Id,
GetKind(mediaSource),
GetName(mediaSource),
address,
libraryModels));
}
return result
.OrderBy(s => s.Kind == "Local" ? 0 : 1)
.ThenBy(s => s.Kind)
.ThenBy(s => s.Name)
.ToList();
}
private static async Task<Dictionary<int, int>> GetItemCountsByLibrary(
TvContext dbContext,
CancellationToken cancellationToken)
{
// EF instead of Dapper: with zero rows, Microsoft.Data.Sqlite reports the
// COUNT(*) column as BLOB, so Dapper builds an incompatible deserializer
// and every fresh (empty) database returned 500 from this endpoint.
var counts = await dbContext.MediaItems
.AsNoTracking()
.GroupBy(mi => mi.LibraryPath.LibraryId)
.Select(g => new { LibraryId = g.Key, Count = g.Count() })
.ToListAsync(cancellationToken);
return counts.ToDictionary(c => c.LibraryId, c => c.Count);
}
private static async Task<Dictionary<int, string>> GetConnectionAddresses(
TvContext dbContext,
CancellationToken cancellationToken)
{
var addresses = new Dictionary<int, string>();
foreach (PlexMediaSource plex in await dbContext.PlexMediaSources
.AsNoTracking()
.Include(s => s.Connections)
.ToListAsync(cancellationToken))
{
foreach (PlexConnection connection in Optional(plex.Connections.SingleOrDefault(c => c.IsActive)))
{
addresses[plex.Id] = connection.Uri;
}
}
foreach (JellyfinMediaSource jellyfin in await dbContext.JellyfinMediaSources
.AsNoTracking()
.Include(s => s.Connections)
.ToListAsync(cancellationToken))
{
foreach (JellyfinConnection connection in jellyfin.Connections.HeadOrNone())
{
addresses[jellyfin.Id] = connection.Address;
}
}
foreach (EmbyMediaSource emby in await dbContext.EmbyMediaSources
.AsNoTracking()
.Include(s => s.Connections)
.ToListAsync(cancellationToken))
{
foreach (EmbyConnection connection in emby.Connections.HeadOrNone())
{
addresses[emby.Id] = connection.Address;
}
}
return addresses;
}
private static bool ShouldIncludeLibrary(Library library) =>
library switch
{
LocalLibrary => library.Paths.Count > 0,
PlexLibrary plex => plex.ShouldSyncItems,
JellyfinLibrary jellyfin => jellyfin.ShouldSyncItems,
EmbyLibrary emby => emby.ShouldSyncItems,
_ => false
};
private static string GetKind(MediaSource mediaSource) =>
mediaSource switch
{
PlexMediaSource => "Plex",
JellyfinMediaSource => "Jellyfin",
EmbyMediaSource => "Emby",
_ => "Local"
};
private static string GetName(MediaSource mediaSource) =>
mediaSource switch
{
PlexMediaSource plex => plex.ServerName,
JellyfinMediaSource jellyfin => jellyfin.ServerName,
EmbyMediaSource emby => emby.ServerName,
_ => "Local"
};
}
@@ -54,7 +54,9 @@ public class
playout.ProgramSchedule?.Name ?? string.Empty,
playout.ScheduleFile,
playout.DailyRebuildTime,
playout.BuildStatus);
playout.BuildStatus,
playout.DecoId,
playout.Deco?.Name);
}
private static Task<Validation<BaseError, Playout>> Validate(
@@ -46,7 +46,9 @@ public class UpdatePlayoutHandler : IRequestHandler<UpdatePlayout, Either<BaseEr
playout.ProgramSchedule?.Name ?? string.Empty,
playout.ScheduleFile,
playout.DailyRebuildTime,
playout.BuildStatus);
playout.BuildStatus,
playout.DecoId,
playout.Deco?.Name);
}
private static Task<Validation<BaseError, Playout>> Validate(
@@ -49,7 +49,9 @@ public class
playout.ProgramSchedule?.Name ?? string.Empty,
playout.ScheduleFile,
playout.DailyRebuildTime,
playout.BuildStatus);
playout.BuildStatus,
playout.DecoId,
playout.Deco?.Name);
}
private async Task<Validation<BaseError, Playout>> Validate(
@@ -54,7 +54,9 @@ public class
playout.ProgramSchedule?.Name ?? string.Empty,
playout.ScheduleFile,
playout.DailyRebuildTime,
playout.BuildStatus);
playout.BuildStatus,
playout.DecoId,
playout.Deco?.Name);
}
private static Task<Validation<BaseError, Playout>> Validate(
+5 -1
View File
@@ -15,7 +15,11 @@ internal static class Mapper
playout.ProgramScheduleId == null ? string.Empty : playout.ProgramSchedule.Name,
playout.ScheduleFile,
playout.DailyRebuildTime,
playout.BuildStatus);
playout.BuildStatus,
playout.DecoId,
// the paged-playouts query does not eager-load Deco (the list response does not surface
// the default deco); GetPlayoutById includes it for the detail response
playout.Deco?.Name);
internal static PlayoutItemViewModel ProjectToViewModel(PlayoutItem playoutItem) =>
new(
@@ -11,7 +11,9 @@ public record PlayoutNameViewModel(
string ScheduleName,
string ScheduleFile,
TimeSpan? DbDailyRebuildTime,
PlayoutBuildStatus BuildStatus)
PlayoutBuildStatus BuildStatus,
int? DecoId,
string DecoName)
{
public Option<TimeSpan> DailyRebuildTime => Optional(DbDailyRebuildTime);
@@ -16,6 +16,8 @@ public class GetPlayoutByIdHandler(IDbContextFactory<TvContext> dbContextFactory
.AsNoTracking()
.Include(p => p.ProgramSchedule)
.Include(p => p.Channel)
.Include(p => p.BuildStatus)
.Include(p => p.Deco)
.SelectOneAsync(p => p.Id, p => p.Id == request.PlayoutId, cancellationToken)
.MapT(p => new PlayoutNameViewModel(
p.Id,
@@ -26,6 +28,8 @@ public class GetPlayoutByIdHandler(IDbContextFactory<TvContext> dbContextFactory
p.ProgramScheduleId == null ? string.Empty : p.ProgramSchedule.Name,
p.ScheduleFile,
p.DailyRebuildTime,
p.BuildStatus));
p.BuildStatus,
p.DecoId,
p.DecoId == null ? null : p.Deco.Name));
}
}
@@ -44,6 +44,33 @@ public abstract record ProgramScheduleItemViewModel(
string PreferredSubtitleLanguageCode,
ChannelSubtitleMode? SubtitleMode)
{
/// <summary>
/// A rough estimate, in wall-clock time, of how long a single pass of this schedule item will play,
/// derived from the aggregated playout runtimes of the referenced content.
/// <para>
/// Semantics by <see cref="PlayoutMode" />:
/// <list type="bullet">
/// <item><b>One</b> — the average runtime of one item in the referenced collection.</item>
/// <item>
/// <b>Multiple</b> — the average item runtime multiplied by the configured count
/// (<see cref="MultipleMode.Count" />), or the whole collection runtime for
/// <see cref="MultipleMode.CollectionSize" />. An expression-based (non-integer)
/// count cannot be evaluated here and yields <c>null</c>.
/// </item>
/// <item><b>Flood</b> — always <c>null</c>: a flood item fills the remaining time and is unbounded.</item>
/// <item><b>Duration</b> — the explicit <c>playoutDuration</c> setting on the item.</item>
/// </list>
/// </para>
/// <para>
/// <c>null</c> whenever a bounded estimate cannot be produced — an unbounded mode (Flood),
/// a referenced collection with no items that have a known positive duration, a Multiple mode other
/// than Count/CollectionSize, or a collection type other than <see cref="CollectionType.Collection" />
/// (smart/multi/playlist/search/rerun/show/season/artist references are not aggregated in this pass).
/// Callers should treat a <c>null</c> as "unknown", never as zero.
/// </para>
/// </summary>
public TimeSpan? DurationEstimate { get; init; }
public string Name => CollectionType switch
{
CollectionType.Collection => Collection?.Name,
@@ -0,0 +1,18 @@
namespace ErsatzTV.Application.ProgramSchedules;
/// <summary>
/// The items of a schedule together with computed runtime estimates. Each item carries its own
/// <see cref="ProgramScheduleItemViewModel.DurationEstimate" /> (nullable — see that property for
/// the per-mode semantics), and <see cref="TotalDurationEstimate" /> is the sum of the items that
/// could be estimated.
/// </summary>
/// <param name="Items">The schedule items, each with a nullable <c>DurationEstimate</c>.</param>
/// <param name="TotalDurationEstimate">
/// The sum of every non-null per-item estimate, i.e. a rough runtime for a single pass through the
/// estimable items. <c>null</c> when no item in the schedule could be estimated (for example a
/// schedule made up entirely of Flood items, or of collection types that are not aggregated).
/// Because unbounded items contribute nothing, this is a lower bound, never an exact schedule length.
/// </param>
public record ProgramScheduleItemsWithDurationViewModel(
List<ProgramScheduleItemViewModel> Items,
TimeSpan? TotalDurationEstimate);
@@ -0,0 +1,4 @@
namespace ErsatzTV.Application.ProgramSchedules;
public record GetProgramScheduleItemsWithDurations(int Id)
: IRequest<ProgramScheduleItemsWithDurationViewModel>;
@@ -0,0 +1,70 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Repositories;
using static ErsatzTV.Application.ProgramSchedules.ScheduleItemDurationEstimator;
namespace ErsatzTV.Application.ProgramSchedules;
public class GetProgramScheduleItemsWithDurationsHandler(
IMediator mediator,
IMediaCollectionRepository mediaCollectionRepository)
: IRequestHandler<GetProgramScheduleItemsWithDurations, ProgramScheduleItemsWithDurationViewModel>
{
public async Task<ProgramScheduleItemsWithDurationViewModel> Handle(
GetProgramScheduleItemsWithDurations request,
CancellationToken cancellationToken)
{
List<ProgramScheduleItemViewModel> items =
await mediator.Send(new GetProgramScheduleItems(request.Id), cancellationToken);
Dictionary<int, CollectionDuration> durationsByCollectionId =
await AggregateReferencedCollections(items);
var itemsWithEstimates = items
.Map(item => item with { DurationEstimate = Estimate(item, durationsByCollectionId) })
.ToList();
List<TimeSpan> estimates = itemsWithEstimates
.Map(item => Optional(item.DurationEstimate))
.Somes()
.ToList();
TimeSpan? total = estimates.Count > 0
? TimeSpan.FromTicks(estimates.Sum(estimate => estimate.Ticks))
: null;
return new ProgramScheduleItemsWithDurationViewModel(itemsWithEstimates, total);
}
// Aggregate MediaVersion.Duration once per distinct referenced collection (not per item). Only plain
// collections are resolved here; other reference types are estimated as null (see DurationEstimate docs).
private async Task<Dictionary<int, CollectionDuration>> AggregateReferencedCollections(
IReadOnlyList<ProgramScheduleItemViewModel> items)
{
List<int> collectionIds = items
.Filter(item => item.CollectionType is CollectionType.Collection && item.Collection is not null)
.Filter(item => item.PlayoutMode is PlayoutMode.One or PlayoutMode.Multiple)
.Map(item => item.Collection.Id)
.Distinct()
.ToList();
var result = new Dictionary<int, CollectionDuration>();
foreach (int collectionId in collectionIds)
{
List<MediaItem> mediaItems = await mediaCollectionRepository.GetItems(collectionId);
List<TimeSpan> durations = mediaItems
.Map(mediaItem => mediaItem.GetDurationForPlayout())
.Filter(duration => duration > TimeSpan.Zero)
.ToList();
if (durations.Count > 0)
{
var total = TimeSpan.FromTicks(durations.Sum(duration => duration.Ticks));
result[collectionId] = new CollectionDuration(total, durations.Count);
}
}
return result;
}
}
@@ -0,0 +1,73 @@
using System.Globalization;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.ProgramSchedules;
/// <summary>
/// Pure computation of per-item runtime estimates from pre-aggregated collection durations.
/// Kept separate from the query handler so the (non-trivial) per-mode math can be unit-tested
/// without a database. See <see cref="ProgramScheduleItemViewModel.DurationEstimate" /> for the
/// documented semantics this implements.
/// </summary>
internal static class ScheduleItemDurationEstimator
{
/// <summary>
/// Aggregate runtime of a single referenced collection: the total runtime of every item with a
/// known non-zero duration, and how many such items there are.
/// </summary>
public sealed record CollectionDuration(TimeSpan Total, int ItemCount)
{
public TimeSpan? Average => ItemCount > 0 ? Total / ItemCount : null;
}
/// <summary>
/// Estimate the runtime of one pass of <paramref name="item" />, or <c>null</c> when no bounded
/// estimate is possible. <paramref name="durationsByCollectionId" /> holds aggregates only for the
/// plain collections that were resolved; a missing entry yields <c>null</c>.
/// </summary>
public static TimeSpan? Estimate(
ProgramScheduleItemViewModel item,
IReadOnlyDictionary<int, CollectionDuration> durationsByCollectionId)
{
if (item is ProgramScheduleItemDurationViewModel durationItem)
{
return durationItem.PlayoutDuration;
}
// only plain collections are aggregated in this pass
if (item.CollectionType is not CollectionType.Collection || item.Collection is null)
{
return null;
}
if (!durationsByCollectionId.TryGetValue(item.Collection.Id, out CollectionDuration duration))
{
return null;
}
return item switch
{
// one item per pass -> the average item runtime
ProgramScheduleItemOneViewModel => duration.Average,
// a fixed count of items, or the whole collection once
ProgramScheduleItemMultipleViewModel multiple => EstimateMultiple(multiple, duration),
// Flood is an unbounded fill; Duration is handled above from its explicit playoutDuration.
_ => null
};
}
private static TimeSpan? EstimateMultiple(
ProgramScheduleItemMultipleViewModel multiple,
CollectionDuration duration) =>
multiple.MultipleMode switch
{
MultipleMode.Count when
int.TryParse(multiple.Count, NumberStyles.Integer, CultureInfo.InvariantCulture, out int count)
&& count > 0
&& duration.Average is { } average => average * count,
MultipleMode.CollectionSize => duration.Total,
_ => null
};
}
@@ -1,6 +1,7 @@
using Dapper;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
@@ -18,23 +19,28 @@ public class DeleteCustomResolutionHandler : IRequestHandler<DeleteCustomResolut
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<Resolution> maybeResolution = await dbContext.Resolutions
Option<Resolution> maybeAnyResolution = await dbContext.Resolutions
.AsNoTracking()
.SelectOneAsync(p => p.Id, p => p.Id == request.ResolutionId && p.IsCustom == true, cancellationToken);
.SelectOneAsync(p => p.Id, p => p.Id == request.ResolutionId, cancellationToken);
foreach (Resolution resolution in maybeResolution)
foreach (Resolution existingResolution in maybeAnyResolution)
{
if (!existingResolution.IsCustom)
{
return BaseError.New($"Resolution {request.ResolutionId} is not a custom resolution.");
}
// reset any ffmpeg profiles using this resolution to 1920x1080
await dbContext.Connection.ExecuteAsync(
@"UPDATE FFmpegProfile SET ResolutionId = 3 WHERE ResolutionId = @ResolutionId",
new { request.ResolutionId });
dbContext.Resolutions.Remove(resolution);
dbContext.Resolutions.Remove(existingResolution);
await dbContext.SaveChangesAsync(cancellationToken);
}
return maybeResolution.IsNone
? BaseError.New($"Resolution {request.ResolutionId} does not exist.")
return maybeAnyResolution.IsNone
? new NotFoundError($"Resolution {request.ResolutionId} does not exist.")
: Option<BaseError>.None;
}
}
@@ -25,13 +25,29 @@ public class CreateBlockHandler(IDbContextFactory<TvContext> dbContextFactory)
return Mapper.ProjectToViewModel(block);
}
private static async Task<Validation<BaseError, Block>> Validate(TvContext dbContext, CreateBlock request) =>
await ValidateBlockName(dbContext, request).MapT(name => new Block
private static async Task<Validation<BaseError, Block>> Validate(TvContext dbContext, CreateBlock request)
{
Validation<BaseError, Unit> blockGroupValidation = await ValidateBlockGroupExists(dbContext, request);
Validation<BaseError, string> nameValidation = await ValidateBlockName(dbContext, request);
return (blockGroupValidation, nameValidation).Apply((_, name) => new Block
{
BlockGroupId = request.BlockGroupId,
Name = name,
Minutes = 30
});
}
private static async Task<Validation<BaseError, Unit>> ValidateBlockGroupExists(
TvContext dbContext,
CreateBlock request)
{
bool blockGroupExists = await dbContext.BlockGroups.AnyAsync(bg => bg.Id == request.BlockGroupId);
return blockGroupExists
? Success<BaseError, Unit>(Unit.Default)
: BaseError.New("Block group does not exist");
}
private static async Task<Validation<BaseError, string>> ValidateBlockName(
TvContext dbContext,
@@ -25,8 +25,12 @@ public class CreateDecoHandler(IDbContextFactory<TvContext> dbContextFactory)
return Mapper.ProjectToViewModel(deco);
}
private static async Task<Validation<BaseError, Deco>> Validate(TvContext dbContext, CreateDeco request) =>
await ValidateDecoName(dbContext, request).MapT(name => new Deco
private static async Task<Validation<BaseError, Deco>> Validate(TvContext dbContext, CreateDeco request)
{
Validation<BaseError, Unit> decoGroupValidation = await ValidateDecoGroupExists(dbContext, request);
Validation<BaseError, string> nameValidation = await ValidateDecoName(dbContext, request);
return (decoGroupValidation, nameValidation).Apply((_, name) => new Deco
{
DecoGroupId = request.DecoGroupId,
Name = name,
@@ -34,6 +38,18 @@ public class CreateDecoHandler(IDbContextFactory<TvContext> dbContextFactory)
DecoWatermarks = [],
DecoGraphicsElements = []
});
}
private static async Task<Validation<BaseError, Unit>> ValidateDecoGroupExists(
TvContext dbContext,
CreateDeco request)
{
bool decoGroupExists = await dbContext.DecoGroups.AnyAsync(dg => dg.Id == request.DecoGroupId);
return decoGroupExists
? Success<BaseError, Unit>(Unit.Default)
: BaseError.New("Deco group does not exist");
}
private static async Task<Validation<BaseError, string>> ValidateDecoName(
TvContext dbContext,
@@ -27,12 +27,30 @@ public class CreateDecoTemplateHandler(IDbContextFactory<TvContext> dbContextFac
private static async Task<Validation<BaseError, DecoTemplate>> Validate(
TvContext dbContext,
CreateDecoTemplate request) =>
await ValidateDecoTemplateName(dbContext, request).MapT(name => new DecoTemplate
CreateDecoTemplate request)
{
Validation<BaseError, Unit> decoTemplateGroupValidation =
await ValidateDecoTemplateGroupExists(dbContext, request);
Validation<BaseError, string> nameValidation = await ValidateDecoTemplateName(dbContext, request);
return (decoTemplateGroupValidation, nameValidation).Apply((_, name) => new DecoTemplate
{
DecoTemplateGroupId = request.DecoTemplateGroupId,
Name = name
});
}
private static async Task<Validation<BaseError, Unit>> ValidateDecoTemplateGroupExists(
TvContext dbContext,
CreateDecoTemplate request)
{
bool decoTemplateGroupExists =
await dbContext.DecoTemplateGroups.AnyAsync(dtg => dtg.Id == request.DecoTemplateGroupId);
return decoTemplateGroupExists
? Success<BaseError, Unit>(Unit.Default)
: BaseError.New("Deco template group does not exist");
}
private static async Task<Validation<BaseError, string>> ValidateDecoTemplateName(
TvContext dbContext,
@@ -25,12 +25,28 @@ public class CreateTemplateHandler(IDbContextFactory<TvContext> dbContextFactory
return Mapper.ProjectToViewModel(template);
}
private static async Task<Validation<BaseError, Template>> Validate(TvContext dbContext, CreateTemplate request) =>
await ValidateTemplateName(dbContext, request).MapT(name => new Template
private static async Task<Validation<BaseError, Template>> Validate(TvContext dbContext, CreateTemplate request)
{
Validation<BaseError, Unit> templateGroupValidation = await ValidateTemplateGroupExists(dbContext, request);
Validation<BaseError, string> nameValidation = await ValidateTemplateName(dbContext, request);
return (templateGroupValidation, nameValidation).Apply((_, name) => new Template
{
TemplateGroupId = request.TemplateGroupId,
Name = name
});
}
private static async Task<Validation<BaseError, Unit>> ValidateTemplateGroupExists(
TvContext dbContext,
CreateTemplate request)
{
bool templateGroupExists = await dbContext.TemplateGroups.AnyAsync(tg => tg.Id == request.TemplateGroupId);
return templateGroupExists
? Success<BaseError, Unit>(Unit.Default)
: BaseError.New("Template group does not exist");
}
private static async Task<Validation<BaseError, string>> ValidateTemplateName(
TvContext dbContext,
@@ -29,11 +29,7 @@ public class ReplaceDecoTemplateItemsHandler(IDbContextFactory<TvContext> dbCont
dbContext.RemoveRange(decoTemplate.Items);
// drop items that are invalid
decoTemplate.Items = request.Items
.Map(i => BuildItem(decoTemplate, i))
.Filter(i => i.StartTime < i.EndTime || i.EndTime == TimeSpan.Zero)
.ToList();
decoTemplate.Items = request.Items.Map(i => BuildItem(decoTemplate, i)).ToList();
await dbContext.SaveChangesAsync(cancellationToken);
@@ -66,7 +62,78 @@ public class ReplaceDecoTemplateItemsHandler(IDbContextFactory<TvContext> dbCont
ReplaceDecoTemplateItems request,
CancellationToken cancellationToken) =>
DecoTemplateMustExist(dbContext, request.DecoTemplateId, cancellationToken)
.BindT(decoTemplate => DecoTemplateNameMustBeValid(dbContext, decoTemplate, request));
.BindT(decoTemplate => DecoTemplateNameMustBeValid(dbContext, decoTemplate, request))
.BindT(decoTemplate => DecoTemplateItemsMustBeValid(dbContext, decoTemplate, request));
// Hardening (deliberate deviation from the original handler): rather than silently filtering out
// invalid items (unknown DecoId, StartTime >= EndTime, or overlapping ranges) - the same silent-drop
// and silent-overlap bug class fixed for ReplaceTemplateItemsHandler in #144 S2 - reject the whole
// request with a 422 so the caller knows exactly what is wrong. EndTime == TimeSpan.Zero means
// "end of day" (24:00) and is treated as such for both the ordering and overlap checks.
private static async Task<Validation<BaseError, DecoTemplate>> DecoTemplateItemsMustBeValid(
TvContext dbContext,
DecoTemplate decoTemplate,
ReplaceDecoTemplateItems request)
{
var allDecoIds = request.Items.Map(i => i.DecoId).Distinct().ToList();
Dictionary<int, Deco> allDecos = await dbContext.Decos
.AsNoTracking()
.Filter(d => allDecoIds.Contains(d.Id))
.ToListAsync()
.Map(list => list.ToDictionary(d => d.Id, d => d));
var missingDecoIds = allDecoIds.Filter(id => !allDecos.ContainsKey(id)).ToList();
if (missingDecoIds.Count > 0)
{
return BaseError.New($"[DecoId] {missingDecoIds.Head()} does not exist.");
}
var itemsWithEffectiveEnd = request.Items
.Map(i => new DecoTemplateItemRange(
i.DecoId,
i.StartTime,
i.EndTime,
i.EndTime == TimeSpan.Zero ? TimeSpan.FromHours(24) : i.EndTime))
.ToList();
foreach (DecoTemplateItemRange item in itemsWithEffectiveEnd)
{
if (item.StartTime < TimeSpan.Zero || item.StartTime >= TimeSpan.FromHours(24) ||
item.EndTime < TimeSpan.Zero || item.EndTime > TimeSpan.FromHours(24))
{
return BaseError.New(
$"Deco from {item.StartTime} to {item.EndTime} must be within a single day (00:00 to 24:00)");
}
if (item.StartTime >= item.EffectiveEndTime)
{
return BaseError.New(
$"Deco from {item.StartTime} to {item.EndTime} must start before it ends");
}
}
foreach (DecoTemplateItemRange item in itemsWithEffectiveEnd)
{
foreach (DecoTemplateItemRange otherItem in itemsWithEffectiveEnd)
{
if (item == otherItem)
{
continue;
}
if (item.StartTime < otherItem.EffectiveEndTime && otherItem.StartTime < item.EffectiveEndTime)
{
return BaseError.New(
$"Deco from {item.StartTime} to {item.EndTime} intersects deco from {otherItem.StartTime} to {otherItem.EndTime}");
}
}
}
return decoTemplate;
}
private sealed record DecoTemplateItemRange(int DecoId, TimeSpan StartTime, TimeSpan EndTime, TimeSpan EffectiveEndTime);
private static Task<Validation<BaseError, DecoTemplate>> DecoTemplateMustExist(
TvContext dbContext,
@@ -76,16 +76,29 @@ public class ReplaceTemplateItemsHandler(IDbContextFactory<TvContext> dbContextF
.ToListAsync()
.Map(list => list.ToDictionary(b => b.Id, b => b));
var missingBlockIds = allBlockIds.Filter(id => !allBlocks.ContainsKey(id)).ToList();
if (missingBlockIds.Count > 0)
{
return BaseError.New($"[BlockId] {missingBlockIds.Head()} does not exist.");
}
var allTemplateItems = request.Items.Map(i =>
{
Block block = allBlocks[i.BlockId];
return new BlockTemplateItem(
i.BlockId,
i.StartTime,
i.StartTime + TimeSpan.FromMinutes(block.Minutes));
var endTime = i.StartTime + TimeSpan.FromMinutes(block.Minutes);
return new BlockTemplateItem(i.BlockId, i.StartTime, endTime);
})
.ToList();
foreach (BlockTemplateItem item in allTemplateItems)
{
if (item.EndTime > TimeSpan.FromHours(24))
{
return BaseError.New(
$"Block from {item.StartTime} to {item.EndTime} crosses midnight, which is not supported");
}
}
foreach (BlockTemplateItem item in allTemplateItems)
{
foreach (BlockTemplateItem otherItem in allTemplateItems)
@@ -97,6 +97,7 @@ public class UpdateDecoHandler(IDbContextFactory<TvContext> dbContextFactory)
{
ex.CollectionType = toUpdate.CollectionType;
ex.CollectionId = toUpdate.CollectionId;
ex.MediaItemId = toUpdate.MediaItemId;
ex.MultiCollectionId = toUpdate.MultiCollectionId;
ex.SmartCollectionId = toUpdate.SmartCollectionId;
ex.PlaylistId = toUpdate.PlaylistId;
@@ -111,6 +112,7 @@ public class UpdateDecoHandler(IDbContextFactory<TvContext> dbContextFactory)
DecoId = existing.Id,
CollectionType = add.CollectionType,
CollectionId = add.CollectionId,
MediaItemId = add.MediaItemId,
MultiCollectionId = add.MultiCollectionId,
SmartCollectionId = add.SmartCollectionId,
PlaylistId = add.PlaylistId,
@@ -0,0 +1,5 @@
using ErsatzTV.Core.Api.Search;
namespace ErsatzTV.Application.Search;
public record GetSearchResults(string Query, int PageSize) : IRequest<SearchResultsResponseModel>;
@@ -0,0 +1,65 @@
using ErsatzTV.Application.LibraryBrowse;
using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Api.Search;
using MediatR;
namespace ErsatzTV.Application.Search;
// Fans out to the shared library-browse query once per media kind (mirroring the legacy Search.razor page,
// which sends one `type:{kind} AND ({query})` query per kind). Reusing GetLibraryBrowseItems keeps hydration,
// artwork resolution and the response shape identical to /api/library/browse. Raw Lucene queries pass through
// unchanged, so state filters such as `state:FileNotFound` (the Trash screen) work here too.
public class GetSearchResultsHandler(IMediator mediator)
: IRequestHandler<GetSearchResults, SearchResultsResponseModel>
{
public async Task<SearchResultsResponseModel> Handle(
GetSearchResults request,
CancellationToken cancellationToken)
{
async Task<SearchResultGroupResponseModel> ForKind(LibraryBrowseMediaType kind)
{
PagedLibraryBrowseItemsResponseModel paged = await mediator.Send(
new GetLibraryBrowseItems(request.Query, null, kind, 0, request.PageSize),
cancellationToken);
return new SearchResultGroupResponseModel(paged.TotalCount, paged.Page);
}
// Each ForKind call sends through IMediator to GetLibraryBrowseItemsHandler, which creates its
// own TvContext per invocation via IDbContextFactory<TvContext> (never shares one across calls),
// so these are safe to run concurrently.
Task<SearchResultGroupResponseModel> movies = ForKind(LibraryBrowseMediaType.Movie);
Task<SearchResultGroupResponseModel> shows = ForKind(LibraryBrowseMediaType.TelevisionShow);
Task<SearchResultGroupResponseModel> seasons = ForKind(LibraryBrowseMediaType.TelevisionSeason);
Task<SearchResultGroupResponseModel> artists = ForKind(LibraryBrowseMediaType.Artist);
Task<SearchResultGroupResponseModel> episodes = ForKind(LibraryBrowseMediaType.Episode);
Task<SearchResultGroupResponseModel> musicVideos = ForKind(LibraryBrowseMediaType.MusicVideo);
Task<SearchResultGroupResponseModel> songs = ForKind(LibraryBrowseMediaType.Song);
Task<SearchResultGroupResponseModel> otherVideos = ForKind(LibraryBrowseMediaType.OtherVideo);
Task<SearchResultGroupResponseModel> images = ForKind(LibraryBrowseMediaType.Image);
Task<SearchResultGroupResponseModel> remoteStreams = ForKind(LibraryBrowseMediaType.RemoteStream);
await Task.WhenAll(
movies,
shows,
seasons,
artists,
episodes,
musicVideos,
songs,
otherVideos,
images,
remoteStreams);
return new SearchResultsResponseModel(
movies.Result,
shows.Result,
seasons.Result,
artists.Result,
episodes.Result,
musicVideos.Result,
songs.Result,
otherVideos.Result,
images.Result,
remoteStreams.Result);
}
}
@@ -1,8 +1,5 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json;
namespace ErsatzTV.Application.Troubleshooting.Queries;
@@ -15,83 +12,10 @@ public class DecodePlayoutHistoryHandler(IDbContextFactory<TvContext> dbContextF
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
var decodedKey = JsonConvert.DeserializeObject<BlockItemHistoryKey>(request.PlayoutHistory.Key);
PlaybackOrder playbackOrder = decodedKey.PlaybackOrder ?? PlaybackOrder.None;
CollectionType collectionType = decodedKey.CollectionType ?? CollectionType.Collection;
string name = string.Empty;
switch (collectionType)
{
case CollectionType.Collection:
name = await dbContext.Collections
.AsNoTracking()
.Where(c => c.Id == (decodedKey.CollectionId ?? 0))
.Map(c => c.Name)
.FirstOrDefaultAsync(cancellationToken);
break;
case CollectionType.SmartCollection:
name = await dbContext.SmartCollections
.AsNoTracking()
.Where(c => c.Id == (decodedKey.SmartCollectionId ?? 0))
.Map(c => c.Name)
.FirstOrDefaultAsync(cancellationToken);
break;
}
string mediaItemType = string.Empty;
string mediaItemTitle = string.Empty;
Details details = JsonConvert.DeserializeObject<Details>(request.PlayoutHistory.Details);
if (details?.MediaItemId != null)
{
Option<MediaItem> maybeMediaItem = await dbContext.MediaItems
.AsNoTracking()
.Include(i => i.LibraryPath)
.ThenInclude(lp => lp.Library)
.ThenInclude(l => l.MediaSource)
.Include(i => (i as Movie).MovieMetadata)
.Include(i => (i as Episode).EpisodeMetadata)
.Include(i => (i as Episode).Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.Include(i => (i as OtherVideo).OtherVideoMetadata)
.Include(i => (i as Image).ImageMetadata)
.Include(i => (i as RemoteStream).RemoteStreamMetadata)
.Include(i => (i as Song).SongMetadata)
.Include(i => (i as MusicVideo).MusicVideoMetadata)
.Include(i => (i as MusicVideo).Artist)
.ThenInclude(a => a.ArtistMetadata)
.SelectOneAsync(i => i.Id, i => i.Id == details.MediaItemId, cancellationToken);
foreach (var mediaItem in maybeMediaItem)
{
mediaItemType = mediaItem switch
{
Episode => "Episode",
Movie => "Movie",
MusicVideo => "Music Video",
OtherVideo => "Other Video",
Song => "Song",
Image => "Image",
RemoteStream => "Remote Stream",
_ => $"Unknown ({mediaItem.GetType().Name})"
};
mediaItemTitle = Playouts.Mapper.GetDisplayTitle(mediaItem, Option<string>.None);
}
}
return new PlayoutHistoryDetailsViewModel(playbackOrder, collectionType, name, mediaItemType, mediaItemTitle);
return await PlayoutHistoryDecoder.Decode(
dbContext,
request.PlayoutHistory.Key,
request.PlayoutHistory.Details,
cancellationToken);
}
private sealed record BlockItemHistoryKey(
int? BlockId,
PlaybackOrder? PlaybackOrder,
CollectionType? CollectionType,
int? CollectionId,
int? SmartCollectionId);
private sealed record Details(int? MediaItemId);
}
@@ -0,0 +1,5 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Troubleshooting.Queries;
public record GetPlayoutHistoryDetails(int Id) : IRequest<Either<BaseError, PlayoutHistoryDetailsViewModel>>;
@@ -0,0 +1,42 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.Errors;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Troubleshooting.Queries;
public class GetPlayoutHistoryDetailsHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetPlayoutHistoryDetails, Either<BaseError, PlayoutHistoryDetailsViewModel>>
{
public async Task<Either<BaseError, PlayoutHistoryDetailsViewModel>> Handle(
GetPlayoutHistoryDetails request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<PlayoutHistory> maybeHistory = await dbContext.PlayoutHistory
.AsNoTracking()
.SelectOneAsync(ph => ph.Id, ph => ph.Id == request.Id, cancellationToken);
foreach (PlayoutHistory history in maybeHistory)
{
try
{
return await PlayoutHistoryDecoder.Decode(
dbContext,
history.Key,
history.Details,
cancellationToken);
}
catch (Exception ex)
{
// old/corrupt rows may carry malformed Key/Details JSON; surface a 422 rather than a 500
return BaseError.New($"Unable to decode playout history: {ex.Message}");
}
}
return new NotFoundError($"Playout history {request.Id} does not exist");
}
}
@@ -0,0 +1,99 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json;
namespace ErsatzTV.Application.Troubleshooting.Queries;
// Shared decode logic for a single PlayoutHistory row's Key/Details JSON. Both
// DecodePlayoutHistoryHandler (Blazor) and GetPlayoutHistoryDetailsHandler (API) use this
// so the collection/media-item lookup lives in exactly one place.
internal static class PlayoutHistoryDecoder
{
public static async Task<PlayoutHistoryDetailsViewModel> Decode(
TvContext dbContext,
string key,
string details,
CancellationToken cancellationToken)
{
var decodedKey = JsonConvert.DeserializeObject<BlockItemHistoryKey>(key);
PlaybackOrder playbackOrder = decodedKey.PlaybackOrder ?? PlaybackOrder.None;
CollectionType collectionType = decodedKey.CollectionType ?? CollectionType.Collection;
string name = string.Empty;
switch (collectionType)
{
case CollectionType.Collection:
name = await dbContext.Collections
.AsNoTracking()
.Where(c => c.Id == (decodedKey.CollectionId ?? 0))
.Map(c => c.Name)
.FirstOrDefaultAsync(cancellationToken);
break;
case CollectionType.SmartCollection:
name = await dbContext.SmartCollections
.AsNoTracking()
.Where(c => c.Id == (decodedKey.SmartCollectionId ?? 0))
.Map(c => c.Name)
.FirstOrDefaultAsync(cancellationToken);
break;
}
string mediaItemType = string.Empty;
string mediaItemTitle = string.Empty;
Details decodedDetails = JsonConvert.DeserializeObject<Details>(details);
if (decodedDetails?.MediaItemId != null)
{
Option<MediaItem> maybeMediaItem = await dbContext.MediaItems
.AsNoTracking()
.Include(i => i.LibraryPath)
.ThenInclude(lp => lp.Library)
.ThenInclude(l => l.MediaSource)
.Include(i => (i as Movie).MovieMetadata)
.Include(i => (i as Episode).EpisodeMetadata)
.Include(i => (i as Episode).Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.Include(i => (i as OtherVideo).OtherVideoMetadata)
.Include(i => (i as Image).ImageMetadata)
.Include(i => (i as RemoteStream).RemoteStreamMetadata)
.Include(i => (i as Song).SongMetadata)
.Include(i => (i as MusicVideo).MusicVideoMetadata)
.Include(i => (i as MusicVideo).Artist)
.ThenInclude(a => a.ArtistMetadata)
.SelectOneAsync(i => i.Id, i => i.Id == decodedDetails.MediaItemId, cancellationToken);
foreach (var mediaItem in maybeMediaItem)
{
mediaItemType = mediaItem switch
{
Episode => "Episode",
Movie => "Movie",
MusicVideo => "Music Video",
OtherVideo => "Other Video",
Song => "Song",
Image => "Image",
RemoteStream => "Remote Stream",
_ => $"Unknown ({mediaItem.GetType().Name})"
};
mediaItemTitle = Playouts.Mapper.GetDisplayTitle(mediaItem, Option<string>.None);
}
}
return new PlayoutHistoryDetailsViewModel(playbackOrder, collectionType, name, mediaItemType, mediaItemTitle);
}
private sealed record BlockItemHistoryKey(
int? BlockId,
PlaybackOrder? PlaybackOrder,
CollectionType? CollectionType,
int? CollectionId,
int? SmartCollectionId);
private sealed record Details(int? MediaItemId);
}
@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Troubleshooting.Queries;
public record ValidateSequentialSchedule(string Yaml, bool IsImport) : IRequest<ValidateSequentialScheduleViewModel>;
@@ -0,0 +1,25 @@
using ErsatzTV.Core.Interfaces.Scheduling;
namespace ErsatzTV.Application.Troubleshooting.Queries;
public class ValidateSequentialScheduleHandler(ISequentialScheduleValidator validator)
: IRequestHandler<ValidateSequentialSchedule, ValidateSequentialScheduleViewModel>
{
public async Task<ValidateSequentialScheduleViewModel> Handle(
ValidateSequentialSchedule request,
CancellationToken cancellationToken)
{
try
{
// ToJson runs first and can throw on malformed YAML (GetValidationMessages catches its
// own exceptions internally); mirrors the Blazor YamlValidator ordering.
string json = validator.ToJson(request.Yaml);
IList<string> messages = await validator.GetValidationMessages(request.Yaml, request.IsImport);
return new ValidateSequentialScheduleViewModel(messages.Count == 0, messages.ToList(), json);
}
catch (Exception ex)
{
return new ValidateSequentialScheduleViewModel(false, [ex.Message], string.Empty);
}
}
}
@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Troubleshooting;
public record ValidateSequentialScheduleViewModel(bool IsValid, List<string> Messages, string Json);
@@ -1,5 +1,6 @@
using ErsatzTV.Core;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
@@ -23,11 +24,14 @@ public class DeleteWatermarkHandler : IRequestHandler<DeleteWatermark, Either<Ba
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, ChannelWatermark> validation = await WatermarkMustExist(
dbContext,
request,
cancellationToken);
return await validation.Apply(p => DoDeletion(dbContext, p));
Option<ChannelWatermark> maybeWatermark = await WatermarkMustExist(dbContext, request, cancellationToken);
// must-exist maps to a NotFoundError Either directly (not via Validation, which
// aggregates errors and loses the subtype the API layer maps to 404)
return await maybeWatermark.Match(
Some: watermark => DoDeletion(dbContext, watermark).Map(Right<BaseError, Unit>),
None: () => Task.FromResult<Either<BaseError, Unit>>(
new NotFoundError($"Watermark {request.WatermarkId} does not exist")));
}
private async Task<Unit> DoDeletion(TvContext dbContext, ChannelWatermark watermark)
@@ -40,11 +44,10 @@ public class DeleteWatermarkHandler : IRequestHandler<DeleteWatermark, Either<Ba
return Unit.Default;
}
private static Task<Validation<BaseError, ChannelWatermark>> WatermarkMustExist(
private static Task<Option<ChannelWatermark>> WatermarkMustExist(
TvContext dbContext,
DeleteWatermark request,
CancellationToken cancellationToken) =>
dbContext.ChannelWatermarks
.SelectOneAsync(p => p.Id, p => p.Id == request.WatermarkId, cancellationToken)
.Map(o => o.ToValidation<BaseError>($"Watermark {request.WatermarkId} does not exist"));
.SelectOneAsync(p => p.Id, p => p.Id == request.WatermarkId, cancellationToken);
}
@@ -1,5 +1,6 @@
using ErsatzTV.Core;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
@@ -23,8 +24,18 @@ public class UpdateWatermarkHandler : IRequestHandler<UpdateWatermark, Either<Ba
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, ChannelWatermark> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Apply(p => ApplyUpdateRequest(dbContext, p, request));
Option<ChannelWatermark> maybeWatermark = await WatermarkMustExist(dbContext, request, cancellationToken);
// must-exist maps to a NotFoundError Either directly (not via Validation, which
// aggregates errors and loses the subtype the API layer maps to 404)
return await maybeWatermark.Match(
Some: async watermark =>
{
Validation<BaseError, string> validation = await ValidateName(dbContext, request);
return await validation.Apply((string _) => ApplyUpdateRequest(dbContext, watermark, request));
},
None: () => Task.FromResult<Either<BaseError, UpdateWatermarkResult>>(
new NotFoundError("Watermark does not exist.")));
}
private async Task<UpdateWatermarkResult> ApplyUpdateRequest(
@@ -61,20 +72,12 @@ public class UpdateWatermarkHandler : IRequestHandler<UpdateWatermark, Either<Ba
return new UpdateWatermarkResult(p.Id);
}
private static async Task<Validation<BaseError, ChannelWatermark>> Validate(
TvContext dbContext,
UpdateWatermark request,
CancellationToken cancellationToken) =>
(await WatermarkMustExist(dbContext, request, cancellationToken), await ValidateName(dbContext, request))
.Apply((watermark, _) => watermark);
private static Task<Validation<BaseError, ChannelWatermark>> WatermarkMustExist(
private static Task<Option<ChannelWatermark>> WatermarkMustExist(
TvContext dbContext,
UpdateWatermark updateWatermark,
CancellationToken cancellationToken) =>
dbContext.ChannelWatermarks
.SelectOneAsync(p => p.Id, p => p.Id == updateWatermark.Id, cancellationToken)
.Map(o => o.ToValidation<BaseError>("Watermark does not exist."));
.SelectOneAsync(p => p.Id, p => p.Id == updateWatermark.Id, cancellationToken);
private static async Task<Validation<BaseError, string>> ValidateName(
TvContext dbContext,
+24
View File
@@ -1,10 +1,34 @@
using ErsatzTV.Application.Artworks;
using ErsatzTV.Core.Api.Watermarks;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Watermarks;
internal static class Mapper
{
internal static WatermarkResponseModel ProjectToResponseModel(ChannelWatermark watermark) =>
new(watermark.Id, watermark.Name);
internal static WatermarkFullResponseModel ProjectToFullResponseModel(ChannelWatermark watermark) =>
new(
watermark.Id,
watermark.Name,
watermark.Mode,
watermark.ImageSource,
watermark.Image,
watermark.OriginalContentType,
watermark.Location,
watermark.Size,
watermark.WidthPercent,
watermark.HorizontalMarginPercent,
watermark.VerticalMarginPercent,
watermark.FrequencyMinutes,
watermark.DurationSeconds,
watermark.Opacity,
watermark.OpacityExpression,
watermark.ZIndex,
watermark.PlaceWithinSourceContent);
public static WatermarkViewModel ProjectToViewModel(ChannelWatermark watermark) =>
new(
watermark.Id,
@@ -0,0 +1,5 @@
using ErsatzTV.Core.Api.Watermarks;
namespace ErsatzTV.Application.Watermarks;
public record GetAllWatermarksForApi : IRequest<List<WatermarkResponseModel>>;
@@ -0,0 +1,22 @@
using ErsatzTV.Core.Api.Watermarks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.Watermarks.Mapper;
namespace ErsatzTV.Application.Watermarks;
public class GetAllWatermarksForApiHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetAllWatermarksForApi, List<WatermarkResponseModel>>
{
public async Task<List<WatermarkResponseModel>> Handle(
GetAllWatermarksForApi request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
List<ChannelWatermark> watermarks = await dbContext.ChannelWatermarks
.AsNoTracking()
.ToListAsync(cancellationToken);
return watermarks.Map(ProjectToResponseModel).ToList();
}
}

Some files were not shown because too many files have changed in this diff Show More