Compare commits

..
Author SHA1 Message Date
timothyandClaude Fable 5 cf36c30997 fix(api): rework from-lineup to generated playlist design + review fixes (#63)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m23s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m54s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Adversarial review found the previous multi-flood design non-viable:
PlayoutModeSchedulerFlood never yields to a following Dynamic-start
schedule item, so only the first item ever played, and grouping
media items into a Collection silently dropped the requested order.

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

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

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

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

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

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

Review: PR #128 findings

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

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

Review: PR #127 findings

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

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

Review: PR #125 findings; refs #126

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

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

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

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

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

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

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

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

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

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

refs #102

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

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

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

refs #102

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

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

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

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

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

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

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

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

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

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

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

Also add handler tests (FillerPresetHandlerTests, WatermarkHandlerTests,
GraphicsElementHandlerTests) covering the GetAll*ForApi handlers using
the InMemoryTvContext harness: multi-row projection to (Id, Name) DTOs,
the empty-DB case, and the graphics-element ordering rule (named
elements sort before elements whose projected Name equals FileName).
2026-07-02 22:06:19 +02:00
timothyandClaude Fable 5 8912686a47 wip: partial implementation salvaged from interrupted workflow run
Untrusted draft — no build/test had run yet. Review before building on it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 21:27:40 +02:00
timothy d1dfe6eb5a docs: add Codex verification guidance
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 4m6s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m4s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m23s
2026-07-01 22:39:16 +02:00
153 changed files with 34887 additions and 906 deletions
+15
View File
@@ -0,0 +1,15 @@
# Codex Instructions
## Verification Commands
Run .NET restore, build, and test commands outside the sandbox by default in this repo. Sandboxed .NET commands can stall on NuGet/package/compiler cache access, while the same commands complete normally with approved unsandboxed execution.
Preferred verification commands:
```bash
TZ=UTC dotnet restore ErsatzTV.sln -v minimal
TZ=UTC dotnet build ErsatzTV.sln --no-restore -v minimal
TZ=UTC dotnet test ErsatzTV.sln --no-build -v minimal
```
Use scoped escalated execution for these commands rather than first trying a sandboxed run.
+1 -1
View File
@@ -36,7 +36,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>`. Cutover done (2026-06-27, server-management#481/#482): prod container runs fork `:prod` (currently v26.3.1), test container tracks `:latest`; prod advances only when a new `v*` tag is pushed (next: `v26.4.0`, first app-change release). Pipeline details: `docs/ci-cd.md`.
## Development
@@ -0,0 +1,13 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Artwork;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Artworks;
/// <summary>
/// Validates and stores an uploaded image as channel logo or watermark artwork,
/// landing it in the same on-disk cache the Blazor UI uses (via <c>IImageCache</c>),
/// so the returned path is equivalent to a Blazor-uploaded image.
/// </summary>
public record UploadArtwork(Stream Stream, string ContentType, ArtworkKind ArtworkKind)
: IRequest<Either<BaseError, ArtworkUploadResponseModel>>;
@@ -0,0 +1,53 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Artwork;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Images;
namespace ErsatzTV.Application.Artworks;
public class UploadArtworkHandler : IRequestHandler<UploadArtwork, Either<BaseError, ArtworkUploadResponseModel>>
{
// png/jpeg/gif/webp are all decoded by SkiaSharp and read by FFmpeg, matching the
// formats the Blazor logo/watermark upload already accepts. Format expansion is ersatztv#66.
private static readonly System.Collections.Generic.HashSet<string> AcceptedContentTypes = new(StringComparer.OrdinalIgnoreCase)
{
"image/png",
"image/jpeg",
"image/gif",
"image/webp"
};
private readonly IImageCache _imageCache;
public UploadArtworkHandler(IImageCache imageCache) => _imageCache = imageCache;
public async Task<Either<BaseError, ArtworkUploadResponseModel>> Handle(
UploadArtwork request,
CancellationToken cancellationToken)
{
string contentType = (request.ContentType ?? string.Empty).Trim();
if (!AcceptedContentTypes.Contains(contentType))
{
return BaseError.New(
$"Unsupported image content type '{contentType}'; supported types are: {string.Join(", ", AcceptedContentTypes)}");
}
Either<BaseError, string> maybeFileName = await _imageCache.SaveArtworkToCache(
request.Stream,
request.ArtworkKind);
return maybeFileName.Map(fileName => new ArtworkUploadResponseModel(
BuildPath(request.ArtworkKind, fileName),
contentType));
}
// Mirror the on-disk conventions the Blazor editors use so the returned path is a drop-in
// for ArtworkContentTypeModel.Path: channel logos are addressed as "iptv/logos/{file}"
// (see ChannelEditor.UploadLogo), watermarks by the bare cache file name (see WatermarkEditor).
private static string BuildPath(ArtworkKind artworkKind, string fileName) =>
artworkKind switch
{
ArtworkKind.Logo => $"iptv/logos/{fileName}",
_ => fileName
};
}
@@ -0,0 +1,24 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
namespace ErsatzTV.Application.ChannelTemplates;
internal static class ChannelTemplateDefault
{
public static async Task<int?> GetDefaultTemplateId(
IConfigElementRepository configElementRepository,
CancellationToken cancellationToken)
{
Option<int> maybeDefault =
await configElementRepository.GetValue<int>(
ConfigElementKey.ChannelTemplatesDefaultTemplateId,
cancellationToken);
int? result = null;
foreach (int id in maybeDefault)
{
result = id;
}
return result;
}
}
@@ -0,0 +1,38 @@
using ErsatzTV.Core.Api.ChannelTemplates;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.ChannelTemplates;
public static class ChannelTemplateMapper
{
public static ChannelTemplateResponseModel ProjectToResponseModel(ChannelTemplate template, int? defaultTemplateId) =>
new(
template.Id,
template.Name,
template.Description,
template.IsSystem,
defaultTemplateId == template.Id,
template.FFmpegProfileId,
template.WatermarkId,
template.FallbackFillerId,
template.PreRollFillerId,
template.MidRollFillerId,
template.PostRollFillerId,
template.StreamSelectorMode,
template.StreamSelector,
template.PreferredAudioLanguageCode,
template.PreferredAudioTitle,
template.PlayoutSource,
template.PlayoutMode,
template.StreamingMode,
template.PreferredSubtitleLanguageCode,
template.SubtitleMode,
template.MusicVideoCreditsMode,
template.MusicVideoCreditsTemplate,
template.SongVideoMode,
template.TranscodeMode,
template.IdleBehavior,
template.ShuffleScheduleItems,
template.RandomStartPoint,
template.FixedStartTimeBehavior);
}
@@ -0,0 +1,155 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.ChannelTemplates;
public abstract record ChannelTemplateCommandBase(
string Name,
string Description,
int FFmpegProfileId,
int? WatermarkId,
int? FallbackFillerId,
int? PreRollFillerId,
int? MidRollFillerId,
int? PostRollFillerId,
ChannelStreamSelectorMode StreamSelectorMode,
string StreamSelector,
string PreferredAudioLanguageCode,
string PreferredAudioTitle,
ChannelPlayoutSource PlayoutSource,
ChannelPlayoutMode PlayoutMode,
StreamingMode StreamingMode,
string PreferredSubtitleLanguageCode,
ChannelSubtitleMode SubtitleMode,
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
string MusicVideoCreditsTemplate,
ChannelSongVideoMode SongVideoMode,
ChannelTranscodeMode TranscodeMode,
ChannelIdleBehavior IdleBehavior,
bool ShuffleScheduleItems,
bool RandomStartPoint,
FixedStartTimeBehavior FixedStartTimeBehavior)
{
internal static async Task<Option<BaseError>> ValidateCommon(
TvContext dbContext,
ChannelTemplateCommandBase request,
int? existingTemplateId,
CancellationToken cancellationToken)
{
string name = NormalizeName(request.Name);
if (string.IsNullOrWhiteSpace(name))
{
return BaseError.New("Name is required.");
}
if (name.Length > 50)
{
return BaseError.New("Name must be 50 characters or less.");
}
if (request.Description?.Length > 500)
{
return BaseError.New("Description must be 500 characters or less.");
}
bool duplicateName = await dbContext.ChannelTemplates
.AnyAsync(t => t.Id != existingTemplateId && t.Name == name, cancellationToken);
if (duplicateName)
{
return BaseError.New("Channel template name must be unique.");
}
bool ffmpegProfileExists = await dbContext.FFmpegProfiles
.AnyAsync(p => p.Id == request.FFmpegProfileId, cancellationToken);
if (!ffmpegProfileExists)
{
return new NotFoundError($"FFmpegProfile {request.FFmpegProfileId} does not exist.");
}
foreach (int watermarkId in Optional(request.WatermarkId))
{
bool watermarkExists = await dbContext.ChannelWatermarks
.AnyAsync(w => w.Id == watermarkId, cancellationToken);
if (!watermarkExists)
{
return new NotFoundError($"Watermark {watermarkId} does not exist.");
}
}
Option<BaseError> maybeFillerError =
await FillerMustExist(dbContext, request.FallbackFillerId, FillerKind.Fallback, cancellationToken);
if (maybeFillerError.IsSome)
{
return maybeFillerError;
}
maybeFillerError = await FillerMustExist(dbContext, request.PreRollFillerId, FillerKind.PreRoll, cancellationToken);
if (maybeFillerError.IsSome)
{
return maybeFillerError;
}
maybeFillerError = await FillerMustExist(dbContext, request.MidRollFillerId, FillerKind.MidRoll, cancellationToken);
if (maybeFillerError.IsSome)
{
return maybeFillerError;
}
return await FillerMustExist(dbContext, request.PostRollFillerId, FillerKind.PostRoll, cancellationToken);
}
internal void ApplyTo(ChannelTemplate template)
{
template.Name = NormalizeName(Name);
template.Description = Description ?? string.Empty;
template.FFmpegProfileId = FFmpegProfileId;
template.WatermarkId = WatermarkId;
template.FallbackFillerId = FallbackFillerId;
template.PreRollFillerId = PreRollFillerId;
template.MidRollFillerId = MidRollFillerId;
template.PostRollFillerId = PostRollFillerId;
template.StreamSelectorMode = StreamSelectorMode;
template.StreamSelector = StreamSelector ?? string.Empty;
template.PreferredAudioLanguageCode = PreferredAudioLanguageCode ?? string.Empty;
template.PreferredAudioTitle = PreferredAudioTitle ?? string.Empty;
template.PlayoutSource = PlayoutSource;
template.PlayoutMode = PlayoutMode;
template.StreamingMode = StreamingMode;
template.PreferredSubtitleLanguageCode = PreferredSubtitleLanguageCode ?? string.Empty;
template.SubtitleMode = SubtitleMode;
template.MusicVideoCreditsMode = MusicVideoCreditsMode;
template.MusicVideoCreditsTemplate = MusicVideoCreditsTemplate ?? string.Empty;
template.SongVideoMode = SongVideoMode;
template.TranscodeMode = TranscodeMode;
template.IdleBehavior = IdleBehavior;
template.ShuffleScheduleItems = ShuffleScheduleItems;
template.RandomStartPoint = RandomStartPoint;
template.FixedStartTimeBehavior = FixedStartTimeBehavior;
}
internal static string NormalizeName(string name) => (name ?? string.Empty).Trim();
private static async Task<Option<BaseError>> FillerMustExist(
TvContext dbContext,
int? fillerPresetId,
FillerKind fillerKind,
CancellationToken cancellationToken)
{
foreach (int id in Optional(fillerPresetId))
{
bool exists = await dbContext.FillerPresets
.AnyAsync(f => f.Id == id && f.FillerKind == fillerKind, cancellationToken);
if (!exists)
{
return new NotFoundError($"{fillerKind} filler {id} does not exist.");
}
}
return Option<BaseError>.None;
}
}
@@ -0,0 +1,60 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Api.ChannelTemplates;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Application.ChannelTemplates;
public record CreateChannelTemplate(
string Name,
string Description,
int FFmpegProfileId,
int? WatermarkId,
int? FallbackFillerId,
int? PreRollFillerId,
int? MidRollFillerId,
int? PostRollFillerId,
ChannelStreamSelectorMode StreamSelectorMode,
string StreamSelector,
string PreferredAudioLanguageCode,
string PreferredAudioTitle,
ChannelPlayoutSource PlayoutSource,
ChannelPlayoutMode PlayoutMode,
StreamingMode StreamingMode,
string PreferredSubtitleLanguageCode,
ChannelSubtitleMode SubtitleMode,
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
string MusicVideoCreditsTemplate,
ChannelSongVideoMode SongVideoMode,
ChannelTranscodeMode TranscodeMode,
ChannelIdleBehavior IdleBehavior,
bool ShuffleScheduleItems,
bool RandomStartPoint,
FixedStartTimeBehavior FixedStartTimeBehavior)
: ChannelTemplateCommandBase(
Name,
Description,
FFmpegProfileId,
WatermarkId,
FallbackFillerId,
PreRollFillerId,
MidRollFillerId,
PostRollFillerId,
StreamSelectorMode,
StreamSelector,
PreferredAudioLanguageCode,
PreferredAudioTitle,
PlayoutSource,
PlayoutMode,
StreamingMode,
PreferredSubtitleLanguageCode,
SubtitleMode,
MusicVideoCreditsMode,
MusicVideoCreditsTemplate,
SongVideoMode,
TranscodeMode,
IdleBehavior,
ShuffleScheduleItems,
RandomStartPoint,
FixedStartTimeBehavior),
IRequest<Either<BaseError, ChannelTemplateResponseModel>>;
@@ -0,0 +1,36 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Api.ChannelTemplates;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.ChannelTemplates;
public class CreateChannelTemplateHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository)
: IRequestHandler<CreateChannelTemplate, Either<BaseError, ChannelTemplateResponseModel>>
{
public async Task<Either<BaseError, ChannelTemplateResponseModel>> Handle(
CreateChannelTemplate request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<BaseError> maybeError =
await ChannelTemplateCommandBase.ValidateCommon(dbContext, request, null, cancellationToken);
foreach (BaseError error in maybeError)
{
return error;
}
var template = new ChannelTemplate();
request.ApplyTo(template);
await dbContext.ChannelTemplates.AddAsync(template, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
int? defaultTemplateId =
await ChannelTemplateDefault.GetDefaultTemplateId(configElementRepository, cancellationToken);
return ChannelTemplateMapper.ProjectToResponseModel(template, defaultTemplateId);
}
}
@@ -0,0 +1,5 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.ChannelTemplates;
public record DeleteChannelTemplate(int ChannelTemplateId) : IRequest<Either<BaseError, Unit>>;
@@ -0,0 +1,42 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.ChannelTemplates;
public class DeleteChannelTemplateHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository)
: IRequestHandler<DeleteChannelTemplate, Either<BaseError, Unit>>
{
public async Task<Either<BaseError, Unit>> Handle(DeleteChannelTemplate request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<ChannelTemplate> maybeTemplate = await dbContext.ChannelTemplates
.SelectOneAsync(t => t.Id, t => t.Id == request.ChannelTemplateId, cancellationToken);
foreach (ChannelTemplate template in maybeTemplate)
{
if (template.IsSystem)
{
return BaseError.New("System templates cannot be deleted.");
}
int? defaultTemplateId =
await ChannelTemplateDefault.GetDefaultTemplateId(configElementRepository, cancellationToken);
if (defaultTemplateId == template.Id)
{
return BaseError.New("Default channel template cannot be deleted.");
}
dbContext.ChannelTemplates.Remove(template);
await dbContext.SaveChangesAsync(cancellationToken);
return Unit.Default;
}
return new NotFoundError($"ChannelTemplate {request.ChannelTemplateId} does not exist.");
}
}
@@ -0,0 +1,6 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Api.ChannelTemplates;
namespace ErsatzTV.Application.ChannelTemplates;
public record SetDefaultChannelTemplate(int ChannelTemplateId) : IRequest<Either<BaseError, ChannelTemplateResponseModel>>;
@@ -0,0 +1,36 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Api.ChannelTemplates;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.ChannelTemplates;
public class SetDefaultChannelTemplateHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository)
: IRequestHandler<SetDefaultChannelTemplate, Either<BaseError, ChannelTemplateResponseModel>>
{
public async Task<Either<BaseError, ChannelTemplateResponseModel>> Handle(
SetDefaultChannelTemplate request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<ChannelTemplate> maybeTemplate = await dbContext.ChannelTemplates
.AsNoTracking()
.SelectOneAsync(t => t.Id, t => t.Id == request.ChannelTemplateId, cancellationToken);
foreach (ChannelTemplate template in maybeTemplate)
{
await configElementRepository.Upsert(
ConfigElementKey.ChannelTemplatesDefaultTemplateId,
template.Id,
cancellationToken);
return ChannelTemplateMapper.ProjectToResponseModel(template, template.Id);
}
return new NotFoundError($"ChannelTemplate {request.ChannelTemplateId} does not exist.");
}
}
@@ -0,0 +1,61 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Api.ChannelTemplates;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Application.ChannelTemplates;
public record UpdateChannelTemplate(
int ChannelTemplateId,
string Name,
string Description,
int FFmpegProfileId,
int? WatermarkId,
int? FallbackFillerId,
int? PreRollFillerId,
int? MidRollFillerId,
int? PostRollFillerId,
ChannelStreamSelectorMode StreamSelectorMode,
string StreamSelector,
string PreferredAudioLanguageCode,
string PreferredAudioTitle,
ChannelPlayoutSource PlayoutSource,
ChannelPlayoutMode PlayoutMode,
StreamingMode StreamingMode,
string PreferredSubtitleLanguageCode,
ChannelSubtitleMode SubtitleMode,
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
string MusicVideoCreditsTemplate,
ChannelSongVideoMode SongVideoMode,
ChannelTranscodeMode TranscodeMode,
ChannelIdleBehavior IdleBehavior,
bool ShuffleScheduleItems,
bool RandomStartPoint,
FixedStartTimeBehavior FixedStartTimeBehavior)
: ChannelTemplateCommandBase(
Name,
Description,
FFmpegProfileId,
WatermarkId,
FallbackFillerId,
PreRollFillerId,
MidRollFillerId,
PostRollFillerId,
StreamSelectorMode,
StreamSelector,
PreferredAudioLanguageCode,
PreferredAudioTitle,
PlayoutSource,
PlayoutMode,
StreamingMode,
PreferredSubtitleLanguageCode,
SubtitleMode,
MusicVideoCreditsMode,
MusicVideoCreditsTemplate,
SongVideoMode,
TranscodeMode,
IdleBehavior,
ShuffleScheduleItems,
RandomStartPoint,
FixedStartTimeBehavior),
IRequest<Either<BaseError, ChannelTemplateResponseModel>>;
@@ -0,0 +1,51 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Api.ChannelTemplates;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.ChannelTemplates;
public class UpdateChannelTemplateHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository)
: IRequestHandler<UpdateChannelTemplate, Either<BaseError, ChannelTemplateResponseModel>>
{
public async Task<Either<BaseError, ChannelTemplateResponseModel>> Handle(
UpdateChannelTemplate request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<ChannelTemplate> maybeTemplate = await dbContext.ChannelTemplates
.SelectOneAsync(t => t.Id, t => t.Id == request.ChannelTemplateId, cancellationToken);
foreach (ChannelTemplate template in maybeTemplate)
{
if (template.IsSystem)
{
return BaseError.New("System templates cannot be updated.");
}
Option<BaseError> maybeError =
await ChannelTemplateCommandBase.ValidateCommon(
dbContext,
request,
request.ChannelTemplateId,
cancellationToken);
foreach (BaseError error in maybeError)
{
return error;
}
request.ApplyTo(template);
await dbContext.SaveChangesAsync(cancellationToken);
int? defaultTemplateId =
await ChannelTemplateDefault.GetDefaultTemplateId(configElementRepository, cancellationToken);
return ChannelTemplateMapper.ProjectToResponseModel(template, defaultTemplateId);
}
return new NotFoundError($"ChannelTemplate {request.ChannelTemplateId} does not exist.");
}
}
@@ -0,0 +1,5 @@
using ErsatzTV.Core.Api.ChannelTemplates;
namespace ErsatzTV.Application.ChannelTemplates;
public record GetAllChannelTemplates : IRequest<List<ChannelTemplateResponseModel>>;
@@ -0,0 +1,28 @@
using ErsatzTV.Core.Api.ChannelTemplates;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.ChannelTemplates;
public class GetAllChannelTemplatesHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository)
: IRequestHandler<GetAllChannelTemplates, List<ChannelTemplateResponseModel>>
{
public async Task<List<ChannelTemplateResponseModel>> Handle(
GetAllChannelTemplates request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
int? defaultTemplateId =
await ChannelTemplateDefault.GetDefaultTemplateId(configElementRepository, cancellationToken);
return await dbContext.ChannelTemplates
.AsNoTracking()
.OrderBy(t => t.IsSystem ? 0 : 1)
.ThenBy(t => t.Name)
.Select(t => ChannelTemplateMapper.ProjectToResponseModel(t, defaultTemplateId))
.ToListAsync(cancellationToken);
}
}
@@ -0,0 +1,5 @@
using ErsatzTV.Core.Api.ChannelTemplates;
namespace ErsatzTV.Application.ChannelTemplates;
public record GetChannelTemplateById(int ChannelTemplateId) : IRequest<Option<ChannelTemplateResponseModel>>;
@@ -0,0 +1,27 @@
using ErsatzTV.Core.Api.ChannelTemplates;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.ChannelTemplates;
public class GetChannelTemplateByIdHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository)
: IRequestHandler<GetChannelTemplateById, Option<ChannelTemplateResponseModel>>
{
public async Task<Option<ChannelTemplateResponseModel>> Handle(
GetChannelTemplateById request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
int? defaultTemplateId =
await ChannelTemplateDefault.GetDefaultTemplateId(configElementRepository, cancellationToken);
Option<ChannelTemplate> maybeTemplate = await dbContext.ChannelTemplates
.AsNoTracking()
.SelectOneAsync(t => t.Id, t => t.Id == request.ChannelTemplateId, cancellationToken);
return maybeTemplate.Map(t => ChannelTemplateMapper.ProjectToResponseModel(t, defaultTemplateId));
}
}
@@ -0,0 +1,5 @@
using ErsatzTV.Core.Api.ChannelTemplates;
namespace ErsatzTV.Application.ChannelTemplates;
public record GetDefaultChannelTemplate : IRequest<Option<ChannelTemplateResponseModel>>;
@@ -0,0 +1,40 @@
using ErsatzTV.Core.Api.ChannelTemplates;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.ChannelTemplates;
public class GetDefaultChannelTemplateHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository)
: IRequestHandler<GetDefaultChannelTemplate, Option<ChannelTemplateResponseModel>>
{
public async Task<Option<ChannelTemplateResponseModel>> Handle(
GetDefaultChannelTemplate request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
int? defaultTemplateId =
await ChannelTemplateDefault.GetDefaultTemplateId(configElementRepository, cancellationToken);
foreach (int id in Optional(defaultTemplateId))
{
Option<ChannelTemplate> maybeConfigured = await dbContext.ChannelTemplates
.AsNoTracking()
.SelectOneAsync(t => t.Id, t => t.Id == id, cancellationToken);
foreach (ChannelTemplate template in maybeConfigured)
{
return ChannelTemplateMapper.ProjectToResponseModel(template, id);
}
}
ChannelTemplate fallback = await dbContext.ChannelTemplates
.AsNoTracking()
.Where(t => t.IsSystem)
.OrderBy(t => t.Name)
.FirstOrDefaultAsync(cancellationToken);
return Optional(fallback).Map(t => ChannelTemplateMapper.ProjectToResponseModel(t, t.Id));
}
}
@@ -0,0 +1,71 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Channels;
/// <summary>
/// Shared programme-metadata projection for guide output. Both the XMLTV cache builder
/// (<see cref="RefreshChannelDataHandler" />) and the JSON guide query
/// (<see cref="GetChannelGuideDataHandler" />) resolve the display title/subtitle/category from a
/// <see cref="PlayoutItem" /> here so the two representations stay consistent.
/// </summary>
public static class ChannelGuideMetadata
{
public static string GetTitle(PlayoutItem playoutItem)
{
if (!string.IsNullOrWhiteSpace(playoutItem.CustomTitle))
{
return playoutItem.CustomTitle;
}
return playoutItem.MediaItem switch
{
Movie m => m.MovieMetadata.HeadOrNone().Map(mm => mm.Title ?? string.Empty)
.IfNone("[unknown movie]"),
Episode e => e.Season.Show.ShowMetadata.HeadOrNone().Map(em => em.Title ?? string.Empty)
.IfNone("[unknown show]"),
MusicVideo mv => mv.Artist.ArtistMetadata.HeadOrNone().Map(am => am.Title ?? string.Empty)
.IfNone("[unknown artist]"),
OtherVideo ov => ov.OtherVideoMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty)
.IfNone("[unknown video]"),
RemoteStream rs => rs.RemoteStreamMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty)
.IfNone("[unknown remote stream]"),
_ => "[unknown]"
};
}
public static string GetSubtitle(PlayoutItem playoutItem)
{
if (!string.IsNullOrWhiteSpace(playoutItem.CustomTitle))
{
return string.Empty;
}
return playoutItem.MediaItem switch
{
Episode e => e.EpisodeMetadata.HeadOrNone().Match(
em => em.Title ?? string.Empty,
() => string.Empty),
MusicVideo mv => mv.MusicVideoMetadata.HeadOrNone().Match(
mvm => mvm.Title ?? string.Empty,
() => string.Empty),
Song s => s.SongMetadata.HeadOrNone().Match(
mvm => mvm.Title ?? string.Empty,
() => string.Empty),
_ => string.Empty
};
}
/// <summary>
/// The primary guide category, mirroring the fixed <c>&lt;category&gt;</c> the XMLTV templates
/// emit per media kind (Movie / Series / Music). Media kinds without a fixed category return null.
/// </summary>
public static string GetCategory(PlayoutItem playoutItem) =>
playoutItem.MediaItem switch
{
Movie => "Movie",
Episode => "Series",
MusicVideo => "Music",
Song => "Music",
_ => null
};
}
@@ -0,0 +1,164 @@
using ErsatzTV.Application.Configuration;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
namespace ErsatzTV.Application.Channels;
/// <summary>
/// A single guide programme resolved from one or more <see cref="PlayoutItem" />s: the
/// <see cref="DisplayItem" /> whose metadata is shown, plus the coalesced <see cref="Start" />/
/// <see cref="Stop" /> window and whether the originating item carried a custom title.
/// </summary>
public readonly record struct ChannelGuideEntry(
PlayoutItem DisplayItem,
DateTimeOffset Start,
DateTimeOffset Stop,
bool HasCustomTitle);
/// <summary>
/// Shared guide-group / filler-merge projection. This is the single source of truth for turning a
/// channel's sorted <see cref="PlayoutItem" />s into guide programmes; both the XMLTV cache builder
/// (<see cref="RefreshChannelDataHandler" />) and the JSON guide query
/// (<see cref="GetChannelGuideDataHandler" />) consume it so the two representations cannot drift.
/// The XMLTV path formats <see cref="ChannelGuideEntry.Start" />/<see cref="ChannelGuideEntry.Stop" />
/// into the XMLTV timestamp strings; the JSON path returns them (and the display item's
/// <see cref="FillerKind" />) directly and lets the UI decide how to render filler.
/// </summary>
public static class ChannelGuideProjector
{
public static IEnumerable<ChannelGuideEntry> Project(
PlayoutScheduleKind scheduleKind,
IReadOnlyList<PlayoutItem> sorted,
XmltvTimeZone timeZone,
XmltvBlockBehavior blockBehavior) =>
scheduleKind switch
{
PlayoutScheduleKind.Block => ProjectBlock(sorted, timeZone, blockBehavior),
_ => ProjectFlood(sorted, timeZone)
};
// Classic / Sequential / Scripted / ExternalJson: skip leading non-preroll filler, then coalesce
// each guide group (following filler) into a single programme using the display item's GuideFinish
// override when present.
private static IEnumerable<ChannelGuideEntry> ProjectFlood(
IReadOnlyList<PlayoutItem> sorted,
XmltvTimeZone timeZone)
{
// skip all filler that isn't pre-roll
var i = 0;
while (i < sorted.Count && sorted[i].FillerKind != FillerKind.None &&
sorted[i].FillerKind != FillerKind.PreRoll)
{
i++;
}
while (i < sorted.Count)
{
PlayoutItem startItem = sorted[i];
int j = i;
while (sorted[j].FillerKind != FillerKind.None && j + 1 < sorted.Count)
{
j++;
}
PlayoutItem displayItem = sorted[j];
bool hasCustomTitle = !string.IsNullOrWhiteSpace(startItem.CustomTitle);
int finishIndex = j;
while (finishIndex + 1 < sorted.Count && (sorted[finishIndex + 1].GuideGroup == startItem.GuideGroup
|| sorted[finishIndex + 1].FillerKind is FillerKind.GuideMode
or FillerKind.PostRoll or FillerKind.Tail
or FillerKind.Fallback or FillerKind.DecoDefault))
{
finishIndex++;
}
PlayoutItem finishItem = sorted[finishIndex];
i = finishIndex;
DateTimeOffset startTime = timeZone switch
{
XmltvTimeZone.Utc => new DateTimeOffset(startItem.Start, TimeSpan.Zero),
_ => startItem.StartOffset
};
DateTimeOffset stopTime = (timeZone, displayItem.GuideFinishOffset.HasValue) switch
{
(XmltvTimeZone.Utc, true) => new DateTimeOffset(displayItem.GuideFinish!.Value, TimeSpan.Zero),
(XmltvTimeZone.Utc, false) => new DateTimeOffset(finishItem.Finish, TimeSpan.Zero),
(_, true) => displayItem.GuideFinishOffset!.Value,
(_, false) => finishItem.FinishOffset
};
yield return new ChannelGuideEntry(displayItem, startTime, stopTime, hasCustomTitle);
i++;
}
}
// Block: group by guide window, drop filler entirely, then either use the items' actual times or
// split the group window evenly across the non-filler items.
private static IEnumerable<ChannelGuideEntry> ProjectBlock(
IReadOnlyList<PlayoutItem> sorted,
XmltvTimeZone timeZone,
XmltvBlockBehavior blockBehavior)
{
var groups = sorted.GroupBy(s => new { s.GuideStart, s.GuideFinish, s.GuideGroup });
foreach (var group in groups)
{
var itemsToInclude = group.Filter(g => g.FillerKind is FillerKind.None).ToList();
if (itemsToInclude.Count == 0)
{
continue;
}
switch (blockBehavior)
{
case XmltvBlockBehavior.UseActualTimes:
foreach (PlayoutItem item in itemsToInclude)
{
DateTimeOffset actualStart = timeZone switch
{
XmltvTimeZone.Utc => new DateTimeOffset(item.Start, TimeSpan.Zero),
_ => new DateTimeOffset(item.Start, TimeSpan.Zero).ToLocalTime()
};
DateTimeOffset actualFinish = timeZone switch
{
XmltvTimeZone.Utc => new DateTimeOffset(item.Finish, TimeSpan.Zero),
_ => new DateTimeOffset(item.Finish, TimeSpan.Zero).ToLocalTime()
};
yield return new ChannelGuideEntry(item, actualStart, actualFinish, false);
}
break;
case XmltvBlockBehavior.SplitTimeEvenly:
default:
DateTime groupStart = group.Key.GuideStart!.Value;
DateTime groupFinish = group.Key.GuideFinish!.Value;
TimeSpan groupDuration = groupFinish - groupStart;
TimeSpan perItem = groupDuration / itemsToInclude.Count;
DateTimeOffset currentStart = timeZone switch
{
XmltvTimeZone.Utc => new DateTimeOffset(groupStart, TimeSpan.Zero),
_ => new DateTimeOffset(groupStart, TimeSpan.Zero).ToLocalTime()
};
DateTimeOffset currentFinish = currentStart + perItem;
foreach (PlayoutItem item in itemsToInclude)
{
yield return new ChannelGuideEntry(item, currentStart, currentFinish, false);
currentStart = currentFinish;
currentFinish += perItem;
}
break;
}
}
}
}
@@ -0,0 +1,56 @@
using ErsatzTV.Application.Artworks;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Application.Channels;
public record CreateChannelFromLineup(
string Name,
string Number,
string Group,
string Categories,
ArtworkContentTypeModel Logo,
bool IsEnabled,
bool ShowInEpg,
int TemplateId,
CreateChannelFromLineupAdvancedOptions Advanced,
List<CreateChannelFromLineupItem> Lineup) : IRequest<Either<BaseError, CreateChannelFromLineupResponseModel>>;
public record CreateChannelFromLineupAdvancedOptions(
PlaybackOrder? PlaybackOrder = null,
int? FFmpegProfileId = null,
int? WatermarkId = null,
int? FallbackFillerId = null,
int? PreRollFillerId = null,
int? MidRollFillerId = null,
int? PostRollFillerId = null,
ChannelStreamSelectorMode? StreamSelectorMode = null,
string StreamSelector = null,
string PreferredAudioLanguageCode = null,
string PreferredAudioTitle = null,
ChannelPlayoutSource? PlayoutSource = null,
ChannelPlayoutMode? PlayoutMode = null,
StreamingMode? StreamingMode = null,
string PreferredSubtitleLanguageCode = null,
ChannelSubtitleMode? SubtitleMode = null,
ChannelMusicVideoCreditsMode? MusicVideoCreditsMode = null,
string MusicVideoCreditsTemplate = null,
ChannelSongVideoMode? SongVideoMode = null,
ChannelTranscodeMode? TranscodeMode = null,
ChannelIdleBehavior? IdleBehavior = null,
bool? ShuffleScheduleItems = null,
bool? RandomStartPoint = null,
FixedStartTimeBehavior? FixedStartTimeBehavior = null);
public record CreateChannelFromLineupItem(
LibraryBrowseMediaType MediaType,
CollectionType CollectionType,
int? CollectionId,
int? MultiCollectionId,
int? SmartCollectionId,
int? RerunCollectionId,
int? MediaItemId,
int? PlaylistId);
@@ -0,0 +1,754 @@
using System.Globalization;
using System.Text.RegularExpressions;
using System.Threading.Channels;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Channel = ErsatzTV.Core.Domain.Channel;
namespace ErsatzTV.Application.Channels;
public class CreateChannelFromLineupHandler(
ChannelWriter<IBackgroundServiceRequest> workerChannel,
IDbContextFactory<TvContext> dbContextFactory,
ISearchTargets searchTargets,
ILogger<CreateChannelFromLineupHandler> logger)
: IRequestHandler<CreateChannelFromLineup, Either<BaseError, CreateChannelFromLineupResponseModel>>
{
// The single system playlist group that holds every generated channel-lineup playlist.
// Matches the Trakt "Trakt Lists" precedent (DbInitializer + delete guards on IsSystem).
private const string SystemPlaylistGroupName = "Channel Lineups";
public async Task<Either<BaseError, CreateChannelFromLineupResponseModel>> Handle(
CreateChannelFromLineup request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Either<BaseError, PreparedCreate> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Match(
Left: error => Task.FromResult<Either<BaseError, CreateChannelFromLineupResponseModel>>(error),
Right: prepared => PersistAndDispatch(dbContext, prepared, cancellationToken));
}
private async Task<Either<BaseError, CreateChannelFromLineupResponseModel>> PersistAndDispatch(
TvContext dbContext,
PreparedCreate prepared,
CancellationToken cancellationToken)
{
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
try
{
dbContext.Channels.Add(prepared.Channel);
if (prepared.Playlist is not null)
{
dbContext.Playlists.Add(prepared.Playlist);
}
dbContext.ProgramSchedules.Add(prepared.ProgramSchedule);
dbContext.Playouts.Add(prepared.Playout);
await dbContext.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
}
catch (DbUpdateException ex)
{
await transaction.RollbackAsync(cancellationToken);
logger.LogError(ex, "Failed to persist channel created from lineup");
return BaseError.New("Unable to create channel from lineup");
}
searchTargets.SearchTargetsChanged();
await workerChannel.WriteAsync(
new BuildPlayout(prepared.Playout.Id, PlayoutBuildMode.Reset),
cancellationToken);
// Mirror CreateClassicPlayoutHandler: on-demand playouts must be time-shifted to "now" after build.
if (prepared.Channel.PlayoutMode is ChannelPlayoutMode.OnDemand)
{
await workerChannel.WriteAsync(
new TimeShiftOnDemandPlayout(prepared.Playout.Id, DateTimeOffset.Now, false),
cancellationToken);
}
await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
return new CreateChannelFromLineupResponseModel(
prepared.Channel.Id,
prepared.Playlist?.Id,
prepared.ProgramSchedule.Id,
prepared.Playout.Id);
}
private static async Task<Either<BaseError, PreparedCreate>> Validate(
TvContext dbContext,
CreateChannelFromLineup request,
CancellationToken cancellationToken)
{
string name = (request.Name ?? string.Empty).Trim();
string number = (request.Number ?? string.Empty).Trim();
string group = (request.Group ?? string.Empty).Trim();
string categories = (request.Categories ?? string.Empty).Trim();
CreateChannelFromLineupAdvancedOptions advanced = request.Advanced ?? new CreateChannelFromLineupAdvancedOptions();
if (string.IsNullOrWhiteSpace(name))
{
return BaseError.New("Channel name is required");
}
if (name.Length > 50)
{
return BaseError.New("Channel name must be 50 characters or fewer");
}
if (string.IsNullOrWhiteSpace(group))
{
return BaseError.New("Channel group is required");
}
if (!Regex.IsMatch(number, Channel.NumberValidator))
{
return BaseError.New("Invalid channel number; two decimals are allowed for subchannels");
}
if (await dbContext.Channels.AnyAsync(c => c.Number == number, cancellationToken))
{
return BaseError.New("Channel number must be unique");
}
if (!request.IsEnabled && request.ShowInEpg)
{
return BaseError.New("Disabled channels cannot be shown in EPG");
}
if (!string.IsNullOrWhiteSpace(request.Logo?.Path) &&
Uri.TryCreate(request.Logo.Path, UriKind.Absolute, out _) &&
!Artwork.IsExternalUrl(request.Logo.Path))
{
return BaseError.New("External logo url is invalid");
}
if (request.Lineup is null || request.Lineup.Count == 0)
{
return BaseError.New("Lineup must contain at least one item");
}
ChannelTemplate template = await dbContext.ChannelTemplates
.AsNoTracking()
.SingleOrDefaultAsync(t => t.Id == request.TemplateId, cancellationToken);
if (template is null)
{
return new NotFoundError($"Channel template {request.TemplateId} does not exist.");
}
int ffmpegProfileId = advanced.FFmpegProfileId ?? template.FFmpegProfileId;
int? fallbackFillerId = advanced.FallbackFillerId ?? template.FallbackFillerId;
int? preRollFillerId = advanced.PreRollFillerId ?? template.PreRollFillerId;
int? midRollFillerId = advanced.MidRollFillerId ?? template.MidRollFillerId;
int? postRollFillerId = advanced.PostRollFillerId ?? template.PostRollFillerId;
PlaybackOrder playbackOrder = advanced.PlaybackOrder ?? PlaybackOrder.Chronological;
ChannelPlayoutSource playoutSource = advanced.PlayoutSource ?? template.PlayoutSource;
// Mirror channels need special MirrorSourceChannelId plumbing (see CreateChannelHandler);
// this endpoint only builds generated playouts.
if (playoutSource is ChannelPlayoutSource.Mirror)
{
return BaseError.New("Mirror playout source is not supported by this endpoint");
}
Either<BaseError, Unit> referenceValidation = await ValidateReferences(
dbContext,
advanced,
template,
cancellationToken);
foreach (BaseError error in referenceValidation.LeftToSeq())
{
return error;
}
// Normalize + validate every lineup entry once, so validation and build see the same data.
var normalized = new List<NormalizedLineupItem>();
for (int i = 0; i < request.Lineup.Count; i++)
{
Either<BaseError, NormalizedLineupItem> itemValidation =
await NormalizeLineupItem(dbContext, request.Lineup[i], i, cancellationToken);
foreach (BaseError error in itemValidation.LeftToSeq())
{
return error;
}
foreach (NormalizedLineupItem item in itemValidation.RightToSeq())
{
normalized.Add(item);
}
}
bool multiItem = normalized.Count >= 2;
// MultiCollection entries only support Shuffle / ShuffleInOrder (mirrors PlayoutModeMustBeValid).
if (normalized.Any(i => i.CollectionType is CollectionType.MultiCollection) &&
playbackOrder is not (PlaybackOrder.Shuffle or PlaybackOrder.ShuffleInOrder))
{
return BaseError.New($"Invalid playback order for multi collection: '{playbackOrder}'");
}
if (multiItem)
{
// The generated playlist cannot express rerun collections or nested playlists
// (PlaylistItem + CollectionKey.ForPlaylistItem lack those fields).
for (int i = 0; i < normalized.Count; i++)
{
if (normalized[i].CollectionType is CollectionType.RerunFirstRun or CollectionType.Playlist)
{
return BaseError.New(
$"lineup[{normalized[i].Index}]: rerun collections and playlists are only " +
"supported as a single-item lineup");
}
}
}
Channel channel = BuildChannel(
request,
template,
advanced,
name,
number,
group,
categories,
ffmpegProfileId,
fallbackFillerId);
string scheduleName = await DeCollideName(
GeneratedName(number, name, "Schedule"),
(candidate, ct) => dbContext.ProgramSchedules.AnyAsync(ps => ps.Name == candidate, ct),
cancellationToken);
ProgramSchedule schedule = BuildProgramSchedule(scheduleName, template, advanced);
Playlist playlist = null;
ProgramScheduleItemFlood floodItem = BuildFloodBase(
playbackOrder,
advanced,
template,
fallbackFillerId,
preRollFillerId,
midRollFillerId,
postRollFillerId);
if (multiItem)
{
playlist = await BuildPlaylist(dbContext, number, name, normalized, playbackOrder, cancellationToken);
floodItem.CollectionType = CollectionType.Playlist;
floodItem.Playlist = playlist;
}
else
{
NormalizedLineupItem only = normalized[0];
floodItem.CollectionType = only.CollectionType;
floodItem.CollectionId = only.CollectionId;
floodItem.MultiCollectionId = only.MultiCollectionId;
floodItem.SmartCollectionId = only.SmartCollectionId;
floodItem.RerunCollectionId = only.RerunCollectionId;
floodItem.MediaItemId = only.MediaItemId;
floodItem.PlaylistId = only.PlaylistId;
}
schedule.Items = [floodItem];
var playout = new Playout
{
Channel = channel,
ProgramSchedule = schedule,
ScheduleKind = PlayoutScheduleKind.Classic
};
return new PreparedCreate(channel, playlist, schedule, playout);
}
private static async Task<Playlist> BuildPlaylist(
TvContext dbContext,
string channelNumber,
string channelName,
List<NormalizedLineupItem> lineup,
PlaybackOrder playbackOrder,
CancellationToken cancellationToken)
{
// Reuse the single system playlist group, creating it on first use.
PlaylistGroup playlistGroup = await dbContext.PlaylistGroups
.FirstOrDefaultAsync(pg => pg.IsSystem && pg.Name == SystemPlaylistGroupName, cancellationToken);
playlistGroup ??= new PlaylistGroup { Name = SystemPlaylistGroupName, IsSystem = true };
string playlistName = await DeCollideName(
GeneratedName(channelNumber, channelName, "Lineup"),
(candidate, ct) => dbContext.Playlists.AnyAsync(
p => p.PlaylistGroupId == playlistGroup.Id && p.Name == candidate,
ct),
cancellationToken);
var playlist = new Playlist
{
Name = playlistName,
IsSystem = true,
PlaylistGroup = playlistGroup,
PlaylistGroupId = playlistGroup.Id,
Items = []
};
int index = 1;
foreach (NormalizedLineupItem item in lineup)
{
playlist.Items.Add(new PlaylistItem
{
Playlist = playlist,
Index = index++,
CollectionType = item.CollectionType,
CollectionId = item.CollectionId,
MultiCollectionId = item.MultiCollectionId,
SmartCollectionId = item.SmartCollectionId,
MediaItemId = item.MediaItemId,
PlaybackOrder = playbackOrder,
// Play every item in each entry before advancing so lineup order is honored
// (PlaylistEnumerator round-robins one-per-entry unless PlayAll/Count).
PlayAll = true,
IncludeInProgramGuide = true
});
}
return playlist;
}
private static Channel BuildChannel(
CreateChannelFromLineup request,
ChannelTemplate template,
CreateChannelFromLineupAdvancedOptions advanced,
string name,
string number,
string group,
string categories,
int ffmpegProfileId,
int? fallbackFillerId)
{
var artwork = new List<Artwork>();
if (!string.IsNullOrWhiteSpace(request.Logo?.Path))
{
string logo = request.Logo.Path;
if (logo.StartsWith("iptv/logos/", StringComparison.Ordinal))
{
logo = logo.Replace("iptv/logos/", string.Empty);
}
artwork.Add(new Artwork
{
Path = logo,
ArtworkKind = ArtworkKind.Logo,
OriginalContentType = !string.IsNullOrEmpty(request.Logo.ContentType) ? request.Logo.ContentType : null,
DateAdded = DateTime.UtcNow,
DateUpdated = DateTime.UtcNow
});
}
return new Channel(Guid.NewGuid())
{
Name = name,
Number = number,
SortNumber = double.Parse(number, CultureInfo.InvariantCulture),
Group = group,
Categories = categories,
FFmpegProfileId = ffmpegProfileId,
SlugSeconds = null,
PlayoutSource = advanced.PlayoutSource ?? template.PlayoutSource,
PlayoutMode = advanced.PlayoutMode ?? template.PlayoutMode,
StreamingMode = advanced.StreamingMode ?? template.StreamingMode,
WatermarkId = advanced.WatermarkId ?? template.WatermarkId,
FallbackFillerId = fallbackFillerId,
Artwork = artwork,
StreamSelectorMode = advanced.StreamSelectorMode ?? template.StreamSelectorMode,
StreamSelector = advanced.StreamSelector ?? template.StreamSelector ?? string.Empty,
PreferredAudioLanguageCode =
advanced.PreferredAudioLanguageCode ?? template.PreferredAudioLanguageCode ?? string.Empty,
PreferredAudioTitle = advanced.PreferredAudioTitle ?? template.PreferredAudioTitle ?? string.Empty,
PreferredSubtitleLanguageCode =
advanced.PreferredSubtitleLanguageCode ?? template.PreferredSubtitleLanguageCode ?? string.Empty,
SubtitleMode = advanced.SubtitleMode ?? template.SubtitleMode,
MusicVideoCreditsMode = advanced.MusicVideoCreditsMode ?? template.MusicVideoCreditsMode,
MusicVideoCreditsTemplate =
advanced.MusicVideoCreditsTemplate ?? template.MusicVideoCreditsTemplate ?? string.Empty,
SongVideoMode = advanced.SongVideoMode ?? template.SongVideoMode,
TranscodeMode = advanced.TranscodeMode ?? template.TranscodeMode,
IdleBehavior = advanced.IdleBehavior ?? template.IdleBehavior,
IsEnabled = request.IsEnabled,
ShowInEpg = request.IsEnabled && request.ShowInEpg
};
}
private static ProgramSchedule BuildProgramSchedule(
string scheduleName,
ChannelTemplate template,
CreateChannelFromLineupAdvancedOptions advanced) =>
new()
{
Name = scheduleName,
KeepMultiPartEpisodesTogether = true,
TreatCollectionsAsShows = true,
ShuffleScheduleItems = advanced.ShuffleScheduleItems ?? template.ShuffleScheduleItems,
RandomStartPoint = advanced.RandomStartPoint ?? template.RandomStartPoint,
FixedStartTimeBehavior = advanced.FixedStartTimeBehavior ?? template.FixedStartTimeBehavior,
Items = []
};
private static ProgramScheduleItemFlood BuildFloodBase(
PlaybackOrder playbackOrder,
CreateChannelFromLineupAdvancedOptions advanced,
ChannelTemplate template,
int? fallbackFillerId,
int? preRollFillerId,
int? midRollFillerId,
int? postRollFillerId) =>
new()
{
Index = 1,
PlaybackOrder = playbackOrder,
GuideMode = GuideMode.Normal,
CustomTitle = string.Empty,
SearchTitle = string.Empty,
SearchQuery = string.Empty,
PreRollFillerId = preRollFillerId,
MidRollFillerId = midRollFillerId,
PostRollFillerId = postRollFillerId,
FallbackFillerId = fallbackFillerId,
PreferredAudioLanguageCode =
advanced.PreferredAudioLanguageCode ?? template.PreferredAudioLanguageCode ?? string.Empty,
PreferredAudioTitle = advanced.PreferredAudioTitle ?? template.PreferredAudioTitle ?? string.Empty,
PreferredSubtitleLanguageCode =
advanced.PreferredSubtitleLanguageCode ?? template.PreferredSubtitleLanguageCode ?? string.Empty,
SubtitleMode = advanced.SubtitleMode ?? template.SubtitleMode
};
private static string GeneratedName(string channelNumber, string channelName, string suffix)
{
string prefix = $"{channelNumber} {channelName}".Trim();
int maxPrefixLength = Math.Max(0, 50 - suffix.Length - 1);
if (prefix.Length > maxPrefixLength)
{
prefix = prefix[..maxPrefixLength].TrimEnd();
}
return $"{prefix} {suffix}".Trim();
}
// De-collide a generated (already <= 50 char) name against a unique index by appending " 2", " 3", ...
// rather than leaking a raw UNIQUE-constraint failure.
private static async Task<string> DeCollideName(
string baseName,
Func<string, CancellationToken, Task<bool>> exists,
CancellationToken cancellationToken)
{
if (!await exists(baseName, cancellationToken))
{
return baseName;
}
for (int n = 2; ; n++)
{
string suffix = $" {n}";
string candidate = baseName.Length + suffix.Length > 50
? baseName[..(50 - suffix.Length)].TrimEnd() + suffix
: baseName + suffix;
if (!await exists(candidate, cancellationToken))
{
return candidate;
}
}
}
private static async Task<Either<BaseError, Unit>> ValidateReferences(
TvContext dbContext,
CreateChannelFromLineupAdvancedOptions advanced,
ChannelTemplate template,
CancellationToken cancellationToken)
{
int ffmpegProfileId = advanced.FFmpegProfileId ?? template.FFmpegProfileId;
if (!await dbContext.FFmpegProfiles.AnyAsync(p => p.Id == ffmpegProfileId, cancellationToken))
{
return new NotFoundError($"FFmpegProfile {ffmpegProfileId} does not exist.");
}
Either<BaseError, Unit> channelReferences = await ValidateChannelReferences(
dbContext,
advanced.WatermarkId ?? template.WatermarkId,
advanced.FallbackFillerId ?? template.FallbackFillerId,
cancellationToken);
foreach (BaseError error in channelReferences.LeftToSeq())
{
return error;
}
Either<BaseError, Unit> itemFillers = await ValidateItemFillers(
dbContext,
advanced.PreRollFillerId ?? template.PreRollFillerId,
advanced.MidRollFillerId ?? template.MidRollFillerId,
advanced.PostRollFillerId ?? template.PostRollFillerId,
cancellationToken);
foreach (BaseError error in itemFillers.LeftToSeq())
{
return error;
}
return Unit.Default;
}
private static async Task<Either<BaseError, Unit>> ValidateChannelReferences(
TvContext dbContext,
int? watermarkId,
int? fallbackFillerId,
CancellationToken cancellationToken)
{
if (watermarkId.HasValue &&
!await dbContext.ChannelWatermarks.AnyAsync(w => w.Id == watermarkId.Value, cancellationToken))
{
return new NotFoundError($"Watermark {watermarkId.Value} does not exist.");
}
if (fallbackFillerId.HasValue && !await dbContext.FillerPresets.AnyAsync(
fp => fp.Id == fallbackFillerId.Value && fp.FillerKind == FillerKind.Fallback,
cancellationToken))
{
return new NotFoundError($"Fallback filler {fallbackFillerId.Value} does not exist.");
}
return Unit.Default;
}
private static async Task<Either<BaseError, Unit>> ValidateItemFillers(
TvContext dbContext,
int? preRollFillerId,
int? midRollFillerId,
int? postRollFillerId,
CancellationToken cancellationToken)
{
if (preRollFillerId.HasValue && !await FillerExists(dbContext, preRollFillerId.Value, FillerKind.PreRoll, cancellationToken))
{
return new NotFoundError($"Pre-roll filler {preRollFillerId.Value} does not exist.");
}
if (midRollFillerId.HasValue && !await FillerExists(dbContext, midRollFillerId.Value, FillerKind.MidRoll, cancellationToken))
{
return new NotFoundError($"Mid-roll filler {midRollFillerId.Value} does not exist.");
}
if (postRollFillerId.HasValue && !await FillerExists(dbContext, postRollFillerId.Value, FillerKind.PostRoll, cancellationToken))
{
return new NotFoundError($"Post-roll filler {postRollFillerId.Value} does not exist.");
}
return Unit.Default;
}
private static Task<bool> FillerExists(
TvContext dbContext,
int id,
FillerKind fillerKind,
CancellationToken cancellationToken) =>
dbContext.FillerPresets.AnyAsync(fp => fp.Id == id && fp.FillerKind == fillerKind, cancellationToken);
private static async Task<Either<BaseError, NormalizedLineupItem>> NormalizeLineupItem(
TvContext dbContext,
CreateChannelFromLineupItem item,
int index,
CancellationToken cancellationToken)
{
int providedIds = new int?[]
{
item.CollectionId,
item.MultiCollectionId,
item.SmartCollectionId,
item.RerunCollectionId,
item.MediaItemId,
item.PlaylistId
}.Count(id => id.HasValue);
if (providedIds != 1)
{
return BaseError.New($"lineup[{index}] must provide exactly one typed id.");
}
switch (item.MediaType)
{
case LibraryBrowseMediaType.Movie:
return await NormalizeMediaItem(
dbContext.Movies, item, CollectionType.Movie, index, "Movie", cancellationToken);
case LibraryBrowseMediaType.TelevisionShow:
return await NormalizeMediaItem(
dbContext.Shows, item, CollectionType.TelevisionShow, index, "TelevisionShow", cancellationToken);
case LibraryBrowseMediaType.TelevisionSeason:
return await NormalizeMediaItem(
dbContext.Seasons, item, CollectionType.TelevisionSeason, index, "TelevisionSeason", cancellationToken);
case LibraryBrowseMediaType.Artist:
return await NormalizeMediaItem(
dbContext.Artists, item, CollectionType.Artist, index, "Artist", cancellationToken);
case LibraryBrowseMediaType.Collection:
if (item.CollectionType != CollectionType.Collection)
{
return Mismatch(index, item);
}
if (!item.CollectionId.HasValue)
{
return WrongId(index, item.MediaType, "collectionId");
}
return await ExistsThen(
dbContext.Collections,
item.CollectionId.Value,
$"lineup[{index}] Collection",
new NormalizedLineupItem(index, CollectionType.Collection, CollectionId: item.CollectionId),
cancellationToken);
case LibraryBrowseMediaType.SmartCollection:
if (item.CollectionType != CollectionType.SmartCollection)
{
return Mismatch(index, item);
}
if (!item.SmartCollectionId.HasValue)
{
return WrongId(index, item.MediaType, "smartCollectionId");
}
return await ExistsThen(
dbContext.SmartCollections,
item.SmartCollectionId.Value,
$"lineup[{index}] SmartCollection",
new NormalizedLineupItem(index, CollectionType.SmartCollection, SmartCollectionId: item.SmartCollectionId),
cancellationToken);
case LibraryBrowseMediaType.MultiCollection:
if (item.CollectionType != CollectionType.MultiCollection)
{
return Mismatch(index, item);
}
if (!item.MultiCollectionId.HasValue)
{
return WrongId(index, item.MediaType, "multiCollectionId");
}
return await ExistsThen(
dbContext.MultiCollections,
item.MultiCollectionId.Value,
$"lineup[{index}] MultiCollection",
new NormalizedLineupItem(index, CollectionType.MultiCollection, MultiCollectionId: item.MultiCollectionId),
cancellationToken);
case LibraryBrowseMediaType.RerunCollection:
if (item.CollectionType != CollectionType.RerunFirstRun)
{
return Mismatch(index, item);
}
if (!item.RerunCollectionId.HasValue)
{
return WrongId(index, item.MediaType, "rerunCollectionId");
}
return await ExistsThen(
dbContext.RerunCollections,
item.RerunCollectionId.Value,
$"lineup[{index}] RerunCollection",
new NormalizedLineupItem(index, CollectionType.RerunFirstRun, RerunCollectionId: item.RerunCollectionId),
cancellationToken);
case LibraryBrowseMediaType.Playlist:
if (item.CollectionType != CollectionType.Playlist)
{
return Mismatch(index, item);
}
if (!item.PlaylistId.HasValue)
{
return WrongId(index, item.MediaType, "playlistId");
}
return await ExistsThen(
dbContext.Playlists,
item.PlaylistId.Value,
$"lineup[{index}] Playlist",
new NormalizedLineupItem(index, CollectionType.Playlist, PlaylistId: item.PlaylistId),
cancellationToken);
default:
return BaseError.New($"lineup[{index}] has an unsupported media type '{item.MediaType}'.");
}
}
private static async Task<Either<BaseError, NormalizedLineupItem>> NormalizeMediaItem<TEntity>(
DbSet<TEntity> set,
CreateChannelFromLineupItem item,
CollectionType expectedType,
int index,
string label,
CancellationToken cancellationToken)
where TEntity : class
{
if (item.CollectionType != expectedType)
{
return Mismatch(index, item);
}
if (!item.MediaItemId.HasValue)
{
return WrongId(index, item.MediaType, "mediaItemId");
}
return await ExistsThen(
set,
item.MediaItemId.Value,
$"lineup[{index}] {label}",
new NormalizedLineupItem(index, expectedType, MediaItemId: item.MediaItemId),
cancellationToken);
}
private static BaseError Mismatch(int index, CreateChannelFromLineupItem item) =>
BaseError.New(
$"lineup[{index}]: media type '{item.MediaType}' does not match collection type '{item.CollectionType}'.");
private static BaseError WrongId(int index, LibraryBrowseMediaType mediaType, string expectedField) =>
BaseError.New($"lineup[{index}]: media type '{mediaType}' requires a {expectedField}.");
private static async Task<Either<BaseError, NormalizedLineupItem>> ExistsThen<TEntity>(
DbSet<TEntity> set,
int id,
string label,
NormalizedLineupItem normalized,
CancellationToken cancellationToken)
where TEntity : class
{
bool exists = await set.AsNoTracking()
.AnyAsync(e => EF.Property<int>(e, "Id") == id, cancellationToken);
return exists
? normalized
: new NotFoundError($"{label} {id} does not exist.");
}
private sealed record NormalizedLineupItem(
int Index,
CollectionType CollectionType,
int? CollectionId = null,
int? MultiCollectionId = null,
int? SmartCollectionId = null,
int? RerunCollectionId = null,
int? MediaItemId = null,
int? PlaylistId = null);
private sealed record PreparedCreate(
Channel Channel,
Playlist Playlist,
ProgramSchedule ProgramSchedule,
Playout Playout);
}
@@ -129,89 +129,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
List<Playout> playouts = await dbContext.Playouts
.AsNoTracking()
.Filter(pi => pi.Channel.Number == (mirrorChannelNumber ?? request.ChannelNumber))
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Episode).EpisodeMetadata)
.ThenInclude(em => em.Guids)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Episode).EpisodeMetadata)
.ThenInclude(em => em.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Episode).Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Episode).Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Genres)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Episode).Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Guids)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Movie).MovieMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Movie).MovieMetadata)
.ThenInclude(mm => mm.Genres)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Movie).MovieMetadata)
.ThenInclude(mm => mm.Guids)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mvm => mvm.Genres)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mvm => mvm.Studios)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mvm => mvm.Directors)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mvm => mvm.Artists)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).Artist)
.ThenInclude(a => a.ArtistMetadata)
.ThenInclude(am => am.Genres)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as OtherVideo).OtherVideoMetadata)
.ThenInclude(vm => vm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as RemoteStream).RemoteStreamMetadata)
.ThenInclude(vm => vm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Song).SongMetadata)
.ThenInclude(vm => vm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Song).SongMetadata)
.ThenInclude(sm => sm.Genres)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Song).SongMetadata)
.ThenInclude(sm => sm.Studios)
.IncludeGuideMetadata()
.AsSplitQuery()
.ToListAsync(cancellationToken);
@@ -244,8 +162,9 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
item.Finish += playoutOffset;
}
await WritePlayoutXml(
await WriteScheduleXml(
request,
playout.ScheduleKind,
floodSorted,
templateContext,
movieTemplate,
@@ -270,8 +189,9 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
item.Finish += playoutOffset;
}
await WriteBlockPlayoutXml(
await WriteScheduleXml(
request,
playout.ScheduleKind,
blockSorted,
templateContext,
movieTemplate,
@@ -294,8 +214,9 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
item.Finish += playoutOffset;
}
await WritePlayoutXml(
await WriteScheduleXml(
request,
playout.ScheduleKind,
externalJsonSorted,
templateContext,
movieTemplate,
@@ -324,100 +245,9 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
}
}
private async Task WritePlayoutXml(
RefreshChannelData request,
List<PlayoutItem> sorted,
XmlTemplateContext templateContext,
Template movieTemplate,
Template episodeTemplate,
Template musicVideoTemplate,
Template songTemplate,
Template otherVideoTemplate,
Template remoteStreamTemplate,
XmlMinifier minifier,
XmlWriter xml,
CancellationToken cancellationToken)
{
XmltvTimeZone xmltvTimeZone = await _configElementRepository
.GetValue<XmltvTimeZone>(ConfigElementKey.XmltvTimeZone, cancellationToken)
.IfNoneAsync(XmltvTimeZone.Local);
// skip all filler that isn't pre-roll
var i = 0;
while (i < sorted.Count && sorted[i].FillerKind != FillerKind.None &&
sorted[i].FillerKind != FillerKind.PreRoll)
{
i++;
}
while (i < sorted.Count)
{
PlayoutItem startItem = sorted[i];
int j = i;
while (sorted[j].FillerKind != FillerKind.None && j + 1 < sorted.Count)
{
j++;
}
PlayoutItem displayItem = sorted[j];
bool hasCustomTitle = !string.IsNullOrWhiteSpace(startItem.CustomTitle);
int finishIndex = j;
while (finishIndex + 1 < sorted.Count && (sorted[finishIndex + 1].GuideGroup == startItem.GuideGroup
|| sorted[finishIndex + 1].FillerKind is FillerKind.GuideMode
or FillerKind.PostRoll or FillerKind.Tail
or FillerKind.Fallback or FillerKind.DecoDefault))
{
finishIndex++;
}
PlayoutItem finishItem = sorted[finishIndex];
i = finishIndex;
DateTimeOffset startTime = xmltvTimeZone switch
{
XmltvTimeZone.Utc => new DateTimeOffset(startItem.Start, TimeSpan.Zero),
_ => startItem.StartOffset
};
DateTimeOffset stopTime = (xmltvTimeZone, displayItem.GuideFinishOffset.HasValue) switch
{
(XmltvTimeZone.Utc, true) => new DateTimeOffset(displayItem.GuideFinish!.Value, TimeSpan.Zero),
(XmltvTimeZone.Utc, false) => new DateTimeOffset(finishItem.Finish, TimeSpan.Zero),
(_, true) => displayItem.GuideFinishOffset!.Value,
(_, false) => finishItem.FinishOffset
};
string start = startTime
.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
.Replace(":", string.Empty);
string stop = stopTime
.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
.Replace(":", string.Empty);
await WriteItemToXml(
request,
displayItem,
start,
stop,
hasCustomTitle,
templateContext,
movieTemplate,
episodeTemplate,
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
remoteStreamTemplate,
minifier,
xml);
i++;
}
}
private async Task WriteBlockPlayoutXml(
private async Task WriteScheduleXml(
RefreshChannelData request,
PlayoutScheduleKind scheduleKind,
List<PlayoutItem> sorted,
XmlTemplateContext templateContext,
Template movieTemplate,
@@ -438,98 +268,36 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
.GetValue<XmltvBlockBehavior>(ConfigElementKey.XmltvBlockBehavior, cancellationToken)
.IfNoneAsync(XmltvBlockBehavior.SplitTimeEvenly);
var groups = sorted.GroupBy(s => new { s.GuideStart, s.GuideFinish, s.GuideGroup });
foreach (var group in groups)
// guide-group / filler-merge logic is shared with the JSON guide query so the two cannot drift
foreach (ChannelGuideEntry entry in ChannelGuideProjector.Project(
scheduleKind,
sorted,
xmltvTimeZone,
xmltvBlockBehavior))
{
var itemsToInclude = group.Filter(g => g.FillerKind is FillerKind.None).ToList();
if (itemsToInclude.Count == 0)
{
continue;
}
string start = entry.Start
.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
.Replace(":", string.Empty);
switch (xmltvBlockBehavior)
{
case XmltvBlockBehavior.UseActualTimes:
foreach (PlayoutItem item in itemsToInclude)
{
DateTimeOffset actualStart = xmltvTimeZone switch
{
XmltvTimeZone.Utc => new DateTimeOffset(item.Start, TimeSpan.Zero),
_ => new DateTimeOffset(item.Start, TimeSpan.Zero).ToLocalTime()
};
string stop = entry.Stop
.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
.Replace(":", string.Empty);
DateTimeOffset actualFinish = xmltvTimeZone switch
{
XmltvTimeZone.Utc => new DateTimeOffset(item.Finish, TimeSpan.Zero),
_ => new DateTimeOffset(item.Finish, TimeSpan.Zero).ToLocalTime()
};
string start = actualStart.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
.Replace(":", string.Empty);
string stop = actualFinish.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
.Replace(":", string.Empty);
await WriteItemToXml(
request,
item,
start,
stop,
false,
templateContext,
movieTemplate,
episodeTemplate,
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
remoteStreamTemplate,
minifier,
xml);
}
break;
case XmltvBlockBehavior.SplitTimeEvenly:
default:
DateTime groupStart = group.Key.GuideStart!.Value;
DateTime groupFinish = group.Key.GuideFinish!.Value;
TimeSpan groupDuration = groupFinish - groupStart;
TimeSpan perItem = groupDuration / itemsToInclude.Count;
DateTimeOffset currentStart = xmltvTimeZone switch
{
XmltvTimeZone.Utc => new DateTimeOffset(groupStart, TimeSpan.Zero),
_ => new DateTimeOffset(groupStart, TimeSpan.Zero).ToLocalTime()
};
DateTimeOffset currentFinish = currentStart + perItem;
foreach (PlayoutItem item in itemsToInclude)
{
string start = currentStart.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
.Replace(":", string.Empty);
string stop = currentFinish.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
.Replace(":", string.Empty);
await WriteItemToXml(
request,
item,
start,
stop,
false,
templateContext,
movieTemplate,
episodeTemplate,
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
remoteStreamTemplate,
minifier,
xml);
currentStart = currentFinish;
currentFinish += perItem;
}
break;
}
await WriteItemToXml(
request,
entry.DisplayItem,
start,
stop,
entry.HasCustomTitle,
templateContext,
movieTemplate,
episodeTemplate,
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
remoteStreamTemplate,
minifier,
xml);
}
}
@@ -549,8 +317,8 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
XmlMinifier minifier,
XmlWriter xml)
{
string title = GetTitle(displayItem);
string subtitle = GetSubtitle(displayItem);
string title = ChannelGuideMetadata.GetTitle(displayItem);
string subtitle = ChannelGuideMetadata.GetSubtitle(displayItem);
Option<string> maybeTemplateOutput = displayItem.MediaItem switch
{
@@ -1117,51 +885,6 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
return artworkPath;
}
private static string GetTitle(PlayoutItem playoutItem)
{
if (!string.IsNullOrWhiteSpace(playoutItem.CustomTitle))
{
return playoutItem.CustomTitle;
}
return playoutItem.MediaItem switch
{
Movie m => m.MovieMetadata.HeadOrNone().Map(mm => mm.Title ?? string.Empty)
.IfNone("[unknown movie]"),
Episode e => e.Season.Show.ShowMetadata.HeadOrNone().Map(em => em.Title ?? string.Empty)
.IfNone("[unknown show]"),
MusicVideo mv => mv.Artist.ArtistMetadata.HeadOrNone().Map(am => am.Title ?? string.Empty)
.IfNone("[unknown artist]"),
OtherVideo ov => ov.OtherVideoMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty)
.IfNone("[unknown video]"),
RemoteStream rs => rs.RemoteStreamMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty)
.IfNone("[unknown remote stream]"),
_ => "[unknown]"
};
}
private static string GetSubtitle(PlayoutItem playoutItem)
{
if (!string.IsNullOrWhiteSpace(playoutItem.CustomTitle))
{
return string.Empty;
}
return playoutItem.MediaItem switch
{
Episode e => e.EpisodeMetadata.HeadOrNone().Match(
em => em.Title ?? string.Empty,
() => string.Empty),
MusicVideo mv => mv.MusicVideoMetadata.HeadOrNone().Match(
mvm => mvm.Title ?? string.Empty,
() => string.Empty),
Song s => s.SongMetadata.HeadOrNone().Match(
mvm => mvm.Title ?? string.Empty,
() => string.Empty),
_ => string.Empty
};
}
private static string GetPrioritizedArtworkPath(Metadata metadata)
{
Option<string> maybeArtwork = Optional(metadata.Artwork).Flatten()
@@ -0,0 +1,10 @@
using ErsatzTV.Core.Api.Channels;
namespace ErsatzTV.Application.Channels;
/// <summary>
/// JSON channel-guide query for the EPG grid. <paramref name="Start" /> defaults to now and
/// <paramref name="End" /> defaults to now + the configured XmltvDaysToBuild window.
/// </summary>
public record GetChannelGuideData(DateTimeOffset? Start, DateTimeOffset? End)
: IRequest<ChannelGuideResponseModel>;
@@ -0,0 +1,145 @@
using System.Globalization;
using ErsatzTV.Application.Configuration;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Channels;
/// <summary>
/// Builds the JSON channel guide directly from <see cref="Playout" /> items, using the shared
/// <see cref="ChannelGuideProjector" /> guide-group/filler-merge logic (the same logic the XMLTV
/// cache builder uses) so the two representations cannot drift. Only channels with
/// <see cref="Channel.ShowInEpg" /> are included, mirroring <c>GetChannelGuideHandler</c>.
/// Unlike XMLTV, filler programmes are returned (with their <see cref="Core.Domain.Filler.FillerKind" />)
/// so the UI can decide how to render them.
/// </summary>
public class GetChannelGuideDataHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository)
: IRequestHandler<GetChannelGuideData, ChannelGuideResponseModel>
{
public async Task<ChannelGuideResponseModel> Handle(
GetChannelGuideData request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
int daysToBuild = await configElementRepository
.GetValue<int>(ConfigElementKey.XmltvDaysToBuild, cancellationToken)
.IfNoneAsync(2);
XmltvTimeZone xmltvTimeZone = await configElementRepository
.GetValue<XmltvTimeZone>(ConfigElementKey.XmltvTimeZone, cancellationToken)
.IfNoneAsync(XmltvTimeZone.Local);
XmltvBlockBehavior xmltvBlockBehavior = await configElementRepository
.GetValue<XmltvBlockBehavior>(ConfigElementKey.XmltvBlockBehavior, cancellationToken)
.IfNoneAsync(XmltvBlockBehavior.SplitTimeEvenly);
DateTimeOffset start = request.Start ?? DateTimeOffset.UtcNow;
DateTimeOffset end = request.End ?? start.AddDays(daysToBuild);
// Visible channels only (mirror GetChannelGuideHandler's ShowInEpg == false skip).
List<Channel> channels = await dbContext.Channels
.AsNoTracking()
.Where(c => c.ShowInEpg)
.Include(c => c.MirrorSourceChannel)
.ToListAsync(cancellationToken);
// Order channels by their decimal channel number so "2" precedes "10", matching
// ChannelGuide.ToXml (which orders XMLTV channels by decimal.Parse of the number).
channels = channels
.OrderBy(c => decimal.Parse(c.Number, CultureInfo.InvariantCulture))
.ToList();
var responseChannels = new List<ChannelGuideChannelResponseModel>();
foreach (Channel channel in channels)
{
bool isMirror = channel.PlayoutSource == ChannelPlayoutSource.Mirror
&& channel.MirrorSourceChannel is not null;
string sourceChannelNumber = isMirror ? channel.MirrorSourceChannel.Number : channel.Number;
TimeSpan playoutOffset = isMirror ? channel.PlayoutOffset ?? TimeSpan.Zero : TimeSpan.Zero;
List<Playout> playouts = await dbContext.Playouts
.AsNoTracking()
.Filter(p => p.Channel.Number == sourceChannelNumber)
.IncludeGuideMetadata()
.AsSplitQuery()
.ToListAsync(cancellationToken);
var programmes = new List<ChannelGuideProgrammeResponseModel>();
foreach (Playout playout in playouts)
{
// ExternalJson playouts materialize items from a file rather than Playout.Items; they are
// out of scope for the JSON guide (see issue #102 notes).
if (playout.ScheduleKind is PlayoutScheduleKind.ExternalJson)
{
continue;
}
// Filter to the window (on the pre-offset time, mirroring the XMLTV builder) then apply the
// mirror playout offset without mutating the loaded (shared, AsNoTracking) entities.
List<PlayoutItem> sorted = playout.Items
.OrderBy(pi => pi.Start)
.Filter(pi => pi.StartOffset <= end)
.Select(pi => playoutOffset == TimeSpan.Zero ? pi : WithPlayoutOffset(pi, playoutOffset))
.ToList();
foreach (ChannelGuideEntry entry in ChannelGuideProjector.Project(
playout.ScheduleKind,
sorted,
xmltvTimeZone,
xmltvBlockBehavior))
{
// drop programmes that finish before the requested window starts
if (entry.Stop <= start)
{
continue;
}
string subtitle = ChannelGuideMetadata.GetSubtitle(entry.DisplayItem);
programmes.Add(
new ChannelGuideProgrammeResponseModel(
entry.Start,
entry.Stop,
ChannelGuideMetadata.GetTitle(entry.DisplayItem),
string.IsNullOrWhiteSpace(subtitle) ? null : subtitle,
ChannelGuideMetadata.GetCategory(entry.DisplayItem),
entry.DisplayItem.FillerKind));
}
}
responseChannels.Add(
new ChannelGuideChannelResponseModel(
channel.Number,
channel.Name,
programmes.OrderBy(p => p.Start).ToList()));
}
return new ChannelGuideResponseModel(start, end, responseChannels);
}
// Copy (don't mutate) the loaded PlayoutItem when shifting by the mirror playout offset. The loaded
// entities are AsNoTracking and shared; mutating them in place would corrupt the guide projection.
// Mirrors the XMLTV builder, which shifts only Start/Finish (not the Guide* window).
private static PlayoutItem WithPlayoutOffset(PlayoutItem item, TimeSpan offset) =>
new()
{
MediaItem = item.MediaItem,
Start = item.Start + offset,
Finish = item.Finish + offset,
GuideStart = item.GuideStart,
GuideFinish = item.GuideFinish,
GuideGroup = item.GuideGroup,
FillerKind = item.FillerKind,
CustomTitle = item.CustomTitle
};
}
+5 -1
View File
@@ -1,9 +1,13 @@
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Api.Filler;
using ErsatzTV.Core.Domain.Filler;
namespace ErsatzTV.Application.Filler;
internal static class Mapper
{
internal static FillerPresetResponseModel ProjectToResponseModel(FillerPreset fillerPreset) =>
new(fillerPreset.Id, fillerPreset.Name);
internal static FillerPresetViewModel ProjectToViewModel(FillerPreset fillerPreset) =>
new(
fillerPreset.Id,
@@ -0,0 +1,5 @@
using ErsatzTV.Core.Api.Filler;
namespace ErsatzTV.Application.Filler;
public record GetAllFillerPresetsForApi : IRequest<List<FillerPresetResponseModel>>;
@@ -0,0 +1,22 @@
using ErsatzTV.Core.Api.Filler;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.Filler.Mapper;
namespace ErsatzTV.Application.Filler;
public class GetAllFillerPresetsForApiHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetAllFillerPresetsForApi, List<FillerPresetResponseModel>>
{
public async Task<List<FillerPresetResponseModel>> Handle(
GetAllFillerPresetsForApi request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
List<FillerPreset> fillerPresets = await dbContext.FillerPresets
.AsNoTracking()
.ToListAsync(cancellationToken);
return fillerPresets.Map(ProjectToResponseModel).ToList();
}
}
@@ -0,0 +1,5 @@
using ErsatzTV.Core.Api.Graphics;
namespace ErsatzTV.Application.Graphics;
public record GetAllGraphicsElementsForApi : IRequest<List<GraphicsElementResponseModel>>;
@@ -0,0 +1,27 @@
using ErsatzTV.Core.Api.Graphics;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.Graphics.Mapper;
namespace ErsatzTV.Application.Graphics;
public class GetAllGraphicsElementsForApiHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetAllGraphicsElementsForApi, List<GraphicsElementResponseModel>>
{
public async Task<List<GraphicsElementResponseModel>> Handle(
GetAllGraphicsElementsForApi request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
List<GraphicsElement> graphicsElements = await dbContext.GraphicsElements
.AsNoTracking()
.ToListAsync(cancellationToken);
return graphicsElements
.Map(ProjectToViewModel)
.OrderBy(e => e.Name == e.FileName)
.ThenBy(e => e.Name)
.Select(vm => new GraphicsElementResponseModel(vm.Id, vm.Name))
.ToList();
}
}
+24
View File
@@ -0,0 +1,24 @@
using ErsatzTV.Core.Api.Health;
using ErsatzTV.Core.Health;
namespace ErsatzTV.Application.Health;
internal static class Mapper
{
internal static HealthCheckResponseModel ProjectToResponseModel(HealthCheckResult result) =>
new(
result.Title,
GetStatus(result.Status),
result.Message,
result.Link.MatchUnsafe(l => l.Link, () => null));
private static string GetStatus(HealthCheckStatus status) =>
status switch
{
HealthCheckStatus.Pass => "pass",
HealthCheckStatus.Fail => "fail",
HealthCheckStatus.Warning => "warn",
HealthCheckStatus.Info => "info",
_ => throw new ArgumentOutOfRangeException(nameof(status), status, null)
};
}
@@ -0,0 +1,5 @@
using ErsatzTV.Core.Api.Health;
namespace ErsatzTV.Application.Health;
public record GetAllHealthCheckResultsForApi : IRequest<List<HealthCheckResponseModel>>;
@@ -0,0 +1,32 @@
using ErsatzTV.Core.Api.Health;
using ErsatzTV.Core.Health;
using static ErsatzTV.Application.Health.Mapper;
namespace ErsatzTV.Application.Health;
public class GetAllHealthCheckResultsForApiHandler
: IRequestHandler<GetAllHealthCheckResultsForApi, List<HealthCheckResponseModel>>
{
private readonly IHealthCheckService _healthCheckService;
public GetAllHealthCheckResultsForApiHandler(IHealthCheckService healthCheckService) =>
_healthCheckService = healthCheckService;
public async Task<List<HealthCheckResponseModel>> Handle(
GetAllHealthCheckResultsForApi request,
CancellationToken cancellationToken)
{
try
{
List<HealthCheckResult> results = await _healthCheckService.PerformHealthChecks(cancellationToken);
return results
.Filter(r => r.Status != HealthCheckStatus.NotApplicable)
.Map(ProjectToResponseModel)
.ToList();
}
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
{
return [];
}
}
}
@@ -0,0 +1,5 @@
using ErsatzTV.Core.Api.Libraries;
namespace ErsatzTV.Application.Libraries;
public record GetLibraryScanStatus : IRequest<List<LibraryScanStatusResponseModel>>;
@@ -0,0 +1,20 @@
using ErsatzTV.Core.Api.Libraries;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Metadata;
namespace ErsatzTV.Application.Libraries;
public class GetLibraryScanStatusHandler(IScannerProxyService scannerProxyService)
: IRequestHandler<GetLibraryScanStatus, List<LibraryScanStatusResponseModel>>
{
public Task<List<LibraryScanStatusResponseModel>> Handle(
GetLibraryScanStatus request,
CancellationToken cancellationToken)
{
List<LibraryScanStatusResponseModel> result = scannerProxyService.GetActiveScans()
.Select(scan => new LibraryScanStatusResponseModel(scan.LibraryId, scan.Progress))
.ToList();
return Task.FromResult(result);
}
}
@@ -0,0 +1,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);
}
@@ -0,0 +1,5 @@
using ErsatzTV.Core.Api.MediaSources;
namespace ErsatzTV.Application.MediaSources;
public record GetAllMediaSourcesForApi : IRequest<List<MediaSourceResponseModel>>;
@@ -0,0 +1,148 @@
#nullable enable
using Dapper;
using ErsatzTV.Core.Api.MediaSources;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.MediaSources;
public class GetAllMediaSourcesForApiHandler(
IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetAllMediaSourcesForApi, List<MediaSourceResponseModel>>
{
public async Task<List<MediaSourceResponseModel>> Handle(
GetAllMediaSourcesForApi request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
List<MediaSource> mediaSources = await dbContext.MediaSources
.AsNoTracking()
.Include(s => s.Libraries)
.ThenInclude(l => l.Paths)
.ToListAsync(cancellationToken);
Dictionary<int, int> itemCountsByLibrary = await GetItemCountsByLibrary(dbContext, cancellationToken);
Dictionary<int, string> addressByMediaSourceId = await GetConnectionAddresses(dbContext, cancellationToken);
var result = new List<MediaSourceResponseModel>();
foreach (MediaSource mediaSource in mediaSources)
{
List<MediaSourceLibraryResponseModel> libraryModels = mediaSource.Libraries
.Filter(ShouldIncludeLibrary)
.OrderBy(l => l.MediaKind)
.ThenBy(l => l.Name)
.Map(l => new MediaSourceLibraryResponseModel(
l.Id,
l.Name,
l.MediaKind,
l.LastScan,
itemCountsByLibrary.TryGetValue(l.Id, out int count) ? count : 0))
.ToList();
string? address = addressByMediaSourceId.TryGetValue(mediaSource.Id, out string? a) ? a : null;
result.Add(
new MediaSourceResponseModel(
mediaSource.Id,
GetKind(mediaSource),
GetName(mediaSource),
address,
libraryModels));
}
return result
.OrderBy(s => s.Kind == "Local" ? 0 : 1)
.ThenBy(s => s.Kind)
.ThenBy(s => s.Name)
.ToList();
}
private static async Task<Dictionary<int, int>> GetItemCountsByLibrary(
TvContext dbContext,
CancellationToken cancellationToken)
{
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));
return counts.ToDictionary(c => (int)c.LibraryId, c => (int)c.Count);
}
private static async Task<Dictionary<int, string>> GetConnectionAddresses(
TvContext dbContext,
CancellationToken cancellationToken)
{
var addresses = new Dictionary<int, string>();
foreach (PlexMediaSource plex in await dbContext.PlexMediaSources
.AsNoTracking()
.Include(s => s.Connections)
.ToListAsync(cancellationToken))
{
foreach (PlexConnection connection in Optional(plex.Connections.SingleOrDefault(c => c.IsActive)))
{
addresses[plex.Id] = connection.Uri;
}
}
foreach (JellyfinMediaSource jellyfin in await dbContext.JellyfinMediaSources
.AsNoTracking()
.Include(s => s.Connections)
.ToListAsync(cancellationToken))
{
foreach (JellyfinConnection connection in jellyfin.Connections.HeadOrNone())
{
addresses[jellyfin.Id] = connection.Address;
}
}
foreach (EmbyMediaSource emby in await dbContext.EmbyMediaSources
.AsNoTracking()
.Include(s => s.Connections)
.ToListAsync(cancellationToken))
{
foreach (EmbyConnection connection in emby.Connections.HeadOrNone())
{
addresses[emby.Id] = connection.Address;
}
}
return addresses;
}
private static bool ShouldIncludeLibrary(Library library) =>
library switch
{
LocalLibrary => library.Paths.Count > 0,
PlexLibrary plex => plex.ShouldSyncItems,
JellyfinLibrary jellyfin => jellyfin.ShouldSyncItems,
EmbyLibrary emby => emby.ShouldSyncItems,
_ => false
};
private static string GetKind(MediaSource mediaSource) =>
mediaSource switch
{
PlexMediaSource => "Plex",
JellyfinMediaSource => "Jellyfin",
EmbyMediaSource => "Emby",
_ => "Local"
};
private static string GetName(MediaSource mediaSource) =>
mediaSource switch
{
PlexMediaSource plex => plex.ServerName,
JellyfinMediaSource jellyfin => jellyfin.ServerName,
EmbyMediaSource emby => emby.ServerName,
_ => "Local"
};
private sealed record LibraryItemCount(long LibraryId, long Count);
}
@@ -16,6 +16,7 @@ public class GetPlayoutByIdHandler(IDbContextFactory<TvContext> dbContextFactory
.AsNoTracking()
.Include(p => p.ProgramSchedule)
.Include(p => p.Channel)
.Include(p => p.BuildStatus)
.SelectOneAsync(p => p.Id, p => p.Id == request.PlayoutId, cancellationToken)
.MapT(p => new PlayoutNameViewModel(
p.Id,
@@ -44,6 +44,33 @@ public abstract record ProgramScheduleItemViewModel(
string PreferredSubtitleLanguageCode,
ChannelSubtitleMode? SubtitleMode)
{
/// <summary>
/// A rough estimate, in wall-clock time, of how long a single pass of this schedule item will play,
/// derived from the aggregated playout runtimes of the referenced content.
/// <para>
/// Semantics by <see cref="PlayoutMode" />:
/// <list type="bullet">
/// <item><b>One</b> — the average runtime of one item in the referenced collection.</item>
/// <item>
/// <b>Multiple</b> — the average item runtime multiplied by the configured count
/// (<see cref="MultipleMode.Count" />), or the whole collection runtime for
/// <see cref="MultipleMode.CollectionSize" />. An expression-based (non-integer)
/// count cannot be evaluated here and yields <c>null</c>.
/// </item>
/// <item><b>Flood</b> — always <c>null</c>: a flood item fills the remaining time and is unbounded.</item>
/// <item><b>Duration</b> — the explicit <c>playoutDuration</c> setting on the item.</item>
/// </list>
/// </para>
/// <para>
/// <c>null</c> whenever a bounded estimate cannot be produced — an unbounded mode (Flood),
/// a referenced collection with no items that have a known positive duration, a Multiple mode other
/// than Count/CollectionSize, or a collection type other than <see cref="CollectionType.Collection" />
/// (smart/multi/playlist/search/rerun/show/season/artist references are not aggregated in this pass).
/// Callers should treat a <c>null</c> as "unknown", never as zero.
/// </para>
/// </summary>
public TimeSpan? DurationEstimate { get; init; }
public string Name => CollectionType switch
{
CollectionType.Collection => Collection?.Name,
@@ -0,0 +1,18 @@
namespace ErsatzTV.Application.ProgramSchedules;
/// <summary>
/// The items of a schedule together with computed runtime estimates. Each item carries its own
/// <see cref="ProgramScheduleItemViewModel.DurationEstimate" /> (nullable — see that property for
/// the per-mode semantics), and <see cref="TotalDurationEstimate" /> is the sum of the items that
/// could be estimated.
/// </summary>
/// <param name="Items">The schedule items, each with a nullable <c>DurationEstimate</c>.</param>
/// <param name="TotalDurationEstimate">
/// The sum of every non-null per-item estimate, i.e. a rough runtime for a single pass through the
/// estimable items. <c>null</c> when no item in the schedule could be estimated (for example a
/// schedule made up entirely of Flood items, or of collection types that are not aggregated).
/// Because unbounded items contribute nothing, this is a lower bound, never an exact schedule length.
/// </param>
public record ProgramScheduleItemsWithDurationViewModel(
List<ProgramScheduleItemViewModel> Items,
TimeSpan? TotalDurationEstimate);
@@ -0,0 +1,4 @@
namespace ErsatzTV.Application.ProgramSchedules;
public record GetProgramScheduleItemsWithDurations(int Id)
: IRequest<ProgramScheduleItemsWithDurationViewModel>;
@@ -0,0 +1,70 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Repositories;
using static ErsatzTV.Application.ProgramSchedules.ScheduleItemDurationEstimator;
namespace ErsatzTV.Application.ProgramSchedules;
public class GetProgramScheduleItemsWithDurationsHandler(
IMediator mediator,
IMediaCollectionRepository mediaCollectionRepository)
: IRequestHandler<GetProgramScheduleItemsWithDurations, ProgramScheduleItemsWithDurationViewModel>
{
public async Task<ProgramScheduleItemsWithDurationViewModel> Handle(
GetProgramScheduleItemsWithDurations request,
CancellationToken cancellationToken)
{
List<ProgramScheduleItemViewModel> items =
await mediator.Send(new GetProgramScheduleItems(request.Id), cancellationToken);
Dictionary<int, CollectionDuration> durationsByCollectionId =
await AggregateReferencedCollections(items);
var itemsWithEstimates = items
.Map(item => item with { DurationEstimate = Estimate(item, durationsByCollectionId) })
.ToList();
List<TimeSpan> estimates = itemsWithEstimates
.Map(item => Optional(item.DurationEstimate))
.Somes()
.ToList();
TimeSpan? total = estimates.Count > 0
? TimeSpan.FromTicks(estimates.Sum(estimate => estimate.Ticks))
: null;
return new ProgramScheduleItemsWithDurationViewModel(itemsWithEstimates, total);
}
// Aggregate MediaVersion.Duration once per distinct referenced collection (not per item). Only plain
// collections are resolved here; other reference types are estimated as null (see DurationEstimate docs).
private async Task<Dictionary<int, CollectionDuration>> AggregateReferencedCollections(
IReadOnlyList<ProgramScheduleItemViewModel> items)
{
List<int> collectionIds = items
.Filter(item => item.CollectionType is CollectionType.Collection && item.Collection is not null)
.Filter(item => item.PlayoutMode is PlayoutMode.One or PlayoutMode.Multiple)
.Map(item => item.Collection.Id)
.Distinct()
.ToList();
var result = new Dictionary<int, CollectionDuration>();
foreach (int collectionId in collectionIds)
{
List<MediaItem> mediaItems = await mediaCollectionRepository.GetItems(collectionId);
List<TimeSpan> durations = mediaItems
.Map(mediaItem => mediaItem.GetDurationForPlayout())
.Filter(duration => duration > TimeSpan.Zero)
.ToList();
if (durations.Count > 0)
{
var total = TimeSpan.FromTicks(durations.Sum(duration => duration.Ticks));
result[collectionId] = new CollectionDuration(total, durations.Count);
}
}
return result;
}
}
@@ -0,0 +1,73 @@
using System.Globalization;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.ProgramSchedules;
/// <summary>
/// Pure computation of per-item runtime estimates from pre-aggregated collection durations.
/// Kept separate from the query handler so the (non-trivial) per-mode math can be unit-tested
/// without a database. See <see cref="ProgramScheduleItemViewModel.DurationEstimate" /> for the
/// documented semantics this implements.
/// </summary>
internal static class ScheduleItemDurationEstimator
{
/// <summary>
/// Aggregate runtime of a single referenced collection: the total runtime of every item with a
/// known non-zero duration, and how many such items there are.
/// </summary>
public sealed record CollectionDuration(TimeSpan Total, int ItemCount)
{
public TimeSpan? Average => ItemCount > 0 ? Total / ItemCount : null;
}
/// <summary>
/// Estimate the runtime of one pass of <paramref name="item" />, or <c>null</c> when no bounded
/// estimate is possible. <paramref name="durationsByCollectionId" /> holds aggregates only for the
/// plain collections that were resolved; a missing entry yields <c>null</c>.
/// </summary>
public static TimeSpan? Estimate(
ProgramScheduleItemViewModel item,
IReadOnlyDictionary<int, CollectionDuration> durationsByCollectionId)
{
if (item is ProgramScheduleItemDurationViewModel durationItem)
{
return durationItem.PlayoutDuration;
}
// only plain collections are aggregated in this pass
if (item.CollectionType is not CollectionType.Collection || item.Collection is null)
{
return null;
}
if (!durationsByCollectionId.TryGetValue(item.Collection.Id, out CollectionDuration duration))
{
return null;
}
return item switch
{
// one item per pass -> the average item runtime
ProgramScheduleItemOneViewModel => duration.Average,
// a fixed count of items, or the whole collection once
ProgramScheduleItemMultipleViewModel multiple => EstimateMultiple(multiple, duration),
// Flood is an unbounded fill; Duration is handled above from its explicit playoutDuration.
_ => null
};
}
private static TimeSpan? EstimateMultiple(
ProgramScheduleItemMultipleViewModel multiple,
CollectionDuration duration) =>
multiple.MultipleMode switch
{
MultipleMode.Count when
int.TryParse(multiple.Count, NumberStyles.Integer, CultureInfo.InvariantCulture, out int count)
&& count > 0
&& duration.Average is { } average => average * count,
MultipleMode.CollectionSize => duration.Total,
_ => null
};
}
@@ -1,10 +1,14 @@
using ErsatzTV.Application.Artworks;
using ErsatzTV.Core.Api.Watermarks;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Watermarks;
internal static class Mapper
{
internal static WatermarkResponseModel ProjectToResponseModel(ChannelWatermark watermark) =>
new(watermark.Id, watermark.Name);
public static WatermarkViewModel ProjectToViewModel(ChannelWatermark watermark) =>
new(
watermark.Id,
@@ -0,0 +1,5 @@
using ErsatzTV.Core.Api.Watermarks;
namespace ErsatzTV.Application.Watermarks;
public record GetAllWatermarksForApi : IRequest<List<WatermarkResponseModel>>;
@@ -0,0 +1,22 @@
using ErsatzTV.Core.Api.Watermarks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.Watermarks.Mapper;
namespace ErsatzTV.Application.Watermarks;
public class GetAllWatermarksForApiHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetAllWatermarksForApi, List<WatermarkResponseModel>>
{
public async Task<List<WatermarkResponseModel>> Handle(
GetAllWatermarksForApi request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
List<ChannelWatermark> watermarks = await dbContext.ChannelWatermarks
.AsNoTracking()
.ToListAsync(cancellationToken);
return watermarks.Map(ProjectToResponseModel).ToList();
}
}
@@ -0,0 +1,98 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Streaming;
using ErsatzTV.Core.Streaming;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Core.Tests.Streaming;
[TestFixture]
public class DirectStreamSessionTrackerTests
{
[Test]
public void Should_Track_Concurrent_Viewers_Per_Channel()
{
var tracker = new DirectStreamSessionTracker();
using IDisposable session1 = tracker.Register("1", StreamingMode.TransportStream);
using IDisposable session2 = tracker.Register("1", StreamingMode.HttpLiveStreamingDirect);
using IDisposable session3 = tracker.Register("2", StreamingMode.TransportStream);
tracker.IsActive("1").ShouldBeTrue();
tracker.GetViewerCount("1").ShouldBe(2);
tracker.GetViewerCount("2").ShouldBe(1);
tracker.GetActiveSessions().Count.ShouldBe(3);
}
[Test]
public void Should_Remove_Only_Disposed_Session()
{
var tracker = new DirectStreamSessionTracker();
IDisposable session1 = tracker.Register("1", StreamingMode.TransportStream);
IDisposable session2 = tracker.Register("1", StreamingMode.TransportStream);
session1.Dispose();
tracker.IsActive("1").ShouldBeTrue();
tracker.GetViewerCount("1").ShouldBe(1);
session2.Dispose();
tracker.IsActive("1").ShouldBeFalse();
tracker.GetViewerCount("1").ShouldBe(0);
}
[Test]
public void Should_Dispose_Registration_Only_Once()
{
var tracker = new DirectStreamSessionTracker();
IDisposable session = tracker.Register("1", StreamingMode.TransportStream);
session.Dispose();
session.Dispose();
tracker.IsActive("1").ShouldBeFalse();
tracker.GetViewerCount("1").ShouldBe(0);
}
[Test]
public void Should_Filter_Active_Sessions_By_Channel()
{
var tracker = new DirectStreamSessionTracker();
using IDisposable session1 = tracker.Register("1", StreamingMode.TransportStream);
using IDisposable session2 = tracker.Register("2", StreamingMode.HttpLiveStreamingDirect);
IReadOnlyCollection<DirectStreamSession> sessions = tracker.GetActiveSessions("2");
sessions.Count.ShouldBe(1);
sessions.Single().ChannelNumber.ShouldBe("2");
sessions.Single().StreamingMode.ShouldBe(StreamingMode.HttpLiveStreamingDirect);
}
[Test]
public void Should_Not_Orphan_Session_When_Last_Registration_Is_Removed_During_Register()
{
DirectStreamSessionTracker tracker = null;
IDisposable existingSession = null;
tracker = new TestDirectStreamSessionTracker(() => existingSession?.Dispose());
existingSession = tracker.Register("1", StreamingMode.TransportStream);
using IDisposable newSession = tracker.Register("1", StreamingMode.HttpLiveStreamingDirect);
tracker.IsActive("1").ShouldBeTrue();
tracker.GetViewerCount("1").ShouldBe(1);
IReadOnlyCollection<DirectStreamSession> sessions = tracker.GetActiveSessions("1");
sessions.Count.ShouldBe(1);
sessions.Single().StreamingMode.ShouldBe(StreamingMode.HttpLiveStreamingDirect);
}
private sealed class TestDirectStreamSessionTracker(Action onRegisteringSession) : DirectStreamSessionTracker
{
protected override void OnRegisteringSession() => onRegisteringSession();
}
}
@@ -0,0 +1,10 @@
#nullable enable
namespace ErsatzTV.Core.Api.Artwork;
/// <summary>
/// Result of uploading channel logo / watermark artwork via the REST API.
/// <see cref="Path" /> is directly consumable as the <c>Path</c> of an
/// <c>ArtworkContentTypeModel</c> (e.g. <c>CreateChannel.Logo</c> / channel update),
/// and <see cref="ContentType" /> carries the stored MIME type.
/// </summary>
public record ArtworkUploadResponseModel(string Path, string ContentType);
@@ -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,25 @@
#nullable enable
using ErsatzTV.Core.Domain.Filler;
namespace ErsatzTV.Core.Api.Channels;
/// <summary>A single guide programme for the JSON EPG grid.</summary>
public record ChannelGuideProgrammeResponseModel(
DateTimeOffset Start,
DateTimeOffset Stop,
string Title,
string? SubTitle,
string? Category,
FillerKind FillerKind);
/// <summary>One channel's guide programmes for the requested window.</summary>
public record ChannelGuideChannelResponseModel(
string Number,
string Name,
List<ChannelGuideProgrammeResponseModel> Programmes);
/// <summary>The JSON channel-guide response: the resolved window plus per-channel programme arrays.</summary>
public record ChannelGuideResponseModel(
DateTimeOffset Start,
DateTimeOffset End,
List<ChannelGuideChannelResponseModel> Channels);
@@ -1,4 +1,5 @@
using Newtonsoft.Json;
#nullable enable
using Newtonsoft.Json;
namespace ErsatzTV.Core.Api.Channels;
@@ -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,3 @@
namespace ErsatzTV.Core.Api.Filler;
public record FillerPresetResponseModel(int Id, string Name);
@@ -0,0 +1,3 @@
namespace ErsatzTV.Core.Api.Graphics;
public record GraphicsElementResponseModel(int Id, string Name);
@@ -0,0 +1,8 @@
#nullable enable
namespace ErsatzTV.Core.Api.Health;
public record HealthCheckResponseModel(
string Title,
string Status,
string Detail,
string? Link);
@@ -0,0 +1,3 @@
namespace ErsatzTV.Core.Api.Libraries;
public record LibraryScanStatusResponseModel(int LibraryId, decimal Percent);
@@ -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,11 @@
#nullable enable
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Api.MediaSources;
public record MediaSourceLibraryResponseModel(
int Id,
string Name,
LibraryMediaKind MediaKind,
DateTime? LastScan,
int ItemCount);
@@ -0,0 +1,9 @@
#nullable enable
namespace ErsatzTV.Core.Api.MediaSources;
public record MediaSourceResponseModel(
int Id,
string Kind,
string Name,
string? ConnectionAddress,
List<MediaSourceLibraryResponseModel> Libraries);
@@ -0,0 +1,5 @@
namespace ErsatzTV.Core.Api.Playouts;
public record PagedPlayoutItemsResponseModel(
int TotalCount,
List<PlayoutItemResponseModel> Page);
@@ -0,0 +1,5 @@
namespace ErsatzTV.Core.Api.Playouts;
public record PagedPlayoutsResponseModel(
int TotalCount,
List<PlayoutListItemResponseModel> Page);
@@ -0,0 +1,6 @@
namespace ErsatzTV.Core.Api.Playouts;
public record PlayoutBuildStatusResponseModel(
DateTimeOffset LastBuild,
bool Success,
string Message);
@@ -0,0 +1,10 @@
using ErsatzTV.Core.Domain.Filler;
namespace ErsatzTV.Core.Api.Playouts;
public record PlayoutItemResponseModel(
string Title,
DateTimeOffset Start,
DateTimeOffset Finish,
string Duration,
FillerKind? FillerKind);
@@ -0,0 +1,13 @@
#nullable enable
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Api.Playouts;
public record PlayoutListItemResponseModel(
int Id,
string ChannelNumber,
string ChannelName,
PlayoutScheduleKind ScheduleKind,
string ScheduleName,
TimeSpan? DailyRebuildTime,
PlayoutBuildStatusResponseModel? BuildStatus);
@@ -1,3 +1,4 @@
#nullable enable
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Api.Playouts;
@@ -9,8 +10,9 @@ public record PlayoutResponseModel(
string ChannelNumber,
ChannelPlayoutMode PlayoutMode,
string ScheduleName,
string ScheduleFile,
TimeSpan? DailyRebuildTime)
string? ScheduleFile,
TimeSpan? DailyRebuildTime,
PlayoutBuildStatusResponseModel? BuildStatus)
{
public static PlayoutResponseModel From(
int id,
@@ -19,8 +21,9 @@ public record PlayoutResponseModel(
string channelNumber,
ChannelPlayoutMode playoutMode,
string scheduleName,
string scheduleFile,
TimeSpan? dailyRebuildTime) =>
string? scheduleFile,
TimeSpan? dailyRebuildTime,
PlayoutBuildStatusResponseModel? buildStatus) =>
new(
id,
scheduleKind,
@@ -29,5 +32,6 @@ public record PlayoutResponseModel(
playoutMode,
scheduleName,
scheduleFile,
dailyRebuildTime);
dailyRebuildTime,
buildStatus);
}
@@ -0,0 +1,3 @@
namespace ErsatzTV.Core.Api.Watermarks;
public record WatermarkResponseModel(int Id, string Name);
+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");
@@ -1,3 +1,5 @@
using ErsatzTV.Core.Metadata;
namespace ErsatzTV.Core.Interfaces.Metadata;
public interface IScannerProxyService
@@ -7,4 +9,5 @@ public interface IScannerProxyService
Task Progress(Guid scanId, decimal percentComplete);
bool IsActive(Guid scanId);
Option<decimal> GetProgress(int libraryId);
IReadOnlyList<LibraryScanProgress> GetActiveScans();
}
@@ -0,0 +1,13 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Streaming;
namespace ErsatzTV.Core.Interfaces.Streaming;
public interface IDirectStreamSessionTracker
{
IDisposable Register(string channelNumber, StreamingMode streamingMode);
bool IsActive(string channelNumber);
int GetViewerCount(string channelNumber);
IReadOnlyCollection<DirectStreamSession> GetActiveSessions();
IReadOnlyCollection<DirectStreamSession> GetActiveSessions(string channelNumber);
}
@@ -49,4 +49,7 @@ public class ScannerProxyService(IMediator mediator) : IScannerProxyService
public Option<decimal> GetProgress(int libraryId) => _activeLibraries.TryGetValue(libraryId, out decimal progress)
? progress
: Option<decimal>.None;
public IReadOnlyList<LibraryScanProgress> GetActiveScans() =>
_activeLibraries.Select(kvp => new LibraryScanProgress(kvp.Key, kvp.Value)).ToList();
}
@@ -0,0 +1,9 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Streaming;
public record DirectStreamSession(
Guid Id,
string ChannelNumber,
StreamingMode StreamingMode,
DateTimeOffset StartedAt);
@@ -0,0 +1,66 @@
using System.Collections.Concurrent;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Streaming;
namespace ErsatzTV.Core.Streaming;
public class DirectStreamSessionTracker : IDirectStreamSessionTracker
{
private readonly ConcurrentDictionary<string, ConcurrentDictionary<Guid, DirectStreamSession>> _sessions = new();
public IDisposable Register(string channelNumber, StreamingMode streamingMode)
{
var session = new DirectStreamSession(Guid.NewGuid(), channelNumber, streamingMode, DateTimeOffset.Now);
ConcurrentDictionary<Guid, DirectStreamSession> channelSessions =
_sessions.GetOrAdd(channelNumber, _ => new ConcurrentDictionary<Guid, DirectStreamSession>());
OnRegisteringSession();
channelSessions.TryAdd(session.Id, session);
return new Registration(this, session);
}
protected virtual void OnRegisteringSession()
{
}
public bool IsActive(string channelNumber) => GetViewerCount(channelNumber) > 0;
public int GetViewerCount(string channelNumber) =>
_sessions.TryGetValue(channelNumber, out ConcurrentDictionary<Guid, DirectStreamSession> channelSessions)
? channelSessions.Count
: 0;
public IReadOnlyCollection<DirectStreamSession> GetActiveSessions() =>
_sessions.Values.SelectMany(s => s.Values).ToList();
public IReadOnlyCollection<DirectStreamSession> GetActiveSessions(string channelNumber) =>
_sessions.TryGetValue(channelNumber, out ConcurrentDictionary<Guid, DirectStreamSession> channelSessions)
? channelSessions.Values.ToList()
: [];
private void Remove(DirectStreamSession session)
{
if (!_sessions.TryGetValue(session.ChannelNumber, out ConcurrentDictionary<Guid, DirectStreamSession> channelSessions))
{
return;
}
channelSessions.TryRemove(session.Id, out _);
}
private sealed class Registration(DirectStreamSessionTracker tracker, DirectStreamSession session) : IDisposable
{
private int _disposed;
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) == 0)
{
tracker.Remove(session);
}
}
}
}
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,98 @@
using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Infrastructure.Extensions;
public static class PlayoutGuideQueryableExtensions
{
/// <summary>
/// Eager-loads the full playout-item metadata graph needed to render guide programme
/// titles/subtitles/categories/artwork. Shared by the XMLTV cache builder and the JSON guide
/// query so both surfaces see identical data. Callers should apply <c>AsSplitQuery()</c>.
/// </summary>
public static IQueryable<Playout> IncludeGuideMetadata(this IQueryable<Playout> playouts) =>
playouts
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Episode).EpisodeMetadata)
.ThenInclude(em => em.Guids)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Episode).EpisodeMetadata)
.ThenInclude(em => em.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Episode).Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Episode).Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Genres)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Episode).Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Guids)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Movie).MovieMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Movie).MovieMetadata)
.ThenInclude(mm => mm.Genres)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Movie).MovieMetadata)
.ThenInclude(mm => mm.Guids)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mvm => mvm.Genres)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mvm => mvm.Studios)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mvm => mvm.Directors)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mvm => mvm.Artists)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).Artist)
.ThenInclude(a => a.ArtistMetadata)
.ThenInclude(am => am.Genres)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as OtherVideo).OtherVideoMetadata)
.ThenInclude(vm => vm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as RemoteStream).RemoteStreamMetadata)
.ThenInclude(vm => vm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Song).SongMetadata)
.ThenInclude(vm => vm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Song).SongMetadata)
.ThenInclude(sm => sm.Genres)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Song).SongMetadata)
.ThenInclude(sm => sm.Studios);
}
@@ -0,0 +1,87 @@
using ErsatzTV.Application.Artworks;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Artwork;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Images;
using LanguageExt;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using static LanguageExt.Prelude;
namespace ErsatzTV.Tests.Application.Artworks;
[TestFixture]
public class UploadArtworkHandlerTests
{
private IImageCache _imageCache = null!;
private UploadArtworkHandler _handler = null!;
[SetUp]
public void SetUp()
{
_imageCache = Substitute.For<IImageCache>();
_handler = new UploadArtworkHandler(_imageCache);
}
[Test]
public async Task Handle_Should_Return_Logo_Path_With_Iptv_Logos_Prefix()
{
_imageCache.SaveArtworkToCache(Arg.Any<Stream>(), ArtworkKind.Logo)
.Returns(Right<BaseError, string>("abc123.png"));
using var stream = new MemoryStream();
Either<BaseError, ArtworkUploadResponseModel> result =
await _handler.Handle(new UploadArtwork(stream, "image/png", ArtworkKind.Logo), CancellationToken.None);
ArtworkUploadResponseModel response = RightOf(result);
response.Path.ShouldBe("iptv/logos/abc123.png");
response.ContentType.ShouldBe("image/png");
}
[Test]
public async Task Handle_Should_Return_Bare_File_Name_For_Watermark()
{
_imageCache.SaveArtworkToCache(Arg.Any<Stream>(), ArtworkKind.Watermark)
.Returns(Right<BaseError, string>("def456.webp"));
using var stream = new MemoryStream();
Either<BaseError, ArtworkUploadResponseModel> result = await _handler.Handle(
new UploadArtwork(stream, "image/webp", ArtworkKind.Watermark),
CancellationToken.None);
RightOf(result).Path.ShouldBe("def456.webp");
}
[Test]
public async Task Handle_Should_Reject_Unsupported_Content_Type()
{
using var stream = new MemoryStream();
Either<BaseError, ArtworkUploadResponseModel> result = await _handler.Handle(
new UploadArtwork(stream, "image/bmp", ArtworkKind.Logo),
CancellationToken.None);
LeftOf(result).Value.ShouldContain("Unsupported image content type");
await _imageCache.DidNotReceive().SaveArtworkToCache(Arg.Any<Stream>(), Arg.Any<ArtworkKind>());
}
[Test]
public async Task Handle_Should_Propagate_Cache_Save_Failure()
{
_imageCache.SaveArtworkToCache(Arg.Any<Stream>(), ArtworkKind.Logo)
.Returns(Left<BaseError, string>(BaseError.New("disk full")));
using var stream = new MemoryStream();
Either<BaseError, ArtworkUploadResponseModel> result = await _handler.Handle(
new UploadArtwork(stream, "image/png", ArtworkKind.Logo),
CancellationToken.None);
LeftOf(result).Value.ShouldBe("disk full");
}
private static TR RightOf<TR>(Either<BaseError, TR> either) =>
either.Match(Right: v => v, Left: e => throw new AssertionException($"Expected Right, got Left: {e.Value}"));
private static BaseError LeftOf<TR>(Either<BaseError, TR> either) =>
either.Match(Right: _ => throw new AssertionException("Expected Left, got Right"), Left: e => e);
}
@@ -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,316 @@
using ErsatzTV.Application.Channels;
using ErsatzTV.Application.Configuration;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using DomainChannel = ErsatzTV.Core.Domain.Channel;
namespace ErsatzTV.Tests.Application.Channels;
[TestFixture]
public class GetChannelGuideDataHandlerTests
{
private static readonly DateTime BaseTime = new(2026, 1, 1, 8, 0, 0, DateTimeKind.Utc);
private InMemoryTvContext _db = null!;
private IConfigElementRepository _config = null!;
[SetUp]
public async Task SetUp()
{
_db = await InMemoryTvContext.CreateAsync();
_config = Substitute.For<IConfigElementRepository>();
// ConfigElementKey has reference equality and each static accessor returns a fresh instance, so we
// match by the generic value type (GetValue<T>) with Arg.Any key. Pin the time zone to UTC so
// projected times are deterministic regardless of the machine/CI time zone, and split block time
// evenly. XmltvDaysToBuild is left unconfigured (falls back to 2) except in the default-window test.
_config.GetValue<XmltvTimeZone>(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>())
.Returns(Option<XmltvTimeZone>.Some(XmltvTimeZone.Utc));
_config.GetValue<XmltvBlockBehavior>(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>())
.Returns(Option<XmltvBlockBehavior>.Some(XmltvBlockBehavior.SplitTimeEvenly));
}
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
private GetChannelGuideDataHandler MakeHandler() => new(_db.Factory, _config);
[Test]
public async Task Handle_Should_Only_Include_Visible_Channels()
{
await using (TvContext context = _db.CreateContext())
{
DomainChannel visible = NewChannel("2", "Visible", showInEpg: true);
DomainChannel hidden = NewChannel("3", "Hidden", showInEpg: false);
visible.Playouts = [MakeFloodPlayout(visible, (BaseTime, BaseTime.AddHours(1), 1, "Visible Show"))];
hidden.Playouts = [MakeFloodPlayout(hidden, (BaseTime, BaseTime.AddHours(1), 1, "Hidden Show"))];
context.Channels.AddRange(visible, hidden);
await context.SaveChangesAsync();
}
ChannelGuideResponseModel result = await MakeHandler().Handle(
new GetChannelGuideData(BaseTime, BaseTime.AddDays(1)),
CancellationToken.None);
result.Channels.Select(c => c.Number).ShouldBe(["2"]);
}
[Test]
public async Task Handle_Should_Order_Channels_By_Decimal_Number()
{
await using (TvContext context = _db.CreateContext())
{
context.Channels.Add(NewChannel("10", "Ten", showInEpg: true));
context.Channels.Add(NewChannel("2", "Two", showInEpg: true));
context.Channels.Add(NewChannel("5.1", "FiveOne", showInEpg: true));
await context.SaveChangesAsync();
}
ChannelGuideResponseModel result = await MakeHandler().Handle(
new GetChannelGuideData(BaseTime, BaseTime.AddDays(1)),
CancellationToken.None);
// decimal order: 2 < 5.1 < 10 (string order would put "10" first)
result.Channels.Select(c => c.Number).ShouldBe(["2", "5.1", "10"]);
}
[Test]
public async Task Handle_Should_Filter_Programmes_To_Requested_Window()
{
await using (TvContext context = _db.CreateContext())
{
DomainChannel channel = NewChannel("2", "Two", showInEpg: true);
channel.Playouts =
[
MakeFloodPlayout(
channel,
(BaseTime, BaseTime.AddHours(1), 1, "Before Window"),
(BaseTime.AddHours(10), BaseTime.AddHours(11), 2, "In Window"))
];
context.Channels.Add(channel);
await context.SaveChangesAsync();
}
// window starts after the first programme has finished
ChannelGuideResponseModel result = await MakeHandler().Handle(
new GetChannelGuideData(BaseTime.AddHours(5), BaseTime.AddHours(20)),
CancellationToken.None);
List<ChannelGuideProgrammeResponseModel> programmes = result.Channels.Single().Programmes;
programmes.Select(p => p.Title).ShouldBe(["In Window"]);
}
[Test]
public async Task Handle_Should_Passthrough_Custom_Title_And_Null_SubTitle()
{
await using (TvContext context = _db.CreateContext())
{
DomainChannel channel = NewChannel("2", "Two", showInEpg: true);
PlayoutItem item = MakeItem(BaseTime, BaseTime.AddHours(1), guideGroup: 1, movieTitle: "Ignored");
item.CustomTitle = "Custom Title";
channel.Playouts = [MakePlayout(channel, PlayoutScheduleKind.Classic, [item])];
context.Channels.Add(channel);
await context.SaveChangesAsync();
}
ChannelGuideResponseModel result = await MakeHandler().Handle(
new GetChannelGuideData(BaseTime, BaseTime.AddDays(1)),
CancellationToken.None);
ChannelGuideProgrammeResponseModel programme = result.Channels.Single().Programmes.Single();
programme.Title.ShouldBe("Custom Title");
programme.SubTitle.ShouldBeNull();
programme.Category.ShouldBe("Movie");
}
[Test]
public async Task Handle_Should_Merge_Leading_PreRoll_Filler_Into_Following_Programme()
{
await using (TvContext context = _db.CreateContext())
{
DomainChannel channel = NewChannel("2", "Two", showInEpg: true);
PlayoutItem preRoll = MakeItem(BaseTime, BaseTime.AddMinutes(5), guideGroup: 1, movieTitle: "Bumper");
preRoll.FillerKind = FillerKind.PreRoll;
PlayoutItem content = MakeItem(BaseTime.AddMinutes(5), BaseTime.AddHours(1), guideGroup: 1, movieTitle: "Feature");
channel.Playouts = [MakePlayout(channel, PlayoutScheduleKind.Classic, [preRoll, content])];
context.Channels.Add(channel);
await context.SaveChangesAsync();
}
ChannelGuideResponseModel result = await MakeHandler().Handle(
new GetChannelGuideData(BaseTime, BaseTime.AddDays(1)),
CancellationToken.None);
ChannelGuideProgrammeResponseModel programme = result.Channels.Single().Programmes.Single();
// filler is merged: the programme starts at the pre-roll start but displays the feature metadata
programme.Title.ShouldBe("Feature");
programme.Start.ShouldBe(new DateTimeOffset(BaseTime, TimeSpan.Zero));
programme.Stop.ShouldBe(new DateTimeOffset(BaseTime.AddHours(1), TimeSpan.Zero));
programme.FillerKind.ShouldBe(FillerKind.None);
}
[Test]
public async Task Handle_Should_Split_Block_Window_Evenly_Across_Content_Items()
{
await using (TvContext context = _db.CreateContext())
{
DomainChannel channel = NewChannel("2", "Two", showInEpg: true);
PlayoutItem one = MakeItem(BaseTime, BaseTime.AddMinutes(20), guideGroup: 7, movieTitle: "One");
PlayoutItem two = MakeItem(BaseTime.AddMinutes(20), BaseTime.AddMinutes(40), guideGroup: 7, movieTitle: "Two");
foreach (PlayoutItem item in new[] { one, two })
{
item.GuideStart = BaseTime;
item.GuideFinish = BaseTime.AddHours(1);
}
channel.Playouts = [MakePlayout(channel, PlayoutScheduleKind.Block, [one, two])];
context.Channels.Add(channel);
await context.SaveChangesAsync();
}
ChannelGuideResponseModel result = await MakeHandler().Handle(
new GetChannelGuideData(BaseTime, BaseTime.AddDays(1)),
CancellationToken.None);
List<ChannelGuideProgrammeResponseModel> programmes = result.Channels.Single().Programmes;
programmes.Count.ShouldBe(2);
// 1-hour guide window split evenly across the 2 content items -> 30 minutes each
programmes[0].Start.ShouldBe(new DateTimeOffset(BaseTime, TimeSpan.Zero));
programmes[0].Stop.ShouldBe(new DateTimeOffset(BaseTime.AddMinutes(30), TimeSpan.Zero));
programmes[1].Start.ShouldBe(new DateTimeOffset(BaseTime.AddMinutes(30), TimeSpan.Zero));
programmes[1].Stop.ShouldBe(new DateTimeOffset(BaseTime.AddHours(1), TimeSpan.Zero));
}
[Test]
public async Task Handle_Should_Default_Start_To_Now_And_End_To_DaysToBuild()
{
_config.GetValue<int>(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>())
.Returns(Option<int>.Some(3));
DateTimeOffset before = DateTimeOffset.UtcNow;
ChannelGuideResponseModel result = await MakeHandler().Handle(
new GetChannelGuideData(null, null),
CancellationToken.None);
DateTimeOffset after = DateTimeOffset.UtcNow;
result.Start.ShouldBeInRange(before, after);
(result.End - result.Start).ShouldBe(TimeSpan.FromDays(3));
}
[Test]
public async Task Handle_Should_Shift_Mirror_Channel_Programmes_By_PlayoutOffset_Without_Mutating_Source_Items()
{
PlayoutItem sourceItem = MakeItem(BaseTime, BaseTime.AddHours(1), guideGroup: 1, movieTitle: "Live Show");
var playoutOffset = TimeSpan.FromHours(3);
await using (TvContext context = _db.CreateContext())
{
DomainChannel source = NewChannel("1", "Source", showInEpg: false);
source.Playouts = [MakePlayout(source, PlayoutScheduleKind.Classic, [sourceItem])];
DomainChannel mirror = NewChannel("2", "Mirror", showInEpg: true);
mirror.PlayoutSource = ChannelPlayoutSource.Mirror;
mirror.MirrorSourceChannel = source;
mirror.PlayoutOffset = playoutOffset;
context.Channels.AddRange(source, mirror);
await context.SaveChangesAsync();
}
ChannelGuideResponseModel result = await MakeHandler().Handle(
new GetChannelGuideData(BaseTime, BaseTime.AddDays(1)),
CancellationToken.None);
ChannelGuideProgrammeResponseModel programme = result.Channels.Single(c => c.Number == "2").Programmes.Single();
programme.Title.ShouldBe("Live Show");
programme.Start.ShouldBe(new DateTimeOffset(BaseTime.Add(playoutOffset), TimeSpan.Zero));
programme.Stop.ShouldBe(new DateTimeOffset(BaseTime.AddHours(1).Add(playoutOffset), TimeSpan.Zero));
// the WithPlayoutOffset copy fix: applying the mirror offset must not mutate the source playout's
// own (shared, AsNoTracking) PlayoutItem entities in place.
sourceItem.Start.ShouldBe(BaseTime);
sourceItem.Finish.ShouldBe(BaseTime.AddHours(1));
}
[Test]
public async Task Handle_Should_Return_Empty_Programmes_For_Channel_Without_Playout()
{
await using (TvContext context = _db.CreateContext())
{
context.Channels.Add(NewChannel("2", "Two", showInEpg: true));
await context.SaveChangesAsync();
}
ChannelGuideResponseModel result = await MakeHandler().Handle(
new GetChannelGuideData(BaseTime, BaseTime.AddDays(1)),
CancellationToken.None);
result.Channels.Single().Programmes.ShouldBeEmpty();
}
// --- seeding helpers ---
private static Playout MakeFloodPlayout(
DomainChannel channel,
params (DateTime Start, DateTime Finish, int GuideGroup, string Title)[] items) =>
MakePlayout(
channel,
PlayoutScheduleKind.Classic,
items.Select(i => MakeItem(i.Start, i.Finish, i.GuideGroup, i.Title)).ToList());
private static Playout MakePlayout(
DomainChannel channel,
PlayoutScheduleKind scheduleKind,
List<PlayoutItem> items) =>
new()
{
Channel = channel,
ScheduleKind = scheduleKind,
ScheduleFile = string.Empty,
Items = items
};
private static PlayoutItem MakeItem(
DateTime start,
DateTime finish,
int guideGroup,
string movieTitle) =>
new()
{
Start = start,
Finish = finish,
GuideGroup = guideGroup,
FillerKind = FillerKind.None,
MediaItem = new Movie
{
MovieMetadata = [new MovieMetadata { Title = movieTitle }],
MediaVersions = []
}
};
private static DomainChannel NewChannel(string number, string name, bool showInEpg) =>
new(Guid.NewGuid())
{
Number = number,
Name = name,
Group = "ErsatzTV",
Categories = string.Empty,
StreamSelector = string.Empty,
PreferredAudioLanguageCode = string.Empty,
PreferredAudioTitle = string.Empty,
PreferredSubtitleLanguageCode = string.Empty,
MusicVideoCreditsTemplate = string.Empty,
PlayoutSource = ChannelPlayoutSource.Generated,
PlayoutMode = ChannelPlayoutMode.Continuous,
ShowInEpg = showInEpg,
Playouts = []
};
}
@@ -0,0 +1,63 @@
using ErsatzTV.Application.Filler;
using ErsatzTV.Core.Api.Filler;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Filler;
[TestFixture]
public class FillerPresetHandlerTests
{
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 GetAllFillerPresetsForApi_Should_Return_All_Presets()
{
await SeedPreset(1, "Intro");
await SeedPreset(2, "Outro");
var handler = new GetAllFillerPresetsForApiHandler(_db.Factory);
List<FillerPresetResponseModel> result =
await handler.Handle(new GetAllFillerPresetsForApi(), CancellationToken.None);
result.Count.ShouldBe(2);
result.ShouldContain(new FillerPresetResponseModel(1, "Intro"));
result.ShouldContain(new FillerPresetResponseModel(2, "Outro"));
}
[Test]
public async Task GetAllFillerPresetsForApi_Should_Return_Empty_List_When_None_Exist()
{
var handler = new GetAllFillerPresetsForApiHandler(_db.Factory);
List<FillerPresetResponseModel> result =
await handler.Handle(new GetAllFillerPresetsForApi(), CancellationToken.None);
result.ShouldBeEmpty();
}
private async Task SeedPreset(int id, string name)
{
await using TvContext context = _db.CreateContext();
context.FillerPresets.Add(new FillerPreset
{
Id = id,
Name = name,
FillerKind = FillerKind.PreRoll,
FillerMode = FillerMode.Duration,
CollectionType = CollectionType.Collection
});
await context.SaveChangesAsync();
}
}
@@ -0,0 +1,77 @@
using ErsatzTV.Application.Graphics;
using ErsatzTV.Core.Api.Graphics;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Graphics;
[TestFixture]
public class GraphicsElementHandlerTests
{
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 GetAllGraphicsElementsForApi_Should_Return_All_Elements()
{
await SeedElement(1, "watermark.png", GraphicsElementKind.Image, "Custom Watermark");
await SeedElement(2, "clock.png", GraphicsElementKind.Image, string.Empty);
var handler = new GetAllGraphicsElementsForApiHandler(_db.Factory);
List<GraphicsElementResponseModel> result =
await handler.Handle(new GetAllGraphicsElementsForApi(), CancellationToken.None);
result.Count.ShouldBe(2);
result.Select(e => e.Id).ShouldBe([1, 2]);
}
[Test]
public async Task GetAllGraphicsElementsForApi_Should_Order_Named_Elements_Before_Unnamed()
{
// unnamed element's projected Name equals its FileName -> should sort after named elements
await SeedElement(1, "aaa.png", GraphicsElementKind.Image, string.Empty);
await SeedElement(2, "zzz.png", GraphicsElementKind.Image, "A Custom Name");
var handler = new GetAllGraphicsElementsForApiHandler(_db.Factory);
List<GraphicsElementResponseModel> result =
await handler.Handle(new GetAllGraphicsElementsForApi(), CancellationToken.None);
result.Count.ShouldBe(2);
result[0].Id.ShouldBe(2);
result[1].Id.ShouldBe(1);
}
[Test]
public async Task GetAllGraphicsElementsForApi_Should_Return_Empty_List_When_None_Exist()
{
var handler = new GetAllGraphicsElementsForApiHandler(_db.Factory);
List<GraphicsElementResponseModel> result =
await handler.Handle(new GetAllGraphicsElementsForApi(), CancellationToken.None);
result.ShouldBeEmpty();
}
private async Task SeedElement(int id, string path, GraphicsElementKind kind, string name)
{
await using TvContext context = _db.CreateContext();
context.GraphicsElements.Add(new GraphicsElement
{
Id = id,
Path = path,
Name = name,
Kind = kind
});
await context.SaveChangesAsync();
}
}
@@ -0,0 +1,111 @@
using ErsatzTV.Application.Health;
using ErsatzTV.Core.Api.Health;
using ErsatzTV.Core.Health;
using LanguageExt;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Health;
[TestFixture]
public class GetAllHealthCheckResultsForApiHandlerTests
{
private IHealthCheckService _healthCheckService = null!;
private GetAllHealthCheckResultsForApiHandler _handler = null!;
[SetUp]
public void SetUp()
{
_healthCheckService = Substitute.For<IHealthCheckService>();
_handler = new GetAllHealthCheckResultsForApiHandler(_healthCheckService);
}
[Test]
public async Task Should_Map_Status_Codes_To_Lowercase_Strings()
{
var results = new List<HealthCheckResult>
{
new("Pass Check", HealthCheckStatus.Pass, "all good", "ok", Option<HealthCheckLink>.None),
new("Fail Check", HealthCheckStatus.Fail, "broken", "bad", Option<HealthCheckLink>.None),
new("Warn Check", HealthCheckStatus.Warning, "watch out", "warn", Option<HealthCheckLink>.None),
new("Info Check", HealthCheckStatus.Info, "fyi", "info", Option<HealthCheckLink>.None)
};
_healthCheckService.PerformHealthChecks(Arg.Any<CancellationToken>()).Returns(results);
List<HealthCheckResponseModel> response =
await _handler.Handle(new GetAllHealthCheckResultsForApi(), CancellationToken.None);
response.Count.ShouldBe(4);
response.Select(r => r.Status).ShouldBe(["pass", "fail", "warn", "info"]);
}
[Test]
public async Task Should_Filter_Out_NotApplicable_Results()
{
var results = new List<HealthCheckResult>
{
new("Pass Check", HealthCheckStatus.Pass, "all good", "ok", Option<HealthCheckLink>.None),
new("NA Check", HealthCheckStatus.NotApplicable, "skip", "skip", Option<HealthCheckLink>.None)
};
_healthCheckService.PerformHealthChecks(Arg.Any<CancellationToken>()).Returns(results);
List<HealthCheckResponseModel> response =
await _handler.Handle(new GetAllHealthCheckResultsForApi(), CancellationToken.None);
response.Count.ShouldBe(1);
response[0].Title.ShouldBe("Pass Check");
}
[Test]
public async Task Should_Include_Link_When_Present()
{
var results = new List<HealthCheckResult>
{
new(
"Linked Check",
HealthCheckStatus.Warning,
"detail message",
"brief",
Option<HealthCheckLink>.Some(new HealthCheckLink("https://example.com/docs")))
};
_healthCheckService.PerformHealthChecks(Arg.Any<CancellationToken>()).Returns(results);
List<HealthCheckResponseModel> response =
await _handler.Handle(new GetAllHealthCheckResultsForApi(), CancellationToken.None);
response[0].Link.ShouldBe("https://example.com/docs");
response[0].Detail.ShouldBe("detail message");
}
[Test]
public async Task Should_Have_Null_Link_When_Absent()
{
var results = new List<HealthCheckResult>
{
new("No Link Check", HealthCheckStatus.Pass, "detail", "brief", Option<HealthCheckLink>.None)
};
_healthCheckService.PerformHealthChecks(Arg.Any<CancellationToken>()).Returns(results);
List<HealthCheckResponseModel> response =
await _handler.Handle(new GetAllHealthCheckResultsForApi(), CancellationToken.None);
response[0].Link.ShouldBeNull();
}
[Test]
public async Task Should_Return_Empty_List_On_Cancellation()
{
_healthCheckService.PerformHealthChecks(Arg.Any<CancellationToken>())
.Returns<Task<List<HealthCheckResult>>>(_ => throw new TaskCanceledException());
List<HealthCheckResponseModel> response =
await _handler.Handle(new GetAllHealthCheckResultsForApi(), CancellationToken.None);
response.ShouldBeEmpty();
}
}
@@ -0,0 +1,32 @@
using ErsatzTV.Application.Libraries;
using ErsatzTV.Core.Api.Libraries;
using ErsatzTV.Core.Metadata;
using MediatR;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Libraries;
[TestFixture]
public class GetLibraryScanStatusHandlerTests
{
[Test]
public async Task Handle_Should_Return_Active_Scans()
{
var mediator = Substitute.For<IMediator>();
var scannerProxyService = new ScannerProxyService(mediator);
Guid scanId = scannerProxyService.StartScan(42)
.Match(
Some: id => id,
None: () => throw new AssertionException("Expected scan to start"));
await scannerProxyService.Progress(scanId, 62.5m);
var handler = new GetLibraryScanStatusHandler(scannerProxyService);
List<LibraryScanStatusResponseModel> result =
await handler.Handle(new GetLibraryScanStatus(), CancellationToken.None);
result.ShouldBe([new LibraryScanStatusResponseModel(42, 62.5m)]);
}
}
@@ -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 = []
};
}
@@ -0,0 +1,179 @@
using ErsatzTV.Application.MediaSources;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using Microsoft.EntityFrameworkCore;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.MediaSources;
[TestFixture]
public class GetAllMediaSourcesForApiHandlerTests
{
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 Handle_Should_Group_Configured_Libraries_By_Source_With_Item_Counts()
{
await SeedMediaSources();
var handler = new GetAllMediaSourcesForApiHandler(_db.Factory);
var result = await handler.Handle(new GetAllMediaSourcesForApi(), CancellationToken.None);
result.Count.ShouldBe(4);
result[0].Kind.ShouldBe("Local");
result[0].Name.ShouldBe("Local");
result[0].ConnectionAddress.ShouldBeNull();
result[0].Libraries.Single().Name.ShouldBe("Local Movies");
result[0].Libraries.Single().LastScan.ShouldBe(new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc));
result[0].Libraries.Single().ItemCount.ShouldBe(2);
result[1].Kind.ShouldBe("Emby");
result[1].Name.ShouldBe("Emby Server");
result[1].ConnectionAddress.ShouldBe("http://emby.local");
result[1].Libraries.Single().Name.ShouldBe("Emby Shows");
result[1].Libraries.Single().ItemCount.ShouldBe(1);
result[2].Kind.ShouldBe("Jellyfin");
result[2].Name.ShouldBe("Jellyfin Server");
result[2].ConnectionAddress.ShouldBe("http://jellyfin.local");
result[2].Libraries.ShouldBeEmpty();
result[3].Kind.ShouldBe("Plex");
result[3].Name.ShouldBe("Plex Server");
result[3].ConnectionAddress.ShouldBe("http://plex.local");
result[3].Libraries.Single().Name.ShouldBe("Plex Movies");
result[3].Libraries.Single().ItemCount.ShouldBe(0);
}
[Test]
public async Task Handle_Should_Exclude_Unconfigured_And_Not_Synced_Libraries()
{
await SeedMediaSources();
var handler = new GetAllMediaSourcesForApiHandler(_db.Factory);
var result = await handler.Handle(new GetAllMediaSourcesForApi(), CancellationToken.None);
result.SelectMany(s => s.Libraries).Select(l => l.Name)
.ShouldNotContain("Empty Local");
result.SelectMany(s => s.Libraries).Select(l => l.Name)
.ShouldNotContain("Disabled Jellyfin");
}
private async Task SeedMediaSources()
{
await using TvContext context = _db.CreateContext();
var localSource = new LocalMediaSource
{
Libraries =
[
new LocalLibrary
{
Name = "Local Movies",
MediaKind = LibraryMediaKind.Movies,
LastScan = new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc),
Paths = [MakePath("/media/movies", 2)]
},
new LocalLibrary
{
Name = "Empty Local",
MediaKind = LibraryMediaKind.Shows,
Paths = []
}
]
};
var plexSource = new PlexMediaSource
{
ServerName = "Plex Server",
ProductVersion = "1",
Platform = "Linux",
PlatformVersion = "1",
ClientIdentifier = "plex",
Connections = [new PlexConnection { IsActive = true, Uri = "http://plex.local" }],
PathReplacements = [],
Libraries =
[
new PlexLibrary
{
Name = "Plex Movies",
MediaKind = LibraryMediaKind.Movies,
Key = "1",
ShouldSyncItems = true,
Paths = []
}
]
};
var embySource = new EmbyMediaSource
{
ServerName = "Emby Server",
OperatingSystem = "Linux",
Connections = [new EmbyConnection { Address = "http://emby.local" }],
PathReplacements = [],
Libraries =
[
new EmbyLibrary
{
Name = "Emby Shows",
MediaKind = LibraryMediaKind.Shows,
ItemId = "emby-shows",
ShouldSyncItems = true,
PathInfos = [],
Paths = [MakePath("/emby/shows", 1)]
}
]
};
var jellyfinSource = new JellyfinMediaSource
{
ServerName = "Jellyfin Server",
OperatingSystem = "Linux",
Connections = [new JellyfinConnection { Address = "http://jellyfin.local" }],
PathReplacements = [],
Libraries =
[
new JellyfinLibrary
{
Name = "Disabled Jellyfin",
MediaKind = LibraryMediaKind.Movies,
ItemId = "jellyfin-movies",
ShouldSyncItems = false,
PathInfos = [],
Paths = [MakePath("/jellyfin/movies", 1)]
}
]
};
context.MediaSources.AddRange(localSource, plexSource, embySource, jellyfinSource);
await context.SaveChangesAsync();
context.ChangeTracker.Clear();
}
private static LibraryPath MakePath(string path, int mediaItemCount) =>
new()
{
Path = path,
LibraryFolders = [],
MediaItems = Enumerable.Range(0, mediaItemCount)
.Select(_ => new Movie
{
MovieMetadata = [],
MediaVersions = [],
Collections = [],
CollectionItems = [],
TraktListItems = []
})
.Cast<MediaItem>()
.ToList()
};
}

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