Compare commits

...
Author SHA1 Message Date
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 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 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
273 changed files with 40048 additions and 759 deletions
+2 -1
View File
@@ -5,7 +5,7 @@ Custom IPTV channel server for Jellyfin. Forked from [ErsatzTV/ErsatzTV](https:/
## Architecture
- **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 un-migrated admin screens (collections, media browse, trakt, filler, watermarks, ffmpeg profiles, blocks/decos/templates, playout editors, logs, troubleshooting; Blazor home = `/system/health`); its removal is #91 phase (b), gated on parity issues #140#147
- **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
@@ -56,6 +56,7 @@ 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 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
@@ -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,
+19
View File
@@ -8,6 +8,25 @@ 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 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,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,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;
}
}
@@ -7,4 +7,5 @@ public record GetLibraryBrowseItems(
int? LibraryId,
LibraryBrowseMediaType? MediaType,
int PageNum,
int PageSize) : IRequest<PagedLibraryBrowseItemsResponseModel>;
int PageSize,
int? ParentId = null) : IRequest<PagedLibraryBrowseItemsResponseModel>;
@@ -1,12 +1,9 @@
using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Jellyfin;
using ErsatzTV.Core.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Search;
using Flurl;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.LibraryBrowse;
@@ -22,6 +19,34 @@ public class GetLibraryBrowseItemsHandler(
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);
@@ -91,12 +116,24 @@ public class GetLibraryBrowseItemsHandler(
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.ArtistType,
LuceneSearchIndex.EpisodeType,
LuceneSearchIndex.MusicVideoType,
LuceneSearchIndex.SongType,
LuceneSearchIndex.OtherVideoType,
LuceneSearchIndex.ImageType,
LuceneSearchIndex.RemoteStreamType
],
_ => []
};
@@ -115,219 +152,184 @@ public class GetLibraryBrowseItemsHandler(
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 GetMovies(dbContext, movieIds, cancellationToken))
foreach (LibraryBrowseItemResponseModel item in
await LibraryBrowseItemMapper.GetMovies(dbContext, movieIds, cancellationToken))
{
hydrated[(LuceneSearchIndex.MovieType, item.Id)] = item;
}
foreach (LibraryBrowseItemResponseModel item in await GetShows(dbContext, showIds, cancellationToken))
foreach (LibraryBrowseItemResponseModel item in
await LibraryBrowseItemMapper.GetShows(dbContext, showIds, cancellationToken))
{
hydrated[(LuceneSearchIndex.ShowType, item.Id)] = item;
}
foreach (LibraryBrowseItemResponseModel item in await GetSeasons(dbContext, seasonIds, cancellationToken))
foreach (LibraryBrowseItemResponseModel item in
await LibraryBrowseItemMapper.GetSeasons(dbContext, seasonIds, cancellationToken))
{
hydrated[(LuceneSearchIndex.SeasonType, item.Id)] = item;
}
foreach (LibraryBrowseItemResponseModel item in await GetArtists(dbContext, artistIds, cancellationToken))
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<List<LibraryBrowseItemResponseModel>> GetMovies(
private static async Task<PagedLibraryBrowseItemsResponseModel> BrowseSeasonsForShow(
TvContext dbContext,
List<int> ids,
GetLibraryBrowseItems request,
CancellationToken cancellationToken)
{
if (ids.Count == 0)
{
return [];
}
return await dbContext.MovieMetadata
List<int> allSeasonIds = await dbContext.Seasons
.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());
.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);
}
private static async Task<List<LibraryBrowseItemResponseModel>> GetShows(
// Drill-in: episodes of a specific season, in episode-number order (#141/#161).
private static async Task<PagedLibraryBrowseItemsResponseModel> BrowseEpisodesForSeason(
TvContext dbContext,
List<int> ids,
GetLibraryBrowseItems request,
CancellationToken cancellationToken)
{
if (ids.Count == 0)
{
return [];
}
Dictionary<int, int> counts = await dbContext.Episodes
List<int> allEpisodeIds = await dbContext.EpisodeMetadata
.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);
.Where(em => em.Episode.SeasonId == request.ParentId.Value)
.OrderBy(em => em.EpisodeNumber)
.ThenBy(em => em.EpisodeId)
.Select(em => em.EpisodeId)
.ToListAsync(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());
// 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);
}
private static async Task<List<LibraryBrowseItemResponseModel>> GetSeasons(
// Drill-in: music videos of a specific artist, in album/track/title order (#141/#161).
private static async Task<PagedLibraryBrowseItemsResponseModel> BrowseMusicVideosForArtist(
TvContext dbContext,
List<int> ids,
GetLibraryBrowseItems request,
CancellationToken cancellationToken)
{
if (ids.Count == 0)
{
return [];
}
Dictionary<int, int> counts = await dbContext.Episodes
List<int> allMusicVideoIds = await dbContext.MusicVideoMetadata
.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);
.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);
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)
.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,
Artwork(sm, ArtworkKind.Poster),
null,
counts.TryGetValue(sm.SeasonId, out int count) ? count : 0,
null,
CollectionType.TelevisionSeason,
null,
null,
null,
null,
sm.SeasonId,
null)).ToList());
}
allMusicVideoIds = allMusicVideoIds.Distinct().ToList();
private static async Task<List<LibraryBrowseItemResponseModel>> GetArtists(
TvContext dbContext,
List<int> ids,
CancellationToken cancellationToken)
{
if (ids.Count == 0)
{
return [];
}
int total = allMusicVideoIds.Count;
List<int> pageIds = allMusicVideoIds
.Skip(request.PageNum * request.PageSize)
.Take(request.PageSize)
.ToList();
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);
List<LibraryBrowseItemResponseModel> musicVideos =
await LibraryBrowseItemMapper.GetMusicVideos(dbContext, pageIds, 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());
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(
@@ -659,16 +661,6 @@ public class GetLibraryBrowseItemsHandler(
private static bool ShouldInclude(LibraryBrowseMediaType? requestType, LibraryBrowseMediaType itemType) =>
requestType is null || requestType == itemType;
private 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;
}
private static async Task<Dictionary<int, TimeSpan?>> GetManualCollectionDurations(
TvContext dbContext,
List<int> collectionIds,
@@ -684,7 +676,7 @@ public class GetLibraryBrowseItemsHandler(
.Include(m => m.MediaVersions)
.ToListAsync(cancellationToken))
{
TimeSpan? duration = BestDuration(movie.MediaVersions);
TimeSpan? duration = LibraryBrowseItemMapper.BestDuration(movie.MediaVersions);
if (duration.HasValue)
{
mediaItemDurations[movie.Id] = duration.Value;
@@ -697,7 +689,7 @@ public class GetLibraryBrowseItemsHandler(
.Include(e => e.MediaVersions)
.ToListAsync(cancellationToken))
{
TimeSpan? duration = BestDuration(episode.MediaVersions);
TimeSpan? duration = LibraryBrowseItemMapper.BestDuration(episode.MediaVersions);
if (duration.HasValue)
{
mediaItemDurations[episode.Id] = duration.Value;
@@ -710,7 +702,7 @@ public class GetLibraryBrowseItemsHandler(
.Include(mv => mv.MediaVersions)
.ToListAsync(cancellationToken))
{
TimeSpan? duration = BestDuration(musicVideo.MediaVersions);
TimeSpan? duration = LibraryBrowseItemMapper.BestDuration(musicVideo.MediaVersions);
if (duration.HasValue)
{
mediaItemDurations[musicVideo.Id] = duration.Value;
@@ -723,7 +715,7 @@ public class GetLibraryBrowseItemsHandler(
.Include(ov => ov.MediaVersions)
.ToListAsync(cancellationToken))
{
TimeSpan? duration = BestDuration(otherVideo.MediaVersions);
TimeSpan? duration = LibraryBrowseItemMapper.BestDuration(otherVideo.MediaVersions);
if (duration.HasValue)
{
mediaItemDurations[otherVideo.Id] = duration.Value;
@@ -736,7 +728,7 @@ public class GetLibraryBrowseItemsHandler(
.Include(s => s.MediaVersions)
.ToListAsync(cancellationToken))
{
TimeSpan? duration = BestDuration(song.MediaVersions);
TimeSpan? duration = LibraryBrowseItemMapper.BestDuration(song.MediaVersions);
if (duration.HasValue)
{
mediaItemDurations[song.Id] = duration.Value;
@@ -749,7 +741,7 @@ public class GetLibraryBrowseItemsHandler(
.Include(i => i.MediaVersions)
.ToListAsync(cancellationToken))
{
TimeSpan? duration = BestDuration(image.MediaVersions);
TimeSpan? duration = LibraryBrowseItemMapper.BestDuration(image.MediaVersions);
if (duration.HasValue)
{
mediaItemDurations[image.Id] = duration.Value;
@@ -762,7 +754,7 @@ public class GetLibraryBrowseItemsHandler(
.Include(rs => rs.MediaVersions)
.ToListAsync(cancellationToken))
{
TimeSpan? duration = BestDuration(remoteStream.MediaVersions);
TimeSpan? duration = LibraryBrowseItemMapper.BestDuration(remoteStream.MediaVersions);
if (duration.HasValue)
{
mediaItemDurations[remoteStream.Id] = duration.Value;
@@ -793,7 +785,7 @@ public class GetLibraryBrowseItemsHandler(
.OrderBy(mm => mm.Id)
.ToListAsync(cancellationToken))
{
string poster = Artwork(metadata, ArtworkKind.Poster);
string poster = LibraryBrowseItemMapper.Artwork(metadata, ArtworkKind.Poster);
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.MovieId))
{
mediaItemArtwork[metadata.MovieId] = poster;
@@ -807,7 +799,7 @@ public class GetLibraryBrowseItemsHandler(
.OrderBy(sm => sm.Id)
.ToListAsync(cancellationToken))
{
string poster = Artwork(metadata, ArtworkKind.Poster);
string poster = LibraryBrowseItemMapper.Artwork(metadata, ArtworkKind.Poster);
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.ShowId))
{
mediaItemArtwork[metadata.ShowId] = poster;
@@ -821,7 +813,7 @@ public class GetLibraryBrowseItemsHandler(
.OrderBy(sm => sm.Id)
.ToListAsync(cancellationToken))
{
string poster = Artwork(metadata, ArtworkKind.Poster);
string poster = LibraryBrowseItemMapper.Artwork(metadata, ArtworkKind.Poster);
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.SeasonId))
{
mediaItemArtwork[metadata.SeasonId] = poster;
@@ -835,7 +827,7 @@ public class GetLibraryBrowseItemsHandler(
.OrderBy(ovm => ovm.Id)
.ToListAsync(cancellationToken))
{
string poster = Artwork(metadata, ArtworkKind.Poster);
string poster = LibraryBrowseItemMapper.Artwork(metadata, ArtworkKind.Poster);
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.OtherVideoId))
{
mediaItemArtwork[metadata.OtherVideoId] = poster;
@@ -849,7 +841,7 @@ public class GetLibraryBrowseItemsHandler(
.OrderBy(sm => sm.Id)
.ToListAsync(cancellationToken))
{
string poster = Artwork(metadata, ArtworkKind.Poster);
string poster = LibraryBrowseItemMapper.Artwork(metadata, ArtworkKind.Poster);
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.SongId))
{
mediaItemArtwork[metadata.SongId] = poster;
@@ -863,7 +855,7 @@ public class GetLibraryBrowseItemsHandler(
.OrderBy(im => im.Id)
.ToListAsync(cancellationToken))
{
string poster = Artwork(metadata, ArtworkKind.Poster);
string poster = LibraryBrowseItemMapper.Artwork(metadata, ArtworkKind.Poster);
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.ImageId))
{
mediaItemArtwork[metadata.ImageId] = poster;
@@ -877,7 +869,7 @@ public class GetLibraryBrowseItemsHandler(
.OrderBy(rsm => rsm.Id)
.ToListAsync(cancellationToken))
{
string poster = Artwork(metadata, ArtworkKind.Poster);
string poster = LibraryBrowseItemMapper.Artwork(metadata, ArtworkKind.Poster);
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.RemoteStreamId))
{
mediaItemArtwork[metadata.RemoteStreamId] = poster;
@@ -902,47 +894,6 @@ public class GetLibraryBrowseItemsHandler(
.Select(ci => new CollectionMediaItem(ci.CollectionId, ci.MediaItemId, ci.CustomIndex))
.ToListAsync(cancellationToken);
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}";
}
private static string Artwork(Metadata metadata, ArtworkKind artworkKind)
{
string artwork = Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == artworkKind))
.Match(a => a.Path, string.Empty);
if (artwork.StartsWith("jellyfin://", StringComparison.OrdinalIgnoreCase))
{
Url url = JellyfinUrl.RelativeProxyForArtwork(artwork);
if (artworkKind is ArtworkKind.Poster or ArtworkKind.Thumbnail)
{
url.SetQueryParam("fillHeight", 440);
}
return 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 url;
}
return artwork;
}
private static string EscapeLike(string searchQuery) =>
searchQuery
.Replace("\\", "\\\\", StringComparison.Ordinal)
@@ -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);
}
}
@@ -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);
@@ -17,6 +17,7 @@ public class GetPlayoutByIdHandler(IDbContextFactory<TvContext> dbContextFactory
.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,
@@ -27,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));
}
}
@@ -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,
+20
View File
@@ -9,6 +9,26 @@ 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 GetWatermarkByIdForApi(int Id) : IRequest<Option<WatermarkFullResponseModel>>;
@@ -0,0 +1,22 @@
using ErsatzTV.Core.Api.Watermarks;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.Watermarks.Mapper;
namespace ErsatzTV.Application.Watermarks;
public class GetWatermarkByIdForApiHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetWatermarkByIdForApi, Option<WatermarkFullResponseModel>>
{
public async Task<Option<WatermarkFullResponseModel>> Handle(
GetWatermarkByIdForApi request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.ChannelWatermarks
.AsNoTracking()
.SelectOneAsync(w => w.Id, w => w.Id == request.Id, cancellationToken)
.MapT(ProjectToFullResponseModel);
}
}
+66
View File
@@ -0,0 +1,66 @@
#nullable enable
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Jellyfin;
using Flurl;
namespace ErsatzTV.Core.Api;
/// <summary>
/// Roots an artwork value (as produced by the Application view-model mappers) into a directly-usable
/// <c>&lt;img src&gt;</c> URL for the React SPA. The Blazor pages prefix "artwork/{folder}/" themselves and
/// resolve relative to <c>&lt;base href="/"&gt;</c>, but the SPA renders the value raw from under <c>/app/</c>,
/// so the API must root the URL itself (issue #180/#181). Mirrors the projection helper in
/// GetLibraryBrowseItemsHandler so detail endpoints stay consistent with the browse grid.
/// </summary>
public static class ApiArtwork
{
public static string Root(string? artwork, ArtworkKind artworkKind)
{
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 switch
{
ArtworkKind.Thumbnail => "thumbnails",
ArtworkKind.FanArt => "fanart",
_ => "posters"
};
// Some mappers (e.g. Artists) leave the raw jellyfin://emby:// scheme in the value; convert it here so
// the SPA gets a working proxy URL even when the source mapper didn't pre-convert. Movie/TV mappers
// already produce a relative "jellyfin/{id}?..." path, which falls through to the plain prefix below.
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}";
}
}
@@ -0,0 +1,14 @@
#nullable enable
namespace ErsatzTV.Core.Api.Artists;
public record ArtistDetailResponseModel(
int Id,
string Name,
string? Disambiguation,
string? Biography,
string Thumbnail,
string FanArt,
List<string> Genres,
List<string> Styles,
List<string> Moods,
List<string> Languages);
@@ -1,4 +1,5 @@
using ErsatzTV.Core.Domain;
#nullable enable
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
namespace ErsatzTV.Core.Api.FFmpegProfiles;
@@ -7,13 +8,17 @@ public record FFmpegFullProfileResponseModel(
int Id,
string Name,
int ThreadCount,
bool NormalizeAudio,
bool NormalizeVideo,
HardwareAccelerationKind HardwareAcceleration,
string VaapiDisplay,
VaapiDriver VaapiDriver,
string VaapiDevice,
int? QsvExtraHardwareFrames,
int ResolutionId,
string Resolution,
ScalingBehavior ScalingBehavior,
FilterMode PadMode,
FFmpegProfileVideoFormat VideoFormat,
string VideoProfile,
string VideoPreset,
@@ -26,7 +31,9 @@ public record FFmpegFullProfileResponseModel(
int AudioBitrate,
int AudioBufferSize,
NormalizeLoudnessMode NormalizeLoudnessMode,
double? TargetLoudness,
int AudioChannels,
int AudioSampleRate,
bool NormalizeFramerate,
bool? DeinterlaceVideo);
bool NormalizeColors,
bool DeinterlaceVideo);
@@ -0,0 +1,23 @@
#nullable enable
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
namespace ErsatzTV.Core.Api.Filler;
public record FillerPresetFullResponseModel(
int Id,
string Name,
FillerKind FillerKind,
FillerMode FillerMode,
TimeSpan? Duration,
int? Count,
int? PadToNearestMinute,
bool AllowWatermarks,
CollectionType CollectionType,
int? CollectionId,
int? MediaItemId,
int? MultiCollectionId,
int? SmartCollectionId,
int? PlaylistId,
string? Expression,
bool UseChaptersAsMediaItems);
@@ -0,0 +1,10 @@
#nullable enable
namespace ErsatzTV.Core.Api.Images;
public record ImageFolderResponseModel(
int LibraryFolderId,
string Name,
string FullPath,
int SubfolderCount,
int ImageCount,
double? DurationSeconds);
@@ -0,0 +1,4 @@
#nullable enable
namespace ErsatzTV.Core.Api.Images;
public record UpdateImageFolderDurationResponseModel(double? DurationSeconds);
@@ -19,4 +19,5 @@ public record LibraryBrowseItemResponseModel(
int? SmartCollectionId,
int? RerunCollectionId,
int? MediaItemId,
int? PlaylistId);
int? PlaylistId,
string? Subtitle = null);
@@ -11,5 +11,11 @@ public enum LibraryBrowseMediaType
SmartCollection = 6,
MultiCollection = 7,
RerunCollection = 8,
Playlist = 9
Playlist = 9,
Episode = 10,
MusicVideo = 11,
Song = 12,
OtherVideo = 13,
Image = 14,
RemoteStream = 15
}
@@ -0,0 +1,7 @@
#nullable enable
namespace ErsatzTV.Core.Api.Logs;
public record LogEntryResponseModel(
DateTimeOffset Timestamp,
string Level,
string Message);
@@ -0,0 +1,6 @@
#nullable enable
namespace ErsatzTV.Core.Api.Logs;
public record PagedLogEntriesResponseModel(
int TotalCount,
List<LogEntryResponseModel> Page);
@@ -0,0 +1,8 @@
#nullable enable
namespace ErsatzTV.Core.Api.Media;
public record ActorResponseModel(
int Id,
string Name,
string? Role,
string Thumb);
@@ -0,0 +1,4 @@
#nullable enable
namespace ErsatzTV.Core.Api.MediaCollections;
public record PlaylistGroupResponseModel(int Id, string Name, int PlaylistCount, bool IsSystem);
@@ -0,0 +1,4 @@
#nullable enable
namespace ErsatzTV.Core.Api.MediaCollections;
public record PlaylistResponseModel(int Id, int PlaylistGroupId, string Name, bool IsSystem);
@@ -0,0 +1,49 @@
#nullable enable
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Api.MediaItems;
public record MediaItemInfoResponseModel(
int Id,
string Title,
string Kind,
string LibraryKind,
string? ServerName,
string LibraryName,
MediaItemState State,
TimeSpan Duration,
string? SampleAspectRatio,
string? DisplayAspectRatio,
string? RFrameRate,
VideoScanKind VideoScanKind,
double? InterlacedRatio,
int Width,
int Height,
List<MediaItemInfoStreamResponseModel> Streams,
List<MediaItemInfoChapterResponseModel> Chapters);
public record MediaItemInfoStreamResponseModel(
int? Index,
MediaStreamKind Kind,
string? Title,
string? Codec,
string? Profile,
string? Language,
int? Channels,
bool? Default,
bool? Forced,
bool? AttachedPic,
string? PixelFormat,
string? ColorRange,
string? ColorSpace,
string? ColorTransfer,
string? ColorPrimaries,
int? BitsPerRawSample,
string? MimeType,
string? FileName,
bool? IsExtracted);
public record MediaItemInfoChapterResponseModel(
string? Title,
TimeSpan StartTime,
TimeSpan EndTime);
@@ -0,0 +1,24 @@
#nullable enable
using ErsatzTV.Core.Api.Media;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Api.Movies;
public record MovieDetailResponseModel(
int Id,
string Title,
string? Year,
string? Plot,
List<string> Genres,
List<string> Tags,
List<string> Studios,
List<string> ContentRatings,
List<string> Languages,
List<ActorResponseModel> Actors,
List<string> Directors,
List<string> Writers,
string? Path,
string? LocalPath,
MediaItemState State,
string Poster,
string FanArt);
@@ -0,0 +1,4 @@
#nullable enable
namespace ErsatzTV.Core.Api.Playouts;
public record PagedPlayoutHistoryResponseModel(int TotalCount, List<PlayoutHistoryResponseModel> Page);
@@ -0,0 +1,17 @@
#nullable enable
namespace ErsatzTV.Core.Api.Playouts;
public record PlayoutAlternateScheduleResponseModel(
int Id,
int Index,
int ProgramScheduleId,
ICollection<DayOfWeek> DaysOfWeek,
ICollection<int> DaysOfMonth,
ICollection<int> MonthsOfYear,
bool LimitToDateRange,
int StartMonth,
int StartDay,
int? StartYear,
int EndMonth,
int EndDay,
int? EndYear);
@@ -0,0 +1,11 @@
#nullable enable
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Api.Playouts;
public record PlayoutHistoryDetailsResponseModel(
PlaybackOrder PlaybackOrder,
CollectionType CollectionType,
string Name,
string MediaItemType,
string MediaItemTitle);
@@ -0,0 +1,9 @@
#nullable enable
namespace ErsatzTV.Core.Api.Playouts;
public record PlayoutHistoryResponseModel(
int Id,
DateTimeOffset When,
DateTimeOffset Finish,
string Key,
string Details);
@@ -12,7 +12,9 @@ public record PlayoutResponseModel(
string ScheduleName,
string? ScheduleFile,
TimeSpan? DailyRebuildTime,
PlayoutBuildStatusResponseModel? BuildStatus)
PlayoutBuildStatusResponseModel? BuildStatus,
int? DecoId,
string? DecoName)
{
public static PlayoutResponseModel From(
int id,
@@ -23,7 +25,9 @@ public record PlayoutResponseModel(
string scheduleName,
string? scheduleFile,
TimeSpan? dailyRebuildTime,
PlayoutBuildStatusResponseModel? buildStatus) =>
PlayoutBuildStatusResponseModel? buildStatus,
int? decoId,
string? decoName) =>
new(
id,
scheduleKind,
@@ -33,5 +37,7 @@ public record PlayoutResponseModel(
scheduleName,
scheduleFile,
dailyRebuildTime,
buildStatus);
buildStatus,
decoId,
decoName);
}
@@ -0,0 +1,4 @@
#nullable enable
namespace ErsatzTV.Core.Api.Scheduling;
public record BlockGroupResponseModel(int Id, string Name);
@@ -0,0 +1,24 @@
#nullable enable
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Api.Scheduling;
public record BlockItemResponseModel(
int Id,
int Index,
CollectionType CollectionType,
int? CollectionId,
string? CollectionName,
int? MultiCollectionId,
string? MultiCollectionName,
int? SmartCollectionId,
string? SmartCollectionName,
int? MediaItemId,
string? MediaItemName,
string SearchTitle,
string SearchQuery,
PlaybackOrder PlaybackOrder,
bool IncludeInProgramGuide,
bool DisableWatermarks,
List<int> WatermarkIds,
List<int> GraphicsElementIds);
@@ -0,0 +1,8 @@
#nullable enable
namespace ErsatzTV.Core.Api.Scheduling;
public record BlockPreviewItemResponseModel(
string Title,
TimeSpan Start,
TimeSpan Finish,
string Duration);
@@ -0,0 +1,12 @@
#nullable enable
using ErsatzTV.Core.Domain.Scheduling;
namespace ErsatzTV.Core.Api.Scheduling;
public record BlockResponseModel(
int Id,
int GroupId,
string GroupName,
string Name,
int Minutes,
BlockStopScheduling StopScheduling);
@@ -0,0 +1,13 @@
#nullable enable
using ErsatzTV.Core.Domain.Scheduling;
namespace ErsatzTV.Core.Api.Scheduling;
public record BlockWithItemsResponseModel(
int Id,
int GroupId,
string GroupName,
string Name,
int Minutes,
BlockStopScheduling StopScheduling,
List<BlockItemResponseModel> Items);
@@ -0,0 +1,17 @@
#nullable enable
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Scheduling;
namespace ErsatzTV.Core.Api.Scheduling;
public record DecoBreakContentResponseModel(
int Id,
CollectionType CollectionType,
int? CollectionId,
int? MediaItemId,
int? MultiCollectionId,
int? SmartCollectionId,
int? PlaylistId,
int? PlaylistGroupId,
string? SelectionName,
DecoBreakPlacement Placement);
@@ -0,0 +1,4 @@
#nullable enable
namespace ErsatzTV.Core.Api.Scheduling;
public record DecoGroupResponseModel(int Id, string Name, int DecoCount);
@@ -0,0 +1,4 @@
#nullable enable
namespace ErsatzTV.Core.Api.Scheduling;
public record DecoListItemResponseModel(int Id, int DecoGroupId, string DecoGroupName, string Name);
@@ -0,0 +1,34 @@
#nullable enable
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Scheduling;
namespace ErsatzTV.Core.Api.Scheduling;
public record DecoResponseModel(
int Id,
int DecoGroupId,
string DecoGroupName,
string Name,
DecoMode WatermarkMode,
List<int> WatermarkIds,
bool UseWatermarkDuringFiller,
DecoMode GraphicsElementsMode,
List<int> GraphicsElementIds,
bool UseGraphicsElementsDuringFiller,
DecoMode BreakContentMode,
List<DecoBreakContentResponseModel> BreakContent,
DecoMode DefaultFillerMode,
CollectionType DefaultFillerCollectionType,
int? DefaultFillerCollectionId,
int? DefaultFillerMediaItemId,
int? DefaultFillerMultiCollectionId,
int? DefaultFillerSmartCollectionId,
string? DefaultFillerSelectionName,
bool DefaultFillerTrimToFit,
DecoMode DeadAirFallbackMode,
CollectionType DeadAirFallbackCollectionType,
int? DeadAirFallbackCollectionId,
int? DeadAirFallbackMediaItemId,
int? DeadAirFallbackMultiCollectionId,
int? DeadAirFallbackSmartCollectionId,
string? DeadAirFallbackSelectionName);
@@ -0,0 +1,4 @@
#nullable enable
namespace ErsatzTV.Core.Api.Scheduling;
public record DecoTemplateGroupResponseModel(int Id, string Name, int DecoTemplateCount);
@@ -0,0 +1,4 @@
#nullable enable
namespace ErsatzTV.Core.Api.Scheduling;
public record DecoTemplateItemResponseModel(int DecoId, string DecoName, TimeSpan StartTime, TimeSpan EndTime);
@@ -0,0 +1,4 @@
#nullable enable
namespace ErsatzTV.Core.Api.Scheduling;
public record DecoTemplateResponseModel(int Id, int DecoTemplateGroupId, string GroupName, string Name);
@@ -0,0 +1,9 @@
#nullable enable
namespace ErsatzTV.Core.Api.Scheduling;
public record DecoTemplateWithItemsResponseModel(
int Id,
int DecoTemplateGroupId,
string GroupName,
string Name,
List<DecoTemplateItemResponseModel> Items);
@@ -0,0 +1,22 @@
#nullable enable
namespace ErsatzTV.Core.Api.Scheduling;
public record PlayoutTemplateResponseModel(
int Id,
int Index,
int TemplateId,
string TemplateName,
string TemplateGroupName,
int? DecoTemplateId,
string? DecoTemplateName,
string? DecoTemplateGroupName,
ICollection<DayOfWeek> DaysOfWeek,
ICollection<int> DaysOfMonth,
ICollection<int> MonthsOfYear,
bool LimitToDateRange,
int StartMonth,
int StartDay,
int? StartYear,
int EndMonth,
int EndDay,
int? EndYear);
@@ -0,0 +1,8 @@
#nullable enable
namespace ErsatzTV.Core.Api.Scheduling;
/// <summary>
/// A lightweight {id, name} option used by the block/schedule editor pickers
/// (collection, television show, television season, smart collection search).
/// </summary>
public record SchedulingPickerOptionResponseModel(int Id, string Name);
@@ -0,0 +1,4 @@
#nullable enable
namespace ErsatzTV.Core.Api.Scheduling;
public record TemplateGroupResponseModel(int Id, string Name, int TemplateCount);
@@ -0,0 +1,4 @@
#nullable enable
namespace ErsatzTV.Core.Api.Scheduling;
public record TemplateItemResponseModel(int BlockId, string BlockName, int BlockMinutes, TimeSpan StartTime);
@@ -0,0 +1,4 @@
#nullable enable
namespace ErsatzTV.Core.Api.Scheduling;
public record TemplateResponseModel(int Id, int TemplateGroupId, string GroupName, string Name);
@@ -0,0 +1,9 @@
#nullable enable
namespace ErsatzTV.Core.Api.Scheduling;
public record TemplateWithItemsResponseModel(
int Id,
int TemplateGroupId,
string GroupName,
string Name,
List<TemplateItemResponseModel> Items);
@@ -0,0 +1,8 @@
#nullable enable
using ErsatzTV.Core.Api.LibraryBrowse;
namespace ErsatzTV.Core.Api.Search;
public record SearchResultGroupResponseModel(
int TotalCount,
List<LibraryBrowseItemResponseModel> Items);
@@ -0,0 +1,14 @@
#nullable enable
namespace ErsatzTV.Core.Api.Search;
public record SearchResultsResponseModel(
SearchResultGroupResponseModel Movies,
SearchResultGroupResponseModel Shows,
SearchResultGroupResponseModel Seasons,
SearchResultGroupResponseModel Artists,
SearchResultGroupResponseModel Episodes,
SearchResultGroupResponseModel MusicVideos,
SearchResultGroupResponseModel Songs,
SearchResultGroupResponseModel OtherVideos,
SearchResultGroupResponseModel Images,
SearchResultGroupResponseModel RemoteStreams);
@@ -0,0 +1,11 @@
#nullable enable
namespace ErsatzTV.Core.Api.Television;
public record SeasonDetailResponseModel(
int Id,
int ShowId,
string Title,
string? Year,
string Name,
string Poster,
string FanArt);
@@ -0,0 +1,22 @@
#nullable enable
using ErsatzTV.Core.Api.Media;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Api.Television;
public record ShowDetailResponseModel(
int Id,
int LibraryId,
MediaSourceKind MediaSourceKind,
string Title,
string? Year,
string? Plot,
string Poster,
string FanArt,
List<string> Genres,
List<string> Tags,
List<string> Studios,
List<string> Networks,
List<string> ContentRatings,
List<string> Languages,
List<ActorResponseModel> Actors);
@@ -0,0 +1,4 @@
#nullable enable
namespace ErsatzTV.Core.Api.Trakt;
public record PagedTraktListsResponseModel(int TotalCount, List<TraktListResponseModel> Page);
@@ -0,0 +1,12 @@
#nullable enable
namespace ErsatzTV.Core.Api.Trakt;
public record TraktListResponseModel(
int Id,
int TraktId,
string Slug,
string Name,
int ItemCount,
int MatchCount,
bool AutoRefresh,
bool GeneratePlaylist);
@@ -0,0 +1,8 @@
#nullable enable
namespace ErsatzTV.Core.Api.Trakt;
/// <summary>
/// HTTP-observable substitute for the Blazor <c>IEntityLocker.OnTraktChanged</c> event — the SPA polls this
/// while an add/match/delete operation is in flight (no push channel exists for the REST API).
/// </summary>
public record TraktStatusResponseModel(bool Busy);
@@ -0,0 +1,14 @@
#nullable enable
namespace ErsatzTV.Core.Api.Troubleshooting;
// GeneralJson mirrors the JSON blob rendered on the "General" tab of the legacy Blazor
// Troubleshooting page (version, environment, cpus, video controllers, health, ffmpeg settings,
// AviSynth flags, channels, ffmpeg profiles) - kept pre-serialized so the SPA can render/copy it
// verbatim without re-deriving the same shape. The remaining fields mirror the per-platform
// capability dump tabs (only the ones relevant to the current OS/GPU are non-empty).
public record TroubleshootingInfoResponseModel(
string GeneralJson,
string? NvidiaCapabilities,
string? QsvCapabilities,
string? VaapiCapabilities,
string? VideoToolboxCapabilities);
@@ -0,0 +1,4 @@
#nullable enable
namespace ErsatzTV.Core.Api.Troubleshooting;
public record ValidateSequentialScheduleResponseModel(bool IsValid, List<string> Messages, string Json);
@@ -0,0 +1,24 @@
#nullable enable
using ErsatzTV.Core.Domain;
using ErsatzTV.FFmpeg.State;
namespace ErsatzTV.Core.Api.Watermarks;
public record WatermarkFullResponseModel(
int Id,
string Name,
ChannelWatermarkMode Mode,
ChannelWatermarkImageSource ImageSource,
string? Image,
string? ImageContentType,
WatermarkLocation Location,
WatermarkSize Size,
double Width,
double HorizontalMargin,
double VerticalMargin,
int FrequencyMinutes,
int DurationSeconds,
int Opacity,
string? OpacityExpression,
int ZIndex,
bool PlaceWithinSourceContent);
@@ -86,7 +86,7 @@ public class GetLibraryBrowseItemsHandlerTests
result.Page[0].MediaItemId.ShouldBe(20);
result.Page[1].MediaType.ShouldBe(LibraryBrowseMediaType.Movie);
result.Page[1].Title.ShouldBe("Collision Movie");
result.Page[1].Artwork.ShouldBe("movie-poster.jpg");
result.Page[1].Artwork.ShouldBe("/artwork/posters/movie-poster.jpg");
result.Page[1].Duration.ShouldBe(TimeSpan.FromMinutes(95));
result.Page[1].MediaItemId.ShouldBe(10);
}
@@ -203,7 +203,7 @@ public class GetLibraryBrowseItemsHandlerTests
result.TotalCount.ShouldBe(1);
result.Page.Count.ShouldBe(1);
result.Page[0].Title.ShouldBe("Manual Metadata");
result.Page[0].Artwork.ShouldBe("first-poster.jpg");
result.Page[0].Artwork.ShouldBe("/artwork/posters/first-poster.jpg");
result.Page[0].Duration.ShouldBe(TimeSpan.FromMinutes(75));
result.Page[0].ItemCount.ShouldBe(2);
}
@@ -236,9 +236,111 @@ public class GetLibraryBrowseItemsHandlerTests
result.Page[0].ItemCount.ShouldBe(2);
result.Page[0].CollectionType.ShouldBe(CollectionType.TelevisionSeason);
result.Page[0].MediaItemId.ShouldBe(301);
// Season 301 has its own poster.
result.Page[0].Artwork.ShouldBe("/artwork/posters/season-2.jpg");
result.Page[1].Title.ShouldBe("Season Show - Specials");
result.Page[1].ItemCount.ShouldBe(1);
result.Page[1].MediaItemId.ShouldBe(302);
// Specials has no poster of its own, so it falls back to the show's poster.
result.Page[1].Artwork.ShouldBe("/artwork/posters/show.jpg");
}
[Test]
public async Task Handle_Should_Browse_Seasons_For_A_Specific_Show_By_ParentId()
{
await SeedSeasonGraph();
// ParentId drill-in bypasses the search index entirely.
var handler = new GetLibraryBrowseItemsHandler(_searchIndex, _db.Factory);
PagedLibraryBrowseItemsResponseModel result = await handler.Handle(
new GetLibraryBrowseItems("", null, LibraryBrowseMediaType.TelevisionSeason, 0, 10, 300),
CancellationToken.None);
result.TotalCount.ShouldBe(2);
// Ordered by season number: Specials (0) then Season 2.
result.Page[0].Title.ShouldBe("Season Show - Specials");
result.Page[0].MediaItemId.ShouldBe(302);
result.Page[1].Title.ShouldBe("Season Show - Season 2");
result.Page[1].MediaItemId.ShouldBe(301);
await _searchIndex.DidNotReceive().Search(
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<int>(),
Arg.Any<int>(),
Arg.Any<List<string>>(),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Handle_Should_Browse_Episodes_For_A_Specific_Season_By_ParentId()
{
await SeedEpisodeDrillInGraph();
var handler = new GetLibraryBrowseItemsHandler(_searchIndex, _db.Factory);
PagedLibraryBrowseItemsResponseModel result = await handler.Handle(
new GetLibraryBrowseItems("", null, LibraryBrowseMediaType.Episode, 0, 10, 701),
CancellationToken.None);
result.TotalCount.ShouldBe(3);
// Ordered by episode number regardless of insertion order.
result.Page[0].MediaItemId.ShouldBe(712);
result.Page[1].MediaItemId.ShouldBe(711);
result.Page[2].MediaItemId.ShouldBe(713);
await _searchIndex.DidNotReceive().Search(
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<int>(),
Arg.Any<int>(),
Arg.Any<List<string>>(),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Handle_Should_Browse_Music_Videos_For_A_Specific_Artist_By_ParentId()
{
await SeedMusicVideoDrillInGraph();
var handler = new GetLibraryBrowseItemsHandler(_searchIndex, _db.Factory);
PagedLibraryBrowseItemsResponseModel result = await handler.Handle(
new GetLibraryBrowseItems("", null, LibraryBrowseMediaType.MusicVideo, 0, 10, 801),
CancellationToken.None);
result.TotalCount.ShouldBe(2);
// Ordered by album then track: "Album A"/track 1 before "Album B"/track 1.
result.Page[0].MediaItemId.ShouldBe(812);
result.Page[1].MediaItemId.ShouldBe(811);
await _searchIndex.DidNotReceive().Search(
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<int>(),
Arg.Any<int>(),
Arg.Any<List<string>>(),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Handle_Should_Root_Jellyfin_Artwork_Urls_With_FillHeight()
{
await SeedJellyfinMovieGraph();
_searchIndex.Search(
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<int>(),
Arg.Any<int>(),
Arg.Any<List<string>>(),
Arg.Any<CancellationToken>())
.Returns(new SearchResult([new SearchItem(LuceneSearchIndex.MovieType, 601)], 1));
var handler = new GetLibraryBrowseItemsHandler(_searchIndex, _db.Factory);
PagedLibraryBrowseItemsResponseModel result = await handler.Handle(
new GetLibraryBrowseItems("", null, LibraryBrowseMediaType.Movie, 0, 10),
CancellationToken.None);
result.TotalCount.ShouldBe(1);
result.Page[0].Artwork.ShouldBe("/artwork/posters/jellyfin/Items/abc/Images/Primary?tag=xyz&fillHeight=440");
}
[Test]
@@ -262,7 +364,7 @@ public class GetLibraryBrowseItemsHandlerTests
result.TotalCount.ShouldBe(1);
result.Page[0].Title.ShouldBe("Counted Artist");
result.Page[0].ItemCount.ShouldBe(2);
result.Page[0].Artwork.ShouldBe("artist-thumb.jpg");
result.Page[0].Artwork.ShouldBe("/artwork/thumbnails/artist-thumb.jpg");
result.Page[0].CollectionType.ShouldBe(CollectionType.Artist);
result.Page[0].MediaItemId.ShouldBe(401);
}
@@ -456,8 +558,8 @@ public class GetLibraryBrowseItemsHandlerTests
Seasons = [],
ShowMetadata = [MakeShowMetadata("Season Show", "show.jpg")]
};
var season = MakeSeason(301, path, show, 2);
var specials = MakeSeason(302, path, show, 0);
var season = MakeSeason(301, path, show, 2, "season-2.jpg");
var specials = MakeSeason(302, path, show, 0, string.Empty);
season.Episodes.AddRange([
MakeEpisode(311, path, season),
MakeEpisode(312, path, season)
@@ -472,6 +574,116 @@ public class GetLibraryBrowseItemsHandlerTests
await context.SaveChangesAsync();
}
private async Task SeedEpisodeDrillInGraph()
{
await using TvContext context = _db.CreateContext();
(LocalLibrary library, LibraryPath path) = MakeLibrary(700, "Episode Library");
var show = new Show
{
Id = 700,
LibraryPath = path,
Collections = [],
CollectionItems = [],
TraktListItems = [],
Seasons = [],
ShowMetadata = [MakeShowMetadata("Drill Show", "show.jpg")]
};
var season = MakeSeason(701, path, show, 1, "season.jpg");
// Inserted out of episode-number order to prove the handler sorts by number.
Episode e2 = MakeNumberedEpisode(711, path, season, 2);
Episode e1 = MakeNumberedEpisode(712, path, season, 1);
Episode e3 = MakeNumberedEpisode(713, path, season, 3);
season.Episodes.AddRange([e2, e1, e3]);
show.Seasons.Add(season);
path.MediaItems.AddRange([show, season, e2, e1, e3]);
context.LocalLibraries.Add(library);
context.Shows.Add(show);
context.Seasons.Add(season);
context.Episodes.AddRange(e2, e1, e3);
await context.SaveChangesAsync();
}
private async Task SeedMusicVideoDrillInGraph()
{
await using TvContext context = _db.CreateContext();
(LocalLibrary library, LibraryPath path) = MakeLibrary(800, "Music Video Library");
var artist = new Artist
{
Id = 801,
LibraryPath = path,
Collections = [],
CollectionItems = [],
TraktListItems = [],
MusicVideos = [],
ArtistMetadata = [MakeArtistMetadata("Drill Artist", "artist.jpg")]
};
MusicVideo mvB = MakeTitledMusicVideo(811, path, artist, "Track On B", "Album B", 1);
MusicVideo mvA = MakeTitledMusicVideo(812, path, artist, "Track On A", "Album A", 1);
artist.MusicVideos.AddRange([mvB, mvA]);
path.MediaItems.AddRange([artist, mvB, mvA]);
context.LocalLibraries.Add(library);
context.Artists.Add(artist);
context.MusicVideos.AddRange(mvB, mvA);
await context.SaveChangesAsync();
}
private static Episode MakeNumberedEpisode(int id, LibraryPath path, Season season, int episodeNumber)
{
Episode episode = MakeEpisode(id, path, season);
episode.EpisodeMetadata =
[
new EpisodeMetadata
{
Title = $"Episode {episodeNumber}",
SortTitle = $"Episode {episodeNumber}",
EpisodeNumber = episodeNumber,
Artwork = [],
Genres = [],
Tags = [],
Studios = [],
Actors = [],
Guids = [],
Subtitles = [],
Directors = [],
Writers = []
}
];
return episode;
}
private static MusicVideo MakeTitledMusicVideo(
int id,
LibraryPath path,
Artist artist,
string title,
string album,
int track)
{
MusicVideo musicVideo = MakeMusicVideo(id, path, artist);
musicVideo.MusicVideoMetadata =
[
new MusicVideoMetadata
{
Title = title,
SortTitle = title,
Album = album,
Track = track,
Artwork = [],
Genres = [],
Tags = [],
Studios = [],
Actors = [],
Guids = [],
Subtitles = []
}
];
return musicVideo;
}
private async Task SeedArtistGraph()
{
await using TvContext context = _db.CreateContext();
@@ -497,6 +709,50 @@ public class GetLibraryBrowseItemsHandlerTests
await context.SaveChangesAsync();
}
private async Task SeedJellyfinMovieGraph()
{
await using TvContext context = _db.CreateContext();
(LocalLibrary library, LibraryPath path) = MakeLibrary(600, "Jellyfin Library");
var movie = new Movie
{
Id = 601,
LibraryPath = path,
Collections = [],
CollectionItems = [],
TraktListItems = [],
MediaVersions = [new MediaVersion { Duration = TimeSpan.FromMinutes(100) }],
MovieMetadata =
[
new MovieMetadata
{
Title = "Jellyfin Movie",
SortTitle = "Jellyfin Movie",
Artwork =
[
new Artwork
{
Path = "jellyfin://Items/abc/Images/Primary?tag=xyz",
ArtworkKind = ArtworkKind.Poster
}
],
Genres = [],
Tags = [],
Studios = [],
Actors = [],
Guids = [],
Subtitles = [],
Directors = [],
Writers = []
}
]
};
path.MediaItems.Add(movie);
context.LocalLibraries.Add(library);
context.Movies.Add(movie);
await context.SaveChangesAsync();
}
private async Task SeedLibraryFilteredCollectionsGraph()
{
await using TvContext context = _db.CreateContext();
@@ -563,7 +819,7 @@ public class GetLibraryBrowseItemsHandlerTests
MediaVersions = [new MediaVersion { Duration = duration }]
};
private static Season MakeSeason(int id, LibraryPath path, Show show, int seasonNumber) =>
private static Season MakeSeason(int id, LibraryPath path, Show show, int seasonNumber, string poster) =>
new()
{
Id = id,
@@ -574,7 +830,7 @@ public class GetLibraryBrowseItemsHandlerTests
CollectionItems = [],
TraktListItems = [],
Episodes = [],
SeasonMetadata = [MakeSeasonMetadata()]
SeasonMetadata = [MakeSeasonMetadata(poster)]
};
private static Episode MakeEpisode(int id, LibraryPath path, Season season) =>
@@ -633,12 +889,14 @@ public class GetLibraryBrowseItemsHandlerTests
Subtitles = []
};
private static SeasonMetadata MakeSeasonMetadata() =>
private static SeasonMetadata MakeSeasonMetadata(string poster = "") =>
new()
{
Title = string.Empty,
SortTitle = string.Empty,
Artwork = [],
Artwork = string.IsNullOrEmpty(poster)
? []
: [new Artwork { Path = poster, ArtworkKind = ArtworkKind.Poster }],
Genres = [],
Tags = [],
Studios = [],
@@ -2,8 +2,11 @@ using ErsatzTV.Application;
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Application.Search;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using LanguageExt;
using ErsatzTV.Tests.Support;
using NSubstitute;
@@ -130,6 +133,93 @@ public class CollectionHandlerTests : MediaCollectionHandlerTestBase
LeftOf(result).ShouldBeOfType<NotFoundError>();
}
[Test]
public async Task GetItems_Should_Return_NotFoundError_When_Collection_Missing()
{
var handler = new GetCollectionItemsHandler(Db.Factory);
Either<BaseError, PagedLibraryBrowseItemsResponseModel> result =
await handler.Handle(new GetCollectionItems(999, 0, 100), CancellationToken.None);
LeftOf(result).ShouldBeOfType<NotFoundError>();
}
[Test]
public async Task GetItems_Should_Return_Members_With_Rooted_Artwork()
{
await SeedCollectionWithMovie(collectionId: 1, movieId: 10, title: "Fake Movie", poster: "movie.jpg");
var handler = new GetCollectionItemsHandler(Db.Factory);
Either<BaseError, PagedLibraryBrowseItemsResponseModel> result =
await handler.Handle(new GetCollectionItems(1, 0, 100), CancellationToken.None);
PagedLibraryBrowseItemsResponseModel page = result.Match(
Left: _ => throw new AssertionException("Expected a Right result"),
Right: value => value);
page.TotalCount.ShouldBe(1);
page.Page.Count.ShouldBe(1);
page.Page[0].Title.ShouldBe("Fake Movie");
page.Page[0].MediaItemId.ShouldBe(10);
page.Page[0].MediaType.ShouldBe(LibraryBrowseMediaType.Movie);
page.Page[0].Artwork.ShouldBe("/artwork/posters/movie.jpg");
}
private async Task SeedCollectionWithMovie(int collectionId, int movieId, string title, string poster)
{
await using TvContext context = Db.CreateContext();
var library = new LocalLibrary
{
Id = collectionId,
Name = "Local",
MediaKind = LibraryMediaKind.Movies,
Paths = []
};
var path = new LibraryPath
{
Id = collectionId,
Path = "/media",
Library = library,
LibraryFolders = [],
MediaItems = []
};
library.Paths.Add(path);
var collection = new Collection { Id = collectionId, Name = "Collection", MediaItems = [] };
var movie = new Movie
{
Id = movieId,
LibraryPath = path,
Collections = [collection],
CollectionItems = [],
TraktListItems = [],
MediaVersions = [new MediaVersion { Duration = TimeSpan.FromMinutes(42) }],
MovieMetadata =
[
new MovieMetadata
{
Title = title,
SortTitle = title,
Artwork = [new Artwork { Path = poster, ArtworkKind = ArtworkKind.Poster }],
Genres = [],
Tags = [],
Studios = [],
Actors = [],
Guids = [],
Subtitles = [],
Directors = [],
Writers = []
}
]
};
collection.MediaItems.Add(movie);
context.LocalLibraries.Add(library);
context.Collections.Add(collection);
context.Movies.Add(movie);
await context.SaveChangesAsync();
}
private static AddItemsToCollection MakeAddItems(int collectionId, List<int>? movieIds = null) =>
new(
collectionId,
@@ -0,0 +1,104 @@
using ErsatzTV.Application.Troubleshooting;
using ErsatzTV.Application.Troubleshooting.Queries;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.Errors;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Troubleshooting;
[TestFixture]
public class GetPlayoutHistoryDetailsHandlerTests
{
private InMemoryTvContext _db = null!;
[SetUp]
public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
[Test]
public async Task Should_Return_NotFoundError_When_Row_Missing()
{
var handler = new GetPlayoutHistoryDetailsHandler(_db.Factory);
Either<BaseError, PlayoutHistoryDetailsViewModel> result =
await handler.Handle(new GetPlayoutHistoryDetails(999), CancellationToken.None);
result.IsLeft.ShouldBeTrue();
LeftOf(result).ShouldBeOfType<NotFoundError>();
}
[Test]
public async Task Should_Decode_Collection_Key_When_Row_Exists()
{
int historyId = await SeedHistory(
"""{"CollectionType":"Collection","CollectionId":1,"PlaybackOrder":"Shuffle"}""",
"{}",
seedCollection: true);
var handler = new GetPlayoutHistoryDetailsHandler(_db.Factory);
Either<BaseError, PlayoutHistoryDetailsViewModel> result =
await handler.Handle(new GetPlayoutHistoryDetails(historyId), CancellationToken.None);
result.IsRight.ShouldBeTrue();
PlayoutHistoryDetailsViewModel vm = RightOf(result);
vm.PlaybackOrder.ShouldBe(PlaybackOrder.Shuffle);
vm.CollectionType.ShouldBe(CollectionType.Collection);
vm.Name.ShouldBe("Cartoons");
}
[Test]
public async Task Should_Return_Error_When_Key_Json_Is_Malformed()
{
int historyId = await SeedHistory("this is not json", "{}", seedCollection: false);
var handler = new GetPlayoutHistoryDetailsHandler(_db.Factory);
Either<BaseError, PlayoutHistoryDetailsViewModel> result =
await handler.Handle(new GetPlayoutHistoryDetails(historyId), CancellationToken.None);
result.IsLeft.ShouldBeTrue();
LeftOf(result).ShouldNotBeOfType<NotFoundError>();
}
private async Task<int> SeedHistory(string key, string details, bool seedCollection)
{
await using TvContext context = _db.CreateContext();
if (seedCollection)
{
context.Collections.Add(new Collection { Id = 1, Name = "Cartoons" });
}
var history = new PlayoutHistory
{
PlayoutId = 1,
BlockId = 10,
PlaybackOrder = PlaybackOrder.Shuffle,
Index = 0,
Key = key,
ChildKey = string.Empty,
IsCurrentChild = false,
When = DateTime.UtcNow,
Finish = DateTime.UtcNow.AddHours(1),
Details = details
};
context.PlayoutHistory.Add(history);
await context.SaveChangesAsync();
return history.Id;
}
private static BaseError LeftOf(Either<BaseError, PlayoutHistoryDetailsViewModel> either) =>
either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected Left"));
private static PlayoutHistoryDetailsViewModel RightOf(Either<BaseError, PlayoutHistoryDetailsViewModel> either) =>
either.Match(Left: _ => throw new AssertionException("Expected Right"), Right: v => v);
}
@@ -0,0 +1,65 @@
using ErsatzTV.Application.Troubleshooting;
using ErsatzTV.Application.Troubleshooting.Queries;
using ErsatzTV.Core.Interfaces.Scheduling;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Troubleshooting;
[TestFixture]
public class ValidateSequentialScheduleHandlerTests
{
private ISequentialScheduleValidator _validator = null!;
private ValidateSequentialScheduleHandler _handler = null!;
[SetUp]
public void SetUp()
{
_validator = Substitute.For<ISequentialScheduleValidator>();
_handler = new ValidateSequentialScheduleHandler(_validator);
}
[Test]
public async Task Should_Report_Valid_When_No_Messages()
{
_validator.ToJson(Arg.Any<string>()).Returns("{ }");
_validator.GetValidationMessages(Arg.Any<string>(), Arg.Any<bool>())
.Returns(Task.FromResult<IList<string>>([]));
ValidateSequentialScheduleViewModel result =
await _handler.Handle(new ValidateSequentialSchedule("content: []", false), CancellationToken.None);
result.IsValid.ShouldBeTrue();
result.Messages.ShouldBeEmpty();
result.Json.ShouldBe("{ }");
}
[Test]
public async Task Should_Report_Invalid_With_Messages()
{
_validator.ToJson(Arg.Any<string>()).Returns("{ }");
_validator.GetValidationMessages(Arg.Any<string>(), Arg.Any<bool>())
.Returns(Task.FromResult<IList<string>>(["content is required"]));
ValidateSequentialScheduleViewModel result =
await _handler.Handle(new ValidateSequentialSchedule("nope: true", true), CancellationToken.None);
result.IsValid.ShouldBeFalse();
result.Messages.ShouldBe(["content is required"]);
result.Json.ShouldBe("{ }");
}
[Test]
public async Task Should_Capture_Exception_Message_When_Conversion_Throws()
{
_validator.ToJson(Arg.Any<string>()).Returns(_ => throw new InvalidOperationException("bad yaml"));
ValidateSequentialScheduleViewModel result =
await _handler.Handle(new ValidateSequentialSchedule(": : :", false), CancellationToken.None);
result.IsValid.ShouldBeFalse();
result.Messages.ShouldBe(["bad yaml"]);
result.Json.ShouldBeEmpty();
}
}
@@ -23,22 +23,26 @@ public class ApiControllerSecurityTests
[Test]
public void Every_Mutating_Api_Action_Should_Be_Globally_Protected_Or_Explicitly_Exempt()
{
Type[] apiControllers =
[
typeof(ChannelController),
typeof(CollectionController),
typeof(FFmpegProfileController),
typeof(LibrariesController),
typeof(MaintenanceController),
typeof(PlayoutController),
typeof(ResolutionController),
typeof(ScannerController),
typeof(ScheduleController),
typeof(ScriptedScheduleController),
typeof(SessionController),
typeof(SettingsController),
typeof(SmartCollectionController)
];
// Scan the assembly instead of hand-maintaining a list of controllers here: a
// hardcoded array silently drifts as new controllers are added (9 were missing at
// one point, including two with real mutating actions - ArtworkUploadController POST
// and ChannelTemplateController POST/PUT/DELETE - that were never actually checked).
// Note: not every controller in this namespace derives from ControllerBase (e.g.
// ChannelController, ScannerController, SessionController, MaintenanceController,
// TroubleshootController) - filter on [ApiController] + concrete class only, matching
// the pattern already used by ScannerController_Should_Be_Only_Api_Key_Exempt_Api_Controller
// below, so those controllers stay covered rather than silently dropping out of the scan.
Type[] apiControllers = typeof(CollectionController)
.Assembly
.GetTypes()
.Where(t => t.Namespace == typeof(CollectionController).Namespace)
.Where(t => t is { IsClass: true, IsAbstract: false })
.Where(t => t.GetCustomAttributes<ApiControllerAttribute>(inherit: true).Any())
.ToArray();
// Guard against the scan silently matching nothing (e.g. a namespace rename) and
// giving this test a false pass.
apiControllers.Length.ShouldBeGreaterThanOrEqualTo(20);
foreach (Type controllerType in apiControllers)
{
@@ -68,11 +68,67 @@ public class ApiErrorResponseMetadataTests
[TestCase(typeof(ScheduleController), nameof(ScheduleController.ReplaceItems), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(ScheduleController), nameof(ScheduleController.DeleteItem), StatusCodes.Status404NotFound)]
[TestCase(typeof(ScheduleController), nameof(ScheduleController.DeleteItem), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(BlockController), nameof(BlockController.GetById), StatusCodes.Status404NotFound)]
[TestCase(typeof(BlockController), nameof(BlockController.CreateGroup), StatusCodes.Status404NotFound)]
[TestCase(typeof(BlockController), nameof(BlockController.CreateGroup), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(BlockController), nameof(BlockController.DeleteGroup), StatusCodes.Status404NotFound)]
[TestCase(typeof(BlockController), nameof(BlockController.Create), StatusCodes.Status404NotFound)]
[TestCase(typeof(BlockController), nameof(BlockController.Create), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(BlockController), nameof(BlockController.Delete), StatusCodes.Status404NotFound)]
[TestCase(typeof(BlockController), nameof(BlockController.GetItems), StatusCodes.Status404NotFound)]
[TestCase(typeof(BlockController), nameof(BlockController.Replace), StatusCodes.Status404NotFound)]
[TestCase(typeof(BlockController), nameof(BlockController.Replace), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(BlockController), nameof(BlockController.Preview), StatusCodes.Status404NotFound)]
[TestCase(typeof(BlockController), nameof(BlockController.Copy), StatusCodes.Status404NotFound)]
[TestCase(typeof(BlockController), nameof(BlockController.Copy), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(TemplateController), nameof(TemplateController.GetById), StatusCodes.Status404NotFound)]
[TestCase(typeof(TemplateController), nameof(TemplateController.CreateGroup), StatusCodes.Status404NotFound)]
[TestCase(typeof(TemplateController), nameof(TemplateController.CreateGroup), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(TemplateController), nameof(TemplateController.DeleteGroup), StatusCodes.Status404NotFound)]
[TestCase(typeof(TemplateController), nameof(TemplateController.Create), StatusCodes.Status404NotFound)]
[TestCase(typeof(TemplateController), nameof(TemplateController.Create), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(TemplateController), nameof(TemplateController.Delete), StatusCodes.Status404NotFound)]
[TestCase(typeof(TemplateController), nameof(TemplateController.GetItems), StatusCodes.Status404NotFound)]
[TestCase(typeof(TemplateController), nameof(TemplateController.Replace), StatusCodes.Status404NotFound)]
[TestCase(typeof(TemplateController), nameof(TemplateController.Replace), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(TemplateController), nameof(TemplateController.Copy), StatusCodes.Status404NotFound)]
[TestCase(typeof(TemplateController), nameof(TemplateController.Copy), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(DecoController), nameof(DecoController.GetById), StatusCodes.Status404NotFound)]
[TestCase(typeof(DecoController), nameof(DecoController.CreateGroup), StatusCodes.Status404NotFound)]
[TestCase(typeof(DecoController), nameof(DecoController.CreateGroup), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(DecoController), nameof(DecoController.DeleteGroup), StatusCodes.Status404NotFound)]
[TestCase(typeof(DecoController), nameof(DecoController.Create), StatusCodes.Status404NotFound)]
[TestCase(typeof(DecoController), nameof(DecoController.Create), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(DecoController), nameof(DecoController.Delete), StatusCodes.Status404NotFound)]
[TestCase(typeof(DecoController), nameof(DecoController.Replace), StatusCodes.Status404NotFound)]
[TestCase(typeof(DecoController), nameof(DecoController.Replace), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(DecoTemplateController), nameof(DecoTemplateController.GetById), StatusCodes.Status404NotFound)]
[TestCase(typeof(DecoTemplateController), nameof(DecoTemplateController.CreateGroup), StatusCodes.Status404NotFound)]
[TestCase(typeof(DecoTemplateController), nameof(DecoTemplateController.CreateGroup), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(DecoTemplateController), nameof(DecoTemplateController.DeleteGroup), StatusCodes.Status404NotFound)]
[TestCase(typeof(DecoTemplateController), nameof(DecoTemplateController.Create), StatusCodes.Status404NotFound)]
[TestCase(typeof(DecoTemplateController), nameof(DecoTemplateController.Create), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(DecoTemplateController), nameof(DecoTemplateController.Delete), StatusCodes.Status404NotFound)]
[TestCase(typeof(DecoTemplateController), nameof(DecoTemplateController.GetItems), StatusCodes.Status404NotFound)]
[TestCase(typeof(DecoTemplateController), nameof(DecoTemplateController.Replace), StatusCodes.Status404NotFound)]
[TestCase(typeof(DecoTemplateController), nameof(DecoTemplateController.Replace), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.UpdateDefaultDeco), StatusCodes.Status404NotFound)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.UpdateDefaultDeco), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.GetById), StatusCodes.Status404NotFound)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Create), StatusCodes.Status404NotFound)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Create), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Update), StatusCodes.Status404NotFound)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Update), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Delete), StatusCodes.Status404NotFound)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Delete), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.GetAlternateSchedules), StatusCodes.Status404NotFound)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.GetAlternateSchedules), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.ReplaceAlternateSchedules), StatusCodes.Status404NotFound)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.ReplaceAlternateSchedules), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.GetTemplates), StatusCodes.Status404NotFound)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.GetTemplates), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.ReplaceTemplates), StatusCodes.Status404NotFound)]
[TestCase(typeof(PlayoutController), nameof(PlayoutController.ReplaceTemplates), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.GetById), StatusCodes.Status404NotFound)]
[TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.AddOne), StatusCodes.Status404NotFound)]
[TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.AddOne), StatusCodes.Status401Unauthorized)]
@@ -0,0 +1,427 @@
using System.Reflection;
using ErsatzTV.Application.Scheduling;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Scheduling;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Scheduling;
using LanguageExt;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Routing;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using static LanguageExt.Prelude;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Tests.Controllers;
[TestFixture]
public class BlockControllerTests
{
private BlockController _controller = null!;
private IMediator _mediator = null!;
[SetUp]
public void SetUp()
{
_mediator = Substitute.For<IMediator>();
_controller = new BlockController(_mediator);
}
[Test]
public void Controller_Should_Expose_Idiomatic_Rest_Routes()
{
ShouldHaveActionRoute(nameof(BlockController.GetGroups), "GET", "/api/blocks/groups");
ShouldHaveActionRoute(nameof(BlockController.CreateGroup), "POST", "/api/blocks/groups");
ShouldHaveActionRoute(nameof(BlockController.DeleteGroup), "DELETE", "/api/blocks/groups/{id:int}");
ShouldHaveActionRoute(nameof(BlockController.GetAll), "GET", "/api/blocks");
ShouldHaveActionRoute(nameof(BlockController.GetById), "GET", "/api/blocks/{id:int}");
ShouldHaveActionRoute(nameof(BlockController.Create), "POST", "/api/blocks");
ShouldHaveActionRoute(nameof(BlockController.Delete), "DELETE", "/api/blocks/{id:int}");
ShouldHaveActionRoute(nameof(BlockController.GetItems), "GET", "/api/blocks/{id:int}/items");
ShouldHaveActionRoute(nameof(BlockController.Replace), "PUT", "/api/blocks/{id:int}");
ShouldHaveActionRoute(nameof(BlockController.Preview), "POST", "/api/blocks/{id:int}/preview");
ShouldHaveActionRoute(nameof(BlockController.Copy), "POST", "/api/blocks/{id:int}/copy");
}
[Test]
public async Task CreateGroup_Should_Return_201_With_Location_And_Body()
{
_mediator.Send(Arg.Any<CreateBlockGroup>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, BlockGroupViewModel>(new BlockGroupViewModel(5, "Prime Time")));
IActionResult result = await _controller.CreateGroup(
new CreateBlockGroupRequest("Prime Time"),
CancellationToken.None);
var created = result.ShouldBeOfType<CreatedResult>();
created.Location.ShouldBe("/api/blocks/groups/5");
created.Value.ShouldBeOfType<BlockGroupResponseModel>().Name.ShouldBe("Prime Time");
await _mediator.Received(1).Send(
Arg.Is<CreateBlockGroup>(c => c.Name == "Prime Time"),
Arg.Any<CancellationToken>());
}
[Test]
public async Task CreateGroup_Should_Return_422_On_Validation_Error()
{
_mediator.Send(Arg.Any<CreateBlockGroup>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, BlockGroupViewModel>(BaseError.New("bad")));
IActionResult result = await _controller.CreateGroup(
new CreateBlockGroupRequest(string.Empty),
CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
[Test]
public async Task DeleteGroup_Should_Return_204_When_Exists()
{
_mediator.Send(Arg.Any<GetAllBlockGroups>(), Arg.Any<CancellationToken>())
.Returns(new List<BlockGroupViewModel> { new(3, "Prime Time") });
_mediator.Send(Arg.Any<DeleteBlockGroup>(), Arg.Any<CancellationToken>())
.Returns(Option<BaseError>.None);
IActionResult result = await _controller.DeleteGroup(3, CancellationToken.None);
result.ShouldBeOfType<NoContentResult>();
await _mediator.Received(1).Send(
Arg.Is<DeleteBlockGroup>(c => c.BlockGroupId == 3),
Arg.Any<CancellationToken>());
}
[Test]
public async Task DeleteGroup_Should_Return_404_When_Missing()
{
_mediator.Send(Arg.Any<GetAllBlockGroups>(), Arg.Any<CancellationToken>())
.Returns(new List<BlockGroupViewModel>());
IActionResult result = await _controller.DeleteGroup(99, CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
await _mediator.DidNotReceive().Send(Arg.Any<DeleteBlockGroup>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task GetAll_Should_Map_ViewModels_To_Response_Models()
{
_mediator.Send(Arg.Any<GetAllBlocks>(), Arg.Any<CancellationToken>())
.Returns(new List<BlockViewModel> { MakeBlock(1, 2, "Morning") });
List<BlockResponseModel> result = await _controller.GetAll(CancellationToken.None);
result.Count.ShouldBe(1);
result[0].Id.ShouldBe(1);
result[0].GroupId.ShouldBe(2);
result[0].Name.ShouldBe("Morning");
}
[Test]
public async Task Create_Should_Return_201_And_Map_Request()
{
_mediator.Send(Arg.Any<CreateBlock>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, BlockViewModel>(MakeBlock(8, 2, "Morning")));
IActionResult result = await _controller.Create(
new CreateBlockRequest(2, "Morning"),
CancellationToken.None);
var created = result.ShouldBeOfType<CreatedResult>();
created.Location.ShouldBe("/api/blocks/8");
await _mediator.Received(1).Send(
Arg.Is<CreateBlock>(c => c.BlockGroupId == 2 && c.Name == "Morning"),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Create_Should_Return_422_On_Validation_Error()
{
_mediator.Send(Arg.Any<CreateBlock>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, BlockViewModel>(BaseError.New("dupe")));
IActionResult result = await _controller.Create(
new CreateBlockRequest(2, "Morning"),
CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
[Test]
public async Task GetById_Should_Return_200_For_Some()
{
_mediator.Send(Arg.Any<GetBlockById>(), Arg.Any<CancellationToken>())
.Returns(Option<BlockViewModel>.Some(MakeBlock(4, 2, "Morning")));
IActionResult result = await _controller.GetById(4, CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<BlockResponseModel>().Id.ShouldBe(4);
}
[Test]
public async Task GetById_Should_Return_404_For_None()
{
_mediator.Send(Arg.Any<GetBlockById>(), Arg.Any<CancellationToken>())
.Returns(Option<BlockViewModel>.None);
IActionResult result = await _controller.GetById(4, CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
}
[Test]
public async Task Delete_Should_Return_204_When_Exists()
{
_mediator.Send(Arg.Any<GetBlockById>(), Arg.Any<CancellationToken>())
.Returns(Option<BlockViewModel>.Some(MakeBlock(4, 2, "Morning")));
_mediator.Send(Arg.Any<DeleteBlock>(), Arg.Any<CancellationToken>())
.Returns(Option<BaseError>.None);
IActionResult result = await _controller.Delete(4, CancellationToken.None);
result.ShouldBeOfType<NoContentResult>();
}
[Test]
public async Task Delete_Should_Return_404_When_Missing()
{
_mediator.Send(Arg.Any<GetBlockById>(), Arg.Any<CancellationToken>())
.Returns(Option<BlockViewModel>.None);
IActionResult result = await _controller.Delete(4, CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
await _mediator.DidNotReceive().Send(Arg.Any<DeleteBlock>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task GetItems_Should_Return_200_Ordered_By_Index()
{
_mediator.Send(Arg.Any<GetBlockById>(), Arg.Any<CancellationToken>())
.Returns(Option<BlockViewModel>.Some(MakeBlock(4, 2, "Morning")));
_mediator.Send(Arg.Any<GetBlockItems>(), Arg.Any<CancellationToken>())
.Returns(new List<BlockItemViewModel> { MakeItem(20, 1), MakeItem(10, 0) });
IActionResult result = await _controller.GetItems(4, CancellationToken.None);
var items = result.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<List<BlockItemResponseModel>>();
items.Count.ShouldBe(2);
items[0].Index.ShouldBe(0);
items[1].Index.ShouldBe(1);
}
[Test]
public async Task GetItems_Should_Return_404_When_Block_Missing()
{
_mediator.Send(Arg.Any<GetBlockById>(), Arg.Any<CancellationToken>())
.Returns(Option<BlockViewModel>.None);
IActionResult result = await _controller.GetItems(4, CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
}
[Test]
public async Task Replace_Should_Return_200_With_Refreshed_Block_And_Assign_Index_And_GroupId()
{
_mediator.Send(Arg.Any<GetBlockById>(), Arg.Any<CancellationToken>())
.Returns(Option<BlockViewModel>.Some(MakeBlock(4, 7, "Morning")));
_mediator.Send(Arg.Any<ReplaceBlockItems>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default));
_mediator.Send(Arg.Any<GetBlockItems>(), Arg.Any<CancellationToken>())
.Returns(new List<BlockItemViewModel> { MakeItem(10, 0), MakeItem(11, 1) });
IActionResult result = await _controller.Replace(
4,
new ReplaceBlockRequest(
"Morning",
60,
BlockStopScheduling.BeforeDurationEnd,
[MakeItemRequest(), MakeItemRequest()]),
CancellationToken.None);
var body = result.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<BlockWithItemsResponseModel>();
body.Id.ShouldBe(4);
body.Items.Count.ShouldBe(2);
await _mediator.Received(1).Send(
Arg.Is<ReplaceBlockItems>(c =>
c.BlockId == 4 &&
c.BlockGroupId == 7 &&
c.Minutes == 60 &&
c.StopScheduling == BlockStopScheduling.BeforeDurationEnd &&
c.Items.Count == 2 &&
c.Items[0].Index == 0 &&
c.Items[1].Index == 1),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Replace_Should_Return_404_When_Block_Missing()
{
_mediator.Send(Arg.Any<GetBlockById>(), Arg.Any<CancellationToken>())
.Returns(Option<BlockViewModel>.None);
IActionResult result = await _controller.Replace(
4,
new ReplaceBlockRequest("Morning", 60, BlockStopScheduling.AfterDurationEnd, []),
CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
await _mediator.DidNotReceive().Send(Arg.Any<ReplaceBlockItems>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task Replace_Should_Return_422_On_Validation_Error()
{
_mediator.Send(Arg.Any<GetBlockById>(), Arg.Any<CancellationToken>())
.Returns(Option<BlockViewModel>.Some(MakeBlock(4, 7, "Morning")));
_mediator.Send(Arg.Any<ReplaceBlockItems>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, Unit>(BaseError.New("bad minutes")));
IActionResult result = await _controller.Replace(
4,
new ReplaceBlockRequest("Morning", 7, BlockStopScheduling.AfterDurationEnd, []),
CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
[Test]
public async Task Preview_Should_Return_200_With_Preview_Items()
{
_mediator.Send(Arg.Any<GetBlockById>(), Arg.Any<CancellationToken>())
.Returns(Option<BlockViewModel>.Some(MakeBlock(4, 7, "Morning")));
_mediator.Send(Arg.Any<PreviewBlockPlayout>(), Arg.Any<CancellationToken>())
.Returns(new List<PlayoutItemPreviewViewModel>
{
new("Show", TimeSpan.Zero, TimeSpan.FromMinutes(30), "00:30:00")
});
IActionResult result = await _controller.Preview(
4,
new ReplaceBlockRequest("Morning", 60, BlockStopScheduling.AfterDurationEnd, [MakeItemRequest()]),
CancellationToken.None);
var items = result.ShouldBeOfType<OkObjectResult>().Value
.ShouldBeOfType<List<BlockPreviewItemResponseModel>>();
items.Count.ShouldBe(1);
items[0].Title.ShouldBe("Show");
await _mediator.Received(1).Send(
Arg.Is<PreviewBlockPlayout>(p => p.Data.BlockId == 4 && p.Data.BlockGroupId == 7),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Preview_Should_Return_404_When_Block_Missing()
{
_mediator.Send(Arg.Any<GetBlockById>(), Arg.Any<CancellationToken>())
.Returns(Option<BlockViewModel>.None);
IActionResult result = await _controller.Preview(
4,
new ReplaceBlockRequest("Morning", 60, BlockStopScheduling.AfterDurationEnd, []),
CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
await _mediator.DidNotReceive().Send(Arg.Any<PreviewBlockPlayout>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task Copy_Should_Return_201_And_Map_Request()
{
_mediator.Send(Arg.Any<GetBlockById>(), Arg.Any<CancellationToken>())
.Returns(Option<BlockViewModel>.Some(MakeBlock(4, 2, "Morning")));
_mediator.Send(Arg.Any<CopyBlock>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, BlockViewModel>(MakeBlock(9, 3, "Morning Copy")));
IActionResult result = await _controller.Copy(
4,
new CopyBlockRequest(3, "Morning Copy"),
CancellationToken.None);
var created = result.ShouldBeOfType<CreatedResult>();
created.Location.ShouldBe("/api/blocks/9");
await _mediator.Received(1).Send(
Arg.Is<CopyBlock>(c => c.BlockId == 4 && c.NewBlockGroupId == 3 && c.NewBlockName == "Morning Copy"),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Copy_Should_Return_404_When_Source_Missing()
{
_mediator.Send(Arg.Any<GetBlockById>(), Arg.Any<CancellationToken>())
.Returns(Option<BlockViewModel>.None);
IActionResult result = await _controller.Copy(
4,
new CopyBlockRequest(3, "Morning Copy"),
CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
await _mediator.DidNotReceive().Send(Arg.Any<CopyBlock>(), Arg.Any<CancellationToken>());
}
[Test]
public async Task Copy_Should_Return_422_On_Validation_Error()
{
_mediator.Send(Arg.Any<GetBlockById>(), Arg.Any<CancellationToken>())
.Returns(Option<BlockViewModel>.Some(MakeBlock(4, 2, "Morning")));
_mediator.Send(Arg.Any<CopyBlock>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, BlockViewModel>(BaseError.New("dupe")));
IActionResult result = await _controller.Copy(
4,
new CopyBlockRequest(3, "Morning"),
CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
private static BlockViewModel MakeBlock(int id, int groupId, string name) =>
new(id, groupId, "Group", name, 30, BlockStopScheduling.AfterDurationEnd);
private static BlockItemViewModel MakeItem(int id, int index) =>
new(
id,
index,
CollectionType.SearchQuery,
null!,
null!,
null!,
null!,
"News",
"news",
PlaybackOrder.Shuffle,
IncludeInProgramGuide: true,
DisableWatermarks: false,
[],
[]);
private static BlockItemRequest MakeItemRequest() =>
new(
CollectionType.SearchQuery,
CollectionId: null,
MultiCollectionId: null,
SmartCollectionId: null,
MediaItemId: null,
SearchTitle: "News",
SearchQuery: "news",
PlaybackOrder: PlaybackOrder.Shuffle,
IncludeInProgramGuide: true,
DisableWatermarks: false,
WatermarkIds: [],
GraphicsElementIds: []);
private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route)
{
MethodInfo action = typeof(BlockController).GetMethod(actionName)
?? throw new AssertionException($"Missing action {actionName}");
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
attribute.HttpMethods.ShouldContain(httpMethod);
attribute.Template.ShouldBe(route);
}
}

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