Compare commits

..
Author SHA1 Message Date
timothy 65d88b5167 test(#289): pin BoundedLineReader cap/CRLF boundaries + document per-segment path assumption
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m29s
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
Non-blocking polish from the fix-commit re-review (verdict MERGEABLE):
- BuildPath: comment the assumption that each {param} is its own path segment
  (revalidate the whole path if a template ever concatenates adjacent params).
- Two additive BoundedLineReader tests: exactly-at-cap line returned intact
  (inclusive max), and \r\n carriage-return stripping. No logic change.

36 tests (was 34).

Refs #289
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 01:24:35 +02:00
timothy 4f68805d9a security(#289): review fixes — body-read timeout, transport-error response, bounded stdin, env clamps
Folds in findings from the independent reviews of the initial hardening diff
(a cold adversarial pass + a Codex pass — complementary catches).

Fork (HIGH/MED):
- Body-read timeout regression: ResponseHeadersRead moved the body read outside
  HttpClient.Timeout and it used CancellationToken.None, so a slow-drip upstream
  hung the single-threaded session. Now a per-request linked CTS (options
  .EffectiveRequestTimeout) covers headers + body; HttpClient.Timeout set to
  Infinite so one timer owns it. Verified live against a black-hole upstream:
  1s timeout → -32603 in ~1s, no hang.
- Transport/timeout exceptions escaped HandleAsync's catch filter → no response
  for the id → client hangs. Added a broad final catch → -32603 when hasId.

Codex (env/input robustness):
- ERSATZTV_REQUEST_TIMEOUT_SECONDS / ERSATZTV_MAX_RESPONSE_BYTES at int.MaxValue
  crashed at startup / overflowed `cap+1` to a negative alloc. ParseInt now
  clamps to [min,max]; the client also hard-ceils the cap (MaxAllowedResponseBytes).
- A ".."/"." path param collapsed the URL onto another route under Uri
  canonicalization — rejected in BuildPath.

Both reviewers (stdin OOM): ReadLineAsync buffered an unbounded line before any
guard. New BoundedLineReader drains+drops oversized lines (memory-bounded),
keeping subsequent lines aligned.

Nits: case-insensitive ParseBool fallback; UTF-8 boundary backoff so a mid-
codepoint truncation doesn't emit U+FFFD; renamed a misleading test.

34 tests (was 26); +8 covering the transport-error path, dot-segment rejection,
cap-overflow fallback, UTF-8 seam, and the bounded reader.

Refs #289 #197
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 01:20:10 +02:00
timothyandClaude Opus 4.8 55fc210385 security(#289): harden MCP server (read-only posture, JSON-DoS, response cap, arg validation)
From the #197 cold review's MCP-consumer pass. Hardens `ErsatzTV.Mcp` (PR #76)
before it can go live against a soon-remote-exposed API.

- HIGH verb-guard: runtime read-only *posture* (ERSATZTV_ALLOW_WRITES, default
  false) enforced in the executor — a wrong catalog entry can't mutate/delete,
  and future write tools (#58) slot in behind the opt-in. Not a hardcoded
  GET-only clamp, so the non-read-only final design stays reachable.
- HIGH JSON-crash DoS: guard JsonDocument.Parse in HandleAsync (-32700, id null),
  handle missing/non-string method (-32600) instead of throwing, and wrap the
  Program.Main read loop so one bad stdin line can never kill the session.
- MED unbounded response / LOW no-timeout: cap the buffered body
  (ERSATZTV_MAX_RESPONSE_BYTES, truncation marker) + explicit HttpClient.Timeout.
- MED args unvalidated: ToolArgumentValidator checks caller args against each
  tool's InputSchema (required present, no unknown args, basic types) before a
  request is built.
- MED prompt injection: documented untrusted-data posture in docs/mcp.md.
- LOW base-URL prefix: preserve a reverse-proxy path prefix in the URL join.

Also documents that ERSATZTV_API_KEY is now effectively required (Bundle A gates
all /api reads behind X-Api-Key). 14 new tests (26 total); live stdin smoke
confirms malformed/no-method/non-object lines no longer crash the loop.

Refs #289 #197 #58

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 01:08:07 +02:00
timothy d1c04030af chore: retrigger CI (MySQL service port collision with concurrent run)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m0s
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
2026-07-07 11:58:29 +02:00
timothy 945d108334 feat(mcp): add read-only API server foundation refs #58
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m13s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Failing after 0s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-07 11:44:44 +02:00
timothyandClaude Fable 5 1cf4a7a90c docs: v26.5.0 tagged + deployed to prod (Komodo GitOps pin); deployment section reflects pinned-tag releases
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m21s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m41s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m30s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 11:39:02 +02:00
timothyandClaude Fable 5 21ede49253 docs: advance ChicoryTV issue queue past #91 phase (a) (PR #148); CLAUDE.md reflects SPA-default UI; next prompt = post-cutover housekeeping + parity kickoff
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m11s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m34s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m33s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 11:21:35 +02:00
timothy d04769ccdb Merge pull request 'feat(web): SPA cutover — root route (#91)' (#148) from feat/91-cutover into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m14s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m42s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m23s
2026-07-07 09:12:50 +00:00
timothyandClaude Fable 5 439272b405 feat(web): SPA cutover — root route + legacy redirects (#91)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m11s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m31s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Make the ChicoryTV SPA the default UI: GET/HEAD requests to / and to
legacy Blazor routes with SPA equivalents (channels, schedules,
playouts, libraries, all settings pages, channel add) now 302 to their
/app counterparts, preserving query strings and the ETV_BASE_URL path
base. 302 not 301: the map will grow as parity lands and permanent-
redirect caching would make rollback painful.

Blazor-only functionality (collections, media browse/search, trakt,
filler presets, watermarks, ffmpeg profiles, blocks/decos/templates,
playout detail editors, logs, troubleshooting, channel edit) keeps
serving Blazor; the Blazor home stays reachable at /system/health.
Parity gaps are tracked in #140-#146; Blazor removal is phase (b).

Also adds an /app smoke assertion to the docker build workflow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 11:07:01 +02:00
timothyandClaude Fable 5 b564545ff7 docs: advance ChicoryTV issue queue past #90 (PR #139, tagged v26.4.0); next prompt = #91 cutover
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 3m55s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m36s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m54s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 10:20:36 +02:00
timothy 65b1a5e38c Merge pull request 'feat(web): ChicoryTV rebrand (#90)' (#139) from feat/90-rebrand into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m9s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m39s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m6s
2026-07-07 08:11:34 +00:00
timothyandClaude Fable 5 ef88e93256 feat(web): ChicoryTV rebrand (#90)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m27s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m45s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Complete the SPA-surface rebrand: SVG favicon (chicorytv-icon) served
from web/public, description + theme-color meta, sidebar brand mark
switched to the canonical transparent chicory-mark.svg per the design
system lockup, and the Channel Builder default group ErsatzTV->ChicoryTV
(tests updated). Legacy Blazor UI and backend strings intentionally
untouched (product-wide rebrand = epic #59).

closes #90

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

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

ErsatzTV.Tests 495; web suite 145.

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 22:07:36 +02:00
timothyandClaude Fable 5 2596a433a8 docs: advance ChicoryTV issue queue past #63 (PR #134); Claude-only process; next prompt = #89 Channel Builder
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 3m48s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 3m38s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m0s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 21:33:04 +02:00
timothy a126970dcd Merge pull request 'feat(api): composite create-channel endpoint (#63)' (#134) from feat/63-composite-create into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m20s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m53s
Build ErsatzTV Image / Build & push image (amd64) (push) Has been cancelled
2026-07-06 19:28:01 +00:00
timothyandClaude Fable 5 cf36c30997 fix(api): rework from-lineup to generated playlist design + review fixes (#63)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m23s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m54s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Adversarial review found the previous multi-flood design non-viable:
PlayoutModeSchedulerFlood never yields to a following Dynamic-start
schedule item, so only the first item ever played, and grouping
media items into a Collection silently dropped the requested order.

Redesign:
- Single-item lineup: one ProgramScheduleItemFlood referencing the
  target directly (media item / collection / smart / multi / rerun /
  playlist); no generated collection or playlist. Response PlaylistId
  is null.
- Multi-item lineup (>= 2): one generated IsSystem Playlist in a
  get-or-created IsSystem PlaylistGroup ("Channel Lineups"), one
  PlaylistItem per entry in lineup order with PlayAll=true, referenced
  by a single Flood schedule item. Rerun collections and playlists are
  rejected (422) in multi lineups (PlaylistItem/CollectionKey lack the
  fields to enumerate them).

Review fixes:
- Normalize + strict-validate MediaType<->CollectionType pairs once up
  front (422 on mismatch / wrong id / not exactly one id).
- Reject MultiCollection with non-Shuffle order (mirrors
  PlayoutModeMustBeValid), Mirror playout source, all via 422.
- OnDemand parity: queue TimeShiftOnDemandPlayout post-commit.
- De-collide generated ProgramSchedule and Playlist names against their
  unique indexes instead of leaking a UNIQUE-constraint DbUpdateException.
- Generic 422 on save failure + ILogger; AnyAsync existence checks;
  Either/Validation unwrap via Match; XML doc on request DTO + endpoint.
- Response model: ChannelId, PlaylistId (nullable), ProgramScheduleId,
  PlayoutId (CollectionId removed). Regenerated OpenAPI v1.json + v1.d.ts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 21:09:00 +02:00
timothy 6857a0d191 feat(api): composite create-channel endpoint (#63)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m58s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m42s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
2026-07-06 20:27:02 +02:00
timothyandClaude Fable 5 16674ba80e docs: advance ChicoryTV issue queue past #64 (PR #133); next prompt = #63 composite create-channel
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m43s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m22s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m32s
Also: record prod cutover as done in CLAUDE.md (fork :prod live since
2026-06-27) and add standing v26.4.0 release-checkpoint note to the handoff.

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

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

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

Review: PR #128 findings

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

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

Review: PR #127 findings

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

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

Review: PR #125 findings; refs #126

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 21:23:24 +02:00
timothy 4ffd777b49 Merge pull request 'feat(web): Dashboard real data sources (#109)' (#123) from feat/109-dashboard-data into main
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m42s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m21s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m31s
2026-07-04 19:20:38 +00:00
136 changed files with 41697 additions and 199 deletions
+2 -2
View File
@@ -274,8 +274,8 @@ jobs:
done
echo " FAIL $path (no 2xx containing '$needle' within timeout)"; return 1
}
if check "/iptv/channels.m3u" "#EXTM3U" && check "/iptv/xmltv.xml" "<tv"; then
echo "Smoke + IPTV E2E passed: channels.m3u + xmltv.xml serve a valid playlist + guide"
if check "/iptv/channels.m3u" "#EXTM3U" && check "/iptv/xmltv.xml" "<tv" && check "/app/" "ChicoryTV"; then
echo "Smoke + IPTV E2E passed: channels.m3u + xmltv.xml serve a valid playlist + guide; /app/ serves the ChicoryTV SPA"
else
echo "===== container logs (tail) ====="; docker logs "$NAME" 2>&1 | tail -n 40 || true
exit 1
+6 -4
View File
@@ -4,7 +4,8 @@ Custom IPTV channel server for Jellyfin. Forked from [ErsatzTV/ErsatzTV](https:/
## Architecture
- **Language**: C# / .NET 10, Blazor Server UI (MudBlazor)
- **Language**: C# / .NET 10
- **UI**: ChicoryTV React SPA (`web/`, Vite, served at `/app`) over the REST API — the default UI; root `/` and migrated legacy routes 302 there (`ErsatzTV/LegacyUiRedirects.cs`). The legacy Blazor Server UI (MudBlazor) still serves 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
- **Pattern**: CQRS via MediatR — queries/commands in `ErsatzTV.Application/`
- **Database**: EF Core (SQLite default, MySQL optional) — context in `ErsatzTV.Infrastructure/Data/TvContext.cs`
- **Media**: FFmpeg via CliWrap, SkiaSharp for logo generation
@@ -14,7 +15,8 @@ Custom IPTV channel server for Jellyfin. Forked from [ErsatzTV/ErsatzTV](https:/
| Project | Role |
|---------|------|
| `ErsatzTV/` | ASP.NET Core host, Blazor pages, API controllers, DI setup |
| `ErsatzTV/` | ASP.NET Core host, API controllers, SPA static hosting, legacy Blazor pages, DI setup |
| `web/` | ChicoryTV React SPA (Vite + TypeScript; builds into `ErsatzTV/wwwroot/app`) |
| `ErsatzTV.Application/` | MediatR handlers (business logic) |
| `ErsatzTV.Core/` | Domain entities, interfaces, no infrastructure deps |
| `ErsatzTV.Infrastructure/` | EF Core repos, data access |
@@ -36,7 +38,7 @@ Custom IPTV channel server for Jellyfin. Forked from [ErsatzTV/ErsatzTV](https:/
- **Docker host**: jazz (192.168.1.99), container `ersatztv`, port 8409
- **Config volume**: `~/downloadswarm/ersatztv/` on jazz → `/config` in container
- **SQLite DB**: `/config/ersatztv.sqlite3` (WAL mode, root-owned)
- **Images** (our fork, built by `.gitea/workflows/docker-build.yml``192.168.1.95:3000/timothy/ersatztv`): push to `main``:latest` + `:<sha>` (test image); push `v*` tag → `:prod` + `:<version>` + `:<sha>`. Prod container still runs upstream `ghcr.io/ersatztv/ersatztv:latest` pending cutover (server-management#481). Pipeline details: `docs/ci-cd.md`.
- **Images** (our fork, built by `.gitea/workflows/docker-build.yml``192.168.1.95:3000/timothy/ersatztv`): push to `main``:latest` + `:<sha>` (test image); push `v*` tag → `:prod` + `:<version>` + `:<sha>`. Prod deploys via **Komodo GitOps**: the `media-servers` compose in `timothy/server-management` (`docker/bumblebee/stacks/media-servers/compose.yaml`) pins the version tag (currently `26.5.0`, deployed 2026-07-07); releasing = tag here, wait for the image build, bump that pin and push (the Komodo pre-deploy hook backs up before recreating). Test container tracks `:latest`. Pipeline details: `docs/ci-cd.md`.
## Development
@@ -56,7 +58,7 @@ docker build -f docker/Dockerfile -t ersatztv:dev .
- **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.
- Follow existing MediatR CQRS pattern for new features
- Domain logic in `ErsatzTV.Core`, infrastructure in `ErsatzTV.Infrastructure`
- Keep Blazor pages thin — delegate to MediatR handlers
- Keep UI thin: the SPA talks to `/api/*` only; legacy Blazor pages delegate to MediatR handlers. New screens go in the SPA (`web/`), never in Blazor
- Test with **NUnit** + Shouldly + NSubstitute (the existing `*.Tests` projects); xUnit is **not** used here
- **Dependencies use Central Package Management**: versions live in the repo-root `Directory.Packages.props`; csproj reference packages by name only. Add/upgrade by editing the central `<PackageVersion>` — never put `Version=` back on a `<PackageReference>` (trips `NU1008`). See `docs/ci-cd.md` → Dependency management.
- **DB migrations target BOTH providers**: a `TvContext` model change needs a migration in `ErsatzTV.Infrastructure.Sqlite` **and** `ErsatzTV.Infrastructure.MySql` — run `scripts/add-migration.sh <Name>` (does both). CI's `migrations` job enforces model-drift + apply-to-fresh-DB per provider. See `docs/ci-cd.md` → Migration integrity.
+1 -1
View File
@@ -79,7 +79,7 @@
<PackageVersion Include="RichTextKit.Stbear" Version="0.4.167.3" />
<PackageVersion Include="Roslynator.Analyzers" Version="4.15.0" />
<PackageVersion Include="Scalar.AspNetCore" Version="2.12.32" />
<PackageVersion Include="Scriban.Signed" Version="6.5.2" />
<PackageVersion Include="Scriban.Signed" Version="7.2.5" />
<PackageVersion Include="Serilog" Version="4.3.0" />
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageVersion Include="Serilog.Extensions.Hosting" Version="10.0.0" />
@@ -0,0 +1,24 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
namespace ErsatzTV.Application.ChannelTemplates;
internal static class ChannelTemplateDefault
{
public static async Task<int?> GetDefaultTemplateId(
IConfigElementRepository configElementRepository,
CancellationToken cancellationToken)
{
Option<int> maybeDefault =
await configElementRepository.GetValue<int>(
ConfigElementKey.ChannelTemplatesDefaultTemplateId,
cancellationToken);
int? result = null;
foreach (int id in maybeDefault)
{
result = id;
}
return result;
}
}
@@ -0,0 +1,38 @@
using ErsatzTV.Core.Api.ChannelTemplates;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.ChannelTemplates;
public static class ChannelTemplateMapper
{
public static ChannelTemplateResponseModel ProjectToResponseModel(ChannelTemplate template, int? defaultTemplateId) =>
new(
template.Id,
template.Name,
template.Description,
template.IsSystem,
defaultTemplateId == template.Id,
template.FFmpegProfileId,
template.WatermarkId,
template.FallbackFillerId,
template.PreRollFillerId,
template.MidRollFillerId,
template.PostRollFillerId,
template.StreamSelectorMode,
template.StreamSelector,
template.PreferredAudioLanguageCode,
template.PreferredAudioTitle,
template.PlayoutSource,
template.PlayoutMode,
template.StreamingMode,
template.PreferredSubtitleLanguageCode,
template.SubtitleMode,
template.MusicVideoCreditsMode,
template.MusicVideoCreditsTemplate,
template.SongVideoMode,
template.TranscodeMode,
template.IdleBehavior,
template.ShuffleScheduleItems,
template.RandomStartPoint,
template.FixedStartTimeBehavior);
}
@@ -0,0 +1,155 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.ChannelTemplates;
public abstract record ChannelTemplateCommandBase(
string Name,
string Description,
int FFmpegProfileId,
int? WatermarkId,
int? FallbackFillerId,
int? PreRollFillerId,
int? MidRollFillerId,
int? PostRollFillerId,
ChannelStreamSelectorMode StreamSelectorMode,
string StreamSelector,
string PreferredAudioLanguageCode,
string PreferredAudioTitle,
ChannelPlayoutSource PlayoutSource,
ChannelPlayoutMode PlayoutMode,
StreamingMode StreamingMode,
string PreferredSubtitleLanguageCode,
ChannelSubtitleMode SubtitleMode,
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
string MusicVideoCreditsTemplate,
ChannelSongVideoMode SongVideoMode,
ChannelTranscodeMode TranscodeMode,
ChannelIdleBehavior IdleBehavior,
bool ShuffleScheduleItems,
bool RandomStartPoint,
FixedStartTimeBehavior FixedStartTimeBehavior)
{
internal static async Task<Option<BaseError>> ValidateCommon(
TvContext dbContext,
ChannelTemplateCommandBase request,
int? existingTemplateId,
CancellationToken cancellationToken)
{
string name = NormalizeName(request.Name);
if (string.IsNullOrWhiteSpace(name))
{
return BaseError.New("Name is required.");
}
if (name.Length > 50)
{
return BaseError.New("Name must be 50 characters or less.");
}
if (request.Description?.Length > 500)
{
return BaseError.New("Description must be 500 characters or less.");
}
bool duplicateName = await dbContext.ChannelTemplates
.AnyAsync(t => t.Id != existingTemplateId && t.Name == name, cancellationToken);
if (duplicateName)
{
return BaseError.New("Channel template name must be unique.");
}
bool ffmpegProfileExists = await dbContext.FFmpegProfiles
.AnyAsync(p => p.Id == request.FFmpegProfileId, cancellationToken);
if (!ffmpegProfileExists)
{
return new NotFoundError($"FFmpegProfile {request.FFmpegProfileId} does not exist.");
}
foreach (int watermarkId in Optional(request.WatermarkId))
{
bool watermarkExists = await dbContext.ChannelWatermarks
.AnyAsync(w => w.Id == watermarkId, cancellationToken);
if (!watermarkExists)
{
return new NotFoundError($"Watermark {watermarkId} does not exist.");
}
}
Option<BaseError> maybeFillerError =
await FillerMustExist(dbContext, request.FallbackFillerId, FillerKind.Fallback, cancellationToken);
if (maybeFillerError.IsSome)
{
return maybeFillerError;
}
maybeFillerError = await FillerMustExist(dbContext, request.PreRollFillerId, FillerKind.PreRoll, cancellationToken);
if (maybeFillerError.IsSome)
{
return maybeFillerError;
}
maybeFillerError = await FillerMustExist(dbContext, request.MidRollFillerId, FillerKind.MidRoll, cancellationToken);
if (maybeFillerError.IsSome)
{
return maybeFillerError;
}
return await FillerMustExist(dbContext, request.PostRollFillerId, FillerKind.PostRoll, cancellationToken);
}
internal void ApplyTo(ChannelTemplate template)
{
template.Name = NormalizeName(Name);
template.Description = Description ?? string.Empty;
template.FFmpegProfileId = FFmpegProfileId;
template.WatermarkId = WatermarkId;
template.FallbackFillerId = FallbackFillerId;
template.PreRollFillerId = PreRollFillerId;
template.MidRollFillerId = MidRollFillerId;
template.PostRollFillerId = PostRollFillerId;
template.StreamSelectorMode = StreamSelectorMode;
template.StreamSelector = StreamSelector ?? string.Empty;
template.PreferredAudioLanguageCode = PreferredAudioLanguageCode ?? string.Empty;
template.PreferredAudioTitle = PreferredAudioTitle ?? string.Empty;
template.PlayoutSource = PlayoutSource;
template.PlayoutMode = PlayoutMode;
template.StreamingMode = StreamingMode;
template.PreferredSubtitleLanguageCode = PreferredSubtitleLanguageCode ?? string.Empty;
template.SubtitleMode = SubtitleMode;
template.MusicVideoCreditsMode = MusicVideoCreditsMode;
template.MusicVideoCreditsTemplate = MusicVideoCreditsTemplate ?? string.Empty;
template.SongVideoMode = SongVideoMode;
template.TranscodeMode = TranscodeMode;
template.IdleBehavior = IdleBehavior;
template.ShuffleScheduleItems = ShuffleScheduleItems;
template.RandomStartPoint = RandomStartPoint;
template.FixedStartTimeBehavior = FixedStartTimeBehavior;
}
internal static string NormalizeName(string name) => (name ?? string.Empty).Trim();
private static async Task<Option<BaseError>> FillerMustExist(
TvContext dbContext,
int? fillerPresetId,
FillerKind fillerKind,
CancellationToken cancellationToken)
{
foreach (int id in Optional(fillerPresetId))
{
bool exists = await dbContext.FillerPresets
.AnyAsync(f => f.Id == id && f.FillerKind == fillerKind, cancellationToken);
if (!exists)
{
return new NotFoundError($"{fillerKind} filler {id} does not exist.");
}
}
return Option<BaseError>.None;
}
}
@@ -0,0 +1,60 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Api.ChannelTemplates;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Application.ChannelTemplates;
public record CreateChannelTemplate(
string Name,
string Description,
int FFmpegProfileId,
int? WatermarkId,
int? FallbackFillerId,
int? PreRollFillerId,
int? MidRollFillerId,
int? PostRollFillerId,
ChannelStreamSelectorMode StreamSelectorMode,
string StreamSelector,
string PreferredAudioLanguageCode,
string PreferredAudioTitle,
ChannelPlayoutSource PlayoutSource,
ChannelPlayoutMode PlayoutMode,
StreamingMode StreamingMode,
string PreferredSubtitleLanguageCode,
ChannelSubtitleMode SubtitleMode,
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
string MusicVideoCreditsTemplate,
ChannelSongVideoMode SongVideoMode,
ChannelTranscodeMode TranscodeMode,
ChannelIdleBehavior IdleBehavior,
bool ShuffleScheduleItems,
bool RandomStartPoint,
FixedStartTimeBehavior FixedStartTimeBehavior)
: ChannelTemplateCommandBase(
Name,
Description,
FFmpegProfileId,
WatermarkId,
FallbackFillerId,
PreRollFillerId,
MidRollFillerId,
PostRollFillerId,
StreamSelectorMode,
StreamSelector,
PreferredAudioLanguageCode,
PreferredAudioTitle,
PlayoutSource,
PlayoutMode,
StreamingMode,
PreferredSubtitleLanguageCode,
SubtitleMode,
MusicVideoCreditsMode,
MusicVideoCreditsTemplate,
SongVideoMode,
TranscodeMode,
IdleBehavior,
ShuffleScheduleItems,
RandomStartPoint,
FixedStartTimeBehavior),
IRequest<Either<BaseError, ChannelTemplateResponseModel>>;
@@ -0,0 +1,36 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Api.ChannelTemplates;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.ChannelTemplates;
public class CreateChannelTemplateHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository)
: IRequestHandler<CreateChannelTemplate, Either<BaseError, ChannelTemplateResponseModel>>
{
public async Task<Either<BaseError, ChannelTemplateResponseModel>> Handle(
CreateChannelTemplate request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<BaseError> maybeError =
await ChannelTemplateCommandBase.ValidateCommon(dbContext, request, null, cancellationToken);
foreach (BaseError error in maybeError)
{
return error;
}
var template = new ChannelTemplate();
request.ApplyTo(template);
await dbContext.ChannelTemplates.AddAsync(template, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
int? defaultTemplateId =
await ChannelTemplateDefault.GetDefaultTemplateId(configElementRepository, cancellationToken);
return ChannelTemplateMapper.ProjectToResponseModel(template, defaultTemplateId);
}
}
@@ -0,0 +1,5 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.ChannelTemplates;
public record DeleteChannelTemplate(int ChannelTemplateId) : IRequest<Either<BaseError, Unit>>;
@@ -0,0 +1,42 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.ChannelTemplates;
public class DeleteChannelTemplateHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository)
: IRequestHandler<DeleteChannelTemplate, Either<BaseError, Unit>>
{
public async Task<Either<BaseError, Unit>> Handle(DeleteChannelTemplate request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<ChannelTemplate> maybeTemplate = await dbContext.ChannelTemplates
.SelectOneAsync(t => t.Id, t => t.Id == request.ChannelTemplateId, cancellationToken);
foreach (ChannelTemplate template in maybeTemplate)
{
if (template.IsSystem)
{
return BaseError.New("System templates cannot be deleted.");
}
int? defaultTemplateId =
await ChannelTemplateDefault.GetDefaultTemplateId(configElementRepository, cancellationToken);
if (defaultTemplateId == template.Id)
{
return BaseError.New("Default channel template cannot be deleted.");
}
dbContext.ChannelTemplates.Remove(template);
await dbContext.SaveChangesAsync(cancellationToken);
return Unit.Default;
}
return new NotFoundError($"ChannelTemplate {request.ChannelTemplateId} does not exist.");
}
}
@@ -0,0 +1,6 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Api.ChannelTemplates;
namespace ErsatzTV.Application.ChannelTemplates;
public record SetDefaultChannelTemplate(int ChannelTemplateId) : IRequest<Either<BaseError, ChannelTemplateResponseModel>>;
@@ -0,0 +1,36 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Api.ChannelTemplates;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.ChannelTemplates;
public class SetDefaultChannelTemplateHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository)
: IRequestHandler<SetDefaultChannelTemplate, Either<BaseError, ChannelTemplateResponseModel>>
{
public async Task<Either<BaseError, ChannelTemplateResponseModel>> Handle(
SetDefaultChannelTemplate request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<ChannelTemplate> maybeTemplate = await dbContext.ChannelTemplates
.AsNoTracking()
.SelectOneAsync(t => t.Id, t => t.Id == request.ChannelTemplateId, cancellationToken);
foreach (ChannelTemplate template in maybeTemplate)
{
await configElementRepository.Upsert(
ConfigElementKey.ChannelTemplatesDefaultTemplateId,
template.Id,
cancellationToken);
return ChannelTemplateMapper.ProjectToResponseModel(template, template.Id);
}
return new NotFoundError($"ChannelTemplate {request.ChannelTemplateId} does not exist.");
}
}
@@ -0,0 +1,61 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Api.ChannelTemplates;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Application.ChannelTemplates;
public record UpdateChannelTemplate(
int ChannelTemplateId,
string Name,
string Description,
int FFmpegProfileId,
int? WatermarkId,
int? FallbackFillerId,
int? PreRollFillerId,
int? MidRollFillerId,
int? PostRollFillerId,
ChannelStreamSelectorMode StreamSelectorMode,
string StreamSelector,
string PreferredAudioLanguageCode,
string PreferredAudioTitle,
ChannelPlayoutSource PlayoutSource,
ChannelPlayoutMode PlayoutMode,
StreamingMode StreamingMode,
string PreferredSubtitleLanguageCode,
ChannelSubtitleMode SubtitleMode,
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
string MusicVideoCreditsTemplate,
ChannelSongVideoMode SongVideoMode,
ChannelTranscodeMode TranscodeMode,
ChannelIdleBehavior IdleBehavior,
bool ShuffleScheduleItems,
bool RandomStartPoint,
FixedStartTimeBehavior FixedStartTimeBehavior)
: ChannelTemplateCommandBase(
Name,
Description,
FFmpegProfileId,
WatermarkId,
FallbackFillerId,
PreRollFillerId,
MidRollFillerId,
PostRollFillerId,
StreamSelectorMode,
StreamSelector,
PreferredAudioLanguageCode,
PreferredAudioTitle,
PlayoutSource,
PlayoutMode,
StreamingMode,
PreferredSubtitleLanguageCode,
SubtitleMode,
MusicVideoCreditsMode,
MusicVideoCreditsTemplate,
SongVideoMode,
TranscodeMode,
IdleBehavior,
ShuffleScheduleItems,
RandomStartPoint,
FixedStartTimeBehavior),
IRequest<Either<BaseError, ChannelTemplateResponseModel>>;
@@ -0,0 +1,51 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Api.ChannelTemplates;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.ChannelTemplates;
public class UpdateChannelTemplateHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository)
: IRequestHandler<UpdateChannelTemplate, Either<BaseError, ChannelTemplateResponseModel>>
{
public async Task<Either<BaseError, ChannelTemplateResponseModel>> Handle(
UpdateChannelTemplate request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<ChannelTemplate> maybeTemplate = await dbContext.ChannelTemplates
.SelectOneAsync(t => t.Id, t => t.Id == request.ChannelTemplateId, cancellationToken);
foreach (ChannelTemplate template in maybeTemplate)
{
if (template.IsSystem)
{
return BaseError.New("System templates cannot be updated.");
}
Option<BaseError> maybeError =
await ChannelTemplateCommandBase.ValidateCommon(
dbContext,
request,
request.ChannelTemplateId,
cancellationToken);
foreach (BaseError error in maybeError)
{
return error;
}
request.ApplyTo(template);
await dbContext.SaveChangesAsync(cancellationToken);
int? defaultTemplateId =
await ChannelTemplateDefault.GetDefaultTemplateId(configElementRepository, cancellationToken);
return ChannelTemplateMapper.ProjectToResponseModel(template, defaultTemplateId);
}
return new NotFoundError($"ChannelTemplate {request.ChannelTemplateId} does not exist.");
}
}
@@ -0,0 +1,5 @@
using ErsatzTV.Core.Api.ChannelTemplates;
namespace ErsatzTV.Application.ChannelTemplates;
public record GetAllChannelTemplates : IRequest<List<ChannelTemplateResponseModel>>;
@@ -0,0 +1,28 @@
using ErsatzTV.Core.Api.ChannelTemplates;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.ChannelTemplates;
public class GetAllChannelTemplatesHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository)
: IRequestHandler<GetAllChannelTemplates, List<ChannelTemplateResponseModel>>
{
public async Task<List<ChannelTemplateResponseModel>> Handle(
GetAllChannelTemplates request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
int? defaultTemplateId =
await ChannelTemplateDefault.GetDefaultTemplateId(configElementRepository, cancellationToken);
return await dbContext.ChannelTemplates
.AsNoTracking()
.OrderBy(t => t.IsSystem ? 0 : 1)
.ThenBy(t => t.Name)
.Select(t => ChannelTemplateMapper.ProjectToResponseModel(t, defaultTemplateId))
.ToListAsync(cancellationToken);
}
}
@@ -0,0 +1,5 @@
using ErsatzTV.Core.Api.ChannelTemplates;
namespace ErsatzTV.Application.ChannelTemplates;
public record GetChannelTemplateById(int ChannelTemplateId) : IRequest<Option<ChannelTemplateResponseModel>>;
@@ -0,0 +1,27 @@
using ErsatzTV.Core.Api.ChannelTemplates;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.ChannelTemplates;
public class GetChannelTemplateByIdHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository)
: IRequestHandler<GetChannelTemplateById, Option<ChannelTemplateResponseModel>>
{
public async Task<Option<ChannelTemplateResponseModel>> Handle(
GetChannelTemplateById request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
int? defaultTemplateId =
await ChannelTemplateDefault.GetDefaultTemplateId(configElementRepository, cancellationToken);
Option<ChannelTemplate> maybeTemplate = await dbContext.ChannelTemplates
.AsNoTracking()
.SelectOneAsync(t => t.Id, t => t.Id == request.ChannelTemplateId, cancellationToken);
return maybeTemplate.Map(t => ChannelTemplateMapper.ProjectToResponseModel(t, defaultTemplateId));
}
}
@@ -0,0 +1,5 @@
using ErsatzTV.Core.Api.ChannelTemplates;
namespace ErsatzTV.Application.ChannelTemplates;
public record GetDefaultChannelTemplate : IRequest<Option<ChannelTemplateResponseModel>>;
@@ -0,0 +1,40 @@
using ErsatzTV.Core.Api.ChannelTemplates;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.ChannelTemplates;
public class GetDefaultChannelTemplateHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository)
: IRequestHandler<GetDefaultChannelTemplate, Option<ChannelTemplateResponseModel>>
{
public async Task<Option<ChannelTemplateResponseModel>> Handle(
GetDefaultChannelTemplate request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
int? defaultTemplateId =
await ChannelTemplateDefault.GetDefaultTemplateId(configElementRepository, cancellationToken);
foreach (int id in Optional(defaultTemplateId))
{
Option<ChannelTemplate> maybeConfigured = await dbContext.ChannelTemplates
.AsNoTracking()
.SelectOneAsync(t => t.Id, t => t.Id == id, cancellationToken);
foreach (ChannelTemplate template in maybeConfigured)
{
return ChannelTemplateMapper.ProjectToResponseModel(template, id);
}
}
ChannelTemplate fallback = await dbContext.ChannelTemplates
.AsNoTracking()
.Where(t => t.IsSystem)
.OrderBy(t => t.Name)
.FirstOrDefaultAsync(cancellationToken);
return Optional(fallback).Map(t => ChannelTemplateMapper.ProjectToResponseModel(t, t.Id));
}
}
@@ -0,0 +1,56 @@
using ErsatzTV.Application.Artworks;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Application.Channels;
public record CreateChannelFromLineup(
string Name,
string Number,
string Group,
string Categories,
ArtworkContentTypeModel Logo,
bool IsEnabled,
bool ShowInEpg,
int TemplateId,
CreateChannelFromLineupAdvancedOptions Advanced,
List<CreateChannelFromLineupItem> Lineup) : IRequest<Either<BaseError, CreateChannelFromLineupResponseModel>>;
public record CreateChannelFromLineupAdvancedOptions(
PlaybackOrder? PlaybackOrder = null,
int? FFmpegProfileId = null,
int? WatermarkId = null,
int? FallbackFillerId = null,
int? PreRollFillerId = null,
int? MidRollFillerId = null,
int? PostRollFillerId = null,
ChannelStreamSelectorMode? StreamSelectorMode = null,
string StreamSelector = null,
string PreferredAudioLanguageCode = null,
string PreferredAudioTitle = null,
ChannelPlayoutSource? PlayoutSource = null,
ChannelPlayoutMode? PlayoutMode = null,
StreamingMode? StreamingMode = null,
string PreferredSubtitleLanguageCode = null,
ChannelSubtitleMode? SubtitleMode = null,
ChannelMusicVideoCreditsMode? MusicVideoCreditsMode = null,
string MusicVideoCreditsTemplate = null,
ChannelSongVideoMode? SongVideoMode = null,
ChannelTranscodeMode? TranscodeMode = null,
ChannelIdleBehavior? IdleBehavior = null,
bool? ShuffleScheduleItems = null,
bool? RandomStartPoint = null,
FixedStartTimeBehavior? FixedStartTimeBehavior = null);
public record CreateChannelFromLineupItem(
LibraryBrowseMediaType MediaType,
CollectionType CollectionType,
int? CollectionId,
int? MultiCollectionId,
int? SmartCollectionId,
int? RerunCollectionId,
int? MediaItemId,
int? PlaylistId);
@@ -0,0 +1,754 @@
using System.Globalization;
using System.Text.RegularExpressions;
using System.Threading.Channels;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Channel = ErsatzTV.Core.Domain.Channel;
namespace ErsatzTV.Application.Channels;
public class CreateChannelFromLineupHandler(
ChannelWriter<IBackgroundServiceRequest> workerChannel,
IDbContextFactory<TvContext> dbContextFactory,
ISearchTargets searchTargets,
ILogger<CreateChannelFromLineupHandler> logger)
: IRequestHandler<CreateChannelFromLineup, Either<BaseError, CreateChannelFromLineupResponseModel>>
{
// The single system playlist group that holds every generated channel-lineup playlist.
// Matches the Trakt "Trakt Lists" precedent (DbInitializer + delete guards on IsSystem).
private const string SystemPlaylistGroupName = "Channel Lineups";
public async Task<Either<BaseError, CreateChannelFromLineupResponseModel>> Handle(
CreateChannelFromLineup request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Either<BaseError, PreparedCreate> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Match(
Left: error => Task.FromResult<Either<BaseError, CreateChannelFromLineupResponseModel>>(error),
Right: prepared => PersistAndDispatch(dbContext, prepared, cancellationToken));
}
private async Task<Either<BaseError, CreateChannelFromLineupResponseModel>> PersistAndDispatch(
TvContext dbContext,
PreparedCreate prepared,
CancellationToken cancellationToken)
{
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
try
{
dbContext.Channels.Add(prepared.Channel);
if (prepared.Playlist is not null)
{
dbContext.Playlists.Add(prepared.Playlist);
}
dbContext.ProgramSchedules.Add(prepared.ProgramSchedule);
dbContext.Playouts.Add(prepared.Playout);
await dbContext.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
}
catch (DbUpdateException ex)
{
await transaction.RollbackAsync(cancellationToken);
logger.LogError(ex, "Failed to persist channel created from lineup");
return BaseError.New("Unable to create channel from lineup");
}
searchTargets.SearchTargetsChanged();
await workerChannel.WriteAsync(
new BuildPlayout(prepared.Playout.Id, PlayoutBuildMode.Reset),
cancellationToken);
// Mirror CreateClassicPlayoutHandler: on-demand playouts must be time-shifted to "now" after build.
if (prepared.Channel.PlayoutMode is ChannelPlayoutMode.OnDemand)
{
await workerChannel.WriteAsync(
new TimeShiftOnDemandPlayout(prepared.Playout.Id, DateTimeOffset.Now, false),
cancellationToken);
}
await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
return new CreateChannelFromLineupResponseModel(
prepared.Channel.Id,
prepared.Playlist?.Id,
prepared.ProgramSchedule.Id,
prepared.Playout.Id);
}
private static async Task<Either<BaseError, PreparedCreate>> Validate(
TvContext dbContext,
CreateChannelFromLineup request,
CancellationToken cancellationToken)
{
string name = (request.Name ?? string.Empty).Trim();
string number = (request.Number ?? string.Empty).Trim();
string group = (request.Group ?? string.Empty).Trim();
string categories = (request.Categories ?? string.Empty).Trim();
CreateChannelFromLineupAdvancedOptions advanced = request.Advanced ?? new CreateChannelFromLineupAdvancedOptions();
if (string.IsNullOrWhiteSpace(name))
{
return BaseError.New("Channel name is required");
}
if (name.Length > 50)
{
return BaseError.New("Channel name must be 50 characters or fewer");
}
if (string.IsNullOrWhiteSpace(group))
{
return BaseError.New("Channel group is required");
}
if (!Regex.IsMatch(number, Channel.NumberValidator))
{
return BaseError.New("Invalid channel number; two decimals are allowed for subchannels");
}
if (await dbContext.Channels.AnyAsync(c => c.Number == number, cancellationToken))
{
return BaseError.New("Channel number must be unique");
}
if (!request.IsEnabled && request.ShowInEpg)
{
return BaseError.New("Disabled channels cannot be shown in EPG");
}
if (!string.IsNullOrWhiteSpace(request.Logo?.Path) &&
Uri.TryCreate(request.Logo.Path, UriKind.Absolute, out _) &&
!Artwork.IsExternalUrl(request.Logo.Path))
{
return BaseError.New("External logo url is invalid");
}
if (request.Lineup is null || request.Lineup.Count == 0)
{
return BaseError.New("Lineup must contain at least one item");
}
ChannelTemplate template = await dbContext.ChannelTemplates
.AsNoTracking()
.SingleOrDefaultAsync(t => t.Id == request.TemplateId, cancellationToken);
if (template is null)
{
return new NotFoundError($"Channel template {request.TemplateId} does not exist.");
}
int ffmpegProfileId = advanced.FFmpegProfileId ?? template.FFmpegProfileId;
int? fallbackFillerId = advanced.FallbackFillerId ?? template.FallbackFillerId;
int? preRollFillerId = advanced.PreRollFillerId ?? template.PreRollFillerId;
int? midRollFillerId = advanced.MidRollFillerId ?? template.MidRollFillerId;
int? postRollFillerId = advanced.PostRollFillerId ?? template.PostRollFillerId;
PlaybackOrder playbackOrder = advanced.PlaybackOrder ?? PlaybackOrder.Chronological;
ChannelPlayoutSource playoutSource = advanced.PlayoutSource ?? template.PlayoutSource;
// Mirror channels need special MirrorSourceChannelId plumbing (see CreateChannelHandler);
// this endpoint only builds generated playouts.
if (playoutSource is ChannelPlayoutSource.Mirror)
{
return BaseError.New("Mirror playout source is not supported by this endpoint");
}
Either<BaseError, Unit> referenceValidation = await ValidateReferences(
dbContext,
advanced,
template,
cancellationToken);
foreach (BaseError error in referenceValidation.LeftToSeq())
{
return error;
}
// Normalize + validate every lineup entry once, so validation and build see the same data.
var normalized = new List<NormalizedLineupItem>();
for (int i = 0; i < request.Lineup.Count; i++)
{
Either<BaseError, NormalizedLineupItem> itemValidation =
await NormalizeLineupItem(dbContext, request.Lineup[i], i, cancellationToken);
foreach (BaseError error in itemValidation.LeftToSeq())
{
return error;
}
foreach (NormalizedLineupItem item in itemValidation.RightToSeq())
{
normalized.Add(item);
}
}
bool multiItem = normalized.Count >= 2;
// MultiCollection entries only support Shuffle / ShuffleInOrder (mirrors PlayoutModeMustBeValid).
if (normalized.Any(i => i.CollectionType is CollectionType.MultiCollection) &&
playbackOrder is not (PlaybackOrder.Shuffle or PlaybackOrder.ShuffleInOrder))
{
return BaseError.New($"Invalid playback order for multi collection: '{playbackOrder}'");
}
if (multiItem)
{
// The generated playlist cannot express rerun collections or nested playlists
// (PlaylistItem + CollectionKey.ForPlaylistItem lack those fields).
for (int i = 0; i < normalized.Count; i++)
{
if (normalized[i].CollectionType is CollectionType.RerunFirstRun or CollectionType.Playlist)
{
return BaseError.New(
$"lineup[{normalized[i].Index}]: rerun collections and playlists are only " +
"supported as a single-item lineup");
}
}
}
Channel channel = BuildChannel(
request,
template,
advanced,
name,
number,
group,
categories,
ffmpegProfileId,
fallbackFillerId);
string scheduleName = await DeCollideName(
GeneratedName(number, name, "Schedule"),
(candidate, ct) => dbContext.ProgramSchedules.AnyAsync(ps => ps.Name == candidate, ct),
cancellationToken);
ProgramSchedule schedule = BuildProgramSchedule(scheduleName, template, advanced);
Playlist playlist = null;
ProgramScheduleItemFlood floodItem = BuildFloodBase(
playbackOrder,
advanced,
template,
fallbackFillerId,
preRollFillerId,
midRollFillerId,
postRollFillerId);
if (multiItem)
{
playlist = await BuildPlaylist(dbContext, number, name, normalized, playbackOrder, cancellationToken);
floodItem.CollectionType = CollectionType.Playlist;
floodItem.Playlist = playlist;
}
else
{
NormalizedLineupItem only = normalized[0];
floodItem.CollectionType = only.CollectionType;
floodItem.CollectionId = only.CollectionId;
floodItem.MultiCollectionId = only.MultiCollectionId;
floodItem.SmartCollectionId = only.SmartCollectionId;
floodItem.RerunCollectionId = only.RerunCollectionId;
floodItem.MediaItemId = only.MediaItemId;
floodItem.PlaylistId = only.PlaylistId;
}
schedule.Items = [floodItem];
var playout = new Playout
{
Channel = channel,
ProgramSchedule = schedule,
ScheduleKind = PlayoutScheduleKind.Classic
};
return new PreparedCreate(channel, playlist, schedule, playout);
}
private static async Task<Playlist> BuildPlaylist(
TvContext dbContext,
string channelNumber,
string channelName,
List<NormalizedLineupItem> lineup,
PlaybackOrder playbackOrder,
CancellationToken cancellationToken)
{
// Reuse the single system playlist group, creating it on first use.
PlaylistGroup playlistGroup = await dbContext.PlaylistGroups
.FirstOrDefaultAsync(pg => pg.IsSystem && pg.Name == SystemPlaylistGroupName, cancellationToken);
playlistGroup ??= new PlaylistGroup { Name = SystemPlaylistGroupName, IsSystem = true };
string playlistName = await DeCollideName(
GeneratedName(channelNumber, channelName, "Lineup"),
(candidate, ct) => dbContext.Playlists.AnyAsync(
p => p.PlaylistGroupId == playlistGroup.Id && p.Name == candidate,
ct),
cancellationToken);
var playlist = new Playlist
{
Name = playlistName,
IsSystem = true,
PlaylistGroup = playlistGroup,
PlaylistGroupId = playlistGroup.Id,
Items = []
};
int index = 1;
foreach (NormalizedLineupItem item in lineup)
{
playlist.Items.Add(new PlaylistItem
{
Playlist = playlist,
Index = index++,
CollectionType = item.CollectionType,
CollectionId = item.CollectionId,
MultiCollectionId = item.MultiCollectionId,
SmartCollectionId = item.SmartCollectionId,
MediaItemId = item.MediaItemId,
PlaybackOrder = playbackOrder,
// Play every item in each entry before advancing so lineup order is honored
// (PlaylistEnumerator round-robins one-per-entry unless PlayAll/Count).
PlayAll = true,
IncludeInProgramGuide = true
});
}
return playlist;
}
private static Channel BuildChannel(
CreateChannelFromLineup request,
ChannelTemplate template,
CreateChannelFromLineupAdvancedOptions advanced,
string name,
string number,
string group,
string categories,
int ffmpegProfileId,
int? fallbackFillerId)
{
var artwork = new List<Artwork>();
if (!string.IsNullOrWhiteSpace(request.Logo?.Path))
{
string logo = request.Logo.Path;
if (logo.StartsWith("iptv/logos/", StringComparison.Ordinal))
{
logo = logo.Replace("iptv/logos/", string.Empty);
}
artwork.Add(new Artwork
{
Path = logo,
ArtworkKind = ArtworkKind.Logo,
OriginalContentType = !string.IsNullOrEmpty(request.Logo.ContentType) ? request.Logo.ContentType : null,
DateAdded = DateTime.UtcNow,
DateUpdated = DateTime.UtcNow
});
}
return new Channel(Guid.NewGuid())
{
Name = name,
Number = number,
SortNumber = double.Parse(number, CultureInfo.InvariantCulture),
Group = group,
Categories = categories,
FFmpegProfileId = ffmpegProfileId,
SlugSeconds = null,
PlayoutSource = advanced.PlayoutSource ?? template.PlayoutSource,
PlayoutMode = advanced.PlayoutMode ?? template.PlayoutMode,
StreamingMode = advanced.StreamingMode ?? template.StreamingMode,
WatermarkId = advanced.WatermarkId ?? template.WatermarkId,
FallbackFillerId = fallbackFillerId,
Artwork = artwork,
StreamSelectorMode = advanced.StreamSelectorMode ?? template.StreamSelectorMode,
StreamSelector = advanced.StreamSelector ?? template.StreamSelector ?? string.Empty,
PreferredAudioLanguageCode =
advanced.PreferredAudioLanguageCode ?? template.PreferredAudioLanguageCode ?? string.Empty,
PreferredAudioTitle = advanced.PreferredAudioTitle ?? template.PreferredAudioTitle ?? string.Empty,
PreferredSubtitleLanguageCode =
advanced.PreferredSubtitleLanguageCode ?? template.PreferredSubtitleLanguageCode ?? string.Empty,
SubtitleMode = advanced.SubtitleMode ?? template.SubtitleMode,
MusicVideoCreditsMode = advanced.MusicVideoCreditsMode ?? template.MusicVideoCreditsMode,
MusicVideoCreditsTemplate =
advanced.MusicVideoCreditsTemplate ?? template.MusicVideoCreditsTemplate ?? string.Empty,
SongVideoMode = advanced.SongVideoMode ?? template.SongVideoMode,
TranscodeMode = advanced.TranscodeMode ?? template.TranscodeMode,
IdleBehavior = advanced.IdleBehavior ?? template.IdleBehavior,
IsEnabled = request.IsEnabled,
ShowInEpg = request.IsEnabled && request.ShowInEpg
};
}
private static ProgramSchedule BuildProgramSchedule(
string scheduleName,
ChannelTemplate template,
CreateChannelFromLineupAdvancedOptions advanced) =>
new()
{
Name = scheduleName,
KeepMultiPartEpisodesTogether = true,
TreatCollectionsAsShows = true,
ShuffleScheduleItems = advanced.ShuffleScheduleItems ?? template.ShuffleScheduleItems,
RandomStartPoint = advanced.RandomStartPoint ?? template.RandomStartPoint,
FixedStartTimeBehavior = advanced.FixedStartTimeBehavior ?? template.FixedStartTimeBehavior,
Items = []
};
private static ProgramScheduleItemFlood BuildFloodBase(
PlaybackOrder playbackOrder,
CreateChannelFromLineupAdvancedOptions advanced,
ChannelTemplate template,
int? fallbackFillerId,
int? preRollFillerId,
int? midRollFillerId,
int? postRollFillerId) =>
new()
{
Index = 1,
PlaybackOrder = playbackOrder,
GuideMode = GuideMode.Normal,
CustomTitle = string.Empty,
SearchTitle = string.Empty,
SearchQuery = string.Empty,
PreRollFillerId = preRollFillerId,
MidRollFillerId = midRollFillerId,
PostRollFillerId = postRollFillerId,
FallbackFillerId = fallbackFillerId,
PreferredAudioLanguageCode =
advanced.PreferredAudioLanguageCode ?? template.PreferredAudioLanguageCode ?? string.Empty,
PreferredAudioTitle = advanced.PreferredAudioTitle ?? template.PreferredAudioTitle ?? string.Empty,
PreferredSubtitleLanguageCode =
advanced.PreferredSubtitleLanguageCode ?? template.PreferredSubtitleLanguageCode ?? string.Empty,
SubtitleMode = advanced.SubtitleMode ?? template.SubtitleMode
};
private static string GeneratedName(string channelNumber, string channelName, string suffix)
{
string prefix = $"{channelNumber} {channelName}".Trim();
int maxPrefixLength = Math.Max(0, 50 - suffix.Length - 1);
if (prefix.Length > maxPrefixLength)
{
prefix = prefix[..maxPrefixLength].TrimEnd();
}
return $"{prefix} {suffix}".Trim();
}
// De-collide a generated (already <= 50 char) name against a unique index by appending " 2", " 3", ...
// rather than leaking a raw UNIQUE-constraint failure.
private static async Task<string> DeCollideName(
string baseName,
Func<string, CancellationToken, Task<bool>> exists,
CancellationToken cancellationToken)
{
if (!await exists(baseName, cancellationToken))
{
return baseName;
}
for (int n = 2; ; n++)
{
string suffix = $" {n}";
string candidate = baseName.Length + suffix.Length > 50
? baseName[..(50 - suffix.Length)].TrimEnd() + suffix
: baseName + suffix;
if (!await exists(candidate, cancellationToken))
{
return candidate;
}
}
}
private static async Task<Either<BaseError, Unit>> ValidateReferences(
TvContext dbContext,
CreateChannelFromLineupAdvancedOptions advanced,
ChannelTemplate template,
CancellationToken cancellationToken)
{
int ffmpegProfileId = advanced.FFmpegProfileId ?? template.FFmpegProfileId;
if (!await dbContext.FFmpegProfiles.AnyAsync(p => p.Id == ffmpegProfileId, cancellationToken))
{
return new NotFoundError($"FFmpegProfile {ffmpegProfileId} does not exist.");
}
Either<BaseError, Unit> channelReferences = await ValidateChannelReferences(
dbContext,
advanced.WatermarkId ?? template.WatermarkId,
advanced.FallbackFillerId ?? template.FallbackFillerId,
cancellationToken);
foreach (BaseError error in channelReferences.LeftToSeq())
{
return error;
}
Either<BaseError, Unit> itemFillers = await ValidateItemFillers(
dbContext,
advanced.PreRollFillerId ?? template.PreRollFillerId,
advanced.MidRollFillerId ?? template.MidRollFillerId,
advanced.PostRollFillerId ?? template.PostRollFillerId,
cancellationToken);
foreach (BaseError error in itemFillers.LeftToSeq())
{
return error;
}
return Unit.Default;
}
private static async Task<Either<BaseError, Unit>> ValidateChannelReferences(
TvContext dbContext,
int? watermarkId,
int? fallbackFillerId,
CancellationToken cancellationToken)
{
if (watermarkId.HasValue &&
!await dbContext.ChannelWatermarks.AnyAsync(w => w.Id == watermarkId.Value, cancellationToken))
{
return new NotFoundError($"Watermark {watermarkId.Value} does not exist.");
}
if (fallbackFillerId.HasValue && !await dbContext.FillerPresets.AnyAsync(
fp => fp.Id == fallbackFillerId.Value && fp.FillerKind == FillerKind.Fallback,
cancellationToken))
{
return new NotFoundError($"Fallback filler {fallbackFillerId.Value} does not exist.");
}
return Unit.Default;
}
private static async Task<Either<BaseError, Unit>> ValidateItemFillers(
TvContext dbContext,
int? preRollFillerId,
int? midRollFillerId,
int? postRollFillerId,
CancellationToken cancellationToken)
{
if (preRollFillerId.HasValue && !await FillerExists(dbContext, preRollFillerId.Value, FillerKind.PreRoll, cancellationToken))
{
return new NotFoundError($"Pre-roll filler {preRollFillerId.Value} does not exist.");
}
if (midRollFillerId.HasValue && !await FillerExists(dbContext, midRollFillerId.Value, FillerKind.MidRoll, cancellationToken))
{
return new NotFoundError($"Mid-roll filler {midRollFillerId.Value} does not exist.");
}
if (postRollFillerId.HasValue && !await FillerExists(dbContext, postRollFillerId.Value, FillerKind.PostRoll, cancellationToken))
{
return new NotFoundError($"Post-roll filler {postRollFillerId.Value} does not exist.");
}
return Unit.Default;
}
private static Task<bool> FillerExists(
TvContext dbContext,
int id,
FillerKind fillerKind,
CancellationToken cancellationToken) =>
dbContext.FillerPresets.AnyAsync(fp => fp.Id == id && fp.FillerKind == fillerKind, cancellationToken);
private static async Task<Either<BaseError, NormalizedLineupItem>> NormalizeLineupItem(
TvContext dbContext,
CreateChannelFromLineupItem item,
int index,
CancellationToken cancellationToken)
{
int providedIds = new int?[]
{
item.CollectionId,
item.MultiCollectionId,
item.SmartCollectionId,
item.RerunCollectionId,
item.MediaItemId,
item.PlaylistId
}.Count(id => id.HasValue);
if (providedIds != 1)
{
return BaseError.New($"lineup[{index}] must provide exactly one typed id.");
}
switch (item.MediaType)
{
case LibraryBrowseMediaType.Movie:
return await NormalizeMediaItem(
dbContext.Movies, item, CollectionType.Movie, index, "Movie", cancellationToken);
case LibraryBrowseMediaType.TelevisionShow:
return await NormalizeMediaItem(
dbContext.Shows, item, CollectionType.TelevisionShow, index, "TelevisionShow", cancellationToken);
case LibraryBrowseMediaType.TelevisionSeason:
return await NormalizeMediaItem(
dbContext.Seasons, item, CollectionType.TelevisionSeason, index, "TelevisionSeason", cancellationToken);
case LibraryBrowseMediaType.Artist:
return await NormalizeMediaItem(
dbContext.Artists, item, CollectionType.Artist, index, "Artist", cancellationToken);
case LibraryBrowseMediaType.Collection:
if (item.CollectionType != CollectionType.Collection)
{
return Mismatch(index, item);
}
if (!item.CollectionId.HasValue)
{
return WrongId(index, item.MediaType, "collectionId");
}
return await ExistsThen(
dbContext.Collections,
item.CollectionId.Value,
$"lineup[{index}] Collection",
new NormalizedLineupItem(index, CollectionType.Collection, CollectionId: item.CollectionId),
cancellationToken);
case LibraryBrowseMediaType.SmartCollection:
if (item.CollectionType != CollectionType.SmartCollection)
{
return Mismatch(index, item);
}
if (!item.SmartCollectionId.HasValue)
{
return WrongId(index, item.MediaType, "smartCollectionId");
}
return await ExistsThen(
dbContext.SmartCollections,
item.SmartCollectionId.Value,
$"lineup[{index}] SmartCollection",
new NormalizedLineupItem(index, CollectionType.SmartCollection, SmartCollectionId: item.SmartCollectionId),
cancellationToken);
case LibraryBrowseMediaType.MultiCollection:
if (item.CollectionType != CollectionType.MultiCollection)
{
return Mismatch(index, item);
}
if (!item.MultiCollectionId.HasValue)
{
return WrongId(index, item.MediaType, "multiCollectionId");
}
return await ExistsThen(
dbContext.MultiCollections,
item.MultiCollectionId.Value,
$"lineup[{index}] MultiCollection",
new NormalizedLineupItem(index, CollectionType.MultiCollection, MultiCollectionId: item.MultiCollectionId),
cancellationToken);
case LibraryBrowseMediaType.RerunCollection:
if (item.CollectionType != CollectionType.RerunFirstRun)
{
return Mismatch(index, item);
}
if (!item.RerunCollectionId.HasValue)
{
return WrongId(index, item.MediaType, "rerunCollectionId");
}
return await ExistsThen(
dbContext.RerunCollections,
item.RerunCollectionId.Value,
$"lineup[{index}] RerunCollection",
new NormalizedLineupItem(index, CollectionType.RerunFirstRun, RerunCollectionId: item.RerunCollectionId),
cancellationToken);
case LibraryBrowseMediaType.Playlist:
if (item.CollectionType != CollectionType.Playlist)
{
return Mismatch(index, item);
}
if (!item.PlaylistId.HasValue)
{
return WrongId(index, item.MediaType, "playlistId");
}
return await ExistsThen(
dbContext.Playlists,
item.PlaylistId.Value,
$"lineup[{index}] Playlist",
new NormalizedLineupItem(index, CollectionType.Playlist, PlaylistId: item.PlaylistId),
cancellationToken);
default:
return BaseError.New($"lineup[{index}] has an unsupported media type '{item.MediaType}'.");
}
}
private static async Task<Either<BaseError, NormalizedLineupItem>> NormalizeMediaItem<TEntity>(
DbSet<TEntity> set,
CreateChannelFromLineupItem item,
CollectionType expectedType,
int index,
string label,
CancellationToken cancellationToken)
where TEntity : class
{
if (item.CollectionType != expectedType)
{
return Mismatch(index, item);
}
if (!item.MediaItemId.HasValue)
{
return WrongId(index, item.MediaType, "mediaItemId");
}
return await ExistsThen(
set,
item.MediaItemId.Value,
$"lineup[{index}] {label}",
new NormalizedLineupItem(index, expectedType, MediaItemId: item.MediaItemId),
cancellationToken);
}
private static BaseError Mismatch(int index, CreateChannelFromLineupItem item) =>
BaseError.New(
$"lineup[{index}]: media type '{item.MediaType}' does not match collection type '{item.CollectionType}'.");
private static BaseError WrongId(int index, LibraryBrowseMediaType mediaType, string expectedField) =>
BaseError.New($"lineup[{index}]: media type '{mediaType}' requires a {expectedField}.");
private static async Task<Either<BaseError, NormalizedLineupItem>> ExistsThen<TEntity>(
DbSet<TEntity> set,
int id,
string label,
NormalizedLineupItem normalized,
CancellationToken cancellationToken)
where TEntity : class
{
bool exists = await set.AsNoTracking()
.AnyAsync(e => EF.Property<int>(e, "Id") == id, cancellationToken);
return exists
? normalized
: new NotFoundError($"{label} {id} does not exist.");
}
private sealed record NormalizedLineupItem(
int Index,
CollectionType CollectionType,
int? CollectionId = null,
int? MultiCollectionId = null,
int? SmartCollectionId = null,
int? RerunCollectionId = null,
int? MediaItemId = null,
int? PlaylistId = null);
private sealed record PreparedCreate(
Channel Channel,
Playlist Playlist,
ProgramSchedule ProgramSchedule,
Playout Playout);
}
@@ -0,0 +1,10 @@
using ErsatzTV.Core.Api.LibraryBrowse;
namespace ErsatzTV.Application.LibraryBrowse;
public record GetLibraryBrowseItems(
string Query,
int? LibraryId,
LibraryBrowseMediaType? MediaType,
int PageNum,
int PageSize) : IRequest<PagedLibraryBrowseItemsResponseModel>;
@@ -0,0 +1,953 @@
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;
public class GetLibraryBrowseItemsHandler(
ISearchIndex searchIndex,
IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetLibraryBrowseItems, PagedLibraryBrowseItemsResponseModel>
{
private const string LibraryIdField = "library_id";
public async Task<PagedLibraryBrowseItemsResponseModel> Handle(
GetLibraryBrowseItems request,
CancellationToken cancellationToken)
{
int offset = request.PageNum * request.PageSize;
SearchResult mediaResult = await SearchMedia(request, offset, request.PageSize, cancellationToken);
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
List<LibraryBrowseItemResponseModel> mediaItems = await HydrateMediaItems(
dbContext,
mediaResult.Items,
cancellationToken);
int collectionTotal = await CountCollections(dbContext, request, cancellationToken);
// Lucene total count can briefly include stale media ids until the async rescan catches up; collection
// paging may drift in that window, matching the staleness behavior accepted by the existing search UI.
int collectionSkip = Math.Max(0, offset - mediaResult.TotalCount);
int collectionTake = request.PageSize - mediaItems.Count;
List<LibraryBrowseItemResponseModel> collectionItems = collectionTake > 0
? await GetCollectionItems(dbContext, request, collectionSkip, collectionTake, cancellationToken)
: [];
return new PagedLibraryBrowseItemsResponseModel(
mediaResult.TotalCount + collectionTotal,
mediaItems.Concat(collectionItems).ToList());
}
private async Task<SearchResult> SearchMedia(
GetLibraryBrowseItems request,
int offset,
int pageSize,
CancellationToken cancellationToken)
{
List<string> typeFilters = MediaTypesFor(request.MediaType).ToList();
if (typeFilters.Count == 0)
{
return new SearchResult([], 0);
}
var clauses = new List<string>
{
typeFilters.Count == 1
? $"type:{typeFilters[0]}"
: $"({string.Join(" OR ", typeFilters.Map(t => $"type:{t}"))})"
};
if (request.LibraryId.HasValue)
{
clauses.Add($"{LibraryIdField}:{request.LibraryId.Value}");
}
if (!string.IsNullOrWhiteSpace(request.Query))
{
clauses.Add($"({request.Query})");
}
return await searchIndex.Search(
string.Join(" AND ", clauses),
string.Empty,
offset,
pageSize,
[LuceneSearchIndex.TitleAndYearSearchField],
cancellationToken);
}
private static IEnumerable<string> MediaTypesFor(LibraryBrowseMediaType? mediaType) =>
mediaType switch
{
LibraryBrowseMediaType.Movie => [LuceneSearchIndex.MovieType],
LibraryBrowseMediaType.TelevisionShow => [LuceneSearchIndex.ShowType],
LibraryBrowseMediaType.TelevisionSeason => [LuceneSearchIndex.SeasonType],
LibraryBrowseMediaType.Artist => [LuceneSearchIndex.ArtistType],
null =>
[
LuceneSearchIndex.MovieType,
LuceneSearchIndex.ShowType,
LuceneSearchIndex.SeasonType,
LuceneSearchIndex.ArtistType
],
_ => []
};
private static async Task<List<LibraryBrowseItemResponseModel>> HydrateMediaItems(
TvContext dbContext,
List<SearchItem> searchItems,
CancellationToken cancellationToken)
{
if (searchItems.Count == 0)
{
return [];
}
List<int> movieIds = searchItems.Where(i => i.Type == LuceneSearchIndex.MovieType).Select(i => i.Id).ToList();
List<int> showIds = searchItems.Where(i => i.Type == LuceneSearchIndex.ShowType).Select(i => i.Id).ToList();
List<int> seasonIds = searchItems.Where(i => i.Type == LuceneSearchIndex.SeasonType).Select(i => i.Id).ToList();
List<int> artistIds = searchItems.Where(i => i.Type == LuceneSearchIndex.ArtistType).Select(i => i.Id).ToList();
Dictionary<(string Type, int Id), LibraryBrowseItemResponseModel> hydrated = [];
foreach (LibraryBrowseItemResponseModel item in await GetMovies(dbContext, movieIds, cancellationToken))
{
hydrated[(LuceneSearchIndex.MovieType, item.Id)] = item;
}
foreach (LibraryBrowseItemResponseModel item in await GetShows(dbContext, showIds, cancellationToken))
{
hydrated[(LuceneSearchIndex.ShowType, item.Id)] = item;
}
foreach (LibraryBrowseItemResponseModel item in await GetSeasons(dbContext, seasonIds, cancellationToken))
{
hydrated[(LuceneSearchIndex.SeasonType, item.Id)] = item;
}
foreach (LibraryBrowseItemResponseModel item in await GetArtists(dbContext, artistIds, cancellationToken))
{
hydrated[(LuceneSearchIndex.ArtistType, 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(
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());
}
private 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());
}
private 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)
.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());
}
private 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());
}
private static async Task<int> CountCollections(
TvContext dbContext,
GetLibraryBrowseItems request,
CancellationToken cancellationToken)
{
int count = 0;
if (ShouldInclude(request.MediaType, LibraryBrowseMediaType.Collection))
{
count += await FilterCollections(dbContext.Collections.AsNoTracking(), request)
.CountAsync(cancellationToken);
}
if (!request.LibraryId.HasValue && ShouldInclude(request.MediaType, LibraryBrowseMediaType.SmartCollection))
{
count += await FilterByName(dbContext.SmartCollections.AsNoTracking(), request.Query)
.CountAsync(cancellationToken);
}
if (!request.LibraryId.HasValue && ShouldInclude(request.MediaType, LibraryBrowseMediaType.MultiCollection))
{
count += await FilterByName(dbContext.MultiCollections.AsNoTracking(), request.Query)
.CountAsync(cancellationToken);
}
if (!request.LibraryId.HasValue && ShouldInclude(request.MediaType, LibraryBrowseMediaType.RerunCollection))
{
count += await FilterByName(dbContext.RerunCollections.AsNoTracking(), request.Query)
.CountAsync(cancellationToken);
}
if (!request.LibraryId.HasValue && ShouldInclude(request.MediaType, LibraryBrowseMediaType.Playlist))
{
count += await FilterByName(dbContext.Playlists.AsNoTracking(), request.Query)
.CountAsync(cancellationToken);
}
return count;
}
private static async Task<List<LibraryBrowseItemResponseModel>> GetCollectionItems(
TvContext dbContext,
GetLibraryBrowseItems request,
int skip,
int take,
CancellationToken cancellationToken)
{
var results = new List<LibraryBrowseItemResponseModel>();
int remainingSkip = skip;
if (ShouldInclude(request.MediaType, LibraryBrowseMediaType.Collection))
{
IQueryable<Collection> collectionQuery = FilterCollections(dbContext.Collections.AsNoTracking(), request);
int count = await collectionQuery.CountAsync(cancellationToken);
int pageSkip = Math.Min(remainingSkip, count);
remainingSkip -= pageSkip;
List<Collection> collections = await collectionQuery
.OrderBy(c => c.Name)
.Skip(pageSkip)
.Take(take)
.ToListAsync(cancellationToken);
List<int> collectionIds = collections.Map(c => c.Id).ToList();
Dictionary<int, int> itemCounts = await dbContext.CollectionItems
.AsNoTracking()
.Where(ci => collectionIds.Contains(ci.CollectionId))
.GroupBy(ci => ci.CollectionId)
.Select(g => new { CollectionId = g.Key, Count = g.Count() })
.ToDictionaryAsync(g => g.CollectionId, g => g.Count, cancellationToken);
Dictionary<int, TimeSpan?> durations = await GetManualCollectionDurations(
dbContext,
collectionIds,
cancellationToken);
Dictionary<int, string> artwork = await GetManualCollectionArtwork(dbContext, collectionIds, cancellationToken);
results.AddRange(collections.Map(c => new LibraryBrowseItemResponseModel(
c.Id,
LibraryBrowseMediaType.Collection,
c.Name,
null,
null,
artwork.TryGetValue(c.Id, out string poster) ? poster : string.Empty,
durations.TryGetValue(c.Id, out TimeSpan? duration) ? duration : null,
itemCounts.TryGetValue(c.Id, out int itemCount) ? itemCount : 0,
"Manual",
CollectionType.Collection,
c.Id,
null,
null,
null,
null,
null)));
}
if (results.Count >= take || request.LibraryId.HasValue)
{
return results.Take(take).ToList();
}
remainingSkip = await AppendSmartCollections(dbContext, request, results, remainingSkip, take, cancellationToken);
remainingSkip = await AppendMultiCollections(dbContext, request, results, remainingSkip, take, cancellationToken);
remainingSkip = await AppendRerunCollections(dbContext, request, results, remainingSkip, take, cancellationToken);
await AppendPlaylists(dbContext, request, results, remainingSkip, take, cancellationToken);
return results.Take(take).ToList();
}
private static async Task<int> AppendSmartCollections(
TvContext dbContext,
GetLibraryBrowseItems request,
List<LibraryBrowseItemResponseModel> results,
int skip,
int take,
CancellationToken cancellationToken)
{
if (!ShouldInclude(request.MediaType, LibraryBrowseMediaType.SmartCollection) || results.Count >= take)
{
return skip;
}
IQueryable<SmartCollection> query = FilterByName(dbContext.SmartCollections.AsNoTracking(), request.Query);
int count = await query.CountAsync(cancellationToken);
int pageSkip = Math.Min(skip, count);
List<SmartCollection> page = await query
.OrderBy(c => c.Name)
.Skip(pageSkip)
.Take(take - results.Count)
.ToListAsync(cancellationToken);
results.AddRange(page.Map(c => new LibraryBrowseItemResponseModel(
c.Id,
LibraryBrowseMediaType.SmartCollection,
c.Name,
null,
null,
string.Empty,
null,
null,
"Smart",
CollectionType.SmartCollection,
null,
null,
c.Id,
null,
null,
null)));
return skip - pageSkip;
}
private static async Task<int> AppendMultiCollections(
TvContext dbContext,
GetLibraryBrowseItems request,
List<LibraryBrowseItemResponseModel> results,
int skip,
int take,
CancellationToken cancellationToken)
{
if (!ShouldInclude(request.MediaType, LibraryBrowseMediaType.MultiCollection) || results.Count >= take)
{
return skip;
}
IQueryable<MultiCollection> query = FilterByName(dbContext.MultiCollections.AsNoTracking(), request.Query);
int count = await query.CountAsync(cancellationToken);
int pageSkip = Math.Min(skip, count);
List<MultiCollection> page = await query
.OrderBy(c => c.Name)
.Skip(pageSkip)
.Take(take - results.Count)
.ToListAsync(cancellationToken);
List<int> ids = page.Map(c => c.Id).ToList();
Dictionary<int, int> collectionCounts = await dbContext.Set<MultiCollectionItem>()
.AsNoTracking()
.Where(i => ids.Contains(i.MultiCollectionId))
.GroupBy(i => i.MultiCollectionId)
.Select(g => new { MultiCollectionId = g.Key, Count = g.Count() })
.ToDictionaryAsync(g => g.MultiCollectionId, g => g.Count, cancellationToken);
Dictionary<int, int> smartCollectionCounts = await dbContext.Set<MultiCollectionSmartItem>()
.AsNoTracking()
.Where(i => ids.Contains(i.MultiCollectionId))
.GroupBy(i => i.MultiCollectionId)
.Select(g => new { MultiCollectionId = g.Key, Count = g.Count() })
.ToDictionaryAsync(g => g.MultiCollectionId, g => g.Count, cancellationToken);
results.AddRange(page.Map(c => new LibraryBrowseItemResponseModel(
c.Id,
LibraryBrowseMediaType.MultiCollection,
c.Name,
null,
null,
string.Empty,
null,
(collectionCounts.TryGetValue(c.Id, out int collectionCount) ? collectionCount : 0) +
(smartCollectionCounts.TryGetValue(c.Id, out int smartCollectionCount) ? smartCollectionCount : 0),
"Multi",
CollectionType.MultiCollection,
null,
c.Id,
null,
null,
null,
null)));
return skip - pageSkip;
}
private static async Task<int> AppendRerunCollections(
TvContext dbContext,
GetLibraryBrowseItems request,
List<LibraryBrowseItemResponseModel> results,
int skip,
int take,
CancellationToken cancellationToken)
{
if (!ShouldInclude(request.MediaType, LibraryBrowseMediaType.RerunCollection) || results.Count >= take)
{
return skip;
}
IQueryable<RerunCollection> query = FilterByName(dbContext.RerunCollections.AsNoTracking(), request.Query);
int count = await query.CountAsync(cancellationToken);
int pageSkip = Math.Min(skip, count);
List<RerunCollection> page = await query
.OrderBy(c => c.Name)
.Skip(pageSkip)
.Take(take - results.Count)
.ToListAsync(cancellationToken);
results.AddRange(page.Map(c => new LibraryBrowseItemResponseModel(
c.Id,
LibraryBrowseMediaType.RerunCollection,
c.Name,
null,
null,
string.Empty,
null,
null,
"Rerun",
// RerunFirstRun addresses the first-run side; the SPA can substitute RerunRerun for rerun schedule items.
CollectionType.RerunFirstRun,
null,
null,
null,
c.Id,
null,
null)));
return skip - pageSkip;
}
private static async Task AppendPlaylists(
TvContext dbContext,
GetLibraryBrowseItems request,
List<LibraryBrowseItemResponseModel> results,
int skip,
int take,
CancellationToken cancellationToken)
{
if (!ShouldInclude(request.MediaType, LibraryBrowseMediaType.Playlist) || results.Count >= take)
{
return;
}
IQueryable<Playlist> query = FilterByName(dbContext.Playlists.AsNoTracking(), request.Query);
int count = await query.CountAsync(cancellationToken);
int pageSkip = Math.Min(skip, count);
List<Playlist> page = await query
.OrderBy(c => c.Name)
.Skip(pageSkip)
.Take(take - results.Count)
.ToListAsync(cancellationToken);
List<int> ids = page.Map(c => c.Id).ToList();
Dictionary<int, int> itemCounts = await dbContext.PlaylistItems
.AsNoTracking()
.Where(i => ids.Contains(i.PlaylistId))
.GroupBy(i => i.PlaylistId)
.Select(g => new { PlaylistId = g.Key, Count = g.Count() })
.ToDictionaryAsync(g => g.PlaylistId, g => g.Count, cancellationToken);
results.AddRange(page.Map(c => new LibraryBrowseItemResponseModel(
c.Id,
LibraryBrowseMediaType.Playlist,
c.Name,
null,
null,
string.Empty,
null,
itemCounts.TryGetValue(c.Id, out int itemCount) ? itemCount : 0,
null,
CollectionType.Playlist,
null,
null,
null,
null,
null,
c.Id)));
}
private static IQueryable<Collection> FilterCollections(
IQueryable<Collection> query,
GetLibraryBrowseItems request)
{
query = FilterByName(query, request.Query);
if (request.LibraryId.HasValue)
{
query = query.Where(c => c.MediaItems.Any(mi => mi.LibraryPath.LibraryId == request.LibraryId.Value));
}
return query;
}
private static IQueryable<T> FilterByName<T>(IQueryable<T> query, string searchQuery)
where T : class
{
if (string.IsNullOrWhiteSpace(searchQuery))
{
return query;
}
return query.Where(c => EF.Functions.Like(EF.Property<string>(c, "Name"), $"%{EscapeLike(searchQuery)}%", "\\"));
}
private static bool ShouldInclude(LibraryBrowseMediaType? requestType, LibraryBrowseMediaType itemType) =>
requestType is null || requestType == itemType;
private static 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,
CancellationToken cancellationToken)
{
List<CollectionMediaItem> items = await GetCollectionMediaItems(dbContext, collectionIds, cancellationToken);
List<int> mediaItemIds = items.Map(i => i.MediaItemId).Distinct().ToList();
Dictionary<int, TimeSpan> mediaItemDurations = [];
foreach (Movie movie in await dbContext.Movies
.AsNoTracking()
.Where(m => mediaItemIds.Contains(m.Id))
.Include(m => m.MediaVersions)
.ToListAsync(cancellationToken))
{
TimeSpan? duration = BestDuration(movie.MediaVersions);
if (duration.HasValue)
{
mediaItemDurations[movie.Id] = duration.Value;
}
}
foreach (Episode episode in await dbContext.Episodes
.AsNoTracking()
.Where(e => mediaItemIds.Contains(e.Id))
.Include(e => e.MediaVersions)
.ToListAsync(cancellationToken))
{
TimeSpan? duration = BestDuration(episode.MediaVersions);
if (duration.HasValue)
{
mediaItemDurations[episode.Id] = duration.Value;
}
}
foreach (MusicVideo musicVideo in await dbContext.MusicVideos
.AsNoTracking()
.Where(mv => mediaItemIds.Contains(mv.Id))
.Include(mv => mv.MediaVersions)
.ToListAsync(cancellationToken))
{
TimeSpan? duration = BestDuration(musicVideo.MediaVersions);
if (duration.HasValue)
{
mediaItemDurations[musicVideo.Id] = duration.Value;
}
}
foreach (OtherVideo otherVideo in await dbContext.OtherVideos
.AsNoTracking()
.Where(ov => mediaItemIds.Contains(ov.Id))
.Include(ov => ov.MediaVersions)
.ToListAsync(cancellationToken))
{
TimeSpan? duration = BestDuration(otherVideo.MediaVersions);
if (duration.HasValue)
{
mediaItemDurations[otherVideo.Id] = duration.Value;
}
}
foreach (Song song in await dbContext.Songs
.AsNoTracking()
.Where(s => mediaItemIds.Contains(s.Id))
.Include(s => s.MediaVersions)
.ToListAsync(cancellationToken))
{
TimeSpan? duration = BestDuration(song.MediaVersions);
if (duration.HasValue)
{
mediaItemDurations[song.Id] = duration.Value;
}
}
foreach (Image image in await dbContext.Images
.AsNoTracking()
.Where(i => mediaItemIds.Contains(i.Id))
.Include(i => i.MediaVersions)
.ToListAsync(cancellationToken))
{
TimeSpan? duration = BestDuration(image.MediaVersions);
if (duration.HasValue)
{
mediaItemDurations[image.Id] = duration.Value;
}
}
foreach (RemoteStream remoteStream in await dbContext.RemoteStreams
.AsNoTracking()
.Where(rs => mediaItemIds.Contains(rs.Id))
.Include(rs => rs.MediaVersions)
.ToListAsync(cancellationToken))
{
TimeSpan? duration = BestDuration(remoteStream.MediaVersions);
if (duration.HasValue)
{
mediaItemDurations[remoteStream.Id] = duration.Value;
}
}
return items
.Where(i => mediaItemDurations.ContainsKey(i.MediaItemId))
.GroupBy(i => i.CollectionId)
.ToDictionary(
g => g.Key,
g => (TimeSpan?)g.Aggregate(TimeSpan.Zero, (sum, item) => sum + mediaItemDurations[item.MediaItemId]));
}
private static async Task<Dictionary<int, string>> GetManualCollectionArtwork(
TvContext dbContext,
List<int> collectionIds,
CancellationToken cancellationToken)
{
List<CollectionMediaItem> items = await GetCollectionMediaItems(dbContext, collectionIds, cancellationToken);
List<int> mediaItemIds = items.Map(i => i.MediaItemId).Distinct().ToList();
Dictionary<int, string> mediaItemArtwork = [];
foreach (MovieMetadata metadata in await dbContext.MovieMetadata
.AsNoTracking()
.Where(mm => mediaItemIds.Contains(mm.MovieId))
.Include(mm => mm.Artwork)
.OrderBy(mm => mm.Id)
.ToListAsync(cancellationToken))
{
string poster = Artwork(metadata, ArtworkKind.Poster);
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.MovieId))
{
mediaItemArtwork[metadata.MovieId] = poster;
}
}
foreach (ShowMetadata metadata in await dbContext.ShowMetadata
.AsNoTracking()
.Where(sm => mediaItemIds.Contains(sm.ShowId))
.Include(sm => sm.Artwork)
.OrderBy(sm => sm.Id)
.ToListAsync(cancellationToken))
{
string poster = Artwork(metadata, ArtworkKind.Poster);
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.ShowId))
{
mediaItemArtwork[metadata.ShowId] = poster;
}
}
foreach (SeasonMetadata metadata in await dbContext.SeasonMetadata
.AsNoTracking()
.Where(sm => mediaItemIds.Contains(sm.SeasonId))
.Include(sm => sm.Artwork)
.OrderBy(sm => sm.Id)
.ToListAsync(cancellationToken))
{
string poster = Artwork(metadata, ArtworkKind.Poster);
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.SeasonId))
{
mediaItemArtwork[metadata.SeasonId] = poster;
}
}
foreach (OtherVideoMetadata metadata in await dbContext.OtherVideoMetadata
.AsNoTracking()
.Where(ovm => mediaItemIds.Contains(ovm.OtherVideoId))
.Include(ovm => ovm.Artwork)
.OrderBy(ovm => ovm.Id)
.ToListAsync(cancellationToken))
{
string poster = Artwork(metadata, ArtworkKind.Poster);
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.OtherVideoId))
{
mediaItemArtwork[metadata.OtherVideoId] = poster;
}
}
foreach (SongMetadata metadata in await dbContext.SongMetadata
.AsNoTracking()
.Where(sm => mediaItemIds.Contains(sm.SongId))
.Include(sm => sm.Artwork)
.OrderBy(sm => sm.Id)
.ToListAsync(cancellationToken))
{
string poster = Artwork(metadata, ArtworkKind.Poster);
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.SongId))
{
mediaItemArtwork[metadata.SongId] = poster;
}
}
foreach (ImageMetadata metadata in await dbContext.ImageMetadata
.AsNoTracking()
.Where(im => mediaItemIds.Contains(im.ImageId))
.Include(im => im.Artwork)
.OrderBy(im => im.Id)
.ToListAsync(cancellationToken))
{
string poster = Artwork(metadata, ArtworkKind.Poster);
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.ImageId))
{
mediaItemArtwork[metadata.ImageId] = poster;
}
}
foreach (RemoteStreamMetadata metadata in await dbContext.RemoteStreamMetadata
.AsNoTracking()
.Where(rsm => mediaItemIds.Contains(rsm.RemoteStreamId))
.Include(rsm => rsm.Artwork)
.OrderBy(rsm => rsm.Id)
.ToListAsync(cancellationToken))
{
string poster = Artwork(metadata, ArtworkKind.Poster);
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.RemoteStreamId))
{
mediaItemArtwork[metadata.RemoteStreamId] = poster;
}
}
return items
.Where(i => mediaItemArtwork.ContainsKey(i.MediaItemId))
.GroupBy(i => i.CollectionId)
.ToDictionary(
g => g.Key,
g => mediaItemArtwork[g.OrderBy(i => i.CustomIndex ?? int.MaxValue).ThenBy(i => i.MediaItemId).First().MediaItemId]);
}
private static async Task<List<CollectionMediaItem>> GetCollectionMediaItems(
TvContext dbContext,
List<int> collectionIds,
CancellationToken cancellationToken) =>
await dbContext.CollectionItems
.AsNoTracking()
.Where(ci => collectionIds.Contains(ci.CollectionId))
.Select(ci => new CollectionMediaItem(ci.CollectionId, ci.MediaItemId, ci.CustomIndex))
.ToListAsync(cancellationToken);
private static string 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)
.Replace("%", "\\%", StringComparison.Ordinal)
.Replace("_", "\\_", StringComparison.Ordinal);
private sealed record CollectionMediaItem(int CollectionId, int MediaItemId, int? CustomIndex);
}
@@ -1,5 +1,4 @@
#nullable enable
using Dapper;
using ErsatzTV.Core.Api.MediaSources;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
@@ -63,15 +62,16 @@ public class GetAllMediaSourcesForApiHandler(
TvContext dbContext,
CancellationToken cancellationToken)
{
IEnumerable<LibraryItemCount> counts = await dbContext.Connection.QueryAsync<LibraryItemCount>(
new CommandDefinition(
@"SELECT LP.LibraryId AS LibraryId, COUNT(*) AS Count
FROM MediaItem
INNER JOIN LibraryPath LP on MediaItem.LibraryPathId = LP.Id
GROUP BY LP.LibraryId",
cancellationToken: cancellationToken));
// EF instead of Dapper: with zero rows, Microsoft.Data.Sqlite reports the
// COUNT(*) column as BLOB, so Dapper builds an incompatible deserializer
// and every fresh (empty) database returned 500 from this endpoint.
var counts = await dbContext.MediaItems
.AsNoTracking()
.GroupBy(mi => mi.LibraryPath.LibraryId)
.Select(g => new { LibraryId = g.Key, Count = g.Count() })
.ToListAsync(cancellationToken);
return counts.ToDictionary(c => (int)c.LibraryId, c => (int)c.Count);
return counts.ToDictionary(c => c.LibraryId, c => c.Count);
}
private static async Task<Dictionary<int, string>> GetConnectionAddresses(
@@ -143,6 +143,4 @@ public class GetAllMediaSourcesForApiHandler(
EmbyMediaSource emby => emby.ServerName,
_ => "Local"
};
private sealed record LibraryItemCount(long LibraryId, long Count);
}
@@ -1,6 +1,7 @@
using Dapper;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
@@ -18,23 +19,28 @@ public class DeleteCustomResolutionHandler : IRequestHandler<DeleteCustomResolut
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<Resolution> maybeResolution = await dbContext.Resolutions
Option<Resolution> maybeAnyResolution = await dbContext.Resolutions
.AsNoTracking()
.SelectOneAsync(p => p.Id, p => p.Id == request.ResolutionId && p.IsCustom == true, cancellationToken);
.SelectOneAsync(p => p.Id, p => p.Id == request.ResolutionId, cancellationToken);
foreach (Resolution resolution in maybeResolution)
foreach (Resolution existingResolution in maybeAnyResolution)
{
if (!existingResolution.IsCustom)
{
return BaseError.New($"Resolution {request.ResolutionId} is not a custom resolution.");
}
// reset any ffmpeg profiles using this resolution to 1920x1080
await dbContext.Connection.ExecuteAsync(
@"UPDATE FFmpegProfile SET ResolutionId = 3 WHERE ResolutionId = @ResolutionId",
new { request.ResolutionId });
dbContext.Resolutions.Remove(resolution);
dbContext.Resolutions.Remove(existingResolution);
await dbContext.SaveChangesAsync(cancellationToken);
}
return maybeResolution.IsNone
? BaseError.New($"Resolution {request.ResolutionId} does not exist.")
return maybeAnyResolution.IsNone
? new NotFoundError($"Resolution {request.ResolutionId} does not exist.")
: Option<BaseError>.None;
}
}
@@ -0,0 +1,36 @@
#nullable enable
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Core.Api.ChannelTemplates;
public record ChannelTemplateResponseModel(
int Id,
string Name,
string Description,
bool IsSystem,
bool IsDefault,
int FFmpegProfileId,
int? WatermarkId,
int? FallbackFillerId,
int? PreRollFillerId,
int? MidRollFillerId,
int? PostRollFillerId,
ChannelStreamSelectorMode StreamSelectorMode,
string? StreamSelector,
string? PreferredAudioLanguageCode,
string? PreferredAudioTitle,
ChannelPlayoutSource PlayoutSource,
ChannelPlayoutMode PlayoutMode,
StreamingMode StreamingMode,
string? PreferredSubtitleLanguageCode,
ChannelSubtitleMode SubtitleMode,
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
string? MusicVideoCreditsTemplate,
ChannelSongVideoMode SongVideoMode,
ChannelTranscodeMode TranscodeMode,
ChannelIdleBehavior IdleBehavior,
bool ShuffleScheduleItems,
bool RandomStartPoint,
FixedStartTimeBehavior FixedStartTimeBehavior);
@@ -0,0 +1,9 @@
#nullable enable
namespace ErsatzTV.Core.Api.Channels;
public record CreateChannelFromLineupResponseModel(
int ChannelId,
int? PlaylistId,
int ProgramScheduleId,
int PlayoutId);
@@ -0,0 +1,22 @@
#nullable enable
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Api.LibraryBrowse;
public record LibraryBrowseItemResponseModel(
int Id,
LibraryBrowseMediaType MediaType,
string Title,
int? LibraryId,
string? LibraryName,
string Artwork,
TimeSpan? Duration,
int? ItemCount,
string? CollectionKind,
CollectionType CollectionType,
int? CollectionId,
int? MultiCollectionId,
int? SmartCollectionId,
int? RerunCollectionId,
int? MediaItemId,
int? PlaylistId);
@@ -0,0 +1,15 @@
#nullable enable
namespace ErsatzTV.Core.Api.LibraryBrowse;
public enum LibraryBrowseMediaType
{
Movie = 1,
TelevisionShow = 2,
TelevisionSeason = 3,
Artist = 4,
Collection = 5,
SmartCollection = 6,
MultiCollection = 7,
RerunCollection = 8,
Playlist = 9
}
@@ -0,0 +1,6 @@
#nullable enable
namespace ErsatzTV.Core.Api.LibraryBrowse;
public record PagedLibraryBrowseItemsResponseModel(
int TotalCount,
List<LibraryBrowseItemResponseModel> Page);
@@ -0,0 +1,24 @@
using System.Text.Json.Serialization;
using ErsatzTV.FFmpeg.OutputFormat;
namespace ErsatzTV.Core.Api.Settings;
#nullable enable
public record FFmpegSettingsResponseModel(
string FFmpegPath,
string FFprobePath,
int DefaultFFmpegProfileId,
string PreferredAudioLanguageCode,
bool UseEmbeddedSubtitles,
bool ExtractEmbeddedSubtitles,
bool ProbeForInterlacedFrames,
bool SaveReports,
int? GlobalWatermarkId,
int? GlobalFallbackFillerId,
int HlsSegmenterIdleTimeout,
int WorkAheadSegmenterLimit,
int InitialSegmentCount,
[property: JsonConverter(typeof(JsonStringEnumConverter<OutputFormatKind>))]
OutputFormatKind HlsDirectOutputFormat,
string DefaultMpegTsScript);
@@ -0,0 +1,5 @@
namespace ErsatzTV.Core.Api.Settings;
#nullable enable
public record HdhrSettingsResponseModel(int TunerCount, Guid Uuid);
@@ -0,0 +1,20 @@
using System.Text.Json.Serialization;
using Serilog.Events;
namespace ErsatzTV.Core.Api.Settings;
#nullable enable
public record LoggingSettingsResponseModel(
[property: JsonConverter(typeof(JsonStringEnumConverter<LogEventLevel>))]
LogEventLevel DefaultMinimumLogLevel,
[property: JsonConverter(typeof(JsonStringEnumConverter<LogEventLevel>))]
LogEventLevel ScanningMinimumLogLevel,
[property: JsonConverter(typeof(JsonStringEnumConverter<LogEventLevel>))]
LogEventLevel SchedulingMinimumLogLevel,
[property: JsonConverter(typeof(JsonStringEnumConverter<LogEventLevel>))]
LogEventLevel SearchingMinimumLogLevel,
[property: JsonConverter(typeof(JsonStringEnumConverter<LogEventLevel>))]
LogEventLevel StreamingMinimumLogLevel,
[property: JsonConverter(typeof(JsonStringEnumConverter<LogEventLevel>))]
LogEventLevel HttpMinimumLogLevel);
@@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Api.Settings;
#nullable enable
public record PlayoutSettingsResponseModel(
int DaysToBuild,
bool SkipMissingItems,
int ScriptedScheduleTimeoutSeconds);
@@ -0,0 +1,5 @@
namespace ErsatzTV.Core.Api.Settings;
#nullable enable
public record ResolutionResponseModel(int Id, string Name, int Width, int Height, bool IsCustom);
@@ -0,0 +1,6 @@
namespace ErsatzTV.Core.Api.Settings;
#nullable enable
/// <summary>Library scan cadence. <see cref="LibraryRefreshInterval" /> is expressed in hours.</summary>
public record ScannerSettingsResponseModel(int LibraryRefreshInterval);
@@ -0,0 +1,5 @@
namespace ErsatzTV.Core.Api.Settings;
#nullable enable
public record UiSettingsResponseModel(bool IsDarkMode, string Language);
@@ -0,0 +1,16 @@
using System.Text.Json.Serialization;
namespace ErsatzTV.Core.Api.Settings;
/// <summary>
/// Wire-contract mirror of <c>ErsatzTV.Application.Configuration.XmltvBlockBehavior</c>.
/// Duplicated here (rather than referenced directly) because <c>ErsatzTV.Core</c> may not depend on
/// <c>ErsatzTV.Application</c> (see <c>ErsatzTV.Architecture.Tests</c>); the controller mapper translates
/// between the two by value.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<XmltvBlockBehavior>))]
public enum XmltvBlockBehavior
{
SplitTimeEvenly = 0,
UseActualTimes = 1
}
@@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Api.Settings;
#nullable enable
public record XmltvSettingsResponseModel(
int DaysToBuild,
XmltvTimeZone TimeZone,
XmltvBlockBehavior BlockBehavior);
@@ -0,0 +1,16 @@
using System.Text.Json.Serialization;
namespace ErsatzTV.Core.Api.Settings;
/// <summary>
/// Wire-contract mirror of <c>ErsatzTV.Application.Configuration.XmltvTimeZone</c>.
/// Duplicated here (rather than referenced directly) because <c>ErsatzTV.Core</c> may not depend on
/// <c>ErsatzTV.Application</c> (see <c>ErsatzTV.Architecture.Tests</c>); the controller mapper translates
/// between the two by value.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<XmltvTimeZone>))]
public enum XmltvTimeZone
{
Local = 0,
Utc = 1
}
+41
View File
@@ -0,0 +1,41 @@
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Core.Domain;
public class ChannelTemplate
{
public int Id { get; set; }
public string Name { get; set; }
public string Description { get; set; }
public bool IsSystem { get; set; }
public int FFmpegProfileId { get; set; }
public FFmpegProfile FFmpegProfile { get; set; }
public int? WatermarkId { get; set; }
public ChannelWatermark Watermark { get; set; }
public int? FallbackFillerId { get; set; }
public FillerPreset FallbackFiller { get; set; }
public int? PreRollFillerId { get; set; }
public FillerPreset PreRollFiller { get; set; }
public int? MidRollFillerId { get; set; }
public FillerPreset MidRollFiller { get; set; }
public int? PostRollFillerId { get; set; }
public FillerPreset PostRollFiller { get; set; }
public ChannelStreamSelectorMode StreamSelectorMode { get; set; }
public string StreamSelector { get; set; }
public string PreferredAudioLanguageCode { get; set; }
public string PreferredAudioTitle { get; set; }
public ChannelPlayoutSource PlayoutSource { get; set; }
public ChannelPlayoutMode PlayoutMode { get; set; }
public StreamingMode StreamingMode { get; set; }
public string PreferredSubtitleLanguageCode { get; set; }
public ChannelSubtitleMode SubtitleMode { get; set; }
public ChannelMusicVideoCreditsMode MusicVideoCreditsMode { get; set; }
public string MusicVideoCreditsTemplate { get; set; }
public ChannelSongVideoMode SongVideoMode { get; set; }
public ChannelTranscodeMode TranscodeMode { get; set; }
public ChannelIdleBehavior IdleBehavior { get; set; }
public bool ShuffleScheduleItems { get; set; }
public bool RandomStartPoint { get; set; }
public FixedStartTimeBehavior FixedStartTimeBehavior { get; set; }
}
+1
View File
@@ -23,6 +23,7 @@ public class ConfigElementKey
public static ConfigElementKey FFmpegPreferredLanguageCode => new("ffmpeg.preferred_language_code");
public static ConfigElementKey FFmpegGlobalWatermarkId => new("ffmpeg.global_watermark_id");
public static ConfigElementKey FFmpegGlobalFallbackFillerId => new("ffmpeg.global_fallback_filler_id");
public static ConfigElementKey ChannelTemplatesDefaultTemplateId => new("channel_templates.default_template_id");
public static ConfigElementKey FFmpegSegmenterTimeout => new("ffmpeg.segmenter.timeout_seconds");
public static ConfigElementKey FFmpegWorkAheadSegmenters => new("ffmpeg.segmenter.work_ahead_limit");
public static ConfigElementKey FFmpegInitialSegmentCount => new("ffmpeg.segmenter.initial_segment_count");
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,140 @@
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class AddChannelTemplates : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ChannelTemplate",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
Name = table.Column<string>(type: "varchar(50)", maxLength: 50, nullable: false, collation: "utf8mb4_general_ci")
.Annotation("MySql:CharSet", "utf8mb4"),
Description = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: false, defaultValue: "")
.Annotation("MySql:CharSet", "utf8mb4"),
IsSystem = table.Column<bool>(type: "tinyint(1)", nullable: false),
FFmpegProfileId = table.Column<int>(type: "int", nullable: false),
WatermarkId = table.Column<int>(type: "int", nullable: true),
FallbackFillerId = table.Column<int>(type: "int", nullable: true),
PreRollFillerId = table.Column<int>(type: "int", nullable: true),
MidRollFillerId = table.Column<int>(type: "int", nullable: true),
PostRollFillerId = table.Column<int>(type: "int", nullable: true),
StreamSelectorMode = table.Column<int>(type: "int", nullable: false),
StreamSelector = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
PreferredAudioLanguageCode = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
PreferredAudioTitle = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
PlayoutSource = table.Column<int>(type: "int", nullable: false),
PlayoutMode = table.Column<int>(type: "int", nullable: false),
StreamingMode = table.Column<int>(type: "int", nullable: false),
PreferredSubtitleLanguageCode = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
SubtitleMode = table.Column<int>(type: "int", nullable: false),
MusicVideoCreditsMode = table.Column<int>(type: "int", nullable: false),
MusicVideoCreditsTemplate = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
SongVideoMode = table.Column<int>(type: "int", nullable: false),
TranscodeMode = table.Column<int>(type: "int", nullable: false),
IdleBehavior = table.Column<int>(type: "int", nullable: false),
ShuffleScheduleItems = table.Column<bool>(type: "tinyint(1)", nullable: false),
RandomStartPoint = table.Column<bool>(type: "tinyint(1)", nullable: false),
FixedStartTimeBehavior = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ChannelTemplate", x => x.Id);
table.ForeignKey(
name: "FK_ChannelTemplate_ChannelWatermark_WatermarkId",
column: x => x.WatermarkId,
principalTable: "ChannelWatermark",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_ChannelTemplate_FFmpegProfile_FFmpegProfileId",
column: x => x.FFmpegProfileId,
principalTable: "FFmpegProfile",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_ChannelTemplate_FillerPreset_FallbackFillerId",
column: x => x.FallbackFillerId,
principalTable: "FillerPreset",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_ChannelTemplate_FillerPreset_MidRollFillerId",
column: x => x.MidRollFillerId,
principalTable: "FillerPreset",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_ChannelTemplate_FillerPreset_PostRollFillerId",
column: x => x.PostRollFillerId,
principalTable: "FillerPreset",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_ChannelTemplate_FillerPreset_PreRollFillerId",
column: x => x.PreRollFillerId,
principalTable: "FillerPreset",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_ChannelTemplate_FallbackFillerId",
table: "ChannelTemplate",
column: "FallbackFillerId");
migrationBuilder.CreateIndex(
name: "IX_ChannelTemplate_FFmpegProfileId",
table: "ChannelTemplate",
column: "FFmpegProfileId");
migrationBuilder.CreateIndex(
name: "IX_ChannelTemplate_MidRollFillerId",
table: "ChannelTemplate",
column: "MidRollFillerId");
migrationBuilder.CreateIndex(
name: "IX_ChannelTemplate_Name",
table: "ChannelTemplate",
column: "Name",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ChannelTemplate_PostRollFillerId",
table: "ChannelTemplate",
column: "PostRollFillerId");
migrationBuilder.CreateIndex(
name: "IX_ChannelTemplate_PreRollFillerId",
table: "ChannelTemplate",
column: "PreRollFillerId");
migrationBuilder.CreateIndex(
name: "IX_ChannelTemplate_WatermarkId",
table: "ChannelTemplate",
column: "WatermarkId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ChannelTemplate");
}
}
}
@@ -403,6 +403,119 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.ToTable("Channel", (string)null);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ChannelTemplate", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Description")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(500)
.HasColumnType("varchar(500)")
.HasDefaultValue("");
b.Property<int>("FFmpegProfileId")
.HasColumnType("int");
b.Property<int?>("FallbackFillerId")
.HasColumnType("int");
b.Property<int>("FixedStartTimeBehavior")
.HasColumnType("int");
b.Property<int>("IdleBehavior")
.HasColumnType("int");
b.Property<bool>("IsSystem")
.HasColumnType("tinyint(1)");
b.Property<int?>("MidRollFillerId")
.HasColumnType("int");
b.Property<int>("MusicVideoCreditsMode")
.HasColumnType("int");
b.Property<string>("MusicVideoCreditsTemplate")
.HasColumnType("longtext");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)")
.UseCollation("utf8mb4_general_ci");
b.Property<int>("PlayoutMode")
.HasColumnType("int");
b.Property<int>("PlayoutSource")
.HasColumnType("int");
b.Property<int?>("PostRollFillerId")
.HasColumnType("int");
b.Property<int?>("PreRollFillerId")
.HasColumnType("int");
b.Property<string>("PreferredAudioLanguageCode")
.HasColumnType("longtext");
b.Property<string>("PreferredAudioTitle")
.HasColumnType("longtext");
b.Property<string>("PreferredSubtitleLanguageCode")
.HasColumnType("longtext");
b.Property<bool>("RandomStartPoint")
.HasColumnType("tinyint(1)");
b.Property<bool>("ShuffleScheduleItems")
.HasColumnType("tinyint(1)");
b.Property<int>("SongVideoMode")
.HasColumnType("int");
b.Property<string>("StreamSelector")
.HasColumnType("longtext");
b.Property<int>("StreamSelectorMode")
.HasColumnType("int");
b.Property<int>("StreamingMode")
.HasColumnType("int");
b.Property<int>("SubtitleMode")
.HasColumnType("int");
b.Property<int>("TranscodeMode")
.HasColumnType("int");
b.Property<int?>("WatermarkId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("FFmpegProfileId");
b.HasIndex("FallbackFillerId");
b.HasIndex("MidRollFillerId");
b.HasIndex("Name")
.IsUnique();
b.HasIndex("PostRollFillerId");
b.HasIndex("PreRollFillerId");
b.HasIndex("WatermarkId");
b.ToTable("ChannelTemplate", (string)null);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ChannelWatermark", b =>
{
b.Property<int>("Id")
@@ -4559,6 +4672,52 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Navigation("Watermark");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ChannelTemplate", b =>
{
b.HasOne("ErsatzTV.Core.Domain.FFmpegProfile", "FFmpegProfile")
.WithMany()
.HasForeignKey("FFmpegProfileId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.Filler.FillerPreset", "FallbackFiller")
.WithMany()
.HasForeignKey("FallbackFillerId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ErsatzTV.Core.Domain.Filler.FillerPreset", "MidRollFiller")
.WithMany()
.HasForeignKey("MidRollFillerId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ErsatzTV.Core.Domain.Filler.FillerPreset", "PostRollFiller")
.WithMany()
.HasForeignKey("PostRollFillerId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ErsatzTV.Core.Domain.Filler.FillerPreset", "PreRollFiller")
.WithMany()
.HasForeignKey("PreRollFillerId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ErsatzTV.Core.Domain.ChannelWatermark", "Watermark")
.WithMany()
.HasForeignKey("WatermarkId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("FFmpegProfile");
b.Navigation("FallbackFiller");
b.Navigation("MidRollFiller");
b.Navigation("PostRollFiller");
b.Navigation("PreRollFiller");
b.Navigation("Watermark");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.CollectionItem", b =>
{
b.HasOne("ErsatzTV.Core.Domain.Collection", "Collection")
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,131 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class AddChannelTemplates : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ChannelTemplate",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>(type: "varchar(50)", maxLength: 50, nullable: false, collation: "NOCASE"),
Description = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: false, defaultValue: ""),
IsSystem = table.Column<bool>(type: "INTEGER", nullable: false),
FFmpegProfileId = table.Column<int>(type: "INTEGER", nullable: false),
WatermarkId = table.Column<int>(type: "INTEGER", nullable: true),
FallbackFillerId = table.Column<int>(type: "INTEGER", nullable: true),
PreRollFillerId = table.Column<int>(type: "INTEGER", nullable: true),
MidRollFillerId = table.Column<int>(type: "INTEGER", nullable: true),
PostRollFillerId = table.Column<int>(type: "INTEGER", nullable: true),
StreamSelectorMode = table.Column<int>(type: "INTEGER", nullable: false),
StreamSelector = table.Column<string>(type: "TEXT", nullable: true),
PreferredAudioLanguageCode = table.Column<string>(type: "TEXT", nullable: true),
PreferredAudioTitle = table.Column<string>(type: "TEXT", nullable: true),
PlayoutSource = table.Column<int>(type: "INTEGER", nullable: false),
PlayoutMode = table.Column<int>(type: "INTEGER", nullable: false),
StreamingMode = table.Column<int>(type: "INTEGER", nullable: false),
PreferredSubtitleLanguageCode = table.Column<string>(type: "TEXT", nullable: true),
SubtitleMode = table.Column<int>(type: "INTEGER", nullable: false),
MusicVideoCreditsMode = table.Column<int>(type: "INTEGER", nullable: false),
MusicVideoCreditsTemplate = table.Column<string>(type: "TEXT", nullable: true),
SongVideoMode = table.Column<int>(type: "INTEGER", nullable: false),
TranscodeMode = table.Column<int>(type: "INTEGER", nullable: false),
IdleBehavior = table.Column<int>(type: "INTEGER", nullable: false),
ShuffleScheduleItems = table.Column<bool>(type: "INTEGER", nullable: false),
RandomStartPoint = table.Column<bool>(type: "INTEGER", nullable: false),
FixedStartTimeBehavior = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ChannelTemplate", x => x.Id);
table.ForeignKey(
name: "FK_ChannelTemplate_ChannelWatermark_WatermarkId",
column: x => x.WatermarkId,
principalTable: "ChannelWatermark",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_ChannelTemplate_FFmpegProfile_FFmpegProfileId",
column: x => x.FFmpegProfileId,
principalTable: "FFmpegProfile",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_ChannelTemplate_FillerPreset_FallbackFillerId",
column: x => x.FallbackFillerId,
principalTable: "FillerPreset",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_ChannelTemplate_FillerPreset_MidRollFillerId",
column: x => x.MidRollFillerId,
principalTable: "FillerPreset",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_ChannelTemplate_FillerPreset_PostRollFillerId",
column: x => x.PostRollFillerId,
principalTable: "FillerPreset",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_ChannelTemplate_FillerPreset_PreRollFillerId",
column: x => x.PreRollFillerId,
principalTable: "FillerPreset",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateIndex(
name: "IX_ChannelTemplate_FallbackFillerId",
table: "ChannelTemplate",
column: "FallbackFillerId");
migrationBuilder.CreateIndex(
name: "IX_ChannelTemplate_FFmpegProfileId",
table: "ChannelTemplate",
column: "FFmpegProfileId");
migrationBuilder.CreateIndex(
name: "IX_ChannelTemplate_MidRollFillerId",
table: "ChannelTemplate",
column: "MidRollFillerId");
migrationBuilder.CreateIndex(
name: "IX_ChannelTemplate_Name",
table: "ChannelTemplate",
column: "Name",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ChannelTemplate_PostRollFillerId",
table: "ChannelTemplate",
column: "PostRollFillerId");
migrationBuilder.CreateIndex(
name: "IX_ChannelTemplate_PreRollFillerId",
table: "ChannelTemplate",
column: "PreRollFillerId");
migrationBuilder.CreateIndex(
name: "IX_ChannelTemplate_WatermarkId",
table: "ChannelTemplate",
column: "WatermarkId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ChannelTemplate");
}
}
}
@@ -390,6 +390,117 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.ToTable("Channel", (string)null);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ChannelTemplate", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(500)
.HasColumnType("varchar(500)")
.HasDefaultValue("");
b.Property<int>("FFmpegProfileId")
.HasColumnType("INTEGER");
b.Property<int?>("FallbackFillerId")
.HasColumnType("INTEGER");
b.Property<int>("FixedStartTimeBehavior")
.HasColumnType("INTEGER");
b.Property<int>("IdleBehavior")
.HasColumnType("INTEGER");
b.Property<bool>("IsSystem")
.HasColumnType("INTEGER");
b.Property<int?>("MidRollFillerId")
.HasColumnType("INTEGER");
b.Property<int>("MusicVideoCreditsMode")
.HasColumnType("INTEGER");
b.Property<string>("MusicVideoCreditsTemplate")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)")
.UseCollation("NOCASE");
b.Property<int>("PlayoutMode")
.HasColumnType("INTEGER");
b.Property<int>("PlayoutSource")
.HasColumnType("INTEGER");
b.Property<int?>("PostRollFillerId")
.HasColumnType("INTEGER");
b.Property<int?>("PreRollFillerId")
.HasColumnType("INTEGER");
b.Property<string>("PreferredAudioLanguageCode")
.HasColumnType("TEXT");
b.Property<string>("PreferredAudioTitle")
.HasColumnType("TEXT");
b.Property<string>("PreferredSubtitleLanguageCode")
.HasColumnType("TEXT");
b.Property<bool>("RandomStartPoint")
.HasColumnType("INTEGER");
b.Property<bool>("ShuffleScheduleItems")
.HasColumnType("INTEGER");
b.Property<int>("SongVideoMode")
.HasColumnType("INTEGER");
b.Property<string>("StreamSelector")
.HasColumnType("TEXT");
b.Property<int>("StreamSelectorMode")
.HasColumnType("INTEGER");
b.Property<int>("StreamingMode")
.HasColumnType("INTEGER");
b.Property<int>("SubtitleMode")
.HasColumnType("INTEGER");
b.Property<int>("TranscodeMode")
.HasColumnType("INTEGER");
b.Property<int?>("WatermarkId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("FFmpegProfileId");
b.HasIndex("FallbackFillerId");
b.HasIndex("MidRollFillerId");
b.HasIndex("Name")
.IsUnique();
b.HasIndex("PostRollFillerId");
b.HasIndex("PreRollFillerId");
b.HasIndex("WatermarkId");
b.ToTable("ChannelTemplate", (string)null);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ChannelWatermark", b =>
{
b.Property<int>("Id")
@@ -4386,6 +4497,52 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Navigation("Watermark");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ChannelTemplate", b =>
{
b.HasOne("ErsatzTV.Core.Domain.FFmpegProfile", "FFmpegProfile")
.WithMany()
.HasForeignKey("FFmpegProfileId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.Filler.FillerPreset", "FallbackFiller")
.WithMany()
.HasForeignKey("FallbackFillerId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ErsatzTV.Core.Domain.Filler.FillerPreset", "MidRollFiller")
.WithMany()
.HasForeignKey("MidRollFillerId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ErsatzTV.Core.Domain.Filler.FillerPreset", "PostRollFiller")
.WithMany()
.HasForeignKey("PostRollFillerId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ErsatzTV.Core.Domain.Filler.FillerPreset", "PreRollFiller")
.WithMany()
.HasForeignKey("PreRollFillerId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ErsatzTV.Core.Domain.ChannelWatermark", "Watermark")
.WithMany()
.HasForeignKey("WatermarkId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("FFmpegProfile");
b.Navigation("FallbackFiller");
b.Navigation("MidRollFiller");
b.Navigation("PostRollFiller");
b.Navigation("PreRollFiller");
b.Navigation("Watermark");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.CollectionItem", b =>
{
b.HasOne("ErsatzTV.Core.Domain.Collection", "Collection")
@@ -0,0 +1,62 @@
using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ErsatzTV.Infrastructure.Data.Configurations;
public class ChannelTemplateConfiguration : IEntityTypeConfiguration<ChannelTemplate>
{
public void Configure(EntityTypeBuilder<ChannelTemplate> builder)
{
builder.ToTable("ChannelTemplate");
builder.HasIndex(t => t.Name)
.IsUnique();
builder.Property(t => t.Name)
.HasMaxLength(50)
.HasColumnType("varchar(50)")
.IsRequired();
builder.Property(t => t.Description)
.HasMaxLength(500)
.HasColumnType("varchar(500)")
.HasDefaultValue(string.Empty)
.IsRequired();
builder.HasOne(t => t.FFmpegProfile)
.WithMany()
.HasForeignKey(t => t.FFmpegProfileId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne(t => t.Watermark)
.WithMany()
.HasForeignKey(t => t.WatermarkId)
.OnDelete(DeleteBehavior.SetNull)
.IsRequired(false);
builder.HasOne(t => t.FallbackFiller)
.WithMany()
.HasForeignKey(t => t.FallbackFillerId)
.OnDelete(DeleteBehavior.SetNull)
.IsRequired(false);
builder.HasOne(t => t.PreRollFiller)
.WithMany()
.HasForeignKey(t => t.PreRollFillerId)
.OnDelete(DeleteBehavior.SetNull)
.IsRequired(false);
builder.HasOne(t => t.MidRollFiller)
.WithMany()
.HasForeignKey(t => t.MidRollFillerId)
.OnDelete(DeleteBehavior.SetNull)
.IsRequired(false);
builder.HasOne(t => t.PostRollFiller)
.WithMany()
.HasForeignKey(t => t.PostRollFillerId)
.OnDelete(DeleteBehavior.SetNull)
.IsRequired(false);
}
}
+171 -43
View File
@@ -2,6 +2,8 @@
using System.Reflection;
using Dapper;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Infrastructure.Data;
@@ -84,54 +86,180 @@ public static class DbInitializer
await context.SaveChangesAsync(cancellationToken);
}
if (context.Resolutions.Any(x => x.Width == 1920))
if (!context.Resolutions.Any(x => x.Width == 1920))
{
return Unit.Default;
var resolutions = new List<Resolution>
{
new() { Id = 1, Name = "720x480", Width = 720, Height = 480 },
new() { Id = 2, Name = "1280x720", Width = 1280, Height = 720 },
new() { Id = 3, Name = "1920x1080", Width = 1920, Height = 1080 },
new() { Id = 4, Name = "3840x2160", Width = 3840, Height = 2160 }
};
await context.Resolutions.AddRangeAsync(resolutions, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var resolutionConfig = new ConfigElement
{
Key = ConfigElementKey.FFmpegDefaultResolutionId.Key,
Value = "3" // 1920x1080
};
await context.ConfigElements.AddAsync(resolutionConfig, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var defaultProfile = FFmpegProfile.New("1920x1080 x264 aac", resolutions[2]);
await context.FFmpegProfiles.AddAsync(defaultProfile, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var profileConfig = new ConfigElement
{
Key = ConfigElementKey.FFmpegDefaultProfileId.Key,
Value = defaultProfile.Id.ToString(CultureInfo.InvariantCulture)
};
await context.ConfigElements.AddAsync(profileConfig, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var defaultChannel = new Channel(Guid.NewGuid())
{
Number = "1",
Name = "ErsatzTV",
FFmpegProfile = defaultProfile,
StreamingMode = StreamingMode.TransportStreamHybrid,
IsEnabled = true,
ShowInEpg = true
};
await context.Channels.AddAsync(defaultChannel, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
}
var resolutions = new List<Resolution>
{
new() { Id = 1, Name = "720x480", Width = 720, Height = 480 },
new() { Id = 2, Name = "1280x720", Width = 1280, Height = 720 },
new() { Id = 3, Name = "1920x1080", Width = 1920, Height = 1080 },
new() { Id = 4, Name = "3840x2160", Width = 3840, Height = 2160 }
};
await context.Resolutions.AddRangeAsync(resolutions, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var resolutionConfig = new ConfigElement
{
Key = ConfigElementKey.FFmpegDefaultResolutionId.Key,
Value = "3" // 1920x1080
};
await context.ConfigElements.AddAsync(resolutionConfig, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var defaultProfile = FFmpegProfile.New("1920x1080 x264 aac", resolutions[2]);
await context.FFmpegProfiles.AddAsync(defaultProfile, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var profileConfig = new ConfigElement
{
Key = ConfigElementKey.FFmpegDefaultProfileId.Key,
Value = defaultProfile.Id.ToString(CultureInfo.InvariantCulture)
};
await context.ConfigElements.AddAsync(profileConfig, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var defaultChannel = new Channel(Guid.NewGuid())
{
Number = "1",
Name = "ErsatzTV",
FFmpegProfile = defaultProfile,
StreamingMode = StreamingMode.TransportStreamHybrid,
IsEnabled = true,
ShowInEpg = true
};
await context.Channels.AddAsync(defaultChannel, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
await SeedChannelTemplates(context, cancellationToken);
// TODO: create looping static image that mentions configuring via web
return Unit.Default;
}
private static async Task SeedChannelTemplates(TvContext context, CancellationToken cancellationToken)
{
if (await context.ChannelTemplates.AnyAsync(t => t.Name == "Standard", cancellationToken) &&
await context.ChannelTemplates.AnyAsync(t => t.Name == "Music videos", cancellationToken))
{
await EnsureDefaultChannelTemplateConfig(context, cancellationToken);
return;
}
FFmpegProfile defaultProfile = await GetDefaultFFmpegProfile(context, cancellationToken);
if (defaultProfile is null)
{
return;
}
if (!await context.ChannelTemplates.AnyAsync(t => t.Name == "Standard", cancellationToken))
{
await context.ChannelTemplates.AddAsync(
NewSystemTemplate(
"Standard",
"Balanced defaults for generated channels.",
defaultProfile.Id,
ChannelMusicVideoCreditsMode.None,
ChannelSongVideoMode.Default,
shuffleScheduleItems: false,
randomStartPoint: false),
cancellationToken);
await context.SaveChangesAsync(cancellationToken);
}
if (!await context.ChannelTemplates.AnyAsync(t => t.Name == "Music videos", cancellationToken))
{
await context.ChannelTemplates.AddAsync(
NewSystemTemplate(
"Music videos",
"Defaults for shuffled music-video channels with generated credit subtitles.",
defaultProfile.Id,
ChannelMusicVideoCreditsMode.GenerateSubtitles,
ChannelSongVideoMode.WithProgress,
shuffleScheduleItems: true,
randomStartPoint: true),
cancellationToken);
await context.SaveChangesAsync(cancellationToken);
}
await EnsureDefaultChannelTemplateConfig(context, cancellationToken);
}
private static async Task<FFmpegProfile> GetDefaultFFmpegProfile(
TvContext context,
CancellationToken cancellationToken)
{
string defaultProfileConfigKey = ConfigElementKey.FFmpegDefaultProfileId.Key;
ConfigElement profileConfig = await context.ConfigElements
.AsNoTracking()
.FirstOrDefaultAsync(c => c.Key == defaultProfileConfigKey, cancellationToken);
if (profileConfig is not null &&
int.TryParse(profileConfig.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int profileId))
{
FFmpegProfile configuredProfile = await context.FFmpegProfiles
.FirstOrDefaultAsync(p => p.Id == profileId, cancellationToken);
if (configuredProfile is not null)
{
return configuredProfile;
}
}
return await context.FFmpegProfiles.OrderBy(p => p.Id).FirstOrDefaultAsync(cancellationToken);
}
private static async Task EnsureDefaultChannelTemplateConfig(TvContext context, CancellationToken cancellationToken)
{
string defaultTemplateConfigKey = ConfigElementKey.ChannelTemplatesDefaultTemplateId.Key;
bool configExists = await context.ConfigElements
.AnyAsync(c => c.Key == defaultTemplateConfigKey, cancellationToken);
if (configExists)
{
return;
}
ChannelTemplate standard = await context.ChannelTemplates
.AsNoTracking()
.FirstAsync(t => t.Name == "Standard", cancellationToken);
await context.ConfigElements.AddAsync(
new ConfigElement
{
Key = defaultTemplateConfigKey,
Value = standard.Id.ToString(CultureInfo.InvariantCulture)
},
cancellationToken);
await context.SaveChangesAsync(cancellationToken);
}
private static ChannelTemplate NewSystemTemplate(
string name,
string description,
int ffmpegProfileId,
ChannelMusicVideoCreditsMode musicVideoCreditsMode,
ChannelSongVideoMode songVideoMode,
bool shuffleScheduleItems,
bool randomStartPoint) =>
new()
{
Name = name,
Description = description,
IsSystem = true,
FFmpegProfileId = ffmpegProfileId,
StreamSelectorMode = ChannelStreamSelectorMode.Default,
StreamSelector = string.Empty,
PreferredAudioLanguageCode = string.Empty,
PreferredAudioTitle = string.Empty,
PlayoutSource = ChannelPlayoutSource.Generated,
PlayoutMode = ChannelPlayoutMode.Continuous,
StreamingMode = StreamingMode.TransportStreamHybrid,
PreferredSubtitleLanguageCode = string.Empty,
SubtitleMode = ChannelSubtitleMode.None,
MusicVideoCreditsMode = musicVideoCreditsMode,
MusicVideoCreditsTemplate = string.Empty,
SongVideoMode = songVideoMode,
TranscodeMode = ChannelTranscodeMode.OnDemand,
IdleBehavior = ChannelIdleBehavior.StopOnDisconnect,
ShuffleScheduleItems = shuffleScheduleItems,
RandomStartPoint = randomStartPoint,
FixedStartTimeBehavior = FixedStartTimeBehavior.Flexible
};
}
@@ -30,6 +30,7 @@ public class TvContext : DbContext
public DbSet<ConfigElement> ConfigElements { get; set; }
public DbSet<Channel> Channels { get; set; }
public DbSet<ChannelTemplate> ChannelTemplates { get; set; }
public DbSet<ChannelWatermark> ChannelWatermarks { get; set; }
public DbSet<MediaSource> MediaSources { get; set; }
public DbSet<LocalMediaSource> LocalMediaSources { get; set; }
@@ -160,6 +161,7 @@ public class TvContext : DbContext
modelBuilder.Entity<Block>().Property(b => b.Name).UseCollation(collation);
modelBuilder.Entity<BlockGroup>().Property(b => b.Name).UseCollation(collation);
modelBuilder.Entity<Channel>().Property(c => c.Name).UseCollation(collation);
modelBuilder.Entity<ChannelTemplate>().Property(c => c.Name).UseCollation(collation);
modelBuilder.Entity<ChannelWatermark>().Property(c => c.Name).UseCollation(collation);
modelBuilder.Entity<Collection>().Property(c => c.Name).UseCollation(collation);
modelBuilder.Entity<Deco>().Property(d => d.Name).UseCollation(collation);
@@ -0,0 +1,87 @@
using System.Text;
using ErsatzTV.Mcp;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Mcp.Tests;
[TestFixture]
public class BoundedLineReaderTests
{
[Test]
public async Task ReadLineAsync_Should_Return_Line_Without_Trailing_Newline()
{
using var reader = new StringReader("hello world\n");
BoundedLineReader.Line line = await BoundedLineReader.ReadLineAsync(reader, 1024);
line.EndOfStream.ShouldBeFalse();
line.Overflowed.ShouldBeFalse();
line.Text.ShouldBe("hello world");
}
[Test]
public async Task ReadLineAsync_Should_Return_Line_Of_Exactly_Cap_Length_Intact()
{
// The cap is the inclusive max: a line of exactly `cap` chars is returned, not overflowed.
using var reader = new StringReader("abcdefgh\n");
BoundedLineReader.Line line = await BoundedLineReader.ReadLineAsync(reader, 8);
line.Overflowed.ShouldBeFalse();
line.Text.ShouldBe("abcdefgh");
}
[Test]
public async Task ReadLineAsync_Should_Strip_Carriage_Return_In_Crlf()
{
using var reader = new StringReader("hello\r\n");
BoundedLineReader.Line line = await BoundedLineReader.ReadLineAsync(reader, 1024);
line.Text.ShouldBe("hello");
}
[Test]
public async Task ReadLineAsync_Should_Signal_End_Of_Stream()
{
using var reader = new StringReader(string.Empty);
BoundedLineReader.Line line = await BoundedLineReader.ReadLineAsync(reader, 1024);
line.EndOfStream.ShouldBeTrue();
}
[Test]
public async Task ReadLineAsync_Should_Overflow_And_Not_Buffer_Oversized_Line()
{
// A line far longer than the cap must be reported overflowed with no buffered text —
// the memory-exhaustion guard.
string oversized = new string('x', 10_000) + "\n";
using var reader = new StringReader(oversized);
BoundedLineReader.Line line = await BoundedLineReader.ReadLineAsync(reader, 16);
line.EndOfStream.ShouldBeFalse();
line.Overflowed.ShouldBeTrue();
line.Text.ShouldBeEmpty();
}
[Test]
public async Task ReadLineAsync_Should_Keep_Subsequent_Lines_Aligned_After_Overflow()
{
// After draining an oversized line, the next line must still be read intact.
var content = new StringBuilder()
.Append(new string('x', 100)).Append('\n')
.Append("good\n")
.ToString();
using var reader = new StringReader(content);
BoundedLineReader.Line first = await BoundedLineReader.ReadLineAsync(reader, 16);
BoundedLineReader.Line second = await BoundedLineReader.ReadLineAsync(reader, 16);
first.Overflowed.ShouldBeTrue();
second.Overflowed.ShouldBeFalse();
second.Text.ShouldBe("good");
}
}
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="NUnit" />
<PackageReference Include="NUnit.Analyzers">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="NUnit3TestAdapter" />
<PackageReference Include="Shouldly" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ErsatzTV.Mcp\ErsatzTV.Mcp.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,264 @@
using System.Net;
using System.Text.Json;
using ErsatzTV.Mcp;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Mcp.Tests;
[TestFixture]
public class ErsatzTvApiClientTests
{
[Test]
public async Task CallToolAsync_Should_Substitute_Path_Parameters_And_Send_Api_Key()
{
CapturingHandler handler = new("""{"id":12,"name":"Kids"}""");
var client = new ErsatzTvApiClient(
new HttpClient(handler),
new ErsatzTvApiClientOptions(new Uri("http://localhost:8409/"), "secret"));
ToolCallResult result = await client.CallToolAsync(
new ToolDefinition(
"ersatztv_get_channel",
"Get channel",
HttpMethod.Get,
"/api/channels/{id}",
ToolInputSchemas.Object(("id", "integer", "Channel id", true))),
JsonDocument.Parse("""{"id":12}""").RootElement,
CancellationToken.None);
result.IsError.ShouldBeFalse();
result.Text.ShouldBe("""{"id":12,"name":"Kids"}""");
handler.RequestUri.ShouldBe(new Uri("http://localhost:8409/api/channels/12"));
handler.ApiKey.ShouldBe("secret");
}
[Test]
public async Task CallToolAsync_Should_Url_Encode_Path_Parameters()
{
CapturingHandler handler = new("""{"id":1}""");
var client = new ErsatzTvApiClient(new HttpClient(handler), new ErsatzTvApiClientOptions(new Uri("http://localhost"), null));
await client.CallToolAsync(
new ToolDefinition(
"ersatztv_get_resolution_by_name",
"Get resolution",
HttpMethod.Get,
"/api/ffmpeg/resolution/by-name/{name}",
ToolInputSchemas.Object(("name", "string", "Resolution name", true))),
JsonDocument.Parse("""{"name":"1920 x 1080"}""").RootElement,
CancellationToken.None);
handler.RequestUri.ShouldBe(new Uri("http://localhost/api/ffmpeg/resolution/by-name/1920%20x%201080"));
}
[Test]
public async Task CallToolAsync_Should_Return_Error_Result_For_Non_Success_Status()
{
CapturingHandler handler = new("""{"status":404,"title":"Resource not found"}""", HttpStatusCode.NotFound);
var client = new ErsatzTvApiClient(new HttpClient(handler), new ErsatzTvApiClientOptions(new Uri("http://localhost"), null));
ToolCallResult result = await client.CallToolAsync(
new ToolDefinition(
"ersatztv_get_channel",
"Get channel",
HttpMethod.Get,
"/api/channels/{id}",
ToolInputSchemas.Object(("id", "integer", "Channel id", true))),
JsonDocument.Parse("""{"id":404}""").RootElement,
CancellationToken.None);
result.IsError.ShouldBeTrue();
result.Text.ShouldContain("404");
result.Text.ShouldContain("Resource not found");
}
[Test]
public async Task CallToolAsync_Should_Refuse_Non_Get_Tool_When_Read_Only()
{
CapturingHandler handler = new("{}");
var client = new ErsatzTvApiClient(
new HttpClient(handler),
new ErsatzTvApiClientOptions(new Uri("http://localhost"), null));
ToolCallResult result = await client.CallToolAsync(
new ToolDefinition(
"ersatztv_delete_channel",
"Delete channel",
HttpMethod.Delete,
"/api/channels/{id}",
ToolInputSchemas.Object(("id", "integer", "Channel id", true))),
JsonDocument.Parse("""{"id":12}""").RootElement,
CancellationToken.None);
result.IsError.ShouldBeTrue();
result.Text.ShouldContain("read-only");
// The request must never reach the API.
handler.RequestUri.ShouldBeNull();
}
[Test]
public async Task CallToolAsync_Should_Allow_Non_Get_Tool_When_Writes_Enabled()
{
CapturingHandler handler = new("""{"ok":true}""");
var client = new ErsatzTvApiClient(
new HttpClient(handler),
new ErsatzTvApiClientOptions(new Uri("http://localhost"), null, AllowWrites: true));
ToolCallResult result = await client.CallToolAsync(
new ToolDefinition(
"ersatztv_delete_channel",
"Delete channel",
HttpMethod.Delete,
"/api/channels/{id}",
ToolInputSchemas.Object(("id", "integer", "Channel id", true))),
JsonDocument.Parse("""{"id":12}""").RootElement,
CancellationToken.None);
result.IsError.ShouldBeFalse();
handler.RequestUri.ShouldBe(new Uri("http://localhost/api/channels/12"));
}
[Test]
public async Task CallToolAsync_Should_Truncate_Oversized_Response()
{
CapturingHandler handler = new(new string('x', 500));
var client = new ErsatzTvApiClient(
new HttpClient(handler),
new ErsatzTvApiClientOptions(new Uri("http://localhost"), null, MaxResponseBytes: 16));
ToolCallResult result = await client.CallToolAsync(
new ToolDefinition("ersatztv_list_channels", "List", HttpMethod.Get, "/api/channels", ToolInputSchemas.Empty),
JsonDocument.Parse("{}").RootElement,
CancellationToken.None);
result.Text.ShouldStartWith(new string('x', 16));
result.Text.ShouldContain("truncated");
result.Text.Length.ShouldBeLessThan(500);
}
[Test]
public async Task CallToolAsync_Should_Preserve_Reverse_Proxy_Path_Prefix()
{
CapturingHandler handler = new("""{"id":12}""");
var client = new ErsatzTvApiClient(
new HttpClient(handler),
new ErsatzTvApiClientOptions(new Uri("http://host/etv/"), null));
await client.CallToolAsync(
new ToolDefinition(
"ersatztv_get_channel",
"Get channel",
HttpMethod.Get,
"/api/channels/{id}",
ToolInputSchemas.Object(("id", "integer", "Channel id", true))),
JsonDocument.Parse("""{"id":12}""").RootElement,
CancellationToken.None);
handler.RequestUri.ShouldBe(new Uri("http://host/etv/api/channels/12"));
}
[Test]
public async Task CallToolAsync_Should_Reject_Unknown_Argument()
{
CapturingHandler handler = new("{}");
var client = new ErsatzTvApiClient(
new HttpClient(handler),
new ErsatzTvApiClientOptions(new Uri("http://localhost"), null));
await Should.ThrowAsync<ArgumentException>(async () => await client.CallToolAsync(
new ToolDefinition(
"ersatztv_get_channel",
"Get channel",
HttpMethod.Get,
"/api/channels/{id}",
ToolInputSchemas.Object(("id", "integer", "Channel id", true))),
JsonDocument.Parse("""{"id":12,"evil":"drop"}""").RootElement,
CancellationToken.None));
handler.RequestUri.ShouldBeNull();
}
[Test]
public async Task CallToolAsync_Should_Reject_Dot_Segment_Path_Parameter()
{
CapturingHandler handler = new("{}");
var client = new ErsatzTvApiClient(
new HttpClient(handler),
new ErsatzTvApiClientOptions(new Uri("http://localhost"), null));
// ".." would canonicalize the URL onto a different route — must be rejected pre-flight.
await Should.ThrowAsync<ArgumentException>(async () => await client.CallToolAsync(
new ToolDefinition(
"ersatztv_get_resolution_by_name",
"Get resolution",
HttpMethod.Get,
"/api/ffmpeg/resolution/by-name/{name}",
ToolInputSchemas.Object(("name", "string", "Resolution name", true))),
JsonDocument.Parse("""{"name":".."}""").RootElement,
CancellationToken.None));
handler.RequestUri.ShouldBeNull();
}
[Test]
public async Task CallToolAsync_Should_Fall_Back_To_Default_Cap_On_Overflowing_Configured_Cap()
{
CapturingHandler handler = new("""{"ok":true}""");
// int.MaxValue would overflow `cap + 1` to a negative array length; the client must
// clamp to the default instead of crashing.
var client = new ErsatzTvApiClient(
new HttpClient(handler),
new ErsatzTvApiClientOptions(new Uri("http://localhost"), null, MaxResponseBytes: int.MaxValue));
ToolCallResult result = await client.CallToolAsync(
new ToolDefinition("ersatztv_list_channels", "List", HttpMethod.Get, "/api/channels", ToolInputSchemas.Empty),
JsonDocument.Parse("{}").RootElement,
CancellationToken.None);
result.IsError.ShouldBeFalse();
result.Text.ShouldBe("""{"ok":true}""");
}
[Test]
public async Task CallToolAsync_Should_Not_Emit_Replacement_Char_When_Truncating_Mid_Codepoint()
{
// "ab😀" — the emoji is a 4-byte sequence starting at byte index 2; a 4-byte cap cuts it
// mid-sequence. The truncated text must end cleanly, not with a U+FFFD replacement char.
CapturingHandler handler = new("ab\U0001F600");
var client = new ErsatzTvApiClient(
new HttpClient(handler),
new ErsatzTvApiClientOptions(new Uri("http://localhost"), null, MaxResponseBytes: 4));
ToolCallResult result = await client.CallToolAsync(
new ToolDefinition("ersatztv_list_channels", "List", HttpMethod.Get, "/api/channels", ToolInputSchemas.Empty),
JsonDocument.Parse("{}").RootElement,
CancellationToken.None);
result.Text.ShouldStartWith("ab");
result.Text.ShouldNotContain("");
result.Text.ShouldContain("truncated");
}
private sealed class CapturingHandler(string response, HttpStatusCode statusCode = HttpStatusCode.OK)
: HttpMessageHandler
{
public Uri? RequestUri { get; private set; }
public string? ApiKey { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
RequestUri = request.RequestUri;
ApiKey = request.Headers.TryGetValues("X-Api-Key", out IEnumerable<string>? values)
? values.Single()
: null;
return Task.FromResult(new HttpResponseMessage(statusCode)
{
Content = new StringContent(response)
});
}
}
}
+149
View File
@@ -0,0 +1,149 @@
using System.Text.Json;
using ErsatzTV.Mcp;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Mcp.Tests;
[TestFixture]
public class McpServerTests
{
[Test]
public async Task HandleAsync_Should_Return_Server_Capabilities_For_Initialize()
{
using JsonDocument response = await HandleAsync("""{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}""");
JsonElement result = response.RootElement.GetProperty("result");
result.GetProperty("protocolVersion").GetString().ShouldBe("2024-11-05");
result.GetProperty("serverInfo").GetProperty("name").GetString().ShouldBe("ersatztv-mcp");
result.GetProperty("capabilities").TryGetProperty("tools", out _).ShouldBeTrue();
}
[Test]
public async Task HandleAsync_Should_List_Tools()
{
using JsonDocument response = await HandleAsync("""{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}""");
string[] toolNames = response.RootElement
.GetProperty("result")
.GetProperty("tools")
.EnumerateArray()
.Select(t => t.GetProperty("name").GetString())
.OfType<string>()
.ToArray();
toolNames.ShouldContain("ersatztv_list_channels");
toolNames.ShouldContain("ersatztv_get_version");
}
[Test]
public async Task HandleAsync_Should_Call_Tool_And_Return_Text_Content()
{
using JsonDocument response = await HandleAsync(
"""{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"ersatztv_get_version","arguments":{}}}""",
new StubToolExecutor("""{"apiVersion":3,"appVersion":"develop"}"""));
JsonElement content = response.RootElement.GetProperty("result").GetProperty("content").EnumerateArray().Single();
content.GetProperty("type").GetString().ShouldBe("text");
content.GetProperty("text").GetString().ShouldBe("""{"apiVersion":3,"appVersion":"develop"}""");
}
[Test]
public async Task HandleAsync_Should_Return_Method_Not_Found_For_Unknown_Tool()
{
using JsonDocument response = await HandleAsync(
"""{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"missing","arguments":{}}}""");
response.RootElement.GetProperty("error").GetProperty("code").GetInt32().ShouldBe(-32602);
string message = response.RootElement.GetProperty("error").GetProperty("message").GetString().ShouldNotBeNull();
message.ShouldContain("Unknown tool");
}
[Test]
public async Task HandleAsync_Should_Return_Parse_Error_For_Malformed_Json()
{
McpServer server = new(new StubToolExecutor("{}"));
// A malformed line must be answered with a JSON-RPC parse error, never crash the loop.
string? response = await server.HandleAsync("{ this is not json", CancellationToken.None);
using JsonDocument document = JsonDocument.Parse(response.ShouldNotBeNull());
document.RootElement.GetProperty("error").GetProperty("code").GetInt32().ShouldBe(-32700);
document.RootElement.GetProperty("id").ValueKind.ShouldBe(JsonValueKind.Null);
}
[Test]
public async Task HandleAsync_Should_Return_Invalid_Request_For_Missing_Method()
{
McpServer server = new(new StubToolExecutor("{}"));
string? response = await server.HandleAsync("""{"jsonrpc":"2.0","id":7}""", CancellationToken.None);
using JsonDocument document = JsonDocument.Parse(response.ShouldNotBeNull());
document.RootElement.GetProperty("error").GetProperty("code").GetInt32().ShouldBe(-32600);
document.RootElement.GetProperty("id").GetInt32().ShouldBe(7);
}
[Test]
public async Task HandleAsync_Should_Return_Invalid_Request_For_Non_Object_Request()
{
McpServer server = new(new StubToolExecutor("{}"));
string? response = await server.HandleAsync("5", CancellationToken.None);
using JsonDocument document = JsonDocument.Parse(response.ShouldNotBeNull());
document.RootElement.GetProperty("error").GetProperty("code").GetInt32().ShouldBe(-32600);
}
[Test]
public async Task HandleAsync_Should_Ignore_Notifications()
{
McpServer server = new(new StubToolExecutor("{}"));
string? response = await server.HandleAsync("""{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}""", CancellationToken.None);
response.ShouldBeNull();
}
[Test]
public async Task HandleAsync_Should_Return_Internal_Error_When_Executor_Throws_Transport_Error()
{
// A network/transport failure must still yield a JSON-RPC error for the id, not escape
// HandleAsync (which would leave a compliant client hanging).
McpServer server = new(new ThrowingToolExecutor(new HttpRequestException("connection refused")));
string? response = await server.HandleAsync(
"""{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"ersatztv_get_version","arguments":{}}}""",
CancellationToken.None);
using JsonDocument document = JsonDocument.Parse(response.ShouldNotBeNull());
document.RootElement.GetProperty("error").GetProperty("code").GetInt32().ShouldBe(-32603);
document.RootElement.GetProperty("id").GetInt32().ShouldBe(9);
}
private static async Task<JsonDocument> HandleAsync(string request, IToolExecutor? executor = null)
{
McpServer server = new(executor ?? new StubToolExecutor("{}"));
string? response = await server.HandleAsync(request, CancellationToken.None);
response.ShouldNotBeNull();
return JsonDocument.Parse(response);
}
private sealed class StubToolExecutor(string response) : IToolExecutor
{
public Task<ToolCallResult> CallToolAsync(
ToolDefinition tool,
JsonElement arguments,
CancellationToken cancellationToken) =>
Task.FromResult(new ToolCallResult(false, response));
}
private sealed class ThrowingToolExecutor(Exception exception) : IToolExecutor
{
public Task<ToolCallResult> CallToolAsync(
ToolDefinition tool,
JsonElement arguments,
CancellationToken cancellationToken) =>
throw exception;
}
}
@@ -0,0 +1,61 @@
using System.Text.Json;
using ErsatzTV.Mcp;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Mcp.Tests;
[TestFixture]
public class ToolArgumentValidatorTests
{
private static ToolDefinition IdTool() => new(
"ersatztv_get_channel",
"Get channel",
HttpMethod.Get,
"/api/channels/{id}",
ToolInputSchemas.Object(("id", "integer", "Channel id", true)));
[Test]
public void Validate_Should_Accept_Well_Formed_Arguments()
{
Should.NotThrow(() =>
ToolArgumentValidator.Validate(IdTool(), JsonDocument.Parse("""{"id":12}""").RootElement));
}
[Test]
public void Validate_Should_Accept_Empty_Arguments_For_No_Param_Tool()
{
ToolDefinition tool = new("ersatztv_get_version", "Version", HttpMethod.Get, "/api/version", ToolInputSchemas.Empty);
Should.NotThrow(() =>
ToolArgumentValidator.Validate(tool, JsonDocument.Parse("{}").RootElement));
}
[Test]
public void Validate_Should_Reject_Unknown_Argument()
{
Should.Throw<ArgumentException>(() =>
ToolArgumentValidator.Validate(IdTool(), JsonDocument.Parse("""{"id":12,"extra":1}""").RootElement));
}
[Test]
public void Validate_Should_Reject_Missing_Required_Argument()
{
Should.Throw<ArgumentException>(() =>
ToolArgumentValidator.Validate(IdTool(), JsonDocument.Parse("{}").RootElement));
}
[Test]
public void Validate_Should_Reject_Wrong_Type()
{
Should.Throw<ArgumentException>(() =>
ToolArgumentValidator.Validate(IdTool(), JsonDocument.Parse("""{"id":"twelve"}""").RootElement));
}
[Test]
public void Validate_Should_Reject_Non_Object_Arguments()
{
Should.Throw<ArgumentException>(() =>
ToolArgumentValidator.Validate(IdTool(), JsonDocument.Parse("[]").RootElement));
}
}
+63
View File
@@ -0,0 +1,63 @@
using ErsatzTV.Mcp;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Mcp.Tests;
[TestFixture]
public class ToolCatalogTests
{
[Test]
public void All_Should_Expose_Read_First_Current_Api_Tools()
{
string[] names = ToolCatalog.All.Select(t => t.Name).ToArray();
names.ShouldContain("ersatztv_list_channels");
names.ShouldContain("ersatztv_get_channel");
names.ShouldContain("ersatztv_list_collections");
names.ShouldContain("ersatztv_get_collection");
names.ShouldContain("ersatztv_list_smart_collections");
names.ShouldContain("ersatztv_get_smart_collection");
names.ShouldContain("ersatztv_list_schedules");
names.ShouldContain("ersatztv_get_schedule");
names.ShouldContain("ersatztv_list_schedule_items");
names.ShouldContain("ersatztv_get_playout");
names.ShouldContain("ersatztv_list_ffmpeg_profiles");
names.ShouldContain("ersatztv_get_ffmpeg_profile");
names.ShouldContain("ersatztv_get_resolution_by_name");
names.ShouldContain("ersatztv_list_sessions");
names.ShouldContain("ersatztv_get_version");
}
[Test]
public void All_Should_Not_Expose_Deferred_Redesign_Workflow_Tools()
{
string[] names = ToolCatalog.All.Select(t => t.Name).ToArray();
names.ShouldNotContain("ersatztv_create_channel_from_lineup");
names.ShouldNotContain("ersatztv_list_channel_templates");
names.ShouldNotContain("ersatztv_browse_library");
names.ShouldNotContain("ersatztv_upload_channel_logo");
names.ShouldNotContain("ersatztv_resume_playback");
}
[Test]
public void All_Should_Only_Expose_Get_Tools_In_V0()
{
ToolCatalog.All
.Where(t => t.HttpMethod != HttpMethod.Get)
.Select(t => t.Name)
.ShouldBeEmpty();
}
[Test]
public void Get_Channel_Tool_Should_Have_OpenApi_Aligned_Path_And_Id_Input()
{
ToolDefinition tool = ToolCatalog.Find("ersatztv_get_channel").ShouldNotBeNull();
tool.HttpMethod.ShouldBe(HttpMethod.Get);
tool.PathTemplate.ShouldBe("/api/channels/{id}");
tool.InputSchema.RootElement.GetProperty("required").EnumerateArray().Single().GetString().ShouldBe("id");
tool.InputSchema.RootElement.GetProperty("properties").TryGetProperty("id", out _).ShouldBeTrue();
}
}
+56
View File
@@ -0,0 +1,56 @@
namespace ErsatzTV.Mcp;
/// <summary>
/// Reads newline-delimited lines from a <see cref="TextReader"/> with a hard character cap, so a
/// hostile client cannot exhaust memory by sending an enormous line with no newline. A line longer
/// than the cap is drained (not buffered) and reported as overflowed rather than returned.
/// </summary>
public static class BoundedLineReader
{
public readonly record struct Line(bool EndOfStream, bool Overflowed, string Text);
public const int DefaultMaxChars = 1024 * 1024;
public static async Task<Line> ReadLineAsync(TextReader reader, int maxChars = DefaultMaxChars)
{
int cap = maxChars > 0 ? maxChars : DefaultMaxChars;
var builder = new System.Text.StringBuilder();
var buffer = new char[1];
bool sawAny = false;
bool overflowed = false;
while (await reader.ReadAsync(buffer, 0, 1) == 1)
{
sawAny = true;
char c = buffer[0];
if (c == '\n')
{
return new Line(false, overflowed, overflowed ? string.Empty : builder.ToString());
}
if (c == '\r')
{
continue;
}
if (overflowed || builder.Length >= cap)
{
// Past the cap: stop buffering and free what we have, but keep draining to the
// newline so the next line stays aligned.
overflowed = true;
builder.Clear();
continue;
}
builder.Append(c);
}
if (!sawAny)
{
return new Line(true, false, string.Empty);
}
// Final line with no trailing newline.
return new Line(false, overflowed, overflowed ? string.Empty : builder.ToString());
}
}
+10
View File
@@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
+154
View File
@@ -0,0 +1,154 @@
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace ErsatzTV.Mcp;
public sealed partial class ErsatzTvApiClient(HttpClient httpClient, ErsatzTvApiClientOptions options) : IToolExecutor
{
public async Task<ToolCallResult> CallToolAsync(
ToolDefinition tool,
JsonElement arguments,
CancellationToken cancellationToken)
{
// Runtime backstop for the read-only posture: even if a catalog entry is wrong,
// a non-GET tool cannot execute unless writes are explicitly enabled. This is the
// forward-compatible seam for future write/operational tools (#58) — they run only
// when the operator opts in via ERSATZTV_ALLOW_WRITES.
if (!options.AllowWrites && tool.HttpMethod != HttpMethod.Get)
{
return new ToolCallResult(
true,
$"Refused: tool '{tool.Name}' uses HTTP {tool.HttpMethod.Method}, but this MCP server is "
+ "read-only. Set ERSATZTV_ALLOW_WRITES=true to enable write/operational tools.");
}
ToolArgumentValidator.Validate(tool, arguments);
string path = BuildPath(tool.PathTemplate, arguments);
using var request = new HttpRequestMessage(tool.HttpMethod, CombineUri(options.BaseUrl, path));
if (!string.IsNullOrWhiteSpace(options.ApiKey))
{
request.Headers.Add("X-Api-Key", options.ApiKey);
}
// ResponseHeadersRead streams the body so we can cap it without buffering the whole
// thing — but that moves the body read outside HttpClient.Timeout, so a per-request
// timeout token must cover the entire operation (headers + body) or a slow-drip
// upstream would hang the single-threaded session.
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(options.EffectiveRequestTimeout);
CancellationToken token = timeoutCts.Token;
using HttpResponseMessage response = await httpClient.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
token);
string body = await ReadCappedBodyAsync(response.Content, token);
if (response.IsSuccessStatusCode)
{
return new ToolCallResult(false, body);
}
string message = $"{(int)response.StatusCode} {response.ReasonPhrase}: {body}";
return new ToolCallResult(true, message);
}
// Read at most MaxResponseBytes from the response, truncating gracefully with a marker
// rather than buffering an unbounded body into memory / the model's context.
private async Task<string> ReadCappedBodyAsync(HttpContent content, CancellationToken cancellationToken)
{
int cap = options.MaxResponseBytes is > 0 and <= ErsatzTvApiClientOptions.MaxAllowedResponseBytes
? options.MaxResponseBytes
: ErsatzTvApiClientOptions.DefaultMaxResponseBytes;
await using Stream stream = await content.ReadAsStreamAsync(cancellationToken);
// One extra byte lets us detect (but not keep) overflow past the cap.
byte[] buffer = new byte[cap + 1];
int total = 0;
int read;
while (total < buffer.Length
&& (read = await stream.ReadAsync(buffer.AsMemory(total, buffer.Length - total), cancellationToken)) > 0)
{
total += read;
}
bool truncated = total > cap;
int length = truncated ? TrimToUtf8Boundary(buffer, cap) : total;
string text = Encoding.UTF8.GetString(buffer, 0, length);
return truncated
? text + $"\n…[truncated: response exceeded {cap} bytes]"
: text;
}
// When cutting at a fixed byte cap, back off any incomplete trailing UTF-8 sequence so the
// decoded text ends on a complete code point instead of a U+FFFD replacement char.
private static int TrimToUtf8Boundary(byte[] buffer, int length)
{
int i = length;
while (i > 0 && (buffer[i - 1] & 0b1100_0000) == 0b1000_0000)
{
i--; // step back over UTF-8 continuation bytes (10xxxxxx)
}
if (i == 0)
{
return length; // all continuation bytes (not valid UTF-8) — leave the cut as-is
}
byte lead = buffer[i - 1];
int expected = lead switch
{
< 0x80 => 1,
>= 0xF0 => 4,
>= 0xE0 => 3,
>= 0xC0 => 2,
_ => 1 // stray continuation byte as "lead"; leave the cut as-is
};
// Keep the sequence if it is complete within the cap; otherwise drop the incomplete lead.
return length - (i - 1) >= expected ? length : i - 1;
}
private static Uri CombineUri(Uri baseUrl, string absolutePath)
{
// absolutePath is a root-relative "/api/..." path. new Uri(baseUrl, "/api/...") would
// discard any path prefix on baseUrl (e.g. a reverse-proxy mount like http://host/etv/),
// so combine on the base's full path instead to preserve the prefix.
string prefix = baseUrl.GetLeftPart(UriPartial.Path).TrimEnd('/');
return new Uri(prefix + absolutePath);
}
private static string BuildPath(string pathTemplate, JsonElement arguments)
{
string path = pathTemplate;
foreach (JsonProperty property in arguments.EnumerateObject())
{
string value = property.Value.ValueKind == JsonValueKind.String
? property.Value.GetString() ?? string.Empty
: property.Value.GetRawText();
// EscapeDataString escapes '/', but bare "." / ".." survive and would collapse the
// URL onto a different route during Uri canonicalization — reject them. This assumes
// each {param} is its own path segment (true for every current template); if a template
// ever concatenates two adjacent params, revalidate the substituted path as a whole.
if (value is "." or "..")
{
throw new ArgumentException($"Invalid value for argument '{property.Name}': '{value}'.");
}
path = path.Replace("{" + property.Name + "}", Uri.EscapeDataString(value), StringComparison.Ordinal);
}
Match unresolved = PathParameterRegex().Match(path);
if (unresolved.Success)
{
throw new ArgumentException($"Missing required argument '{unresolved.Groups[1].Value}'");
}
return path;
}
[GeneratedRegex(@"\{([^}]+)\}")]
private static partial Regex PathParameterRegex();
}
+166
View File
@@ -0,0 +1,166 @@
using System.Text.Json;
namespace ErsatzTV.Mcp;
public sealed class McpServer(IToolExecutor toolExecutor)
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
// Process-lifetime document so NullId stays valid; used as the JSON-RPC id for
// parse errors / requests with no usable id.
private static readonly JsonDocument NullIdDocument = JsonDocument.Parse("null");
private static readonly JsonElement NullId = NullIdDocument.RootElement;
public async Task<string?> HandleAsync(string requestJson, CancellationToken cancellationToken)
{
JsonDocument request;
try
{
request = JsonDocument.Parse(requestJson);
}
catch (JsonException)
{
// A malformed line must never crash the session loop (JSON-RPC parse error, id null).
return SerializeError(NullId, -32700, "Parse error: invalid JSON.");
}
using (request)
{
JsonElement root = request.RootElement;
JsonElement id = NullId;
bool hasId = false;
if (root.ValueKind == JsonValueKind.Object && root.TryGetProperty("id", out JsonElement idValue))
{
id = idValue;
hasId = true;
}
try
{
if (root.ValueKind != JsonValueKind.Object)
{
throw new JsonRpcException(-32600, "Invalid Request: expected a JSON-RPC object.");
}
if (!root.TryGetProperty("method", out JsonElement methodElement)
|| methodElement.ValueKind != JsonValueKind.String)
{
throw new JsonRpcException(-32600, "Invalid Request: missing or non-string 'method'.");
}
// No id ⇒ notification ⇒ no response.
if (!hasId)
{
return null;
}
string? method = methodElement.GetString();
object result = method switch
{
"initialize" => InitializeResult(),
"tools/list" => ToolsListResult(),
"tools/call" => await CallToolAsync(RequireParams(root), cancellationToken),
_ => throw new JsonRpcException(-32601, $"Method not found: {method}")
};
return SerializeResponse(id, result);
}
catch (JsonRpcException ex)
{
return SerializeError(id, ex.Code, ex.Message);
}
catch (Exception ex) when (ex is ArgumentException or KeyNotFoundException or InvalidOperationException)
{
return hasId ? SerializeError(id, -32602, ex.Message) : null;
}
catch (Exception ex)
{
// Transport/timeout/unexpected failures (HttpRequestException, a fired request
// timeout, etc.) must still return a JSON-RPC error for the id — otherwise a
// compliant client blocks forever awaiting a response that never comes.
return hasId ? SerializeError(id, -32603, $"Internal error: {ex.Message}") : null;
}
}
}
private static JsonElement RequireParams(JsonElement root) =>
root.TryGetProperty("params", out JsonElement parameters)
? parameters
: throw new JsonRpcException(-32602, "Invalid params: missing 'params'.");
private static object InitializeResult() => new
{
protocolVersion = "2024-11-05",
capabilities = new
{
tools = new { }
},
serverInfo = new
{
name = "ersatztv-mcp",
version = "0.1.0"
}
};
private static object ToolsListResult() => new
{
tools = ToolCatalog.All.Select(t => new
{
name = t.Name,
description = t.Description,
inputSchema = t.InputSchema.RootElement
})
};
private async Task<object> CallToolAsync(JsonElement parameters, CancellationToken cancellationToken)
{
string name = parameters.GetProperty("name").GetString() ?? throw new ArgumentException("Tool name is required");
ToolDefinition tool = ToolCatalog.Find(name) ?? throw new ArgumentException($"Unknown tool: {name}");
JsonElement arguments = parameters.TryGetProperty("arguments", out JsonElement args)
? args
: JsonDocument.Parse("{}").RootElement;
ToolCallResult result = await toolExecutor.CallToolAsync(tool, arguments, cancellationToken);
return new
{
content = new[]
{
new
{
type = "text",
text = result.Text
}
},
isError = result.IsError
};
}
private static string SerializeResponse(JsonElement id, object result) =>
JsonSerializer.Serialize(
new
{
jsonrpc = "2.0",
id = id.Clone(),
result
},
JsonOptions);
private static string SerializeError(JsonElement id, int code, string message) =>
JsonSerializer.Serialize(
new
{
jsonrpc = "2.0",
id = id.Clone(),
error = new
{
code,
message
}
},
JsonOptions);
private sealed class JsonRpcException(int code, string message) : Exception(message)
{
public int Code { get; } = code;
}
}
+87
View File
@@ -0,0 +1,87 @@
namespace ErsatzTV.Mcp;
public static class Program
{
public static async Task Main()
{
string baseUrl = Environment.GetEnvironmentVariable("ERSATZTV_URL") ?? "http://localhost:8409";
string? apiKey = Environment.GetEnvironmentVariable("ERSATZTV_API_KEY");
bool allowWrites = ParseBool(Environment.GetEnvironmentVariable("ERSATZTV_ALLOW_WRITES"));
int maxResponseBytes = ParseInt(
Environment.GetEnvironmentVariable("ERSATZTV_MAX_RESPONSE_BYTES"),
fallback: ErsatzTvApiClientOptions.DefaultMaxResponseBytes,
min: 1024,
max: ErsatzTvApiClientOptions.MaxAllowedResponseBytes);
int timeoutSeconds = ParseInt(
Environment.GetEnvironmentVariable("ERSATZTV_REQUEST_TIMEOUT_SECONDS"),
fallback: 30,
min: 1,
max: 3600);
var requestTimeout = TimeSpan.FromSeconds(timeoutSeconds);
// The per-request timeout is enforced via a CancellationToken inside the client (it must
// cover the streamed body read too), so leave HttpClient's own timeout off to avoid a
// second, header-only timer racing it.
using var httpClient = new HttpClient { Timeout = Timeout.InfiniteTimeSpan };
var apiClient = new ErsatzTvApiClient(
httpClient,
new ErsatzTvApiClientOptions(new Uri(baseUrl), apiKey, allowWrites, maxResponseBytes, requestTimeout));
var server = new McpServer(apiClient);
while (true)
{
BoundedLineReader.Line line = await BoundedLineReader.ReadLineAsync(Console.In);
if (line.EndOfStream)
{
break;
}
if (line.Overflowed)
{
await Console.Error.WriteLineAsync("[ersatztv-mcp] dropped oversized request line.");
continue;
}
if (string.IsNullOrWhiteSpace(line.Text))
{
continue;
}
try
{
string? response = await server.HandleAsync(line.Text, CancellationToken.None);
if (response is not null)
{
await Console.Out.WriteLineAsync(response);
await Console.Out.FlushAsync();
}
}
catch (Exception ex)
{
// Last-resort guard: a single failed request must never terminate the session.
await Console.Error.WriteLineAsync($"[ersatztv-mcp] error handling request: {ex.Message}");
}
}
}
private static bool ParseBool(string? value)
{
if (value is null)
{
return false;
}
if (bool.TryParse(value, out bool parsed))
{
return parsed;
}
string trimmed = value.Trim();
return trimmed is "1"
|| string.Equals(trimmed, "yes", StringComparison.OrdinalIgnoreCase)
|| string.Equals(trimmed, "on", StringComparison.OrdinalIgnoreCase);
}
private static int ParseInt(string? value, int fallback, int min, int max) =>
int.TryParse(value, out int parsed) ? Math.Clamp(parsed, min, max) : fallback;
}
+76
View File
@@ -0,0 +1,76 @@
using System.Text.Json;
namespace ErsatzTV.Mcp;
/// <summary>
/// Lightweight validation of caller-supplied tool arguments against a tool's declared
/// <c>InputSchema</c>. Not a full JSON-Schema implementation — it enforces the shapes the
/// catalog actually emits (typed properties, a required list, <c>additionalProperties:false</c>)
/// so unknown/malformed arguments are rejected before an API request is built.
/// Throws <see cref="ArgumentException"/> (mapped to JSON-RPC -32602 by the server).
/// </summary>
public static class ToolArgumentValidator
{
public static void Validate(ToolDefinition tool, JsonElement arguments)
{
if (arguments.ValueKind != JsonValueKind.Object)
{
throw new ArgumentException($"Arguments for tool '{tool.Name}' must be a JSON object.");
}
JsonElement schema = tool.InputSchema.RootElement;
JsonElement properties = schema.TryGetProperty("properties", out JsonElement props)
? props
: default;
bool additionalAllowed = !schema.TryGetProperty("additionalProperties", out JsonElement additional)
|| additional.ValueKind != JsonValueKind.False;
foreach (JsonProperty arg in arguments.EnumerateObject())
{
if (properties.ValueKind != JsonValueKind.Object
|| !properties.TryGetProperty(arg.Name, out JsonElement propertySchema))
{
if (!additionalAllowed)
{
throw new ArgumentException($"Unknown argument '{arg.Name}' for tool '{tool.Name}'.");
}
continue;
}
string? type = propertySchema.TryGetProperty("type", out JsonElement typeElement)
? typeElement.GetString()
: null;
if (!MatchesType(type, arg.Value))
{
throw new ArgumentException(
$"Argument '{arg.Name}' for tool '{tool.Name}' must be of type '{type}'.");
}
}
if (schema.TryGetProperty("required", out JsonElement required)
&& required.ValueKind == JsonValueKind.Array)
{
foreach (JsonElement name in required.EnumerateArray())
{
string? propertyName = name.GetString();
if (propertyName is not null && !arguments.TryGetProperty(propertyName, out _))
{
throw new ArgumentException(
$"Missing required argument '{propertyName}' for tool '{tool.Name}'.");
}
}
}
}
private static bool MatchesType(string? type, JsonElement value) => type switch
{
"integer" => value.ValueKind == JsonValueKind.Number && value.TryGetInt64(out _),
"number" => value.ValueKind == JsonValueKind.Number,
"string" => value.ValueKind == JsonValueKind.String,
"boolean" => value.ValueKind is JsonValueKind.True or JsonValueKind.False,
"array" => value.ValueKind == JsonValueKind.Array,
"object" => value.ValueKind == JsonValueKind.Object,
_ => true
};
}
+45
View File
@@ -0,0 +1,45 @@
namespace ErsatzTV.Mcp;
public static class ToolCatalog
{
public static IReadOnlyList<ToolDefinition> All { get; } =
[
Get("ersatztv_list_channels", "List channels.", "/api/channels"),
Get("ersatztv_get_channel", "Get a channel by id.", "/api/channels/{id}", Id("id", "Channel id.")),
Get("ersatztv_list_collections", "List collections.", "/api/collections"),
Get("ersatztv_get_collection", "Get a collection by id.", "/api/collections/{id}", Id("id", "Collection id.")),
Get("ersatztv_list_smart_collections", "List smart collections.", "/api/smart-collections"),
Get("ersatztv_get_smart_collection", "Get a smart collection by id.", "/api/smart-collections/{id}", Id("id", "Smart collection id.")),
Get("ersatztv_list_schedules", "List schedules.", "/api/schedules"),
Get("ersatztv_get_schedule", "Get a schedule by id.", "/api/schedules/{id}", Id("id", "Schedule id.")),
Get("ersatztv_list_schedule_items", "List schedule items.", "/api/schedules/{id}/items", Id("id", "Schedule id.")),
Get("ersatztv_get_playout", "Get a playout by id.", "/api/playouts/{id}", Id("id", "Playout id.")),
Get("ersatztv_list_ffmpeg_profiles", "List FFmpeg profiles.", "/api/ffmpeg/profiles"),
Get("ersatztv_get_ffmpeg_profile", "Get an FFmpeg profile by id.", "/api/ffmpeg/profiles/{id}", Id("id", "FFmpeg profile id.")),
Get(
"ersatztv_get_resolution_by_name",
"Get an FFmpeg resolution by name.",
"/api/ffmpeg/resolution/by-name/{name}",
("name", "string", "Resolution name.", true)),
Get("ersatztv_list_sessions", "List active HLS sessions.", "/api/sessions"),
Get("ersatztv_get_version", "Get API and app version.", "/api/version")
];
public static ToolDefinition? Find(string name) =>
All.FirstOrDefault(t => string.Equals(t.Name, name, StringComparison.Ordinal));
private static ToolDefinition Get(
string name,
string description,
string path,
params (string Name, string Type, string Description, bool Required)[] properties) =>
new(
name,
description,
HttpMethod.Get,
path,
properties.Length == 0 ? ToolInputSchemas.Empty : ToolInputSchemas.Object(properties));
private static (string Name, string Type, string Description, bool Required) Id(string name, string description) =>
(name, "integer", description, true);
}
+40
View File
@@ -0,0 +1,40 @@
using System.Text.Json;
namespace ErsatzTV.Mcp;
public sealed record ToolDefinition(
string Name,
string Description,
HttpMethod HttpMethod,
string PathTemplate,
JsonDocument InputSchema);
public sealed record ToolCallResult(bool IsError, string Text);
public sealed record ErsatzTvApiClientOptions(
Uri BaseUrl,
string? ApiKey,
bool AllowWrites = false,
int MaxResponseBytes = ErsatzTvApiClientOptions.DefaultMaxResponseBytes,
TimeSpan RequestTimeout = default)
{
// Cap the response body buffered back to the model so a large/hostile API
// response cannot exhaust memory or flood the context window.
public const int DefaultMaxResponseBytes = 1024 * 1024;
// Hard ceiling so a hostile/typo'd cap can't request a huge (or overflowing) allocation.
public const int MaxAllowedResponseBytes = 64 * 1024 * 1024;
public static readonly TimeSpan DefaultRequestTimeout = TimeSpan.FromSeconds(30);
// The per-request timeout, covering headers *and* body (see ErsatzTvApiClient).
public TimeSpan EffectiveRequestTimeout => RequestTimeout > TimeSpan.Zero ? RequestTimeout : DefaultRequestTimeout;
}
public interface IToolExecutor
{
Task<ToolCallResult> CallToolAsync(
ToolDefinition tool,
JsonElement arguments,
CancellationToken cancellationToken);
}
+42
View File
@@ -0,0 +1,42 @@
using System.Text.Json;
namespace ErsatzTV.Mcp;
public static class ToolInputSchemas
{
public static JsonDocument Empty { get; } = JsonDocument.Parse(
"""
{"type":"object","properties":{},"additionalProperties":false}
""");
public static JsonDocument Object(params (string Name, string Type, string Description, bool Required)[] properties)
{
using var stream = new MemoryStream();
using (var writer = new Utf8JsonWriter(stream))
{
writer.WriteStartObject();
writer.WriteString("type", "object");
writer.WriteStartObject("properties");
foreach ((string name, string type, string description, bool _) in properties)
{
writer.WriteStartObject(name);
writer.WriteString("type", type);
writer.WriteString("description", description);
writer.WriteEndObject();
}
writer.WriteEndObject();
writer.WriteStartArray("required");
foreach ((string name, string _, string _, bool required) in properties.Where(p => p.Required))
{
writer.WriteStringValue(name);
}
writer.WriteEndArray();
writer.WriteBoolean("additionalProperties", false);
writer.WriteEndObject();
}
return JsonDocument.Parse(stream.ToArray());
}
}
@@ -0,0 +1,288 @@
using ErsatzTV.Application.ChannelTemplates;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.ChannelTemplates;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Data.Repositories;
using ErsatzTV.Tests.Support;
using LanguageExt;
using NUnit.Framework;
using Shouldly;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Tests.Application.ChannelTemplates;
[TestFixture]
public class ChannelTemplateHandlerTests
{
private InMemoryTvContext _db = null!;
private IConfigElementRepository _config = null!;
[SetUp]
public async Task SetUp()
{
_db = await InMemoryTvContext.CreateAsync();
_config = new ConfigElementRepository(_db.Factory);
}
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
[Test]
public async Task Create_Should_Persist_Template_With_Reference_Ids()
{
await SeedProfile(1);
await SeedFiller(2, FillerKind.PreRoll);
await SeedFiller(3, FillerKind.MidRoll);
await SeedFiller(4, FillerKind.PostRoll);
var handler = new CreateChannelTemplateHandler(_db.Factory, _config);
Either<BaseError, ChannelTemplateResponseModel> result =
await handler.Handle(MakeCreate("Custom"), CancellationToken.None);
ChannelTemplateResponseModel vm = RightOf(result);
vm.Id.ShouldBeGreaterThan(0);
vm.Name.ShouldBe("Custom");
vm.IsSystem.ShouldBeFalse();
vm.PreRollFillerId.ShouldBe(2);
vm.MidRollFillerId.ShouldBe(3);
vm.PostRollFillerId.ShouldBe(4);
await using TvContext context = _db.CreateContext();
context.ChannelTemplates.Single().Name.ShouldBe("Custom");
}
[Test]
public async Task Create_Should_Return_NotFoundError_When_Profile_Missing()
{
var handler = new CreateChannelTemplateHandler(_db.Factory, _config);
Either<BaseError, ChannelTemplateResponseModel> result =
await handler.Handle(MakeCreate("Missing", ffmpegProfileId: 99), CancellationToken.None);
LeftOf(result).ShouldBeOfType<NotFoundError>();
}
[Test]
public async Task Create_Should_Return_Error_When_Trimmed_Name_Already_Exists()
{
await SeedProfile(1);
await SeedTemplate(7, "Standard");
var handler = new CreateChannelTemplateHandler(_db.Factory, _config);
Either<BaseError, ChannelTemplateResponseModel> result =
await handler.Handle(MakeCreate("Standard "), CancellationToken.None);
LeftOf(result).Value.ShouldBe("Channel template name must be unique.");
}
[Test]
public async Task Create_Should_Validate_Filler_Kind()
{
await SeedProfile(1);
await SeedFiller(2, FillerKind.PostRoll);
var handler = new CreateChannelTemplateHandler(_db.Factory, _config);
Either<BaseError, ChannelTemplateResponseModel> result =
await handler.Handle(MakeCreate("Bad", preRollFillerId: 2), CancellationToken.None);
LeftOf(result).ShouldBeOfType<NotFoundError>();
}
[Test]
public async Task Update_Should_Reject_System_Template()
{
await SeedProfile(1);
await SeedTemplate(7, "Standard", isSystem: true);
var handler = new UpdateChannelTemplateHandler(_db.Factory, _config);
Either<BaseError, ChannelTemplateResponseModel> result =
await handler.Handle(MakeUpdate(7, "Changed"), CancellationToken.None);
LeftOf(result).Value.ShouldBe("System templates cannot be updated.");
}
[Test]
public async Task Delete_Should_Reject_Default_Template()
{
await SeedProfile(1);
await SeedTemplate(7, "Default");
await _config.Upsert(ConfigElementKey.ChannelTemplatesDefaultTemplateId, 7, CancellationToken.None);
var handler = new DeleteChannelTemplateHandler(_db.Factory, _config);
Either<BaseError, Unit> result = await handler.Handle(new DeleteChannelTemplate(7), CancellationToken.None);
LeftOf(result).Value.ShouldBe("Default channel template cannot be deleted.");
}
[Test]
public async Task SetDefault_Should_Update_Config_And_Return_Template()
{
await SeedProfile(1);
await SeedTemplate(7, "Default");
var handler = new SetDefaultChannelTemplateHandler(_db.Factory, _config);
Either<BaseError, ChannelTemplateResponseModel> result =
await handler.Handle(new SetDefaultChannelTemplate(7), CancellationToken.None);
ChannelTemplateResponseModel vm = RightOf(result);
vm.IsDefault.ShouldBeTrue();
Option<int> defaultId =
await _config.GetValue<int>(ConfigElementKey.ChannelTemplatesDefaultTemplateId, CancellationToken.None);
defaultId.IfNone(0).ShouldBe(7);
}
[Test]
public async Task GetAll_Should_Flag_Configured_Default()
{
await SeedProfile(1);
await SeedTemplate(7, "Standard");
await SeedTemplate(8, "Music videos");
await _config.Upsert(ConfigElementKey.ChannelTemplatesDefaultTemplateId, 8, CancellationToken.None);
var handler = new GetAllChannelTemplatesHandler(_db.Factory, _config);
List<ChannelTemplateResponseModel> result =
await handler.Handle(new GetAllChannelTemplates(), CancellationToken.None);
result.Single(t => t.Id == 7).IsDefault.ShouldBeFalse();
result.Single(t => t.Id == 8).IsDefault.ShouldBeTrue();
}
[Test]
public async Task GetDefault_Should_Fall_Back_To_First_System_Template_By_Name()
{
await SeedProfile(1);
await SeedTemplate(1, "Zulu", isSystem: true);
await SeedTemplate(99, "Alpha", isSystem: true);
var handler = new GetDefaultChannelTemplateHandler(_db.Factory, _config);
Option<ChannelTemplateResponseModel> result =
await handler.Handle(new GetDefaultChannelTemplate(), CancellationToken.None);
ChannelTemplateResponseModel vm = result.IfNone(() => throw new AssertionException("Expected a template"));
vm.Id.ShouldBe(99);
vm.Name.ShouldBe("Alpha");
vm.IsDefault.ShouldBeTrue();
}
private async Task SeedProfile(int id)
{
await using TvContext context = _db.CreateContext();
context.FFmpegProfiles.Add(new FFmpegProfile { Id = id, Name = $"profile-{id}" });
await context.SaveChangesAsync();
}
private async Task SeedFiller(int id, FillerKind fillerKind)
{
await using TvContext context = _db.CreateContext();
context.FillerPresets.Add(new FillerPreset
{
Id = id,
Name = $"filler-{id}",
FillerKind = fillerKind,
FillerMode = FillerMode.Count,
Count = 1,
CollectionType = CollectionType.Collection
});
await context.SaveChangesAsync();
}
private async Task SeedTemplate(int id, string name, bool isSystem = false)
{
await using TvContext context = _db.CreateContext();
context.ChannelTemplates.Add(new ChannelTemplate
{
Id = id,
Name = name,
Description = string.Empty,
IsSystem = isSystem,
FFmpegProfileId = 1,
StreamSelectorMode = ChannelStreamSelectorMode.Default,
StreamSelector = string.Empty,
PreferredAudioLanguageCode = string.Empty,
PreferredAudioTitle = string.Empty,
PlayoutSource = ChannelPlayoutSource.Generated,
PlayoutMode = ChannelPlayoutMode.Continuous,
StreamingMode = StreamingMode.TransportStreamHybrid,
PreferredSubtitleLanguageCode = string.Empty,
SubtitleMode = ChannelSubtitleMode.None,
MusicVideoCreditsMode = ChannelMusicVideoCreditsMode.None,
MusicVideoCreditsTemplate = string.Empty,
SongVideoMode = ChannelSongVideoMode.Default,
TranscodeMode = ChannelTranscodeMode.OnDemand,
IdleBehavior = ChannelIdleBehavior.StopOnDisconnect,
FixedStartTimeBehavior = FixedStartTimeBehavior.Flexible
});
await context.SaveChangesAsync();
}
private static CreateChannelTemplate MakeCreate(
string name,
int ffmpegProfileId = 1,
int? preRollFillerId = 2) =>
new(
name,
"Description",
ffmpegProfileId,
null,
null,
preRollFillerId,
3,
4,
ChannelStreamSelectorMode.Default,
string.Empty,
string.Empty,
string.Empty,
ChannelPlayoutSource.Generated,
ChannelPlayoutMode.Continuous,
StreamingMode.TransportStreamHybrid,
string.Empty,
ChannelSubtitleMode.None,
ChannelMusicVideoCreditsMode.None,
string.Empty,
ChannelSongVideoMode.Default,
ChannelTranscodeMode.OnDemand,
ChannelIdleBehavior.StopOnDisconnect,
true,
false,
FixedStartTimeBehavior.Flexible);
private static UpdateChannelTemplate MakeUpdate(int id, string name) =>
new(
id,
name,
"Description",
1,
null,
null,
null,
null,
null,
ChannelStreamSelectorMode.Default,
string.Empty,
string.Empty,
string.Empty,
ChannelPlayoutSource.Generated,
ChannelPlayoutMode.Continuous,
StreamingMode.TransportStreamHybrid,
string.Empty,
ChannelSubtitleMode.None,
ChannelMusicVideoCreditsMode.None,
string.Empty,
ChannelSongVideoMode.Default,
ChannelTranscodeMode.OnDemand,
ChannelIdleBehavior.StopOnDisconnect,
false,
false,
FixedStartTimeBehavior.Flexible);
private static BaseError LeftOf<TR>(Either<BaseError, TR> either) =>
either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result"));
private static TR RightOf<TR>(Either<BaseError, TR> either) =>
either.Match(Left: e => throw new AssertionException($"Expected a Right result but got {e.Value}"), Right: r => r);
}
@@ -0,0 +1,751 @@
using System.Threading.Channels;
using ErsatzTV.Application;
using ErsatzTV.Application.Artworks;
using ErsatzTV.Application.Channels;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using DomainChannel = ErsatzTV.Core.Domain.Channel;
using DomainPlaylistItem = ErsatzTV.Core.Domain.PlaylistItem;
namespace ErsatzTV.Tests.Application.Channels;
[TestFixture]
public class CreateChannelFromLineupHandlerTests
{
private Channel<IBackgroundServiceRequest> _background = null!;
private InMemoryTvContext _db = null!;
private ISearchTargets _searchTargets = null!;
[SetUp]
public async Task SetUp()
{
_background = System.Threading.Channels.Channel.CreateUnbounded<IBackgroundServiceRequest>();
_db = await InMemoryTvContext.CreateAsync();
_searchTargets = Substitute.For<ISearchTargets>();
}
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
[Test]
public async Task Should_Create_Single_Media_Item_Directly_Without_Playlist()
{
await SeedTemplateDependencies();
await SeedTemplate();
await SeedMovie(42);
Either<BaseError, CreateChannelFromLineupResponseModel> result =
await MakeHandler().Handle(MakeRequest(), CancellationToken.None);
CreateChannelFromLineupResponseModel response = RightOf(result);
response.ChannelId.ShouldBeGreaterThan(0);
response.PlaylistId.ShouldBeNull();
response.ProgramScheduleId.ShouldBeGreaterThan(0);
response.PlayoutId.ShouldBeGreaterThan(0);
await using TvContext context = _db.CreateContext();
// No generated playlist or collection for a single-item lineup.
(await context.Playlists.CountAsync()).ShouldBe(0);
(await context.Collections.CountAsync()).ShouldBe(0);
DomainChannel channel = await context.Channels.SingleAsync();
channel.Name.ShouldBe("Movies");
channel.Number.ShouldBe("12");
channel.Group.ShouldBe("Kids");
channel.FFmpegProfileId.ShouldBe(1);
channel.FallbackFillerId.ShouldBe(5);
channel.StreamingMode.ShouldBe(StreamingMode.HttpLiveStreamingSegmenter);
channel.ShowInEpg.ShouldBeTrue();
ProgramSchedule schedule = await context.ProgramSchedules.Include(ps => ps.Items).SingleAsync();
schedule.Name.ShouldBe("12 Movies Schedule");
schedule.ShuffleScheduleItems.ShouldBeTrue();
schedule.RandomStartPoint.ShouldBeTrue();
schedule.FixedStartTimeBehavior.ShouldBe(FixedStartTimeBehavior.Strict);
ProgramScheduleItem item = schedule.Items.Single();
item.ShouldBeOfType<ProgramScheduleItemFlood>();
item.CollectionType.ShouldBe(CollectionType.Movie);
item.MediaItemId.ShouldBe(42);
item.CollectionId.ShouldBeNull();
item.PlaylistId.ShouldBeNull();
item.PlaybackOrder.ShouldBe(PlaybackOrder.Shuffle);
item.PreRollFillerId.ShouldBe(2);
item.MidRollFillerId.ShouldBe(3);
item.PostRollFillerId.ShouldBe(4);
item.FallbackFillerId.ShouldBe(5);
Playout playout = await context.Playouts.SingleAsync();
playout.ChannelId.ShouldBe(channel.Id);
playout.ProgramScheduleId.ShouldBe(schedule.Id);
playout.ScheduleKind.ShouldBe(PlayoutScheduleKind.Classic);
_background.Reader.TryRead(out IBackgroundServiceRequest? buildRequest).ShouldBeTrue();
BuildPlayout buildPlayout = buildRequest.ShouldBeOfType<BuildPlayout>();
buildPlayout.PlayoutId.ShouldBe(playout.Id);
buildPlayout.Mode.ShouldBe(PlayoutBuildMode.Reset);
_background.Reader.TryRead(out IBackgroundServiceRequest? refreshRequest).ShouldBeTrue();
refreshRequest.ShouldBeOfType<RefreshChannelList>();
_searchTargets.Received(1).SearchTargetsChanged();
}
[Test]
public async Task Should_Create_Single_Collection_Item_Directly()
{
await SeedTemplateDependencies();
await SeedTemplate();
await SeedCollection(7);
Either<BaseError, CreateChannelFromLineupResponseModel> result =
await MakeHandler().Handle(MakeRequest(lineup: [CollectionItem(7)]), CancellationToken.None);
RightOf(result).PlaylistId.ShouldBeNull();
await using TvContext context = _db.CreateContext();
(await context.Playlists.CountAsync()).ShouldBe(0);
ProgramScheduleItem item = await context.ProgramScheduleItems.SingleAsync();
item.CollectionType.ShouldBe(CollectionType.Collection);
item.CollectionId.ShouldBe(7);
}
[Test]
public async Task Should_Create_Single_Playlist_Item_Directly()
{
await SeedTemplateDependencies();
await SeedTemplate();
await SeedExistingPlaylist(9);
Either<BaseError, CreateChannelFromLineupResponseModel> result =
await MakeHandler().Handle(MakeRequest(lineup: [PlaylistEntry(9)]), CancellationToken.None);
// The generated-playlist id is null for a single-item lineup, even when it references a playlist.
RightOf(result).PlaylistId.ShouldBeNull();
await using TvContext context = _db.CreateContext();
ProgramScheduleItem item = await context.ProgramScheduleItems.SingleAsync();
item.CollectionType.ShouldBe(CollectionType.Playlist);
item.PlaylistId.ShouldBe(9);
}
[Test]
public async Task Should_Create_Single_Rerun_Collection_As_First_Run()
{
await SeedTemplateDependencies();
await SeedTemplate();
await SeedRerunCollection(11);
Either<BaseError, CreateChannelFromLineupResponseModel> result =
await MakeHandler().Handle(MakeRequest(lineup: [RerunItem(11)]), CancellationToken.None);
RightOf(result).PlaylistId.ShouldBeNull();
await using TvContext context = _db.CreateContext();
ProgramScheduleItem item = await context.ProgramScheduleItems.SingleAsync();
item.CollectionType.ShouldBe(CollectionType.RerunFirstRun);
item.RerunCollectionId.ShouldBe(11);
}
[Test]
public async Task Should_Create_Multi_Item_Lineup_As_Generated_System_Playlist()
{
await SeedTemplateDependencies();
await SeedTemplate();
await SeedMovie(42);
await SeedShow(43);
await SeedCollection(7);
Either<BaseError, CreateChannelFromLineupResponseModel> result =
await MakeHandler().Handle(
MakeRequest(lineup: [MovieItem(42), ShowItem(43), CollectionItem(7)]),
CancellationToken.None);
CreateChannelFromLineupResponseModel response = RightOf(result);
response.PlaylistId.ShouldNotBeNull();
await using TvContext context = _db.CreateContext();
PlaylistGroup group = await context.PlaylistGroups.SingleAsync();
group.Name.ShouldBe("Channel Lineups");
group.IsSystem.ShouldBeTrue();
Playlist playlist = await context.Playlists.Include(p => p.Items).SingleAsync();
playlist.Id.ShouldBe(response.PlaylistId!.Value);
playlist.Name.ShouldBe("12 Movies Lineup");
playlist.IsSystem.ShouldBeTrue();
playlist.PlaylistGroupId.ShouldBe(group.Id);
List<DomainPlaylistItem> items = playlist.Items.OrderBy(i => i.Index).ToList();
items.Count.ShouldBe(3);
items.ShouldAllBe(i => i.PlayAll);
items.ShouldAllBe(i => i.IncludeInProgramGuide);
items.ShouldAllBe(i => i.PlaybackOrder == PlaybackOrder.Shuffle);
items[0].Index.ShouldBe(1);
items[0].CollectionType.ShouldBe(CollectionType.Movie);
items[0].MediaItemId.ShouldBe(42);
items[1].Index.ShouldBe(2);
items[1].CollectionType.ShouldBe(CollectionType.TelevisionShow);
items[1].MediaItemId.ShouldBe(43);
items[2].Index.ShouldBe(3);
items[2].CollectionType.ShouldBe(CollectionType.Collection);
items[2].CollectionId.ShouldBe(7);
// Exactly one flood schedule item, referencing the generated playlist.
ProgramScheduleItem scheduleItem = await context.ProgramScheduleItems.SingleAsync();
scheduleItem.ShouldBeOfType<ProgramScheduleItemFlood>();
scheduleItem.CollectionType.ShouldBe(CollectionType.Playlist);
scheduleItem.PlaylistId.ShouldBe(playlist.Id);
}
[Test]
public async Task Should_Reject_Rerun_Collection_In_Multi_Item_Lineup()
{
await SeedTemplateDependencies();
await SeedTemplate();
await SeedMovie(42);
await SeedRerunCollection(11);
Either<BaseError, CreateChannelFromLineupResponseModel> result =
await MakeHandler().Handle(
MakeRequest(lineup: [MovieItem(42), RerunItem(11)]),
CancellationToken.None);
BaseError error = LeftOf(result);
error.ShouldNotBeOfType<NotFoundError>();
error.Value.ShouldContain("single-item");
}
[Test]
public async Task Should_Reject_Playlist_In_Multi_Item_Lineup()
{
await SeedTemplateDependencies();
await SeedTemplate();
await SeedMovie(42);
await SeedExistingPlaylist(9);
Either<BaseError, CreateChannelFromLineupResponseModel> result =
await MakeHandler().Handle(
MakeRequest(lineup: [MovieItem(42), PlaylistEntry(9)]),
CancellationToken.None);
BaseError error = LeftOf(result);
error.ShouldNotBeOfType<NotFoundError>();
error.Value.ShouldContain("single-item");
}
[Test]
public async Task Advanced_Overrides_Should_Beat_Template_Defaults()
{
await SeedTemplateDependencies();
await SeedTemplate();
await SeedMovie(42);
await using (TvContext context = _db.CreateContext())
{
context.FFmpegProfiles.Add(new FFmpegProfile { Id = 2, Name = "advanced-profile" });
context.ChannelWatermarks.Add(new ChannelWatermark { Id = 21, Name = "wm", Image = "wm.png" });
context.FillerPresets.AddRange(
MakeFiller(6, FillerKind.Fallback),
MakeFiller(7, FillerKind.PreRoll));
await context.SaveChangesAsync();
}
var advanced = new CreateChannelFromLineupAdvancedOptions(
PlaybackOrder: PlaybackOrder.Shuffle,
FFmpegProfileId: 2,
WatermarkId: 21,
FallbackFillerId: 6,
PreRollFillerId: 7,
StreamingMode: StreamingMode.TransportStream);
Either<BaseError, CreateChannelFromLineupResponseModel> result =
await MakeHandler().Handle(MakeRequest(advanced: advanced), CancellationToken.None);
RightOf(result);
await using TvContext assert = _db.CreateContext();
DomainChannel channel = await assert.Channels.SingleAsync();
// Advanced wins over the template's values (template = profile 1, no watermark, HLS, fallback 5).
channel.FFmpegProfileId.ShouldBe(2);
channel.WatermarkId.ShouldBe(21);
channel.StreamingMode.ShouldBe(StreamingMode.TransportStream);
channel.FallbackFillerId.ShouldBe(6);
ProgramScheduleItem item = await assert.ProgramScheduleItems.SingleAsync();
item.PlaybackOrder.ShouldBe(PlaybackOrder.Shuffle);
item.PreRollFillerId.ShouldBe(7);
item.FallbackFillerId.ShouldBe(6);
// Not overridden in Advanced -> falls through to the template value.
item.MidRollFillerId.ShouldBe(3);
}
[Test]
public async Task Should_Return_Validation_Error_When_Not_Exactly_One_Id_Provided()
{
await SeedTemplateDependencies();
await SeedTemplate();
var item = new CreateChannelFromLineupItem(
LibraryBrowseMediaType.Movie, CollectionType.Movie, null, null, null, null, null, null);
Either<BaseError, CreateChannelFromLineupResponseModel> result =
await MakeHandler().Handle(MakeRequest(lineup: [item]), CancellationToken.None);
LeftOf(result).Value.ShouldContain("exactly one typed id");
}
[Test]
public async Task Should_Return_Validation_Error_When_Media_Type_Does_Not_Match_Collection_Type()
{
await SeedTemplateDependencies();
await SeedTemplate();
await SeedMovie(42);
var item = new CreateChannelFromLineupItem(
LibraryBrowseMediaType.Movie, CollectionType.Playlist, null, null, null, null, 42, null);
Either<BaseError, CreateChannelFromLineupResponseModel> result =
await MakeHandler().Handle(MakeRequest(lineup: [item]), CancellationToken.None);
BaseError error = LeftOf(result);
error.ShouldNotBeOfType<NotFoundError>();
error.Value.ShouldContain("does not match");
}
[Test]
public async Task Should_Reject_MultiCollection_With_Non_Shuffle_Playback_Order()
{
await SeedTemplateDependencies();
await SeedTemplate();
await SeedMultiCollection(15);
var advanced = new CreateChannelFromLineupAdvancedOptions(PlaybackOrder: PlaybackOrder.Chronological);
Either<BaseError, CreateChannelFromLineupResponseModel> result =
await MakeHandler().Handle(
MakeRequest(advanced: advanced, lineup: [MultiCollectionItem(15)]),
CancellationToken.None);
LeftOf(result).Value.ShouldContain("Invalid playback order for multi collection");
}
[Test]
public async Task Should_Reject_Mirror_Playout_Source()
{
await SeedTemplateDependencies();
await SeedTemplate();
await SeedMovie(42);
var advanced = new CreateChannelFromLineupAdvancedOptions(
PlaybackOrder: PlaybackOrder.Shuffle,
PlayoutSource: ChannelPlayoutSource.Mirror);
Either<BaseError, CreateChannelFromLineupResponseModel> result =
await MakeHandler().Handle(MakeRequest(advanced: advanced), CancellationToken.None);
LeftOf(result).Value.ShouldContain("Mirror playout source");
}
[Test]
public async Task OnDemand_Playout_Mode_Should_Queue_TimeShift_After_Build()
{
await SeedTemplateDependencies();
await SeedTemplate();
await SeedMovie(42);
var advanced = new CreateChannelFromLineupAdvancedOptions(
PlaybackOrder: PlaybackOrder.Shuffle,
PlayoutMode: ChannelPlayoutMode.OnDemand);
Either<BaseError, CreateChannelFromLineupResponseModel> result =
await MakeHandler().Handle(MakeRequest(advanced: advanced), CancellationToken.None);
CreateChannelFromLineupResponseModel response = RightOf(result);
_background.Reader.TryRead(out IBackgroundServiceRequest? first).ShouldBeTrue();
first.ShouldBeOfType<BuildPlayout>();
_background.Reader.TryRead(out IBackgroundServiceRequest? second).ShouldBeTrue();
TimeShiftOnDemandPlayout timeShift = second.ShouldBeOfType<TimeShiftOnDemandPlayout>();
timeShift.PlayoutId.ShouldBe(response.PlayoutId);
timeShift.Force.ShouldBeFalse();
_background.Reader.TryRead(out IBackgroundServiceRequest? third).ShouldBeTrue();
third.ShouldBeOfType<RefreshChannelList>();
}
[Test]
public async Task Should_Recreate_With_De_Collided_Names_After_Channel_Delete()
{
await SeedTemplateDependencies();
await SeedTemplate();
await SeedMovie(42);
await SeedShow(43);
// First create builds "12 Movies Schedule" + "12 Movies Lineup".
RightOf(await MakeHandler().Handle(
MakeRequest(lineup: [MovieItem(42), ShowItem(43)]),
CancellationToken.None));
// Delete the channel + playout but leave the generated schedule/playlist rows behind.
await using (TvContext context = _db.CreateContext())
{
context.Playouts.RemoveRange(await context.Playouts.ToListAsync());
context.Channels.RemoveRange(await context.Channels.ToListAsync());
await context.SaveChangesAsync();
}
// Second create with the same name must succeed via de-collided names.
CreateChannelFromLineupResponseModel response = RightOf(await MakeHandler().Handle(
MakeRequest(lineup: [MovieItem(42), ShowItem(43)]),
CancellationToken.None));
await using TvContext assert = _db.CreateContext();
bool scheduleExists = await assert.ProgramSchedules.AnyAsync(ps => ps.Name == "12 Movies Schedule 2");
scheduleExists.ShouldBeTrue();
Playlist newPlaylist = await assert.Playlists.SingleAsync(p => p.Id == response.PlaylistId!.Value);
newPlaylist.Name.ShouldBe("12 Movies Lineup 2");
// Only one system playlist group is ever created.
(await assert.PlaylistGroups.CountAsync(pg => pg.Name == "Channel Lineups")).ShouldBe(1);
}
[Test]
public async Task Should_Return_NotFound_For_Missing_Advanced_References()
{
await SeedTemplateDependencies();
await SeedTemplate();
await SeedMovie(42);
await AssertNotFound(
new CreateChannelFromLineupAdvancedOptions(FFmpegProfileId: 999),
"FFmpegProfile 999");
await AssertNotFound(
new CreateChannelFromLineupAdvancedOptions(WatermarkId: 999),
"Watermark 999");
await AssertNotFound(
new CreateChannelFromLineupAdvancedOptions(FallbackFillerId: 999),
"Fallback filler 999");
await AssertNotFound(
new CreateChannelFromLineupAdvancedOptions(PreRollFillerId: 999),
"Pre-roll filler 999");
await AssertNotFound(
new CreateChannelFromLineupAdvancedOptions(MidRollFillerId: 999),
"Mid-roll filler 999");
await AssertNotFound(
new CreateChannelFromLineupAdvancedOptions(PostRollFillerId: 999),
"Post-roll filler 999");
}
[Test]
public async Task Should_Roll_Back_When_Save_Fails()
{
await SeedTemplateDependencies();
await SeedTemplate();
await SeedMovie(42);
await SeedShow(43);
// A non-system group with the reserved name forces the handler's new system group insert
// to violate the unique Name index at save time (multi-item lineup path).
await using (TvContext context = _db.CreateContext())
{
context.PlaylistGroups.Add(new PlaylistGroup { Id = 99, Name = "Channel Lineups", IsSystem = false });
await context.SaveChangesAsync();
}
Either<BaseError, CreateChannelFromLineupResponseModel> result =
await MakeHandler().Handle(
MakeRequest(lineup: [MovieItem(42), ShowItem(43)]),
CancellationToken.None);
BaseError error = LeftOf(result);
error.ShouldNotBeOfType<NotFoundError>();
error.Value.ShouldBe("Unable to create channel from lineup");
await using TvContext assertContext = _db.CreateContext();
(await assertContext.Channels.CountAsync()).ShouldBe(0);
(await assertContext.Playlists.CountAsync()).ShouldBe(0);
(await assertContext.ProgramSchedules.CountAsync()).ShouldBe(0);
(await assertContext.Playouts.CountAsync()).ShouldBe(0);
_background.Reader.TryRead(out _).ShouldBeFalse();
}
[Test]
public async Task Should_Return_NotFound_When_Template_Is_Missing()
{
await SeedTemplateDependencies();
await SeedMovie(42);
Either<BaseError, CreateChannelFromLineupResponseModel> result =
await MakeHandler().Handle(MakeRequest(templateId: 999), CancellationToken.None);
LeftOf(result).ShouldBeOfType<NotFoundError>();
}
[Test]
public async Task Should_Return_Validation_Error_When_Channel_Number_Already_Exists_After_Trim()
{
await SeedTemplateDependencies();
await SeedTemplate();
await SeedMovie(42);
await using (TvContext context = _db.CreateContext())
{
context.Channels.Add(new DomainChannel(Guid.NewGuid())
{
Number = "12",
Name = "Existing",
Group = "Kids",
Categories = string.Empty,
FFmpegProfileId = 1,
StreamSelector = string.Empty,
PreferredAudioLanguageCode = string.Empty,
PreferredAudioTitle = string.Empty,
PreferredSubtitleLanguageCode = string.Empty,
MusicVideoCreditsTemplate = string.Empty
});
await context.SaveChangesAsync();
}
Either<BaseError, CreateChannelFromLineupResponseModel> result =
await MakeHandler().Handle(MakeRequest(number: " 12 "), CancellationToken.None);
BaseError error = LeftOf(result);
error.ShouldNotBeOfType<NotFoundError>();
error.Value.ShouldContain("Channel number must be unique");
}
[Test]
public async Task Should_Return_Validation_Error_When_Disabled_But_Shown_In_Epg()
{
await SeedTemplateDependencies();
await SeedTemplate();
await SeedMovie(42);
Either<BaseError, CreateChannelFromLineupResponseModel> result =
await MakeHandler().Handle(
MakeRequest(isEnabled: false, showInEpg: true),
CancellationToken.None);
BaseError error = LeftOf(result);
error.ShouldNotBeOfType<NotFoundError>();
error.Value.ShouldContain("Disabled channels cannot be shown in EPG");
}
[Test]
public async Task Should_Return_Validation_Error_For_Invalid_External_Logo()
{
await SeedTemplateDependencies();
await SeedTemplate();
await SeedMovie(42);
var logo = new ArtworkContentTypeModel("ftp://example.com/logo.png", string.Empty);
Either<BaseError, CreateChannelFromLineupResponseModel> result =
await MakeHandler().Handle(MakeRequest(logo: logo), CancellationToken.None);
LeftOf(result).Value.ShouldContain("External logo url is invalid");
}
[Test]
public async Task Should_Return_NotFound_When_Lineup_Target_Is_Missing()
{
await SeedTemplateDependencies();
await SeedTemplate();
Either<BaseError, CreateChannelFromLineupResponseModel> result =
await MakeHandler().Handle(MakeRequest(), CancellationToken.None);
NotFoundError error = LeftOf(result).ShouldBeOfType<NotFoundError>();
error.Value.ShouldContain("lineup[0]");
error.Value.ShouldContain("Movie 42");
}
private async Task AssertNotFound(CreateChannelFromLineupAdvancedOptions advanced, string expectedFragment)
{
Either<BaseError, CreateChannelFromLineupResponseModel> result =
await MakeHandler().Handle(MakeRequest(advanced: advanced), CancellationToken.None);
NotFoundError error = LeftOf(result).ShouldBeOfType<NotFoundError>();
error.Value.ShouldContain(expectedFragment);
}
private CreateChannelFromLineupHandler MakeHandler() =>
new(
_background.Writer,
_db.Factory,
_searchTargets,
NullLogger<CreateChannelFromLineupHandler>.Instance);
private async Task SeedTemplateDependencies()
{
await using TvContext context = _db.CreateContext();
context.FFmpegProfiles.Add(new FFmpegProfile { Id = 1, Name = "profile" });
context.FillerPresets.AddRange(
MakeFiller(2, FillerKind.PreRoll),
MakeFiller(3, FillerKind.MidRoll),
MakeFiller(4, FillerKind.PostRoll),
MakeFiller(5, FillerKind.Fallback));
await context.SaveChangesAsync();
}
private async Task SeedTemplate()
{
await using TvContext context = _db.CreateContext();
context.ChannelTemplates.Add(new ChannelTemplate
{
Id = 10,
Name = "Template",
Description = string.Empty,
FFmpegProfileId = 1,
FallbackFillerId = 5,
PreRollFillerId = 2,
MidRollFillerId = 3,
PostRollFillerId = 4,
StreamSelectorMode = ChannelStreamSelectorMode.Default,
StreamSelector = string.Empty,
PreferredAudioLanguageCode = string.Empty,
PreferredAudioTitle = string.Empty,
PlayoutSource = ChannelPlayoutSource.Generated,
PlayoutMode = ChannelPlayoutMode.Continuous,
StreamingMode = StreamingMode.HttpLiveStreamingSegmenter,
PreferredSubtitleLanguageCode = string.Empty,
SubtitleMode = ChannelSubtitleMode.None,
MusicVideoCreditsMode = ChannelMusicVideoCreditsMode.None,
MusicVideoCreditsTemplate = string.Empty,
SongVideoMode = ChannelSongVideoMode.Default,
TranscodeMode = ChannelTranscodeMode.OnDemand,
IdleBehavior = ChannelIdleBehavior.StopOnDisconnect,
ShuffleScheduleItems = true,
RandomStartPoint = true,
FixedStartTimeBehavior = FixedStartTimeBehavior.Strict
});
await context.SaveChangesAsync();
}
private async Task SeedMovie(int id)
{
await using TvContext context = _db.CreateContext();
context.Movies.Add(new Movie { Id = id });
await context.SaveChangesAsync();
}
private async Task SeedShow(int id)
{
await using TvContext context = _db.CreateContext();
context.Shows.Add(new Show { Id = id });
await context.SaveChangesAsync();
}
private async Task SeedCollection(int id)
{
await using TvContext context = _db.CreateContext();
context.Collections.Add(new Collection { Id = id, Name = $"Collection {id}" });
await context.SaveChangesAsync();
}
private async Task SeedMultiCollection(int id)
{
await using TvContext context = _db.CreateContext();
context.MultiCollections.Add(new MultiCollection { Id = id, Name = $"Multi {id}" });
await context.SaveChangesAsync();
}
private async Task SeedRerunCollection(int id)
{
await using TvContext context = _db.CreateContext();
context.RerunCollections.Add(new RerunCollection
{
Id = id,
Name = $"Rerun {id}",
CollectionType = CollectionType.Collection
});
await context.SaveChangesAsync();
}
private async Task SeedExistingPlaylist(int id)
{
await using TvContext context = _db.CreateContext();
context.PlaylistGroups.Add(new PlaylistGroup { Id = 500, Name = "User Group", IsSystem = false });
context.Playlists.Add(new Playlist { Id = id, Name = $"Playlist {id}", PlaylistGroupId = 500, IsSystem = false });
await context.SaveChangesAsync();
}
private static FillerPreset MakeFiller(int id, FillerKind kind) =>
new()
{
Id = id,
Name = $"filler-{id}",
FillerKind = kind,
FillerMode = FillerMode.Count,
Count = 1,
CollectionType = CollectionType.Collection
};
private static CreateChannelFromLineupItem MovieItem(int id) =>
new(LibraryBrowseMediaType.Movie, CollectionType.Movie, null, null, null, null, id, null);
private static CreateChannelFromLineupItem ShowItem(int id) =>
new(LibraryBrowseMediaType.TelevisionShow, CollectionType.TelevisionShow, null, null, null, null, id, null);
private static CreateChannelFromLineupItem CollectionItem(int id) =>
new(LibraryBrowseMediaType.Collection, CollectionType.Collection, id, null, null, null, null, null);
private static CreateChannelFromLineupItem MultiCollectionItem(int id) =>
new(LibraryBrowseMediaType.MultiCollection, CollectionType.MultiCollection, null, id, null, null, null, null);
private static CreateChannelFromLineupItem RerunItem(int id) =>
new(LibraryBrowseMediaType.RerunCollection, CollectionType.RerunFirstRun, null, null, null, id, null, null);
private static CreateChannelFromLineupItem PlaylistEntry(int id) =>
new(LibraryBrowseMediaType.Playlist, CollectionType.Playlist, null, null, null, null, null, id);
private static CreateChannelFromLineup MakeRequest(
string number = "12",
string name = "Movies",
int templateId = 10,
bool isEnabled = true,
bool showInEpg = true,
ArtworkContentTypeModel logo = null,
CreateChannelFromLineupAdvancedOptions advanced = null,
List<CreateChannelFromLineupItem> lineup = null) =>
new(
name,
number,
"Kids",
string.Empty,
logo ?? ArtworkContentTypeModel.None,
isEnabled,
showInEpg,
templateId,
advanced ?? new CreateChannelFromLineupAdvancedOptions(PlaybackOrder.Shuffle),
lineup ?? [MovieItem(42)]);
private static BaseError LeftOf<TR>(Either<BaseError, TR> either) =>
either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result"));
private static TR RightOf<TR>(Either<BaseError, TR> either) =>
either.Match(Left: e => throw new AssertionException($"Expected a Right result, got {e.Value}"), Right: r => r);
}
@@ -0,0 +1,665 @@
using ErsatzTV.Application.LibraryBrowse;
using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Search;
using ErsatzTV.Tests.Support;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.LibraryBrowse;
[TestFixture]
public class GetLibraryBrowseItemsHandlerTests
{
private InMemoryTvContext _db = null!;
private ISearchIndex _searchIndex = null!;
[SetUp]
public async Task SetUp()
{
_db = await InMemoryTvContext.CreateAsync();
_searchIndex = Substitute.For<ISearchIndex>();
}
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
[Test]
public async Task Handle_Should_Search_Index_With_Type_Library_And_Title_Filters()
{
_searchIndex.Search(
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<int>(),
Arg.Any<int>(),
Arg.Any<List<string>>(),
Arg.Any<CancellationToken>())
.Returns(new SearchResult([], 0));
var handler = new GetLibraryBrowseItemsHandler(_searchIndex, _db.Factory);
await handler.Handle(
new GetLibraryBrowseItems("star", 5, LibraryBrowseMediaType.Movie, 2, 25),
CancellationToken.None);
await _searchIndex.Received(1).Search(
Arg.Is<string>(q => q.Contains("type:movie") && q.Contains("library_id:5") && q.Contains("star")),
string.Empty,
50,
25,
Arg.Is<List<string>>(fields => fields.SequenceEqual(new[] { LuceneSearchIndex.TitleAndYearSearchField })),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Handle_Should_Hydrate_Media_Items_In_Search_Order_With_Artwork_Duration_And_Counts()
{
await SeedLibraryGraph();
_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.ShowType, 20),
new SearchItem(LuceneSearchIndex.MovieType, 10)
],
2));
var handler = new GetLibraryBrowseItemsHandler(_searchIndex, _db.Factory);
PagedLibraryBrowseItemsResponseModel result = await handler.Handle(
new GetLibraryBrowseItems("", null, null, 0, 10),
CancellationToken.None);
result.TotalCount.ShouldBe(2);
result.Page[0].MediaType.ShouldBe(LibraryBrowseMediaType.TelevisionShow);
result.Page[0].Title.ShouldBe("Collision Show");
result.Page[0].ItemCount.ShouldBe(1);
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].Duration.ShouldBe(TimeSpan.FromMinutes(95));
result.Page[1].MediaItemId.ShouldBe(10);
}
[Test]
public async Task Handle_Should_Return_Typed_Collection_And_Playlist_Picker_Targets()
{
await SeedCollectionGraph();
_searchIndex.Search(
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<int>(),
Arg.Any<int>(),
Arg.Any<List<string>>(),
Arg.Any<CancellationToken>())
.Returns(new SearchResult([], 0));
var handler = new GetLibraryBrowseItemsHandler(_searchIndex, _db.Factory);
PagedLibraryBrowseItemsResponseModel result = await handler.Handle(
new GetLibraryBrowseItems("", null, null, 0, 10),
CancellationToken.None);
result.TotalCount.ShouldBe(5);
result.Page.ShouldContain(i =>
i.MediaType == LibraryBrowseMediaType.Collection &&
i.CollectionKind == "Manual" &&
i.CollectionType == CollectionType.Collection &&
i.CollectionId == 20 &&
i.ItemCount == 1);
result.Page.ShouldContain(i =>
i.MediaType == LibraryBrowseMediaType.SmartCollection &&
i.CollectionKind == "Smart" &&
i.CollectionType == CollectionType.SmartCollection &&
i.SmartCollectionId == 30);
result.Page.ShouldContain(i =>
i.MediaType == LibraryBrowseMediaType.MultiCollection &&
i.CollectionKind == "Multi" &&
i.CollectionType == CollectionType.MultiCollection &&
i.MultiCollectionId == 40);
result.Page.ShouldContain(i =>
i.MediaType == LibraryBrowseMediaType.RerunCollection &&
i.CollectionKind == "Rerun" &&
i.CollectionType == CollectionType.RerunFirstRun &&
i.RerunCollectionId == 50);
result.Page.ShouldContain(i =>
i.MediaType == LibraryBrowseMediaType.Playlist &&
i.CollectionType == CollectionType.Playlist &&
i.PlaylistId == 60);
}
[Test]
public async Task Handle_Should_Page_Across_Media_Manual_Collections_And_Smart_Collections()
{
await SeedMediaAndCollectionPagingGraph();
_searchIndex.Search(
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Is<int>(offset => offset == 0),
Arg.Any<int>(),
Arg.Any<List<string>>(),
Arg.Any<CancellationToken>())
.Returns(new SearchResult(
[
new SearchItem(LuceneSearchIndex.MovieType, 101),
new SearchItem(LuceneSearchIndex.MovieType, 102)
],
2));
_searchIndex.Search(
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Is<int>(offset => offset > 0),
Arg.Any<int>(),
Arg.Any<List<string>>(),
Arg.Any<CancellationToken>())
.Returns(new SearchResult([], 2));
var handler = new GetLibraryBrowseItemsHandler(_searchIndex, _db.Factory);
PagedLibraryBrowseItemsResponseModel page0 = await handler.Handle(
new GetLibraryBrowseItems("", null, null, 0, 3),
CancellationToken.None);
PagedLibraryBrowseItemsResponseModel page1 = await handler.Handle(
new GetLibraryBrowseItems("", null, null, 1, 3),
CancellationToken.None);
PagedLibraryBrowseItemsResponseModel page2 = await handler.Handle(
new GetLibraryBrowseItems("", null, null, 2, 3),
CancellationToken.None);
page0.TotalCount.ShouldBe(7);
page0.Page.Select(i => i.Title).ShouldBe(["Movie One", "Movie Two", "Manual A"]);
page1.TotalCount.ShouldBe(7);
page1.Page.Select(i => i.Title).ShouldBe(["Manual B", "Manual C", "Smart A"]);
page2.TotalCount.ShouldBe(7);
page2.Page.Select(i => i.Title).ShouldBe(["Smart B"]);
}
[Test]
public async Task Handle_Should_Hydrate_Manual_Collection_Artwork_And_Total_Duration()
{
await SeedManualCollectionMetadataGraph();
_searchIndex.Search(
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<int>(),
Arg.Any<int>(),
Arg.Any<List<string>>(),
Arg.Any<CancellationToken>())
.Returns(new SearchResult([], 0));
var handler = new GetLibraryBrowseItemsHandler(_searchIndex, _db.Factory);
PagedLibraryBrowseItemsResponseModel result = await handler.Handle(
new GetLibraryBrowseItems("", null, LibraryBrowseMediaType.Collection, 0, 10),
CancellationToken.None);
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].Duration.ShouldBe(TimeSpan.FromMinutes(75));
result.Page[0].ItemCount.ShouldBe(2);
}
[Test]
public async Task Handle_Should_Hydrate_Seasons_With_Composed_Titles_And_Episode_Counts()
{
await SeedSeasonGraph();
_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.SeasonType, 301),
new SearchItem(LuceneSearchIndex.SeasonType, 302)
],
2));
var handler = new GetLibraryBrowseItemsHandler(_searchIndex, _db.Factory);
PagedLibraryBrowseItemsResponseModel result = await handler.Handle(
new GetLibraryBrowseItems("", null, LibraryBrowseMediaType.TelevisionSeason, 0, 10),
CancellationToken.None);
result.TotalCount.ShouldBe(2);
result.Page[0].Title.ShouldBe("Season Show - Season 2");
result.Page[0].ItemCount.ShouldBe(2);
result.Page[0].CollectionType.ShouldBe(CollectionType.TelevisionSeason);
result.Page[0].MediaItemId.ShouldBe(301);
result.Page[1].Title.ShouldBe("Season Show - Specials");
result.Page[1].ItemCount.ShouldBe(1);
result.Page[1].MediaItemId.ShouldBe(302);
}
[Test]
public async Task Handle_Should_Hydrate_Artists_With_Music_Video_Counts()
{
await SeedArtistGraph();
_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.ArtistType, 401)], 1));
var handler = new GetLibraryBrowseItemsHandler(_searchIndex, _db.Factory);
PagedLibraryBrowseItemsResponseModel result = await handler.Handle(
new GetLibraryBrowseItems("", null, LibraryBrowseMediaType.Artist, 0, 10),
CancellationToken.None);
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].CollectionType.ShouldBe(CollectionType.Artist);
result.Page[0].MediaItemId.ShouldBe(401);
}
[Test]
public async Task Handle_Should_Apply_Library_Filter_To_Manual_Collections_Only()
{
await SeedLibraryFilteredCollectionsGraph();
_searchIndex.Search(
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<int>(),
Arg.Any<int>(),
Arg.Any<List<string>>(),
Arg.Any<CancellationToken>())
.Returns(new SearchResult([], 0));
var handler = new GetLibraryBrowseItemsHandler(_searchIndex, _db.Factory);
PagedLibraryBrowseItemsResponseModel result = await handler.Handle(
new GetLibraryBrowseItems("", 501, null, 0, 10),
CancellationToken.None);
result.TotalCount.ShouldBe(1);
result.Page.Count.ShouldBe(1);
result.Page[0].Title.ShouldBe("Included Manual");
result.Page[0].MediaType.ShouldBe(LibraryBrowseMediaType.Collection);
result.Page[0].CollectionId.ShouldBe(510);
}
private async Task SeedLibraryGraph()
{
await using TvContext context = _db.CreateContext();
var library = new LocalLibrary
{
Id = 1,
Name = "Local Movies",
MediaKind = LibraryMediaKind.Movies,
Paths = []
};
var path = new LibraryPath
{
Id = 1,
Path = "/media",
Library = library,
LibraryFolders = [],
MediaItems = []
};
library.Paths.Add(path);
var movie = new Movie
{
Id = 10,
LibraryPath = path,
Collections = [],
CollectionItems = [],
TraktListItems = [],
MediaVersions = [new MediaVersion { Duration = TimeSpan.FromMinutes(95) }],
MovieMetadata =
[
MakeMovieMetadata("Collision Movie", "movie-poster.jpg")
]
};
var show = new Show
{
Id = 20,
LibraryPath = path,
Collections = [],
CollectionItems = [],
TraktListItems = [],
Seasons = [],
ShowMetadata = [MakeShowMetadata("Collision Show", "show-poster.jpg")]
};
var season = new Season
{
Id = 11,
LibraryPath = path,
Show = show,
Collections = [],
CollectionItems = [],
TraktListItems = [],
Episodes = [],
SeasonMetadata = []
};
var episode = new Episode
{
Id = 12,
LibraryPath = path,
Season = season,
Collections = [],
CollectionItems = [],
TraktListItems = [],
EpisodeMetadata = [],
MediaVersions = [new MediaVersion { Duration = TimeSpan.FromMinutes(25) }]
};
season.Episodes.Add(episode);
show.Seasons.Add(season);
path.MediaItems.AddRange([movie, show, season, episode]);
context.LocalLibraries.Add(library);
context.Movies.Add(movie);
context.Shows.Add(show);
context.Seasons.Add(season);
context.Episodes.Add(episode);
await context.SaveChangesAsync();
}
private async Task SeedCollectionGraph()
{
await using TvContext context = _db.CreateContext();
var collection = new Collection { Id = 20, Name = "Manual", MediaItems = [], CollectionItems = [] };
var movie = new Movie
{
Id = 21,
Collections = [collection],
CollectionItems = [],
TraktListItems = [],
MovieMetadata = [],
MediaVersions = []
};
collection.MediaItems.Add(movie);
var smart = new SmartCollection { Id = 30, Name = "Smart", Query = "tag:kids" };
var multi = new MultiCollection
{
Id = 40,
Name = "Multi",
Collections = [collection],
SmartCollections = [smart]
};
var rerun = new RerunCollection { Id = 50, Name = "Rerun", CollectionType = CollectionType.Collection };
var playlist = new Playlist
{
Id = 60,
Name = "Playlist",
Items = [new PlaylistItem { CollectionType = CollectionType.Collection, CollectionId = 20 }]
};
context.Collections.Add(collection);
context.Movies.Add(movie);
context.SmartCollections.Add(smart);
context.MultiCollections.Add(multi);
context.RerunCollections.Add(rerun);
context.Playlists.Add(playlist);
await context.SaveChangesAsync();
}
private async Task SeedMediaAndCollectionPagingGraph()
{
await using TvContext context = _db.CreateContext();
(LocalLibrary library, LibraryPath path) = MakeLibrary(100, "Paging Library");
var movie1 = MakeMovie(101, path, "Movie One", "movie-one.jpg", TimeSpan.FromMinutes(10));
var movie2 = MakeMovie(102, path, "Movie Two", "movie-two.jpg", TimeSpan.FromMinutes(20));
var manualA = MakeCollection(201, "Manual A");
var manualB = MakeCollection(202, "Manual B");
var manualC = MakeCollection(203, "Manual C");
context.LocalLibraries.Add(library);
context.Movies.AddRange(movie1, movie2);
context.Collections.AddRange(manualA, manualB, manualC);
context.SmartCollections.AddRange(
new SmartCollection { Id = 301, Name = "Smart A", Query = "tag:a", MultiCollections = [], MultiCollectionSmartItems = [] },
new SmartCollection { Id = 302, Name = "Smart B", Query = "tag:b", MultiCollections = [], MultiCollectionSmartItems = [] });
await context.SaveChangesAsync();
}
private async Task SeedManualCollectionMetadataGraph()
{
await using TvContext context = _db.CreateContext();
(LocalLibrary library, LibraryPath path) = MakeLibrary(200, "Manual Metadata Library");
var collection = MakeCollection(210, "Manual Metadata");
var first = MakeMovie(211, path, "First", "first-poster.jpg", TimeSpan.FromMinutes(30));
var second = MakeMovie(212, path, "Second", "second-poster.jpg", TimeSpan.FromMinutes(45));
collection.MediaItems.AddRange([first, second]);
context.LocalLibraries.Add(library);
context.Collections.Add(collection);
context.Movies.AddRange(first, second);
await context.SaveChangesAsync();
}
private async Task SeedSeasonGraph()
{
await using TvContext context = _db.CreateContext();
(LocalLibrary library, LibraryPath path) = MakeLibrary(300, "Season Library");
var show = new Show
{
Id = 300,
LibraryPath = path,
Collections = [],
CollectionItems = [],
TraktListItems = [],
Seasons = [],
ShowMetadata = [MakeShowMetadata("Season Show", "show.jpg")]
};
var season = MakeSeason(301, path, show, 2);
var specials = MakeSeason(302, path, show, 0);
season.Episodes.AddRange([
MakeEpisode(311, path, season),
MakeEpisode(312, path, season)
]);
specials.Episodes.Add(MakeEpisode(313, path, specials));
show.Seasons.AddRange([season, specials]);
path.MediaItems.AddRange([show, season, specials, .. season.Episodes, .. specials.Episodes]);
context.LocalLibraries.Add(library);
context.Shows.Add(show);
context.Seasons.AddRange(season, specials);
await context.SaveChangesAsync();
}
private async Task SeedArtistGraph()
{
await using TvContext context = _db.CreateContext();
(LocalLibrary library, LibraryPath path) = MakeLibrary(400, "Artist Library");
var artist = new Artist
{
Id = 401,
LibraryPath = path,
Collections = [],
CollectionItems = [],
TraktListItems = [],
MusicVideos = [],
ArtistMetadata = [MakeArtistMetadata("Counted Artist", "artist-thumb.jpg")]
};
var musicVideo1 = MakeMusicVideo(411, path, artist);
var musicVideo2 = MakeMusicVideo(412, path, artist);
artist.MusicVideos.AddRange([musicVideo1, musicVideo2]);
path.MediaItems.AddRange([artist, musicVideo1, musicVideo2]);
context.LocalLibraries.Add(library);
context.Artists.Add(artist);
context.MusicVideos.AddRange(musicVideo1, musicVideo2);
await context.SaveChangesAsync();
}
private async Task SeedLibraryFilteredCollectionsGraph()
{
await using TvContext context = _db.CreateContext();
(LocalLibrary includedLibrary, LibraryPath includedPath) = MakeLibrary(501, "Included Library");
(LocalLibrary excludedLibrary, LibraryPath excludedPath) = MakeLibrary(502, "Excluded Library");
var includedCollection = MakeCollection(510, "Included Manual");
var excludedCollection = MakeCollection(511, "Excluded Manual");
var includedMovie = MakeMovie(520, includedPath, "Included Movie", "included.jpg", TimeSpan.FromMinutes(5));
var excludedMovie = MakeMovie(521, excludedPath, "Excluded Movie", "excluded.jpg", TimeSpan.FromMinutes(5));
includedCollection.MediaItems.Add(includedMovie);
excludedCollection.MediaItems.Add(excludedMovie);
context.LocalLibraries.AddRange(includedLibrary, excludedLibrary);
context.Movies.AddRange(includedMovie, excludedMovie);
context.Collections.AddRange(includedCollection, excludedCollection);
context.SmartCollections.Add(new SmartCollection { Id = 530, Name = "Smart", Query = "tag", MultiCollections = [], MultiCollectionSmartItems = [] });
context.MultiCollections.Add(new MultiCollection { Id = 540, Name = "Multi", Collections = [], SmartCollections = [], MultiCollectionItems = [], MultiCollectionSmartItems = [] });
context.RerunCollections.Add(new RerunCollection { Id = 550, Name = "Rerun", CollectionType = CollectionType.Collection });
context.Playlists.Add(new Playlist { Id = 560, Name = "Playlist", Items = [] });
await context.SaveChangesAsync();
}
private static (LocalLibrary Library, LibraryPath Path) MakeLibrary(int id, string name)
{
var library = new LocalLibrary
{
Id = id,
Name = name,
MediaKind = LibraryMediaKind.Movies,
Paths = []
};
var path = new LibraryPath
{
Id = id,
Path = $"/media/{id}",
Library = library,
LibraryFolders = [],
MediaItems = []
};
library.Paths.Add(path);
return (library, path);
}
private static Collection MakeCollection(int id, string name) =>
new()
{
Id = id,
Name = name,
MediaItems = [],
CollectionItems = [],
MultiCollections = [],
MultiCollectionItems = []
};
private static Movie MakeMovie(int id, LibraryPath path, string title, string poster, TimeSpan duration) =>
new()
{
Id = id,
LibraryPath = path,
Collections = [],
CollectionItems = [],
TraktListItems = [],
MovieMetadata = [MakeMovieMetadata(title, poster)],
MediaVersions = [new MediaVersion { Duration = duration }]
};
private static Season MakeSeason(int id, LibraryPath path, Show show, int seasonNumber) =>
new()
{
Id = id,
LibraryPath = path,
Show = show,
SeasonNumber = seasonNumber,
Collections = [],
CollectionItems = [],
TraktListItems = [],
Episodes = [],
SeasonMetadata = [MakeSeasonMetadata()]
};
private static Episode MakeEpisode(int id, LibraryPath path, Season season) =>
new()
{
Id = id,
LibraryPath = path,
Season = season,
Collections = [],
CollectionItems = [],
TraktListItems = [],
EpisodeMetadata = [],
MediaVersions = []
};
private static MusicVideo MakeMusicVideo(int id, LibraryPath path, Artist artist) =>
new()
{
Id = id,
LibraryPath = path,
Artist = artist,
Collections = [],
CollectionItems = [],
TraktListItems = [],
MusicVideoMetadata = [],
MediaVersions = []
};
private static MovieMetadata MakeMovieMetadata(string title, string poster) =>
new()
{
Title = title,
SortTitle = title,
Artwork = [new Artwork { Path = poster, ArtworkKind = ArtworkKind.Poster }],
Genres = [],
Tags = [],
Studios = [],
Actors = [],
Guids = [],
Subtitles = [],
Directors = [],
Writers = []
};
private static ShowMetadata MakeShowMetadata(string title, string poster) =>
new()
{
Title = title,
SortTitle = title,
Artwork = [new Artwork { Path = poster, ArtworkKind = ArtworkKind.Poster }],
Genres = [],
Tags = [],
Studios = [],
Actors = [],
Guids = [],
Subtitles = []
};
private static SeasonMetadata MakeSeasonMetadata() =>
new()
{
Title = string.Empty,
SortTitle = string.Empty,
Artwork = [],
Genres = [],
Tags = [],
Studios = [],
Actors = [],
Guids = [],
Subtitles = []
};
private static ArtistMetadata MakeArtistMetadata(string title, string thumbnail) =>
new()
{
Title = title,
SortTitle = title,
Artwork = [new Artwork { Path = thumbnail, ArtworkKind = ArtworkKind.Thumbnail }],
Genres = [],
Tags = [],
Studios = [],
Actors = [],
Guids = [],
Subtitles = [],
Styles = [],
Moods = []
};
}
@@ -54,6 +54,18 @@ public class GetAllMediaSourcesForApiHandlerTests
result[3].Libraries.Single().ItemCount.ShouldBe(0);
}
[Test]
public async Task Handle_Should_Succeed_On_Empty_Database()
{
// regression: the Dapper item-count query 500'd on a fresh database because
// SQLite reports COUNT(*) as BLOB when there are no rows to infer from
var handler = new GetAllMediaSourcesForApiHandler(_db.Factory);
var result = await handler.Handle(new GetAllMediaSourcesForApi(), CancellationToken.None);
result.ShouldBeEmpty();
}
[Test]
public async Task Handle_Should_Exclude_Unconfigured_And_Not_Synced_Libraries()
{
@@ -31,10 +31,12 @@ public class ApiControllerSecurityTests
typeof(LibrariesController),
typeof(MaintenanceController),
typeof(PlayoutController),
typeof(ResolutionController),
typeof(ScannerController),
typeof(ScheduleController),
typeof(ScriptedScheduleController),
typeof(SessionController),
typeof(SettingsController),
typeof(SmartCollectionController)
];
@@ -13,6 +13,8 @@ public class ApiErrorResponseMetadataTests
[TestCase(typeof(ChannelController), nameof(ChannelController.GetById), StatusCodes.Status404NotFound)]
[TestCase(typeof(ChannelController), nameof(ChannelController.Create), StatusCodes.Status404NotFound)]
[TestCase(typeof(ChannelController), nameof(ChannelController.Create), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(ChannelController), nameof(ChannelController.CreateFromLineup), StatusCodes.Status404NotFound)]
[TestCase(typeof(ChannelController), nameof(ChannelController.CreateFromLineup), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(ChannelController), nameof(ChannelController.Update), StatusCodes.Status404NotFound)]
[TestCase(typeof(ChannelController), nameof(ChannelController.Update), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(ChannelController), nameof(ChannelController.Delete), StatusCodes.Status404NotFound)]
@@ -24,6 +26,16 @@ public class ApiErrorResponseMetadataTests
[TestCase(typeof(ChannelController), nameof(ChannelController.BulkDelete), StatusCodes.Status404NotFound)]
[TestCase(typeof(ChannelController), nameof(ChannelController.BulkDelete), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(ChannelController), nameof(ChannelController.ResetPlayout), StatusCodes.Status404NotFound)]
[TestCase(typeof(ChannelTemplateController), nameof(ChannelTemplateController.GetDefault), StatusCodes.Status404NotFound)]
[TestCase(typeof(ChannelTemplateController), nameof(ChannelTemplateController.SetDefault), StatusCodes.Status404NotFound)]
[TestCase(typeof(ChannelTemplateController), nameof(ChannelTemplateController.SetDefault), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(ChannelTemplateController), nameof(ChannelTemplateController.GetById), StatusCodes.Status404NotFound)]
[TestCase(typeof(ChannelTemplateController), nameof(ChannelTemplateController.Create), StatusCodes.Status404NotFound)]
[TestCase(typeof(ChannelTemplateController), nameof(ChannelTemplateController.Create), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(ChannelTemplateController), nameof(ChannelTemplateController.Update), StatusCodes.Status404NotFound)]
[TestCase(typeof(ChannelTemplateController), nameof(ChannelTemplateController.Update), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(ChannelTemplateController), nameof(ChannelTemplateController.Delete), StatusCodes.Status404NotFound)]
[TestCase(typeof(ChannelTemplateController), nameof(ChannelTemplateController.Delete), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(CollectionController), nameof(CollectionController.GetById), StatusCodes.Status404NotFound)]
[TestCase(typeof(CollectionController), nameof(CollectionController.Create), StatusCodes.Status404NotFound)]
[TestCase(typeof(CollectionController), nameof(CollectionController.Create), StatusCodes.Status422UnprocessableEntity)]
@@ -7,6 +7,7 @@ using ErsatzTV.Controllers.Api;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Scheduling;
@@ -98,6 +99,54 @@ public class ChannelControllerTests
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
[Test]
public async Task CreateFromLineup_Should_Return_201_With_Location_And_Body()
{
var response = new CreateChannelFromLineupResponseModel(5, 6, 7, 8);
_mediator.Send(Arg.Any<CreateChannelFromLineup>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, CreateChannelFromLineupResponseModel>(response));
IActionResult result = await _controller.CreateFromLineup(MakeLineupRequest(), CancellationToken.None);
var created = result.ShouldBeOfType<CreatedResult>();
created.StatusCode.ShouldBe(201);
created.Location.ShouldBe("/api/channels/5");
created.Value.ShouldBe(response);
}
[Test]
public async Task CreateFromLineup_Should_Map_Request_To_Command()
{
_mediator.Send(Arg.Any<CreateChannelFromLineup>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, CreateChannelFromLineupResponseModel>(
new CreateChannelFromLineupResponseModel(5, 6, 7, 8)));
await _controller.CreateFromLineup(
MakeLineupRequest(number: "12", name: "Movies", templateId: 9),
CancellationToken.None);
await _mediator.Received(1).Send(
Arg.Is<CreateChannelFromLineup>(c =>
c.Number == "12" &&
c.Name == "Movies" &&
c.TemplateId == 9 &&
c.Lineup.Count == 1 &&
c.Lineup[0].CollectionType == CollectionType.Movie &&
c.Lineup[0].MediaItemId == 42),
Arg.Any<CancellationToken>());
}
[Test]
public async Task CreateFromLineup_Should_Return_404_For_NotFoundError()
{
_mediator.Send(Arg.Any<CreateChannelFromLineup>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, CreateChannelFromLineupResponseModel>(new NotFoundError("missing")));
IActionResult result = await _controller.CreateFromLineup(MakeLineupRequest(), CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
}
[Test]
public async Task Update_Should_Return_200_And_Map_Route_Id()
{
@@ -413,6 +462,30 @@ public class ChannelControllerTests
true,
false);
private static CreateChannelFromLineupRequest MakeLineupRequest(
string number = "5",
string name = "Test",
int templateId = 1) =>
new(
name,
number,
"ErsatzTV",
string.Empty,
ArtworkContentTypeModel.None,
true,
true,
templateId,
null,
[new CreateChannelFromLineupItemRequest(
LibraryBrowseMediaType.Movie,
CollectionType.Movie,
null,
null,
null,
null,
42,
null)]);
private static UpdateChannelRequest MakeUpdateRequest(string number = "5", string name = "Test") =>
new(
name,
@@ -0,0 +1,276 @@
using System.Reflection;
using ErsatzTV.Application.ChannelTemplates;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.ChannelTemplates;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.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 ChannelTemplateControllerTests
{
private ChannelTemplateController _controller = null!;
private IMediator _mediator = null!;
[SetUp]
public void SetUp()
{
_mediator = Substitute.For<IMediator>();
_controller = new ChannelTemplateController(_mediator);
}
[Test]
public void Controller_Should_Expose_Named_Rest_Routes()
{
ShouldHaveActionRoute(
nameof(ChannelTemplateController.GetAll),
"GET",
"/api/channel-templates",
"GetChannelTemplates");
ShouldHaveActionRoute(
nameof(ChannelTemplateController.GetDefault),
"GET",
"/api/channel-templates/default",
"GetDefaultChannelTemplate");
ShouldHaveActionRoute(
nameof(ChannelTemplateController.SetDefault),
"PUT",
"/api/channel-templates/default/{id:int}",
"SetDefaultChannelTemplate");
ShouldHaveActionRoute(
nameof(ChannelTemplateController.GetById),
"GET",
"/api/channel-templates/{id:int}",
"GetChannelTemplateById");
ShouldHaveActionRoute(
nameof(ChannelTemplateController.Create),
"POST",
"/api/channel-templates",
"CreateChannelTemplate");
ShouldHaveActionRoute(
nameof(ChannelTemplateController.Update),
"PUT",
"/api/channel-templates/{id:int}",
"UpdateChannelTemplate");
ShouldHaveActionRoute(
nameof(ChannelTemplateController.Delete),
"DELETE",
"/api/channel-templates/{id:int}",
"DeleteChannelTemplate");
}
[Test]
public async Task GetAll_Should_Return_ChannelTemplates()
{
List<ChannelTemplateResponseModel> models = [MakeResponse(1, "Standard", isDefault: true)];
_mediator.Send(Arg.Any<GetAllChannelTemplates>(), Arg.Any<CancellationToken>())
.Returns(models);
List<ChannelTemplateResponseModel> result = await _controller.GetAll(CancellationToken.None);
result.ShouldBe(models);
}
[Test]
public async Task GetDefault_Should_Return_404_When_Default_Missing()
{
_mediator.Send(Arg.Any<GetDefaultChannelTemplate>(), Arg.Any<CancellationToken>())
.Returns(Option<ChannelTemplateResponseModel>.None);
IActionResult result = await _controller.GetDefault(CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
}
[Test]
public async Task Create_Should_Return_201_With_Location_And_Body()
{
ChannelTemplateResponseModel vm = MakeResponse(7, "Custom");
_mediator.Send(Arg.Any<CreateChannelTemplate>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, ChannelTemplateResponseModel>(vm));
IActionResult result = await _controller.Create(MakeCreateRequest("Custom"), CancellationToken.None);
var created = result.ShouldBeOfType<CreatedResult>();
created.Location.ShouldBe("/api/channel-templates/7");
created.Value.ShouldBe(vm);
}
[Test]
public async Task Create_Should_Map_Request_To_Command()
{
_mediator.Send(Arg.Any<CreateChannelTemplate>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, ChannelTemplateResponseModel>(MakeResponse(7, "Custom")));
await _controller.Create(MakeCreateRequest("Custom"), CancellationToken.None);
await _mediator.Received(1).Send(
Arg.Is<CreateChannelTemplate>(c =>
c.Name == "Custom" &&
c.FFmpegProfileId == 1 &&
c.PreRollFillerId == 2 &&
c.MidRollFillerId == 3 &&
c.PostRollFillerId == 4 &&
c.ShuffleScheduleItems),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Update_Should_Return_404_For_NotFoundError()
{
_mediator.Send(Arg.Any<UpdateChannelTemplate>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, ChannelTemplateResponseModel>(new NotFoundError("missing")));
IActionResult result = await _controller.Update(99, MakeUpdateRequest("Missing"), CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
}
[Test]
public async Task SetDefault_Should_Return_200_With_Default_Template()
{
ChannelTemplateResponseModel vm = MakeResponse(8, "Default", isDefault: true);
_mediator.Send(Arg.Any<SetDefaultChannelTemplate>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, ChannelTemplateResponseModel>(vm));
IActionResult result = await _controller.SetDefault(8, CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(vm);
await _mediator.Received(1).Send(
Arg.Is<SetDefaultChannelTemplate>(c => c.ChannelTemplateId == 8),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Delete_Should_Return_204_On_Success()
{
_mediator.Send(Arg.Any<DeleteChannelTemplate>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default));
IActionResult result = await _controller.Delete(9, CancellationToken.None);
result.ShouldBeOfType<NoContentResult>();
}
[Test]
public async Task Delete_Should_Return_422_When_System_Template()
{
_mediator.Send(Arg.Any<DeleteChannelTemplate>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, Unit>(BaseError.New("System templates cannot be deleted.")));
IActionResult result = await _controller.Delete(9, CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route, string name)
{
MethodInfo action = typeof(ChannelTemplateController).GetMethod(actionName)
?? throw new AssertionException($"Missing action {actionName}");
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
attribute.HttpMethods.ShouldContain(httpMethod);
attribute.Template.ShouldBe(route);
attribute.Name.ShouldBe(name);
}
private static CreateChannelTemplateRequest MakeCreateRequest(string name) =>
new(
name,
"Description",
1,
null,
null,
2,
3,
4,
ChannelStreamSelectorMode.Default,
string.Empty,
string.Empty,
string.Empty,
ChannelPlayoutSource.Generated,
ChannelPlayoutMode.Continuous,
StreamingMode.TransportStreamHybrid,
string.Empty,
ChannelSubtitleMode.None,
ChannelMusicVideoCreditsMode.None,
string.Empty,
ChannelSongVideoMode.Default,
ChannelTranscodeMode.OnDemand,
ChannelIdleBehavior.StopOnDisconnect,
true,
false,
FixedStartTimeBehavior.Flexible);
private static UpdateChannelTemplateRequest MakeUpdateRequest(string name) =>
new(
name,
"Description",
1,
null,
null,
null,
null,
null,
ChannelStreamSelectorMode.Default,
string.Empty,
string.Empty,
string.Empty,
ChannelPlayoutSource.Generated,
ChannelPlayoutMode.Continuous,
StreamingMode.TransportStreamHybrid,
string.Empty,
ChannelSubtitleMode.None,
ChannelMusicVideoCreditsMode.None,
string.Empty,
ChannelSongVideoMode.Default,
ChannelTranscodeMode.OnDemand,
ChannelIdleBehavior.StopOnDisconnect,
false,
false,
FixedStartTimeBehavior.Flexible);
private static ChannelTemplateResponseModel MakeResponse(int id, string name, bool isDefault = false) =>
new(
id,
name,
"Description",
false,
isDefault,
1,
null,
null,
null,
null,
null,
ChannelStreamSelectorMode.Default,
string.Empty,
string.Empty,
string.Empty,
ChannelPlayoutSource.Generated,
ChannelPlayoutMode.Continuous,
StreamingMode.TransportStreamHybrid,
string.Empty,
ChannelSubtitleMode.None,
ChannelMusicVideoCreditsMode.None,
string.Empty,
ChannelSongVideoMode.Default,
ChannelTranscodeMode.OnDemand,
ChannelIdleBehavior.StopOnDisconnect,
false,
false,
FixedStartTimeBehavior.Flexible);
}
@@ -0,0 +1,92 @@
using System.Reflection;
using ErsatzTV.Application.LibraryBrowse;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Domain;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Routing;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Controllers;
[TestFixture]
public class LibraryBrowseControllerTests
{
private LibraryBrowseController _controller = null!;
private IMediator _mediator = null!;
[SetUp]
public void SetUp()
{
_mediator = Substitute.For<IMediator>();
_controller = new LibraryBrowseController(_mediator);
}
[Test]
public void Controller_Should_Expose_Browse_Route_With_Stable_Operation_Name()
{
MethodInfo action = typeof(LibraryBrowseController).GetMethod(nameof(LibraryBrowseController.Browse))
?? throw new AssertionException($"Missing action {nameof(LibraryBrowseController.Browse)}");
var attribute = action.GetCustomAttributes<HttpGetAttribute>().Single();
attribute.Template.ShouldBe("/api/library/browse");
attribute.Name.ShouldBe("BrowseLibrary");
}
[Test]
public async Task Browse_Should_Clamp_Paging_And_Send_Query()
{
_mediator.Send(Arg.Any<GetLibraryBrowseItems>(), Arg.Any<CancellationToken>())
.Returns(new PagedLibraryBrowseItemsResponseModel(0, []));
await _controller.Browse(
"star",
123,
LibraryBrowseMediaType.Movie,
-1,
500,
CancellationToken.None);
await _mediator.Received(1).Send(
Arg.Is<GetLibraryBrowseItems>(q =>
q.Query == "star" &&
q.LibraryId == 123 &&
q.MediaType == LibraryBrowseMediaType.Movie &&
q.PageNum == 0 &&
q.PageSize == 100),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Browse_Should_Return_Paged_Response()
{
var item = new LibraryBrowseItemResponseModel(
10,
LibraryBrowseMediaType.Movie,
"Movie",
3,
"Movies",
"/artwork/posters/movie.jpg",
TimeSpan.FromMinutes(90),
1,
null,
CollectionType.Movie,
null,
null,
null,
null,
10,
null);
_mediator.Send(Arg.Any<GetLibraryBrowseItems>(), Arg.Any<CancellationToken>())
.Returns(new PagedLibraryBrowseItemsResponseModel(1, [item]));
PagedLibraryBrowseItemsResponseModel result = await _controller.Browse(
cancellationToken: CancellationToken.None);
result.TotalCount.ShouldBe(1);
result.Page.ShouldBe([item]);
}
}
@@ -102,6 +102,8 @@ public class OpenApiErrorResponseContractTests
[TestCase("/api/channels/{id}", "get", "404")]
[TestCase("/api/channels", "post", "404")]
[TestCase("/api/channels", "post", "422")]
[TestCase("/api/channels/from-lineup", "post", "404")]
[TestCase("/api/channels/from-lineup", "post", "422")]
[TestCase("/api/channels/{id}", "put", "404")]
[TestCase("/api/channels/{id}", "put", "422")]
[TestCase("/api/channels/{id}", "delete", "404")]
@@ -113,6 +115,16 @@ public class OpenApiErrorResponseContractTests
[TestCase("/api/channels/bulk/delete", "post", "404")]
[TestCase("/api/channels/bulk/delete", "post", "422")]
[TestCase("/api/channels/{channelNumber}/playout/reset", "post", "404")]
[TestCase("/api/channel-templates/default", "get", "404")]
[TestCase("/api/channel-templates/default/{id}", "put", "404")]
[TestCase("/api/channel-templates/default/{id}", "put", "422")]
[TestCase("/api/channel-templates/{id}", "get", "404")]
[TestCase("/api/channel-templates", "post", "404")]
[TestCase("/api/channel-templates", "post", "422")]
[TestCase("/api/channel-templates/{id}", "put", "404")]
[TestCase("/api/channel-templates/{id}", "put", "422")]
[TestCase("/api/channel-templates/{id}", "delete", "404")]
[TestCase("/api/channel-templates/{id}", "delete", "422")]
[TestCase("/api/collections/{id}", "get", "404")]
[TestCase("/api/collections", "post", "404")]
[TestCase("/api/collections", "post", "422")]
@@ -162,6 +174,25 @@ public class OpenApiErrorResponseContractTests
[TestCase("/api/ffmpeg/profiles/{id}", "delete", "404")]
[TestCase("/api/ffmpeg/profiles/{id}", "delete", "401")]
[TestCase("/api/ffmpeg/profiles/{id}", "delete", "422")]
[TestCase("/api/settings/ffmpeg", "put", "401")]
[TestCase("/api/settings/ffmpeg", "put", "422")]
[TestCase("/api/settings/playout", "put", "401")]
[TestCase("/api/settings/playout", "put", "422")]
[TestCase("/api/settings/xmltv", "put", "401")]
[TestCase("/api/settings/xmltv", "put", "422")]
[TestCase("/api/settings/scanner", "put", "401")]
[TestCase("/api/settings/scanner", "put", "422")]
[TestCase("/api/settings/logging", "put", "401")]
[TestCase("/api/settings/logging", "put", "422")]
[TestCase("/api/settings/ui", "put", "401")]
[TestCase("/api/settings/ui", "put", "422")]
[TestCase("/api/settings/hdhr", "put", "401")]
[TestCase("/api/settings/hdhr", "put", "422")]
[TestCase("/api/settings/resolutions", "post", "401")]
[TestCase("/api/settings/resolutions", "post", "422")]
[TestCase("/api/settings/resolutions/{id}", "delete", "401")]
[TestCase("/api/settings/resolutions/{id}", "delete", "404")]
[TestCase("/api/settings/resolutions/{id}", "delete", "422")]
public void Static_OpenApi_Should_Document_ProblemDetails_For_Api_Error_Responses(
string path,
string method,
@@ -0,0 +1,112 @@
using ErsatzTV.Application.Resolutions;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Settings;
using ErsatzTV.Core.Errors;
using LanguageExt;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using static LanguageExt.Prelude;
namespace ErsatzTV.Tests.Controllers;
[TestFixture]
public class ResolutionControllerTests
{
private ResolutionController _controller = null!;
private IMediator _mediator = null!;
[SetUp]
public void SetUp()
{
_mediator = Substitute.For<IMediator>();
_controller = new ResolutionController(_mediator);
}
[Test]
public async Task GetAll_Should_Map_View_Models_To_Response_Models()
{
_mediator.Send(Arg.Any<GetAllResolutions>(), Arg.Any<CancellationToken>())
.Returns(
new List<ResolutionViewModel>
{
new(1, "1920x1080", 1920, 1080, false),
new(2, "1280x720", 1280, 720, true)
});
List<ResolutionResponseModel> result = await _controller.GetAll(CancellationToken.None);
result.ShouldBe(
[
new ResolutionResponseModel(1, "1920x1080", 1920, 1080, false),
new ResolutionResponseModel(2, "1280x720", 1280, 720, true)
]);
}
[Test]
public async Task Create_Should_Return_201_With_Location_And_Body()
{
_mediator.Send(Arg.Any<CreateCustomResolution>(), Arg.Any<CancellationToken>())
.Returns(Option<BaseError>.None);
_mediator.Send(Arg.Any<GetResolutionByName>(), Arg.Any<CancellationToken>())
.Returns(Option<ResolutionViewModel>.Some(new ResolutionViewModel(9, "640x480", 640, 480, true)));
IActionResult result = await _controller.Create(new CreateResolutionRequest(640, 480), CancellationToken.None);
var created = result.ShouldBeOfType<CreatedResult>();
created.StatusCode.ShouldBe(201);
created.Location.ShouldBe("/api/settings/resolutions/9");
created.Value.ShouldBe(new ResolutionResponseModel(9, "640x480", 640, 480, true));
await _mediator.Received(1).Send(
Arg.Is<CreateCustomResolution>(c => c.Width == 640 && c.Height == 480),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Create_Should_Return_422_When_Resolution_Is_Not_Unique()
{
_mediator.Send(Arg.Any<CreateCustomResolution>(), Arg.Any<CancellationToken>())
.Returns(Option<BaseError>.Some(BaseError.New("Resolution width and height must be unique")));
IActionResult result = await _controller.Create(new CreateResolutionRequest(1920, 1080), CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
[Test]
public async Task Delete_Should_Return_204_On_Success()
{
_mediator.Send(Arg.Any<DeleteCustomResolution>(), Arg.Any<CancellationToken>())
.Returns(Option<BaseError>.None);
IActionResult result = await _controller.Delete(9, CancellationToken.None);
result.ShouldBeOfType<NoContentResult>();
}
[Test]
public async Task Delete_Should_Return_404_For_Unknown_Resolution()
{
_mediator.Send(Arg.Any<DeleteCustomResolution>(), Arg.Any<CancellationToken>())
.Returns(Option<BaseError>.Some(new NotFoundError("Resolution 42 does not exist.")));
IActionResult result = await _controller.Delete(42, CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
}
[Test]
public async Task Delete_Should_Return_422_For_NonCustom_Resolution()
{
_mediator.Send(Arg.Any<DeleteCustomResolution>(), Arg.Any<CancellationToken>())
.Returns(Option<BaseError>.Some(BaseError.New("Resolution 3 is not a custom resolution.")));
IActionResult result = await _controller.Delete(3, CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
}
@@ -0,0 +1,462 @@
using ErsatzTV.Application.Configuration;
using ErsatzTV.Application.FFmpegProfiles;
using ErsatzTV.Application.HDHR;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Settings;
using ErsatzTV.FFmpeg.OutputFormat;
using LanguageExt;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using NSubstitute;
using NUnit.Framework;
using Serilog.Events;
using Shouldly;
using static LanguageExt.Prelude;
using ApiXmltvBlockBehavior = ErsatzTV.Core.Api.Settings.XmltvBlockBehavior;
using ApiXmltvTimeZone = ErsatzTV.Core.Api.Settings.XmltvTimeZone;
using Unit = LanguageExt.Unit;
using VmXmltvBlockBehavior = ErsatzTV.Application.Configuration.XmltvBlockBehavior;
using VmXmltvTimeZone = ErsatzTV.Application.Configuration.XmltvTimeZone;
namespace ErsatzTV.Tests.Controllers;
[TestFixture]
public class SettingsControllerTests
{
private SettingsController _controller = null!;
private IMediator _mediator = null!;
[SetUp]
public void SetUp()
{
_mediator = Substitute.For<IMediator>();
_controller = new SettingsController(_mediator);
}
[Test]
public async Task GetFfmpeg_Should_Map_Vm_To_Response_Model()
{
var vm = new FFmpegSettingsViewModel
{
FFmpegPath = "/usr/bin/ffmpeg",
FFprobePath = "/usr/bin/ffprobe",
DefaultFFmpegProfileId = 1,
PreferredAudioLanguageCode = "eng",
UseEmbeddedSubtitles = true,
ExtractEmbeddedSubtitles = false,
ProbeForInterlacedFrames = false,
SaveReports = false,
GlobalWatermarkId = 5,
GlobalFallbackFillerId = null,
HlsSegmenterIdleTimeout = 60,
WorkAheadSegmenterLimit = 1,
InitialSegmentCount = 1,
HlsDirectOutputFormat = OutputFormatKind.MpegTs,
DefaultMpegTsScript = "Default"
};
_mediator.Send(Arg.Any<GetFFmpegSettings>(), Arg.Any<CancellationToken>()).Returns(vm);
FFmpegSettingsResponseModel result = await _controller.GetFfmpeg(CancellationToken.None);
result.FFmpegPath.ShouldBe("/usr/bin/ffmpeg");
result.GlobalWatermarkId.ShouldBe(5);
result.GlobalFallbackFillerId.ShouldBeNull();
result.HlsDirectOutputFormat.ShouldBe(OutputFormatKind.MpegTs);
}
[Test]
public async Task UpdateFfmpeg_Should_Map_Request_To_Command_And_Return_Refreshed_Settings()
{
_mediator.Send(Arg.Any<UpdateFFmpegSettings>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default));
var refreshed = new FFmpegSettingsViewModel
{
FFmpegPath = "/usr/bin/ffmpeg",
FFprobePath = "/usr/bin/ffprobe",
PreferredAudioLanguageCode = "eng",
HlsDirectOutputFormat = OutputFormatKind.Hls,
DefaultMpegTsScript = "Default"
};
_mediator.Send(Arg.Any<GetFFmpegSettings>(), Arg.Any<CancellationToken>()).Returns(refreshed);
IActionResult result = await _controller.UpdateFfmpeg(MakeFfmpegRequest(), CancellationToken.None);
var ok = result.ShouldBeOfType<OkObjectResult>();
ok.Value.ShouldBeOfType<FFmpegSettingsResponseModel>().HlsDirectOutputFormat.ShouldBe(OutputFormatKind.Hls);
await _mediator.Received(1).Send(
Arg.Is<UpdateFFmpegSettings>(
c => c.Settings.FFmpegPath == "/usr/bin/ffmpeg" &&
c.Settings.FFprobePath == "/usr/bin/ffprobe" &&
c.Settings.DefaultFFmpegProfileId == 1 &&
c.Settings.PreferredAudioLanguageCode == "eng" &&
c.Settings.UseEmbeddedSubtitles &&
!c.Settings.ExtractEmbeddedSubtitles &&
!c.Settings.ProbeForInterlacedFrames &&
!c.Settings.SaveReports &&
c.Settings.GlobalWatermarkId == null &&
c.Settings.GlobalFallbackFillerId == null &&
c.Settings.HlsSegmenterIdleTimeout == 60 &&
c.Settings.WorkAheadSegmenterLimit == 1 &&
c.Settings.InitialSegmentCount == 1 &&
c.Settings.HlsDirectOutputFormat == OutputFormatKind.MpegTs &&
c.Settings.DefaultMpegTsScript == "Default"),
Arg.Any<CancellationToken>());
}
[Test]
public async Task UpdateFfmpeg_Should_Flow_NonNull_Watermark_And_Filler_Ids_Through()
{
_mediator.Send(Arg.Any<UpdateFFmpegSettings>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default));
_mediator.Send(Arg.Any<GetFFmpegSettings>(), Arg.Any<CancellationToken>())
.Returns(new FFmpegSettingsViewModel { FFmpegPath = "/usr/bin/ffmpeg", FFprobePath = "/usr/bin/ffprobe" });
UpdateFFmpegSettingsRequest request = MakeFfmpegRequest() with { GlobalWatermarkId = 7, GlobalFallbackFillerId = 9 };
IActionResult result = await _controller.UpdateFfmpeg(request, CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>();
await _mediator.Received(1).Send(
Arg.Is<UpdateFFmpegSettings>(
c => c.Settings.GlobalWatermarkId == 7 && c.Settings.GlobalFallbackFillerId == 9),
Arg.Any<CancellationToken>());
}
[Test]
public async Task UpdateFfmpeg_Should_Return_422_On_Validation_Error()
{
_mediator.Send(Arg.Any<UpdateFFmpegSettings>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, Unit>(BaseError.New("ffmpeg path does not exist")));
IActionResult result = await _controller.UpdateFfmpeg(MakeFfmpegRequest(), CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
[Test]
public async Task GetPlayout_Should_Map_Vm_To_Response_Model()
{
var vm = new PlayoutSettingsViewModel
{
DaysToBuild = 3,
SkipMissingItems = true,
ScriptedScheduleTimeoutSeconds = 45
};
_mediator.Send(Arg.Any<GetPlayoutSettings>(), Arg.Any<CancellationToken>()).Returns(vm);
PlayoutSettingsResponseModel result = await _controller.GetPlayout(CancellationToken.None);
result.ShouldBe(new PlayoutSettingsResponseModel(3, true, 45));
}
[Test]
public async Task UpdatePlayout_Should_Return_422_On_Validation_Error()
{
_mediator.Send(Arg.Any<UpdatePlayoutSettings>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, Unit>(BaseError.New("bad")));
IActionResult result = await _controller.UpdatePlayout(
new UpdatePlayoutSettingsRequest(0, false, 30),
CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
[Test]
public async Task UpdatePlayout_Should_Return_Refreshed_Settings_On_Success()
{
_mediator.Send(Arg.Any<UpdatePlayoutSettings>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default));
_mediator.Send(Arg.Any<GetPlayoutSettings>(), Arg.Any<CancellationToken>())
.Returns(new PlayoutSettingsViewModel { DaysToBuild = 5, SkipMissingItems = false, ScriptedScheduleTimeoutSeconds = 30 });
IActionResult result = await _controller.UpdatePlayout(
new UpdatePlayoutSettingsRequest(5, false, 30),
CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(new PlayoutSettingsResponseModel(5, false, 30));
await _mediator.Received(1).Send(
Arg.Is<UpdatePlayoutSettings>(
c => c.PlayoutSettings.DaysToBuild == 5 &&
c.PlayoutSettings.SkipMissingItems == false &&
c.PlayoutSettings.ScriptedScheduleTimeoutSeconds == 30),
Arg.Any<CancellationToken>());
}
[Test]
public async Task GetXmltv_Should_Map_Vm_Enums_To_Api_Enums()
{
_mediator.Send(Arg.Any<GetXmltvSettings>(), Arg.Any<CancellationToken>())
.Returns(
new XmltvSettingsViewModel
{
DaysToBuild = 2,
TimeZone = VmXmltvTimeZone.Utc,
BlockBehavior = VmXmltvBlockBehavior.UseActualTimes
});
XmltvSettingsResponseModel result = await _controller.GetXmltv(CancellationToken.None);
result.ShouldBe(
new XmltvSettingsResponseModel(
2,
ApiXmltvTimeZone.Utc,
ApiXmltvBlockBehavior.UseActualTimes));
}
[Test]
public async Task UpdateXmltv_Should_Map_Api_Enums_To_Vm_Enums()
{
_mediator.Send(Arg.Any<UpdateXmltvSettings>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default));
_mediator.Send(Arg.Any<GetXmltvSettings>(), Arg.Any<CancellationToken>())
.Returns(new XmltvSettingsViewModel { DaysToBuild = 2, TimeZone = VmXmltvTimeZone.Local, BlockBehavior = VmXmltvBlockBehavior.SplitTimeEvenly });
IActionResult result = await _controller.UpdateXmltv(
new UpdateXmltvSettingsRequest(2, ApiXmltvTimeZone.Local, ApiXmltvBlockBehavior.SplitTimeEvenly),
CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>();
await _mediator.Received(1).Send(
Arg.Is<UpdateXmltvSettings>(
c => c.XmltvSettings.TimeZone == VmXmltvTimeZone.Local &&
c.XmltvSettings.BlockBehavior == VmXmltvBlockBehavior.SplitTimeEvenly),
Arg.Any<CancellationToken>());
}
[Test]
public async Task UpdateXmltv_Should_Return_422_On_Validation_Error()
{
_mediator.Send(Arg.Any<UpdateXmltvSettings>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, Unit>(BaseError.New("bad")));
IActionResult result = await _controller.UpdateXmltv(
new UpdateXmltvSettingsRequest(
2,
ErsatzTV.Core.Api.Settings.XmltvTimeZone.Local,
ErsatzTV.Core.Api.Settings.XmltvBlockBehavior.SplitTimeEvenly),
CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
[Test]
public async Task GetScanner_Should_Return_Library_Refresh_Interval()
{
_mediator.Send(Arg.Any<GetLibraryRefreshInterval>(), Arg.Any<CancellationToken>()).Returns(6);
ScannerSettingsResponseModel result = await _controller.GetScanner(CancellationToken.None);
result.ShouldBe(new ScannerSettingsResponseModel(6));
}
[Test]
public async Task UpdateScanner_Should_Return_422_On_Validation_Error()
{
_mediator.Send(Arg.Any<UpdateLibraryRefreshInterval>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, Unit>(BaseError.New("bad")));
IActionResult result =
await _controller.UpdateScanner(new UpdateScannerSettingsRequest(-1), CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
[Test]
public async Task UpdateScanner_Should_Return_Refreshed_Settings_On_Success()
{
_mediator.Send(Arg.Any<UpdateLibraryRefreshInterval>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default));
_mediator.Send(Arg.Any<GetLibraryRefreshInterval>(), Arg.Any<CancellationToken>()).Returns(12);
IActionResult result =
await _controller.UpdateScanner(new UpdateScannerSettingsRequest(12), CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(new ScannerSettingsResponseModel(12));
await _mediator.Received(1).Send(
Arg.Is<UpdateLibraryRefreshInterval>(c => c.LibraryRefreshInterval == 12),
Arg.Any<CancellationToken>());
}
[Test]
public async Task GetLogging_Should_Map_Vm_To_Response_Model()
{
var vm = new LoggingSettingsViewModel
{
DefaultMinimumLogLevel = LogEventLevel.Information,
ScanningMinimumLogLevel = LogEventLevel.Debug,
SchedulingMinimumLogLevel = LogEventLevel.Warning,
SearchingMinimumLogLevel = LogEventLevel.Error,
StreamingMinimumLogLevel = LogEventLevel.Verbose,
HttpMinimumLogLevel = LogEventLevel.Fatal
};
_mediator.Send(Arg.Any<GetLoggingSettings>(), Arg.Any<CancellationToken>()).Returns(vm);
LoggingSettingsResponseModel result = await _controller.GetLogging(CancellationToken.None);
result.DefaultMinimumLogLevel.ShouldBe(LogEventLevel.Information);
result.ScanningMinimumLogLevel.ShouldBe(LogEventLevel.Debug);
result.SchedulingMinimumLogLevel.ShouldBe(LogEventLevel.Warning);
result.SearchingMinimumLogLevel.ShouldBe(LogEventLevel.Error);
result.StreamingMinimumLogLevel.ShouldBe(LogEventLevel.Verbose);
result.HttpMinimumLogLevel.ShouldBe(LogEventLevel.Fatal);
}
[Test]
public async Task UpdateLogging_Should_Return_422_On_Validation_Error()
{
_mediator.Send(Arg.Any<UpdateLoggingSettings>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, Unit>(BaseError.New("bad")));
IActionResult result = await _controller.UpdateLogging(MakeLoggingRequest(), CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
[Test]
public async Task UpdateLogging_Should_Map_Request_To_Command_And_Return_Refreshed_Settings()
{
_mediator.Send(Arg.Any<UpdateLoggingSettings>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default));
var refreshed = new LoggingSettingsViewModel
{
DefaultMinimumLogLevel = LogEventLevel.Debug,
ScanningMinimumLogLevel = LogEventLevel.Debug,
SchedulingMinimumLogLevel = LogEventLevel.Debug,
SearchingMinimumLogLevel = LogEventLevel.Debug,
StreamingMinimumLogLevel = LogEventLevel.Debug,
HttpMinimumLogLevel = LogEventLevel.Debug
};
_mediator.Send(Arg.Any<GetLoggingSettings>(), Arg.Any<CancellationToken>()).Returns(refreshed);
var request = new UpdateLoggingSettingsRequest(
LogEventLevel.Warning,
LogEventLevel.Error,
LogEventLevel.Fatal,
LogEventLevel.Verbose,
LogEventLevel.Debug,
LogEventLevel.Information);
IActionResult result = await _controller.UpdateLogging(request, CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>();
await _mediator.Received(1).Send(
Arg.Is<UpdateLoggingSettings>(
c => c.LoggingSettings.DefaultMinimumLogLevel == LogEventLevel.Warning &&
c.LoggingSettings.ScanningMinimumLogLevel == LogEventLevel.Error &&
c.LoggingSettings.SchedulingMinimumLogLevel == LogEventLevel.Fatal &&
c.LoggingSettings.SearchingMinimumLogLevel == LogEventLevel.Verbose &&
c.LoggingSettings.StreamingMinimumLogLevel == LogEventLevel.Debug &&
c.LoggingSettings.HttpMinimumLogLevel == LogEventLevel.Information),
Arg.Any<CancellationToken>());
}
[Test]
public async Task GetUi_Should_Map_Vm_To_Response_Model()
{
_mediator.Send(Arg.Any<GetUiSettings>(), Arg.Any<CancellationToken>())
.Returns(new UiSettingsViewModel { IsDarkMode = true, Language = "en" });
UiSettingsResponseModel result = await _controller.GetUi(CancellationToken.None);
result.ShouldBe(new UiSettingsResponseModel(true, "en"));
}
[Test]
public async Task UpdateUi_Should_Return_Refreshed_Settings_On_Success()
{
_mediator.Send(Arg.Any<UpdateUiSettings>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default));
_mediator.Send(Arg.Any<GetUiSettings>(), Arg.Any<CancellationToken>())
.Returns(new UiSettingsViewModel { IsDarkMode = false, Language = "fr" });
IActionResult result =
await _controller.UpdateUi(new UpdateUiSettingsRequest(false, "fr"), CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(new UiSettingsResponseModel(false, "fr"));
await _mediator.Received(1).Send(
Arg.Is<UpdateUiSettings>(c => c.UiSettings.IsDarkMode == false && c.UiSettings.Language == "fr"),
Arg.Any<CancellationToken>());
}
[Test]
public async Task UpdateUi_Should_Return_422_On_Validation_Error()
{
_mediator.Send(Arg.Any<UpdateUiSettings>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, Unit>(BaseError.New("bad")));
IActionResult result =
await _controller.UpdateUi(new UpdateUiSettingsRequest(false, "fr"), CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
[Test]
public async Task GetHdhr_Should_Combine_Tuner_Count_And_Uuid()
{
var uuid = Guid.NewGuid();
_mediator.Send(Arg.Any<GetHDHRTunerCount>(), Arg.Any<CancellationToken>()).Returns(3);
_mediator.Send(Arg.Any<GetHDHRUUID>(), Arg.Any<CancellationToken>()).Returns(uuid);
HdhrSettingsResponseModel result = await _controller.GetHdhr(CancellationToken.None);
result.ShouldBe(new HdhrSettingsResponseModel(3, uuid));
}
[Test]
public async Task UpdateHdhr_Should_Return_Refreshed_Settings_On_Success()
{
var uuid = Guid.NewGuid();
_mediator.Send(Arg.Any<UpdateHDHRTunerCount>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default));
_mediator.Send(Arg.Any<GetHDHRTunerCount>(), Arg.Any<CancellationToken>()).Returns(4);
_mediator.Send(Arg.Any<GetHDHRUUID>(), Arg.Any<CancellationToken>()).Returns(uuid);
IActionResult result = await _controller.UpdateHdhr(new UpdateHdhrSettingsRequest(4), CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(new HdhrSettingsResponseModel(4, uuid));
await _mediator.Received(1).Send(
Arg.Is<UpdateHDHRTunerCount>(c => c.TunerCount == 4),
Arg.Any<CancellationToken>());
}
[Test]
public async Task UpdateHdhr_Should_Return_422_On_Validation_Error()
{
_mediator.Send(Arg.Any<UpdateHDHRTunerCount>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, Unit>(BaseError.New("Tuner count must be greater than zero")));
IActionResult result = await _controller.UpdateHdhr(new UpdateHdhrSettingsRequest(0), CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
private static UpdateFFmpegSettingsRequest MakeFfmpegRequest() =>
new(
"/usr/bin/ffmpeg",
"/usr/bin/ffprobe",
1,
"eng",
true,
false,
false,
false,
null,
null,
60,
1,
1,
OutputFormatKind.MpegTs,
"Default");
private static UpdateLoggingSettingsRequest MakeLoggingRequest() =>
new(
LogEventLevel.Information,
LogEventLevel.Information,
LogEventLevel.Information,
LogEventLevel.Information,
LogEventLevel.Information,
LogEventLevel.Information);
}
@@ -0,0 +1,51 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Infrastructure;
[TestFixture]
public class DbInitializerChannelTemplateTests
{
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 Initialize_Should_Seed_System_Channel_Templates_And_Default_Config()
{
await using TvContext context = _db.CreateContext();
await DbInitializer.Initialize(context, CancellationToken.None);
List<ChannelTemplate> templates = context.ChannelTemplates.OrderBy(t => t.Name).ToList();
templates.Count.ShouldBe(2);
templates.ShouldContain(t => t.Name == "Music videos" && t.IsSystem);
ChannelTemplate standard = templates.Single(t => t.Name == "Standard");
standard.IsSystem.ShouldBeTrue();
ConfigElement config = context.ConfigElements.Single(
c => c.Key == ConfigElementKey.ChannelTemplatesDefaultTemplateId.Key);
config.Value.ShouldBe(standard.Id.ToString());
}
[Test]
public async Task Initialize_Should_Not_Clobber_Existing_System_Template()
{
await using TvContext context = _db.CreateContext();
await DbInitializer.Initialize(context, CancellationToken.None);
ChannelTemplate standard = context.ChannelTemplates.Single(t => t.Name == "Standard");
standard.Description = "User adjusted description";
await context.SaveChangesAsync();
await DbInitializer.Initialize(context, CancellationToken.None);
context.ChannelTemplates.Single(t => t.Name == "Standard").Description.ShouldBe("User adjusted description");
}
}
+112
View File
@@ -0,0 +1,112 @@
using Microsoft.AspNetCore.Http;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests;
[TestFixture]
public class LegacyUiRedirectsTests
{
private static readonly string StartupSource = File.ReadAllText(FindStartupPath());
[Test]
public void Every_Mapping_Should_Resolve()
{
foreach ((string from, string to) in LegacyUiRedirects.Map)
{
LegacyUiRedirects.TryGetRedirect(new PathString(from), out string target).ShouldBeTrue();
target.ShouldBe(to);
}
}
[TestCase("/", "/app")]
[TestCase("/channels", "/app/channels")]
[TestCase("/channels/add", "/app/new-channel")]
[TestCase("/schedules", "/app/schedules")]
[TestCase("/playouts", "/app/playouts")]
[TestCase("/media/libraries", "/app/libraries")]
[TestCase("/settings/ffmpeg", "/app/settings/streaming")]
[TestCase("/settings/hdhr", "/app/settings/system")]
[TestCase("/settings/logging", "/app/settings/logging")]
[TestCase("/settings/playout", "/app/settings/playout")]
[TestCase("/settings/scanner", "/app/settings/scanner")]
[TestCase("/settings/ui", "/app/settings/general")]
[TestCase("/settings/xmltv", "/app/settings/xmltv")]
public void Known_Route_Should_Redirect(string path, string expected)
{
LegacyUiRedirects.TryGetRedirect(new PathString(path), out string target).ShouldBeTrue();
target.ShouldBe(expected);
}
[TestCase("/channels/", "/app/channels")]
[TestCase("/schedules/", "/app/schedules")]
[TestCase("/settings/ffmpeg/", "/app/settings/streaming")]
public void Trailing_Slash_Should_Match(string path, string expected)
{
LegacyUiRedirects.TryGetRedirect(new PathString(path), out string target).ShouldBeTrue();
target.ShouldBe(expected);
}
[Test]
public void Lookup_Should_Be_Case_Insensitive()
{
LegacyUiRedirects.TryGetRedirect(new PathString("/Channels"), out string target).ShouldBeTrue();
target.ShouldBe("/app/channels");
}
[TestCase("/channels/5")] // channel edit (Blazor-only)
[TestCase("/channels/numbers")] // Blazor-only
[TestCase("/system/health")] // Blazor home escape hatch
[TestCase("/media/collections")] // Blazor-only media page
[TestCase("/ffmpeg")] // Blazor-only
[TestCase("/watermarks")] // Blazor-only
[TestCase("/app")] // already the SPA
[TestCase("/app/channels")] // already the SPA
[TestCase("/iptv/channels.m3u")] // IPTV surface
[TestCase("/api/health")] // API surface
[TestCase("")] // empty
[TestCase("//")] // all-slash path must not collapse to root "/"
[TestCase("/channels//")] // double trailing slash is not normalized to a match
public void Non_Migrated_Route_Should_Not_Redirect(string path)
{
LegacyUiRedirects.TryGetRedirect(new PathString(path), out string target).ShouldBeFalse();
target.ShouldBe(string.Empty);
}
[Test]
public void Startup_Should_Redirect_Legacy_Routes_In_Blazor_Branch_Before_Routing()
{
// The redirect middleware must be wired inside the blazor branch and run
// before UseRouting so migrated routes never reach the Blazor fallback.
int redirectIndex = StartupSource.IndexOf("LegacyUiRedirects.TryGetRedirect", StringComparison.Ordinal);
redirectIndex.ShouldBeGreaterThan(-1);
// The Blazor branch's UseRouting call that follows the redirect middleware.
int routingIndex = StartupSource.IndexOf("blazor.UseRouting()", StringComparison.Ordinal);
routingIndex.ShouldBeGreaterThan(-1);
redirectIndex.ShouldBeLessThan(routingIndex);
// 302 (temporary), not a permanent redirect.
StartupSource.ShouldContain("context.Request.PathBase + target");
StartupSource.ShouldNotContain("RedirectPermanent(target");
}
private static string FindStartupPath()
{
DirectoryInfo? directory = new(TestContext.CurrentContext.TestDirectory);
while (directory is not null)
{
string candidate = Path.Combine(directory.FullName, "ErsatzTV", "Startup.cs");
if (File.Exists(candidate))
{
return candidate;
}
directory = directory.Parent;
}
throw new FileNotFoundException("Could not find ErsatzTV/Startup.cs");
}
}
+40
View File
@@ -32,6 +32,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ErsatzTV.Tests", "ErsatzTV.
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ErsatzTV.Architecture.Tests", "ErsatzTV.Architecture.Tests\ErsatzTV.Architecture.Tests.csproj", "{49123AF1-38FE-4546-9DBD-8B7F1B4CFC7F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ErsatzTV.Mcp", "ErsatzTV.Mcp\ErsatzTV.Mcp.csproj", "{A5BB7668-FE00-49F8-888C-866F75A74BD9}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ErsatzTV.Mcp.Tests", "ErsatzTV.Mcp.Tests\ErsatzTV.Mcp.Tests.csproj", "{C72D3941-6207-4638-AA2A-B5488EDFED28}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -315,6 +319,42 @@ Global
{49123AF1-38FE-4546-9DBD-8B7F1B4CFC7F}.Debug No Sync|x64.Build.0 = Debug|Any CPU
{49123AF1-38FE-4546-9DBD-8B7F1B4CFC7F}.Debug No Sync|x86.ActiveCfg = Debug|Any CPU
{49123AF1-38FE-4546-9DBD-8B7F1B4CFC7F}.Debug No Sync|x86.Build.0 = Debug|Any CPU
{A5BB7668-FE00-49F8-888C-866F75A74BD9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A5BB7668-FE00-49F8-888C-866F75A74BD9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A5BB7668-FE00-49F8-888C-866F75A74BD9}.Debug|x64.ActiveCfg = Debug|Any CPU
{A5BB7668-FE00-49F8-888C-866F75A74BD9}.Debug|x64.Build.0 = Debug|Any CPU
{A5BB7668-FE00-49F8-888C-866F75A74BD9}.Debug|x86.ActiveCfg = Debug|Any CPU
{A5BB7668-FE00-49F8-888C-866F75A74BD9}.Debug|x86.Build.0 = Debug|Any CPU
{A5BB7668-FE00-49F8-888C-866F75A74BD9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A5BB7668-FE00-49F8-888C-866F75A74BD9}.Release|Any CPU.Build.0 = Release|Any CPU
{A5BB7668-FE00-49F8-888C-866F75A74BD9}.Release|x64.ActiveCfg = Release|Any CPU
{A5BB7668-FE00-49F8-888C-866F75A74BD9}.Release|x64.Build.0 = Release|Any CPU
{A5BB7668-FE00-49F8-888C-866F75A74BD9}.Release|x86.ActiveCfg = Release|Any CPU
{A5BB7668-FE00-49F8-888C-866F75A74BD9}.Release|x86.Build.0 = Release|Any CPU
{A5BB7668-FE00-49F8-888C-866F75A74BD9}.Debug No Sync|Any CPU.ActiveCfg = Debug|Any CPU
{A5BB7668-FE00-49F8-888C-866F75A74BD9}.Debug No Sync|Any CPU.Build.0 = Debug|Any CPU
{A5BB7668-FE00-49F8-888C-866F75A74BD9}.Debug No Sync|x64.ActiveCfg = Debug|Any CPU
{A5BB7668-FE00-49F8-888C-866F75A74BD9}.Debug No Sync|x64.Build.0 = Debug|Any CPU
{A5BB7668-FE00-49F8-888C-866F75A74BD9}.Debug No Sync|x86.ActiveCfg = Debug|Any CPU
{A5BB7668-FE00-49F8-888C-866F75A74BD9}.Debug No Sync|x86.Build.0 = Debug|Any CPU
{C72D3941-6207-4638-AA2A-B5488EDFED28}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C72D3941-6207-4638-AA2A-B5488EDFED28}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C72D3941-6207-4638-AA2A-B5488EDFED28}.Debug|x64.ActiveCfg = Debug|Any CPU
{C72D3941-6207-4638-AA2A-B5488EDFED28}.Debug|x64.Build.0 = Debug|Any CPU
{C72D3941-6207-4638-AA2A-B5488EDFED28}.Debug|x86.ActiveCfg = Debug|Any CPU
{C72D3941-6207-4638-AA2A-B5488EDFED28}.Debug|x86.Build.0 = Debug|Any CPU
{C72D3941-6207-4638-AA2A-B5488EDFED28}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C72D3941-6207-4638-AA2A-B5488EDFED28}.Release|Any CPU.Build.0 = Release|Any CPU
{C72D3941-6207-4638-AA2A-B5488EDFED28}.Release|x64.ActiveCfg = Release|Any CPU
{C72D3941-6207-4638-AA2A-B5488EDFED28}.Release|x64.Build.0 = Release|Any CPU
{C72D3941-6207-4638-AA2A-B5488EDFED28}.Release|x86.ActiveCfg = Release|Any CPU
{C72D3941-6207-4638-AA2A-B5488EDFED28}.Release|x86.Build.0 = Release|Any CPU
{C72D3941-6207-4638-AA2A-B5488EDFED28}.Debug No Sync|Any CPU.ActiveCfg = Debug|Any CPU
{C72D3941-6207-4638-AA2A-B5488EDFED28}.Debug No Sync|Any CPU.Build.0 = Debug|Any CPU
{C72D3941-6207-4638-AA2A-B5488EDFED28}.Debug No Sync|x64.ActiveCfg = Debug|Any CPU
{C72D3941-6207-4638-AA2A-B5488EDFED28}.Debug No Sync|x64.Build.0 = Debug|Any CPU
{C72D3941-6207-4638-AA2A-B5488EDFED28}.Debug No Sync|x86.ActiveCfg = Debug|Any CPU
{C72D3941-6207-4638-AA2A-B5488EDFED28}.Debug No Sync|x86.Build.0 = Debug|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -79,6 +79,32 @@ public class ChannelController(ChannelWriter<IBackgroundServiceRequest> workerCh
});
}
[HttpPost("/api/channels/from-lineup", Name = "CreateChannelFromLineup")]
[Tags("Channels")]
[EndpointSummary("Create a channel from a library lineup")]
[EndpointDescription(
"Atomically creates the channel, program schedule, a classic playout, and (for multi-item lineups) a " +
"generated system playlist. A single-item lineup produces one flood schedule item that references the " +
"target directly (movie, show, season, artist, collection, smart/multi collection, rerun collection, or " +
"playlist) and no generated playlist. A lineup with two or more items produces one generated system " +
"playlist whose entries play in the given order (each entry played in full before the next) referenced by " +
"one flood schedule item; only movies, shows, seasons, artists, collections, smart collections and multi " +
"collections are allowed there (rerun collections and playlists are single-item only). playbackOrder sets " +
"how items within each lineup entry are ordered. Template defaults are stamped at create time; advanced " +
"overrides win.")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(CreateChannelFromLineupResponseModel), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> CreateFromLineup(
[Required] [FromBody] CreateChannelFromLineupRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, CreateChannelFromLineupResponseModel> result =
await mediator.Send(request.ToCommand(), cancellationToken);
return result.ToCreatedResult(response => $"/api/channels/{response.ChannelId}", response => response);
}
[HttpPut("/api/channels/{id:int}")]
[Tags("Channels")]
[EndpointSummary("Update a channel")]
@@ -0,0 +1,109 @@
using System.ComponentModel.DataAnnotations;
using ErsatzTV.Application.ChannelTemplates;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.ChannelTemplates;
using ErsatzTV.Extensions;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
public class ChannelTemplateController(IMediator mediator) : ControllerBase
{
[HttpGet("/api/channel-templates", Name = "GetChannelTemplates")]
[Tags("Channel Templates")]
[EndpointSummary("Get all channel templates")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<ChannelTemplateResponseModel>), StatusCodes.Status200OK)]
public async Task<List<ChannelTemplateResponseModel>> GetAll(CancellationToken cancellationToken) =>
await mediator.Send(new GetAllChannelTemplates(), cancellationToken);
[HttpGet("/api/channel-templates/default", Name = "GetDefaultChannelTemplate")]
[Tags("Channel Templates")]
[EndpointSummary("Get the default channel template")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(ChannelTemplateResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetDefault(CancellationToken cancellationToken)
{
Option<ChannelTemplateResponseModel> result =
await mediator.Send(new GetDefaultChannelTemplate(), cancellationToken);
return result.ToGetResult();
}
[HttpPut("/api/channel-templates/default/{id:int}", Name = "SetDefaultChannelTemplate")]
[Tags("Channel Templates")]
[EndpointSummary("Set the default channel template")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(ChannelTemplateResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> SetDefault(int id, CancellationToken cancellationToken)
{
Either<BaseError, ChannelTemplateResponseModel> result =
await mediator.Send(new SetDefaultChannelTemplate(id), cancellationToken);
return result.ToUpdatedResult();
}
[HttpGet("/api/channel-templates/{id:int}", Name = "GetChannelTemplateById")]
[Tags("Channel Templates")]
[EndpointSummary("Get a channel template by id")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(ChannelTemplateResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
{
Option<ChannelTemplateResponseModel> result =
await mediator.Send(new GetChannelTemplateById(id), cancellationToken);
return result.ToGetResult();
}
[HttpPost("/api/channel-templates", Name = "CreateChannelTemplate")]
[Tags("Channel Templates")]
[EndpointSummary("Create a channel template")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(ChannelTemplateResponseModel), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Create(
[Required] [FromBody] CreateChannelTemplateRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, ChannelTemplateResponseModel> result =
await mediator.Send(request.ToCommand(), cancellationToken);
return result.ToCreatedResult(vm => $"/api/channel-templates/{vm.Id}", vm => vm);
}
[HttpPut("/api/channel-templates/{id:int}", Name = "UpdateChannelTemplate")]
[Tags("Channel Templates")]
[EndpointSummary("Update a channel template")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(ChannelTemplateResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Update(
int id,
[Required] [FromBody] UpdateChannelTemplateRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, ChannelTemplateResponseModel> result =
await mediator.Send(request.ToCommand(id), cancellationToken);
return result.ToUpdatedResult();
}
[HttpDelete("/api/channel-templates/{id:int}", Name = "DeleteChannelTemplate")]
[Tags("Channel Templates")]
[EndpointSummary("Delete a channel template")]
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Delete(int id, CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(new DeleteChannelTemplate(id), cancellationToken);
return result.ToDeletedResult();
}
}
@@ -0,0 +1,33 @@
using ErsatzTV.Application.LibraryBrowse;
using ErsatzTV.Core.Api.LibraryBrowse;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
public class LibraryBrowseController(IMediator mediator) : ControllerBase
{
private const int MaxPageSize = 100;
[HttpGet("/api/library/browse", Name = "BrowseLibrary")]
[Tags("Libraries")]
[EndpointSummary("Browse and search library items")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(PagedLibraryBrowseItemsResponseModel), StatusCodes.Status200OK)]
public async Task<PagedLibraryBrowseItemsResponseModel> Browse(
[FromQuery] string query = "",
[FromQuery] int? libraryId = null,
[FromQuery] LibraryBrowseMediaType? mediaType = null,
[FromQuery] int pageNum = 0,
[FromQuery] int pageSize = 100,
CancellationToken cancellationToken = default)
{
int clampedPageNum = Math.Max(0, pageNum);
int clampedPageSize = Math.Clamp(pageSize, 1, MaxPageSize);
return await mediator.Send(
new GetLibraryBrowseItems(query, libraryId, mediaType, clampedPageNum, clampedPageSize),
cancellationToken);
}
}
@@ -0,0 +1,117 @@
#nullable enable
using ErsatzTV.Application.Artworks;
using ErsatzTV.Application.Channels;
using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Controllers.Api.Requests;
/// <summary>
/// Composite request to create a channel, its generated schedule/playlist and a classic playout in one call.
/// Template defaults are stamped at create time; any value set in <see cref="Advanced" /> overrides the template.
/// A single-item lineup references its target directly; a multi-item lineup is played in order via a generated
/// system playlist.
/// </summary>
public record CreateChannelFromLineupRequest(
string Name,
string Number,
string Group,
string Categories,
ArtworkContentTypeModel Logo,
bool IsEnabled,
bool ShowInEpg,
int TemplateId,
CreateChannelFromLineupAdvancedOptionsRequest? Advanced,
List<CreateChannelFromLineupItemRequest> Lineup)
{
public CreateChannelFromLineup ToCommand() =>
new(
Name,
Number,
Group,
Categories,
Logo,
IsEnabled,
ShowInEpg,
TemplateId,
Advanced?.ToCommand() ?? new CreateChannelFromLineupAdvancedOptions(),
Lineup.Map(i => i.ToCommand()).ToList());
}
public record CreateChannelFromLineupAdvancedOptionsRequest(
PlaybackOrder? PlaybackOrder = null,
int? FFmpegProfileId = null,
int? WatermarkId = null,
int? FallbackFillerId = null,
int? PreRollFillerId = null,
int? MidRollFillerId = null,
int? PostRollFillerId = null,
ChannelStreamSelectorMode? StreamSelectorMode = null,
string? StreamSelector = null,
string? PreferredAudioLanguageCode = null,
string? PreferredAudioTitle = null,
ChannelPlayoutSource? PlayoutSource = null,
ChannelPlayoutMode? PlayoutMode = null,
StreamingMode? StreamingMode = null,
string? PreferredSubtitleLanguageCode = null,
ChannelSubtitleMode? SubtitleMode = null,
ChannelMusicVideoCreditsMode? MusicVideoCreditsMode = null,
string? MusicVideoCreditsTemplate = null,
ChannelSongVideoMode? SongVideoMode = null,
ChannelTranscodeMode? TranscodeMode = null,
ChannelIdleBehavior? IdleBehavior = null,
bool? ShuffleScheduleItems = null,
bool? RandomStartPoint = null,
FixedStartTimeBehavior? FixedStartTimeBehavior = null)
{
public CreateChannelFromLineupAdvancedOptions ToCommand() =>
new(
PlaybackOrder,
FFmpegProfileId,
WatermarkId,
FallbackFillerId,
PreRollFillerId,
MidRollFillerId,
PostRollFillerId,
StreamSelectorMode,
StreamSelector,
PreferredAudioLanguageCode,
PreferredAudioTitle,
PlayoutSource,
PlayoutMode,
StreamingMode,
PreferredSubtitleLanguageCode,
SubtitleMode,
MusicVideoCreditsMode,
MusicVideoCreditsTemplate,
SongVideoMode,
TranscodeMode,
IdleBehavior,
ShuffleScheduleItems,
RandomStartPoint,
FixedStartTimeBehavior);
}
public record CreateChannelFromLineupItemRequest(
LibraryBrowseMediaType MediaType,
CollectionType CollectionType,
int? CollectionId,
int? MultiCollectionId,
int? SmartCollectionId,
int? RerunCollectionId,
int? MediaItemId,
int? PlaylistId)
{
public CreateChannelFromLineupItem ToCommand() =>
new(
MediaType,
CollectionType,
CollectionId,
MultiCollectionId,
SmartCollectionId,
RerunCollectionId,
MediaItemId,
PlaylistId);
}
@@ -0,0 +1,63 @@
#nullable enable
using ErsatzTV.Application.ChannelTemplates;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Controllers.Api.Requests;
public record CreateChannelTemplateRequest(
string Name,
string Description,
int FFmpegProfileId,
int? WatermarkId,
int? FallbackFillerId,
int? PreRollFillerId,
int? MidRollFillerId,
int? PostRollFillerId,
ChannelStreamSelectorMode StreamSelectorMode,
string? StreamSelector,
string? PreferredAudioLanguageCode,
string? PreferredAudioTitle,
ChannelPlayoutSource PlayoutSource,
ChannelPlayoutMode PlayoutMode,
StreamingMode StreamingMode,
string? PreferredSubtitleLanguageCode,
ChannelSubtitleMode SubtitleMode,
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
string? MusicVideoCreditsTemplate,
ChannelSongVideoMode SongVideoMode,
ChannelTranscodeMode TranscodeMode,
ChannelIdleBehavior IdleBehavior,
bool ShuffleScheduleItems,
bool RandomStartPoint,
FixedStartTimeBehavior FixedStartTimeBehavior)
{
public CreateChannelTemplate ToCommand() =>
new(
Name,
Description,
FFmpegProfileId,
WatermarkId,
FallbackFillerId,
PreRollFillerId,
MidRollFillerId,
PostRollFillerId,
StreamSelectorMode,
StreamSelector,
PreferredAudioLanguageCode,
PreferredAudioTitle,
PlayoutSource,
PlayoutMode,
StreamingMode,
PreferredSubtitleLanguageCode,
SubtitleMode,
MusicVideoCreditsMode,
MusicVideoCreditsTemplate,
SongVideoMode,
TranscodeMode,
IdleBehavior,
ShuffleScheduleItems,
RandomStartPoint,
FixedStartTimeBehavior);
}
@@ -0,0 +1,8 @@
using ErsatzTV.Application.Resolutions;
namespace ErsatzTV.Controllers.Api.Requests;
public record CreateResolutionRequest(int Width, int Height)
{
public CreateCustomResolution ToCommand() => new(Width, Height);
}
@@ -0,0 +1,64 @@
#nullable enable
using ErsatzTV.Application.ChannelTemplates;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Controllers.Api.Requests;
public record UpdateChannelTemplateRequest(
string Name,
string Description,
int FFmpegProfileId,
int? WatermarkId,
int? FallbackFillerId,
int? PreRollFillerId,
int? MidRollFillerId,
int? PostRollFillerId,
ChannelStreamSelectorMode StreamSelectorMode,
string? StreamSelector,
string? PreferredAudioLanguageCode,
string? PreferredAudioTitle,
ChannelPlayoutSource PlayoutSource,
ChannelPlayoutMode PlayoutMode,
StreamingMode StreamingMode,
string? PreferredSubtitleLanguageCode,
ChannelSubtitleMode SubtitleMode,
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
string? MusicVideoCreditsTemplate,
ChannelSongVideoMode SongVideoMode,
ChannelTranscodeMode TranscodeMode,
ChannelIdleBehavior IdleBehavior,
bool ShuffleScheduleItems,
bool RandomStartPoint,
FixedStartTimeBehavior FixedStartTimeBehavior)
{
public UpdateChannelTemplate ToCommand(int id) =>
new(
id,
Name,
Description,
FFmpegProfileId,
WatermarkId,
FallbackFillerId,
PreRollFillerId,
MidRollFillerId,
PostRollFillerId,
StreamSelectorMode,
StreamSelector,
PreferredAudioLanguageCode,
PreferredAudioTitle,
PlayoutSource,
PlayoutMode,
StreamingMode,
PreferredSubtitleLanguageCode,
SubtitleMode,
MusicVideoCreditsMode,
MusicVideoCreditsTemplate,
SongVideoMode,
TranscodeMode,
IdleBehavior,
ShuffleScheduleItems,
RandomStartPoint,
FixedStartTimeBehavior);
}
@@ -0,0 +1,43 @@
using ErsatzTV.Application.FFmpegProfiles;
using ErsatzTV.FFmpeg.OutputFormat;
namespace ErsatzTV.Controllers.Api.Requests;
public record UpdateFFmpegSettingsRequest(
string FFmpegPath,
string FFprobePath,
int DefaultFFmpegProfileId,
string PreferredAudioLanguageCode,
bool UseEmbeddedSubtitles,
bool ExtractEmbeddedSubtitles,
bool ProbeForInterlacedFrames,
bool SaveReports,
int? GlobalWatermarkId,
int? GlobalFallbackFillerId,
int HlsSegmenterIdleTimeout,
int WorkAheadSegmenterLimit,
int InitialSegmentCount,
OutputFormatKind HlsDirectOutputFormat,
string DefaultMpegTsScript)
{
public UpdateFFmpegSettings ToCommand() =>
new(
new FFmpegSettingsViewModel
{
FFmpegPath = FFmpegPath,
FFprobePath = FFprobePath,
DefaultFFmpegProfileId = DefaultFFmpegProfileId,
PreferredAudioLanguageCode = PreferredAudioLanguageCode,
UseEmbeddedSubtitles = UseEmbeddedSubtitles,
ExtractEmbeddedSubtitles = ExtractEmbeddedSubtitles,
ProbeForInterlacedFrames = ProbeForInterlacedFrames,
SaveReports = SaveReports,
GlobalWatermarkId = GlobalWatermarkId,
GlobalFallbackFillerId = GlobalFallbackFillerId,
HlsSegmenterIdleTimeout = HlsSegmenterIdleTimeout,
WorkAheadSegmenterLimit = WorkAheadSegmenterLimit,
InitialSegmentCount = InitialSegmentCount,
HlsDirectOutputFormat = HlsDirectOutputFormat,
DefaultMpegTsScript = DefaultMpegTsScript
});
}
@@ -0,0 +1,8 @@
using ErsatzTV.Application.HDHR;
namespace ErsatzTV.Controllers.Api.Requests;
public record UpdateHdhrSettingsRequest(int TunerCount)
{
public UpdateHDHRTunerCount ToCommand() => new(TunerCount);
}
@@ -0,0 +1,25 @@
using ErsatzTV.Application.Configuration;
using Serilog.Events;
namespace ErsatzTV.Controllers.Api.Requests;
public record UpdateLoggingSettingsRequest(
LogEventLevel DefaultMinimumLogLevel,
LogEventLevel ScanningMinimumLogLevel,
LogEventLevel SchedulingMinimumLogLevel,
LogEventLevel SearchingMinimumLogLevel,
LogEventLevel StreamingMinimumLogLevel,
LogEventLevel HttpMinimumLogLevel)
{
public UpdateLoggingSettings ToCommand() =>
new(
new LoggingSettingsViewModel
{
DefaultMinimumLogLevel = DefaultMinimumLogLevel,
ScanningMinimumLogLevel = ScanningMinimumLogLevel,
SchedulingMinimumLogLevel = SchedulingMinimumLogLevel,
SearchingMinimumLogLevel = SearchingMinimumLogLevel,
StreamingMinimumLogLevel = StreamingMinimumLogLevel,
HttpMinimumLogLevel = HttpMinimumLogLevel
});
}
@@ -0,0 +1,15 @@
using ErsatzTV.Application.Configuration;
namespace ErsatzTV.Controllers.Api.Requests;
public record UpdatePlayoutSettingsRequest(int DaysToBuild, bool SkipMissingItems, int ScriptedScheduleTimeoutSeconds)
{
public UpdatePlayoutSettings ToCommand() =>
new(
new PlayoutSettingsViewModel
{
DaysToBuild = DaysToBuild,
SkipMissingItems = SkipMissingItems,
ScriptedScheduleTimeoutSeconds = ScriptedScheduleTimeoutSeconds
});
}
@@ -0,0 +1,9 @@
using ErsatzTV.Application.Configuration;
namespace ErsatzTV.Controllers.Api.Requests;
/// <summary>Library scan cadence. <see cref="LibraryRefreshInterval" /> is expressed in hours.</summary>
public record UpdateScannerSettingsRequest(int LibraryRefreshInterval)
{
public UpdateLibraryRefreshInterval ToCommand() => new(LibraryRefreshInterval);
}
@@ -0,0 +1,14 @@
using ErsatzTV.Application.Configuration;
namespace ErsatzTV.Controllers.Api.Requests;
public record UpdateUiSettingsRequest(bool IsDarkMode, string Language)
{
public UpdateUiSettings ToCommand() =>
new(
new UiSettingsViewModel
{
IsDarkMode = IsDarkMode,
Language = Language
});
}
@@ -0,0 +1,33 @@
using ErsatzTV.Application.Configuration;
using ApiXmltvBlockBehavior = ErsatzTV.Core.Api.Settings.XmltvBlockBehavior;
using ApiXmltvTimeZone = ErsatzTV.Core.Api.Settings.XmltvTimeZone;
namespace ErsatzTV.Controllers.Api.Requests;
public record UpdateXmltvSettingsRequest(int DaysToBuild, ApiXmltvTimeZone TimeZone, ApiXmltvBlockBehavior BlockBehavior)
{
public UpdateXmltvSettings ToCommand() =>
new(
new XmltvSettingsViewModel
{
DaysToBuild = DaysToBuild,
TimeZone = ToVmTimeZone(TimeZone),
BlockBehavior = ToVmBlockBehavior(BlockBehavior)
});
private static XmltvTimeZone ToVmTimeZone(ApiXmltvTimeZone timeZone) =>
timeZone switch
{
ApiXmltvTimeZone.Local => XmltvTimeZone.Local,
ApiXmltvTimeZone.Utc => XmltvTimeZone.Utc,
_ => throw new ArgumentOutOfRangeException(nameof(timeZone), timeZone, null)
};
private static XmltvBlockBehavior ToVmBlockBehavior(ApiXmltvBlockBehavior blockBehavior) =>
blockBehavior switch
{
ApiXmltvBlockBehavior.SplitTimeEvenly => XmltvBlockBehavior.SplitTimeEvenly,
ApiXmltvBlockBehavior.UseActualTimes => XmltvBlockBehavior.UseActualTimes,
_ => throw new ArgumentOutOfRangeException(nameof(blockBehavior), blockBehavior, null)
};
}
@@ -1,5 +1,11 @@
using System.ComponentModel.DataAnnotations;
using ErsatzTV.Application.Resolutions;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Settings;
using ErsatzTV.Extensions;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
@@ -14,5 +20,60 @@ public class ResolutionController(IMediator mediator) : ControllerBase
Option<ResolutionViewModel> result = await mediator.Send(new GetResolutionByName(name), cancellationToken);
return result.Match<ActionResult<ResolutionViewModel>>(i => Ok(i), () => NotFound());
}
[HttpGet("/api/settings/resolutions", Name = "GetResolutions")]
[Tags("Settings")]
[EndpointSummary("Get all resolutions, including custom resolutions")]
[ProducesResponseType(typeof(List<ResolutionResponseModel>), StatusCodes.Status200OK)]
public async Task<List<ResolutionResponseModel>> GetAll(CancellationToken cancellationToken)
{
List<ResolutionViewModel> resolutions = await mediator.Send(new GetAllResolutions(), cancellationToken);
return resolutions.Map(ProjectToResponseModel).ToList();
}
[HttpPost("/api/settings/resolutions", Name = "CreateResolution")]
[Tags("Settings")]
[EndpointSummary("Create a custom resolution")]
[ProducesResponseType(typeof(ResolutionResponseModel), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Create(
[Required] [FromBody]
CreateResolutionRequest request,
CancellationToken cancellationToken)
{
Option<BaseError> result = await mediator.Send(request.ToCommand(), cancellationToken);
return await result.Match(
Some: error => Task.FromResult(error.ToErrorResult()),
None: async () =>
{
string name = $"{request.Width}x{request.Height}";
Option<ResolutionViewModel> resolution =
await mediator.Send(new GetResolutionByName(name), cancellationToken);
return resolution.Match(
Some: vm => (IActionResult)new CreatedResult(
$"/api/settings/resolutions/{vm.Id}",
ProjectToResponseModel(vm)),
None: () => ApiResults.NotFoundProblem());
});
}
[HttpDelete("/api/settings/resolutions/{id:int}", Name = "DeleteResolution")]
[Tags("Settings")]
[EndpointSummary("Delete a custom resolution")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Delete(int id, CancellationToken cancellationToken)
{
Option<BaseError> result = await mediator.Send(new DeleteCustomResolution(id), cancellationToken);
return result.Match<IActionResult>(
Some: error => error.ToErrorResult(),
None: () => new NoContentResult());
}
private static ResolutionResponseModel ProjectToResponseModel(ResolutionViewModel vm) =>
new(vm.Id, vm.Name, vm.Width, vm.Height, vm.IsCustom);
}
@@ -0,0 +1,305 @@
using System.ComponentModel.DataAnnotations;
using ErsatzTV.Application.Configuration;
using ErsatzTV.Application.FFmpegProfiles;
using ErsatzTV.Application.HDHR;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Settings;
using ErsatzTV.Extensions;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using ApiXmltvBlockBehavior = ErsatzTV.Core.Api.Settings.XmltvBlockBehavior;
using ApiXmltvTimeZone = ErsatzTV.Core.Api.Settings.XmltvTimeZone;
using VmXmltvBlockBehavior = ErsatzTV.Application.Configuration.XmltvBlockBehavior;
using VmXmltvTimeZone = ErsatzTV.Application.Configuration.XmltvTimeZone;
namespace ErsatzTV.Controllers.Api;
[ApiController]
[EndpointGroupName("general")]
public class SettingsController(IMediator mediator) : ControllerBase
{
// FFmpeg settings
[HttpGet("/api/settings/ffmpeg", Name = "GetFfmpegSettings")]
[Tags("Settings")]
[EndpointSummary("Get global FFmpeg settings")]
[ProducesResponseType(typeof(FFmpegSettingsResponseModel), StatusCodes.Status200OK)]
public async Task<FFmpegSettingsResponseModel> GetFfmpeg(CancellationToken cancellationToken)
{
FFmpegSettingsViewModel settings = await mediator.Send(new GetFFmpegSettings(), cancellationToken);
return ProjectToResponseModel(settings);
}
[HttpPut("/api/settings/ffmpeg", Name = "UpdateFfmpegSettings")]
[Tags("Settings")]
[EndpointSummary("Update global FFmpeg settings")]
[ProducesResponseType(typeof(FFmpegSettingsResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> UpdateFfmpeg(
[Required] [FromBody]
UpdateFFmpegSettingsRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(), cancellationToken);
return await result.Match(
Left: error => Task.FromResult(error.ToErrorResult()),
Right: async _ =>
{
FFmpegSettingsViewModel settings = await mediator.Send(new GetFFmpegSettings(), cancellationToken);
return (IActionResult)new OkObjectResult(ProjectToResponseModel(settings));
});
}
// Playout settings
[HttpGet("/api/settings/playout", Name = "GetPlayoutSettings")]
[Tags("Settings")]
[EndpointSummary("Get global playout settings")]
[ProducesResponseType(typeof(PlayoutSettingsResponseModel), StatusCodes.Status200OK)]
public async Task<PlayoutSettingsResponseModel> GetPlayout(CancellationToken cancellationToken)
{
PlayoutSettingsViewModel settings = await mediator.Send(new GetPlayoutSettings(), cancellationToken);
return ProjectToResponseModel(settings);
}
[HttpPut("/api/settings/playout", Name = "UpdatePlayoutSettings")]
[Tags("Settings")]
[EndpointSummary("Update global playout settings")]
[ProducesResponseType(typeof(PlayoutSettingsResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> UpdatePlayout(
[Required] [FromBody]
UpdatePlayoutSettingsRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(), cancellationToken);
return await result.Match(
Left: error => Task.FromResult(error.ToErrorResult()),
Right: async _ =>
{
PlayoutSettingsViewModel settings = await mediator.Send(new GetPlayoutSettings(), cancellationToken);
return (IActionResult)new OkObjectResult(ProjectToResponseModel(settings));
});
}
// XMLTV settings
[HttpGet("/api/settings/xmltv", Name = "GetXmltvSettings")]
[Tags("Settings")]
[EndpointSummary("Get global XMLTV settings")]
[ProducesResponseType(typeof(XmltvSettingsResponseModel), StatusCodes.Status200OK)]
public async Task<XmltvSettingsResponseModel> GetXmltv(CancellationToken cancellationToken)
{
XmltvSettingsViewModel settings = await mediator.Send(new GetXmltvSettings(), cancellationToken);
return ProjectToResponseModel(settings);
}
[HttpPut("/api/settings/xmltv", Name = "UpdateXmltvSettings")]
[Tags("Settings")]
[EndpointSummary("Update global XMLTV settings")]
[ProducesResponseType(typeof(XmltvSettingsResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> UpdateXmltv(
[Required] [FromBody]
UpdateXmltvSettingsRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(), cancellationToken);
return await result.Match(
Left: error => Task.FromResult(error.ToErrorResult()),
Right: async _ =>
{
XmltvSettingsViewModel settings = await mediator.Send(new GetXmltvSettings(), cancellationToken);
return (IActionResult)new OkObjectResult(ProjectToResponseModel(settings));
});
}
// Scanner settings
[HttpGet("/api/settings/scanner", Name = "GetScannerSettings")]
[Tags("Settings")]
[EndpointSummary("Get library scan cadence")]
[ProducesResponseType(typeof(ScannerSettingsResponseModel), StatusCodes.Status200OK)]
public async Task<ScannerSettingsResponseModel> GetScanner(CancellationToken cancellationToken)
{
int libraryRefreshInterval = await mediator.Send(new GetLibraryRefreshInterval(), cancellationToken);
return new ScannerSettingsResponseModel(libraryRefreshInterval);
}
[HttpPut("/api/settings/scanner", Name = "UpdateScannerSettings")]
[Tags("Settings")]
[EndpointSummary("Update library scan cadence")]
[ProducesResponseType(typeof(ScannerSettingsResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> UpdateScanner(
[Required] [FromBody]
UpdateScannerSettingsRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(), cancellationToken);
return await result.Match(
Left: error => Task.FromResult(error.ToErrorResult()),
Right: async _ =>
{
int libraryRefreshInterval = await mediator.Send(new GetLibraryRefreshInterval(), cancellationToken);
return (IActionResult)new OkObjectResult(new ScannerSettingsResponseModel(libraryRefreshInterval));
});
}
// Logging settings
[HttpGet("/api/settings/logging", Name = "GetLoggingSettings")]
[Tags("Settings")]
[EndpointSummary("Get per-area minimum log levels")]
[ProducesResponseType(typeof(LoggingSettingsResponseModel), StatusCodes.Status200OK)]
public async Task<LoggingSettingsResponseModel> GetLogging(CancellationToken cancellationToken)
{
LoggingSettingsViewModel settings = await mediator.Send(new GetLoggingSettings(), cancellationToken);
return ProjectToResponseModel(settings);
}
[HttpPut("/api/settings/logging", Name = "UpdateLoggingSettings")]
[Tags("Settings")]
[EndpointSummary("Update per-area minimum log levels")]
[ProducesResponseType(typeof(LoggingSettingsResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> UpdateLogging(
[Required] [FromBody]
UpdateLoggingSettingsRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(), cancellationToken);
return await result.Match(
Left: error => Task.FromResult(error.ToErrorResult()),
Right: async _ =>
{
LoggingSettingsViewModel settings = await mediator.Send(new GetLoggingSettings(), cancellationToken);
return (IActionResult)new OkObjectResult(ProjectToResponseModel(settings));
});
}
// UI settings
[HttpGet("/api/settings/ui", Name = "GetUiSettings")]
[Tags("Settings")]
[EndpointSummary("Get UI preferences")]
[ProducesResponseType(typeof(UiSettingsResponseModel), StatusCodes.Status200OK)]
public async Task<UiSettingsResponseModel> GetUi(CancellationToken cancellationToken)
{
UiSettingsViewModel settings = await mediator.Send(new GetUiSettings(), cancellationToken);
return ProjectToResponseModel(settings);
}
[HttpPut("/api/settings/ui", Name = "UpdateUiSettings")]
[Tags("Settings")]
[EndpointSummary("Update UI preferences")]
[ProducesResponseType(typeof(UiSettingsResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> UpdateUi(
[Required] [FromBody]
UpdateUiSettingsRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(), cancellationToken);
return await result.Match(
Left: error => Task.FromResult(error.ToErrorResult()),
Right: async _ =>
{
UiSettingsViewModel settings = await mediator.Send(new GetUiSettings(), cancellationToken);
return (IActionResult)new OkObjectResult(ProjectToResponseModel(settings));
});
}
// HDHR settings
[HttpGet("/api/settings/hdhr", Name = "GetHdhrSettings")]
[Tags("Settings")]
[EndpointSummary("Get HDHomeRun emulation settings")]
[ProducesResponseType(typeof(HdhrSettingsResponseModel), StatusCodes.Status200OK)]
public async Task<HdhrSettingsResponseModel> GetHdhr(CancellationToken cancellationToken) =>
await LoadHdhrSettings(cancellationToken);
[HttpPut("/api/settings/hdhr", Name = "UpdateHdhrSettings")]
[Tags("Settings")]
[EndpointSummary("Update HDHomeRun emulation settings")]
[ProducesResponseType(typeof(HdhrSettingsResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> UpdateHdhr(
[Required] [FromBody]
UpdateHdhrSettingsRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(), cancellationToken);
return await result.Match(
Left: error => Task.FromResult(error.ToErrorResult()),
Right: async _ => (IActionResult)new OkObjectResult(await LoadHdhrSettings(cancellationToken)));
}
private async Task<HdhrSettingsResponseModel> LoadHdhrSettings(CancellationToken cancellationToken)
{
int tunerCount = await mediator.Send(new GetHDHRTunerCount(), cancellationToken);
Guid uuid = await mediator.Send(new GetHDHRUUID(), cancellationToken);
return new HdhrSettingsResponseModel(tunerCount, uuid);
}
private static FFmpegSettingsResponseModel ProjectToResponseModel(FFmpegSettingsViewModel vm) =>
new(
vm.FFmpegPath,
vm.FFprobePath,
vm.DefaultFFmpegProfileId,
vm.PreferredAudioLanguageCode,
vm.UseEmbeddedSubtitles,
vm.ExtractEmbeddedSubtitles,
vm.ProbeForInterlacedFrames,
vm.SaveReports,
vm.GlobalWatermarkId,
vm.GlobalFallbackFillerId,
vm.HlsSegmenterIdleTimeout,
vm.WorkAheadSegmenterLimit,
vm.InitialSegmentCount,
vm.HlsDirectOutputFormat,
vm.DefaultMpegTsScript);
private static PlayoutSettingsResponseModel ProjectToResponseModel(PlayoutSettingsViewModel vm) =>
new(vm.DaysToBuild, vm.SkipMissingItems, vm.ScriptedScheduleTimeoutSeconds);
private static XmltvSettingsResponseModel ProjectToResponseModel(XmltvSettingsViewModel vm) =>
new(vm.DaysToBuild, ToApiTimeZone(vm.TimeZone), ToApiBlockBehavior(vm.BlockBehavior));
private static ApiXmltvTimeZone ToApiTimeZone(VmXmltvTimeZone timeZone) =>
timeZone switch
{
VmXmltvTimeZone.Local => ApiXmltvTimeZone.Local,
VmXmltvTimeZone.Utc => ApiXmltvTimeZone.Utc,
_ => throw new ArgumentOutOfRangeException(nameof(timeZone), timeZone, null)
};
private static ApiXmltvBlockBehavior ToApiBlockBehavior(VmXmltvBlockBehavior blockBehavior) =>
blockBehavior switch
{
VmXmltvBlockBehavior.SplitTimeEvenly => ApiXmltvBlockBehavior.SplitTimeEvenly,
VmXmltvBlockBehavior.UseActualTimes => ApiXmltvBlockBehavior.UseActualTimes,
_ => throw new ArgumentOutOfRangeException(nameof(blockBehavior), blockBehavior, null)
};
private static LoggingSettingsResponseModel ProjectToResponseModel(LoggingSettingsViewModel vm) =>
new(
vm.DefaultMinimumLogLevel,
vm.ScanningMinimumLogLevel,
vm.SchedulingMinimumLogLevel,
vm.SearchingMinimumLogLevel,
vm.StreamingMinimumLogLevel,
vm.HttpMinimumLogLevel);
private static UiSettingsResponseModel ProjectToResponseModel(UiSettingsViewModel vm) =>
new(vm.IsDarkMode, vm.Language);
}
+74
View File
@@ -0,0 +1,74 @@
using Microsoft.AspNetCore.Http;
namespace ErsatzTV;
// Phase (a) of the Blazor -> ChicoryTV SPA cutover (ersatztv#91).
//
// The React SPA (served under /app) is now the default UI: the legacy Blazor
// routes below 302-redirect to their SPA equivalents. Only routes that already
// have SPA parity are listed here. Blazor pages WITHOUT a SPA equivalent are
// deliberately left reachable (no redirect) so their functionality stays
// available while the SPA catches up:
// /system/health (Blazor home escape hatch; Index.razor also lives here),
// /channels/{id} edit, /channels/numbers, /media/* (collections etc.),
// /ffmpeg, /watermarks, /blocks, /decos, /templates, /deco-templates,
// schedule/playout detail editors, /system/logs, /system/troubleshooting.
//
// Phase (b) removes the redirected Blazor pages entirely, but that is GATED on
// full SPA parity for every route in this map. Until then this map is the
// single source of truth for "what has migrated" and is expected to grow.
public static class LegacyUiRedirects
{
// Blazor route -> SPA route. EXACT paths only (no prefix matching); a single
// trailing slash on the request is normalized away before lookup.
public static readonly IReadOnlyDictionary<string, string> Map =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["/"] = "/app",
["/channels"] = "/app/channels",
["/channels/add"] = "/app/new-channel",
["/schedules"] = "/app/schedules",
["/playouts"] = "/app/playouts",
["/media/libraries"] = "/app/libraries",
["/settings/ffmpeg"] = "/app/settings/streaming",
["/settings/hdhr"] = "/app/settings/system",
["/settings/logging"] = "/app/settings/logging",
["/settings/playout"] = "/app/settings/playout",
["/settings/scanner"] = "/app/settings/scanner",
["/settings/ui"] = "/app/settings/general",
["/settings/xmltv"] = "/app/settings/xmltv"
};
public static bool TryGetRedirect(PathString path, out string target)
{
target = string.Empty;
string value = path.Value;
if (string.IsNullOrEmpty(value))
{
return false;
}
// Normalize a single trailing slash so "/channels/" matches "/channels"
// (but keep root "/" intact). Guard against a path of all slashes (e.g.
// "//") collapsing down to "/" and falsely matching the root entry.
if (value.Length > 1 && value.EndsWith('/'))
{
string trimmed = value[..^1];
if (trimmed == "/")
{
return false;
}
value = trimmed;
}
if (Map.TryGetValue(value, out string mapped))
{
target = mapped;
return true;
}
return false;
}
}
+28
View File
@@ -127,8 +127,14 @@ public class Startup
return;
}
// Core's own enums get scanned wholesale. A couple of API response DTOs (settings endpoints)
// also expose enums from lower layers Core is allowed to depend on (ErsatzTV.FFmpeg) or from
// Serilog; list those individually instead of Assembly.GetTypes()-scanning their assemblies,
// since eagerly loading every type in ErsatzTV.FFmpeg (e.g. NvEncSharp-backed types) can throw
// a ReflectionTypeLoadException in environments missing optional native hardware-encoder deps.
Dictionary<string, Type> enumTypes = typeof(Core.Domain.PlayoutMode).Assembly.GetTypes()
.Where(type => type.IsEnum)
.Concat([typeof(FFmpeg.OutputFormat.OutputFormatKind), typeof(Serilog.Events.LogEventLevel)])
.GroupBy(type => type.Name)
.ToDictionary(group => group.Key, group => group.First());
@@ -720,6 +726,28 @@ public class Startup
ctx => !IsIptvPath(ctx.Request.Path) && !IsSpaPath(ctx.Request.Path),
blazor =>
{
// ersatztv#91 phase (a): make the ChicoryTV SPA the default UI by
// redirecting migrated legacy Blazor routes to their /app equivalents.
// 302 (not 301): this map grows as pages migrate, and permanent-redirect
// browser caching would make rollback painful. UsePathBase (ETV_BASE_URL)
// only rewrites the request side (Request.Path/PathBase); it never touches
// redirect Location headers, so the PathBase prefix must be re-applied here.
blazor.Use(async (context, next) =>
{
if (HttpMethods.IsGet(context.Request.Method) ||
HttpMethods.IsHead(context.Request.Method))
{
if (LegacyUiRedirects.TryGetRedirect(context.Request.Path, out string target))
{
context.Response.Redirect(
context.Request.PathBase + target + context.Request.QueryString);
return;
}
}
await next(context);
});
blazor.UseRouting();
if (OidcHelper.IsEnabled)

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