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
66 changed files with 29155 additions and 164 deletions
+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,24 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
namespace ErsatzTV.Application.ChannelTemplates;
internal static class ChannelTemplateDefault
{
public static async Task<int?> GetDefaultTemplateId(
IConfigElementRepository configElementRepository,
CancellationToken cancellationToken)
{
Option<int> maybeDefault =
await configElementRepository.GetValue<int>(
ConfigElementKey.ChannelTemplatesDefaultTemplateId,
cancellationToken);
int? result = null;
foreach (int id in maybeDefault)
{
result = id;
}
return result;
}
}
@@ -0,0 +1,38 @@
using ErsatzTV.Core.Api.ChannelTemplates;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.ChannelTemplates;
public static class ChannelTemplateMapper
{
public static ChannelTemplateResponseModel ProjectToResponseModel(ChannelTemplate template, int? defaultTemplateId) =>
new(
template.Id,
template.Name,
template.Description,
template.IsSystem,
defaultTemplateId == template.Id,
template.FFmpegProfileId,
template.WatermarkId,
template.FallbackFillerId,
template.PreRollFillerId,
template.MidRollFillerId,
template.PostRollFillerId,
template.StreamSelectorMode,
template.StreamSelector,
template.PreferredAudioLanguageCode,
template.PreferredAudioTitle,
template.PlayoutSource,
template.PlayoutMode,
template.StreamingMode,
template.PreferredSubtitleLanguageCode,
template.SubtitleMode,
template.MusicVideoCreditsMode,
template.MusicVideoCreditsTemplate,
template.SongVideoMode,
template.TranscodeMode,
template.IdleBehavior,
template.ShuffleScheduleItems,
template.RandomStartPoint,
template.FixedStartTimeBehavior);
}
@@ -0,0 +1,155 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.ChannelTemplates;
public abstract record ChannelTemplateCommandBase(
string Name,
string Description,
int FFmpegProfileId,
int? WatermarkId,
int? FallbackFillerId,
int? PreRollFillerId,
int? MidRollFillerId,
int? PostRollFillerId,
ChannelStreamSelectorMode StreamSelectorMode,
string StreamSelector,
string PreferredAudioLanguageCode,
string PreferredAudioTitle,
ChannelPlayoutSource PlayoutSource,
ChannelPlayoutMode PlayoutMode,
StreamingMode StreamingMode,
string PreferredSubtitleLanguageCode,
ChannelSubtitleMode SubtitleMode,
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
string MusicVideoCreditsTemplate,
ChannelSongVideoMode SongVideoMode,
ChannelTranscodeMode TranscodeMode,
ChannelIdleBehavior IdleBehavior,
bool ShuffleScheduleItems,
bool RandomStartPoint,
FixedStartTimeBehavior FixedStartTimeBehavior)
{
internal static async Task<Option<BaseError>> ValidateCommon(
TvContext dbContext,
ChannelTemplateCommandBase request,
int? existingTemplateId,
CancellationToken cancellationToken)
{
string name = NormalizeName(request.Name);
if (string.IsNullOrWhiteSpace(name))
{
return BaseError.New("Name is required.");
}
if (name.Length > 50)
{
return BaseError.New("Name must be 50 characters or less.");
}
if (request.Description?.Length > 500)
{
return BaseError.New("Description must be 500 characters or less.");
}
bool duplicateName = await dbContext.ChannelTemplates
.AnyAsync(t => t.Id != existingTemplateId && t.Name == name, cancellationToken);
if (duplicateName)
{
return BaseError.New("Channel template name must be unique.");
}
bool ffmpegProfileExists = await dbContext.FFmpegProfiles
.AnyAsync(p => p.Id == request.FFmpegProfileId, cancellationToken);
if (!ffmpegProfileExists)
{
return new NotFoundError($"FFmpegProfile {request.FFmpegProfileId} does not exist.");
}
foreach (int watermarkId in Optional(request.WatermarkId))
{
bool watermarkExists = await dbContext.ChannelWatermarks
.AnyAsync(w => w.Id == watermarkId, cancellationToken);
if (!watermarkExists)
{
return new NotFoundError($"Watermark {watermarkId} does not exist.");
}
}
Option<BaseError> maybeFillerError =
await FillerMustExist(dbContext, request.FallbackFillerId, FillerKind.Fallback, cancellationToken);
if (maybeFillerError.IsSome)
{
return maybeFillerError;
}
maybeFillerError = await FillerMustExist(dbContext, request.PreRollFillerId, FillerKind.PreRoll, cancellationToken);
if (maybeFillerError.IsSome)
{
return maybeFillerError;
}
maybeFillerError = await FillerMustExist(dbContext, request.MidRollFillerId, FillerKind.MidRoll, cancellationToken);
if (maybeFillerError.IsSome)
{
return maybeFillerError;
}
return await FillerMustExist(dbContext, request.PostRollFillerId, FillerKind.PostRoll, cancellationToken);
}
internal void ApplyTo(ChannelTemplate template)
{
template.Name = NormalizeName(Name);
template.Description = Description ?? string.Empty;
template.FFmpegProfileId = FFmpegProfileId;
template.WatermarkId = WatermarkId;
template.FallbackFillerId = FallbackFillerId;
template.PreRollFillerId = PreRollFillerId;
template.MidRollFillerId = MidRollFillerId;
template.PostRollFillerId = PostRollFillerId;
template.StreamSelectorMode = StreamSelectorMode;
template.StreamSelector = StreamSelector ?? string.Empty;
template.PreferredAudioLanguageCode = PreferredAudioLanguageCode ?? string.Empty;
template.PreferredAudioTitle = PreferredAudioTitle ?? string.Empty;
template.PlayoutSource = PlayoutSource;
template.PlayoutMode = PlayoutMode;
template.StreamingMode = StreamingMode;
template.PreferredSubtitleLanguageCode = PreferredSubtitleLanguageCode ?? string.Empty;
template.SubtitleMode = SubtitleMode;
template.MusicVideoCreditsMode = MusicVideoCreditsMode;
template.MusicVideoCreditsTemplate = MusicVideoCreditsTemplate ?? string.Empty;
template.SongVideoMode = SongVideoMode;
template.TranscodeMode = TranscodeMode;
template.IdleBehavior = IdleBehavior;
template.ShuffleScheduleItems = ShuffleScheduleItems;
template.RandomStartPoint = RandomStartPoint;
template.FixedStartTimeBehavior = FixedStartTimeBehavior;
}
internal static string NormalizeName(string name) => (name ?? string.Empty).Trim();
private static async Task<Option<BaseError>> FillerMustExist(
TvContext dbContext,
int? fillerPresetId,
FillerKind fillerKind,
CancellationToken cancellationToken)
{
foreach (int id in Optional(fillerPresetId))
{
bool exists = await dbContext.FillerPresets
.AnyAsync(f => f.Id == id && f.FillerKind == fillerKind, cancellationToken);
if (!exists)
{
return new NotFoundError($"{fillerKind} filler {id} does not exist.");
}
}
return Option<BaseError>.None;
}
}
@@ -0,0 +1,60 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Api.ChannelTemplates;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Application.ChannelTemplates;
public record CreateChannelTemplate(
string Name,
string Description,
int FFmpegProfileId,
int? WatermarkId,
int? FallbackFillerId,
int? PreRollFillerId,
int? MidRollFillerId,
int? PostRollFillerId,
ChannelStreamSelectorMode StreamSelectorMode,
string StreamSelector,
string PreferredAudioLanguageCode,
string PreferredAudioTitle,
ChannelPlayoutSource PlayoutSource,
ChannelPlayoutMode PlayoutMode,
StreamingMode StreamingMode,
string PreferredSubtitleLanguageCode,
ChannelSubtitleMode SubtitleMode,
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
string MusicVideoCreditsTemplate,
ChannelSongVideoMode SongVideoMode,
ChannelTranscodeMode TranscodeMode,
ChannelIdleBehavior IdleBehavior,
bool ShuffleScheduleItems,
bool RandomStartPoint,
FixedStartTimeBehavior FixedStartTimeBehavior)
: ChannelTemplateCommandBase(
Name,
Description,
FFmpegProfileId,
WatermarkId,
FallbackFillerId,
PreRollFillerId,
MidRollFillerId,
PostRollFillerId,
StreamSelectorMode,
StreamSelector,
PreferredAudioLanguageCode,
PreferredAudioTitle,
PlayoutSource,
PlayoutMode,
StreamingMode,
PreferredSubtitleLanguageCode,
SubtitleMode,
MusicVideoCreditsMode,
MusicVideoCreditsTemplate,
SongVideoMode,
TranscodeMode,
IdleBehavior,
ShuffleScheduleItems,
RandomStartPoint,
FixedStartTimeBehavior),
IRequest<Either<BaseError, ChannelTemplateResponseModel>>;
@@ -0,0 +1,36 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Api.ChannelTemplates;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.ChannelTemplates;
public class CreateChannelTemplateHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository)
: IRequestHandler<CreateChannelTemplate, Either<BaseError, ChannelTemplateResponseModel>>
{
public async Task<Either<BaseError, ChannelTemplateResponseModel>> Handle(
CreateChannelTemplate request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<BaseError> maybeError =
await ChannelTemplateCommandBase.ValidateCommon(dbContext, request, null, cancellationToken);
foreach (BaseError error in maybeError)
{
return error;
}
var template = new ChannelTemplate();
request.ApplyTo(template);
await dbContext.ChannelTemplates.AddAsync(template, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
int? defaultTemplateId =
await ChannelTemplateDefault.GetDefaultTemplateId(configElementRepository, cancellationToken);
return ChannelTemplateMapper.ProjectToResponseModel(template, defaultTemplateId);
}
}
@@ -0,0 +1,5 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.ChannelTemplates;
public record DeleteChannelTemplate(int ChannelTemplateId) : IRequest<Either<BaseError, Unit>>;
@@ -0,0 +1,42 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.ChannelTemplates;
public class DeleteChannelTemplateHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository)
: IRequestHandler<DeleteChannelTemplate, Either<BaseError, Unit>>
{
public async Task<Either<BaseError, Unit>> Handle(DeleteChannelTemplate request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<ChannelTemplate> maybeTemplate = await dbContext.ChannelTemplates
.SelectOneAsync(t => t.Id, t => t.Id == request.ChannelTemplateId, cancellationToken);
foreach (ChannelTemplate template in maybeTemplate)
{
if (template.IsSystem)
{
return BaseError.New("System templates cannot be deleted.");
}
int? defaultTemplateId =
await ChannelTemplateDefault.GetDefaultTemplateId(configElementRepository, cancellationToken);
if (defaultTemplateId == template.Id)
{
return BaseError.New("Default channel template cannot be deleted.");
}
dbContext.ChannelTemplates.Remove(template);
await dbContext.SaveChangesAsync(cancellationToken);
return Unit.Default;
}
return new NotFoundError($"ChannelTemplate {request.ChannelTemplateId} does not exist.");
}
}
@@ -0,0 +1,6 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Api.ChannelTemplates;
namespace ErsatzTV.Application.ChannelTemplates;
public record SetDefaultChannelTemplate(int ChannelTemplateId) : IRequest<Either<BaseError, ChannelTemplateResponseModel>>;
@@ -0,0 +1,36 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Api.ChannelTemplates;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.ChannelTemplates;
public class SetDefaultChannelTemplateHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository)
: IRequestHandler<SetDefaultChannelTemplate, Either<BaseError, ChannelTemplateResponseModel>>
{
public async Task<Either<BaseError, ChannelTemplateResponseModel>> Handle(
SetDefaultChannelTemplate request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<ChannelTemplate> maybeTemplate = await dbContext.ChannelTemplates
.AsNoTracking()
.SelectOneAsync(t => t.Id, t => t.Id == request.ChannelTemplateId, cancellationToken);
foreach (ChannelTemplate template in maybeTemplate)
{
await configElementRepository.Upsert(
ConfigElementKey.ChannelTemplatesDefaultTemplateId,
template.Id,
cancellationToken);
return ChannelTemplateMapper.ProjectToResponseModel(template, template.Id);
}
return new NotFoundError($"ChannelTemplate {request.ChannelTemplateId} does not exist.");
}
}
@@ -0,0 +1,61 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Api.ChannelTemplates;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Application.ChannelTemplates;
public record UpdateChannelTemplate(
int ChannelTemplateId,
string Name,
string Description,
int FFmpegProfileId,
int? WatermarkId,
int? FallbackFillerId,
int? PreRollFillerId,
int? MidRollFillerId,
int? PostRollFillerId,
ChannelStreamSelectorMode StreamSelectorMode,
string StreamSelector,
string PreferredAudioLanguageCode,
string PreferredAudioTitle,
ChannelPlayoutSource PlayoutSource,
ChannelPlayoutMode PlayoutMode,
StreamingMode StreamingMode,
string PreferredSubtitleLanguageCode,
ChannelSubtitleMode SubtitleMode,
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
string MusicVideoCreditsTemplate,
ChannelSongVideoMode SongVideoMode,
ChannelTranscodeMode TranscodeMode,
ChannelIdleBehavior IdleBehavior,
bool ShuffleScheduleItems,
bool RandomStartPoint,
FixedStartTimeBehavior FixedStartTimeBehavior)
: ChannelTemplateCommandBase(
Name,
Description,
FFmpegProfileId,
WatermarkId,
FallbackFillerId,
PreRollFillerId,
MidRollFillerId,
PostRollFillerId,
StreamSelectorMode,
StreamSelector,
PreferredAudioLanguageCode,
PreferredAudioTitle,
PlayoutSource,
PlayoutMode,
StreamingMode,
PreferredSubtitleLanguageCode,
SubtitleMode,
MusicVideoCreditsMode,
MusicVideoCreditsTemplate,
SongVideoMode,
TranscodeMode,
IdleBehavior,
ShuffleScheduleItems,
RandomStartPoint,
FixedStartTimeBehavior),
IRequest<Either<BaseError, ChannelTemplateResponseModel>>;
@@ -0,0 +1,51 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Api.ChannelTemplates;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.ChannelTemplates;
public class UpdateChannelTemplateHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository)
: IRequestHandler<UpdateChannelTemplate, Either<BaseError, ChannelTemplateResponseModel>>
{
public async Task<Either<BaseError, ChannelTemplateResponseModel>> Handle(
UpdateChannelTemplate request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<ChannelTemplate> maybeTemplate = await dbContext.ChannelTemplates
.SelectOneAsync(t => t.Id, t => t.Id == request.ChannelTemplateId, cancellationToken);
foreach (ChannelTemplate template in maybeTemplate)
{
if (template.IsSystem)
{
return BaseError.New("System templates cannot be updated.");
}
Option<BaseError> maybeError =
await ChannelTemplateCommandBase.ValidateCommon(
dbContext,
request,
request.ChannelTemplateId,
cancellationToken);
foreach (BaseError error in maybeError)
{
return error;
}
request.ApplyTo(template);
await dbContext.SaveChangesAsync(cancellationToken);
int? defaultTemplateId =
await ChannelTemplateDefault.GetDefaultTemplateId(configElementRepository, cancellationToken);
return ChannelTemplateMapper.ProjectToResponseModel(template, defaultTemplateId);
}
return new NotFoundError($"ChannelTemplate {request.ChannelTemplateId} does not exist.");
}
}
@@ -0,0 +1,5 @@
using ErsatzTV.Core.Api.ChannelTemplates;
namespace ErsatzTV.Application.ChannelTemplates;
public record GetAllChannelTemplates : IRequest<List<ChannelTemplateResponseModel>>;
@@ -0,0 +1,28 @@
using ErsatzTV.Core.Api.ChannelTemplates;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.ChannelTemplates;
public class GetAllChannelTemplatesHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository)
: IRequestHandler<GetAllChannelTemplates, List<ChannelTemplateResponseModel>>
{
public async Task<List<ChannelTemplateResponseModel>> Handle(
GetAllChannelTemplates request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
int? defaultTemplateId =
await ChannelTemplateDefault.GetDefaultTemplateId(configElementRepository, cancellationToken);
return await dbContext.ChannelTemplates
.AsNoTracking()
.OrderBy(t => t.IsSystem ? 0 : 1)
.ThenBy(t => t.Name)
.Select(t => ChannelTemplateMapper.ProjectToResponseModel(t, defaultTemplateId))
.ToListAsync(cancellationToken);
}
}
@@ -0,0 +1,5 @@
using ErsatzTV.Core.Api.ChannelTemplates;
namespace ErsatzTV.Application.ChannelTemplates;
public record GetChannelTemplateById(int ChannelTemplateId) : IRequest<Option<ChannelTemplateResponseModel>>;
@@ -0,0 +1,27 @@
using ErsatzTV.Core.Api.ChannelTemplates;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.ChannelTemplates;
public class GetChannelTemplateByIdHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository)
: IRequestHandler<GetChannelTemplateById, Option<ChannelTemplateResponseModel>>
{
public async Task<Option<ChannelTemplateResponseModel>> Handle(
GetChannelTemplateById request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
int? defaultTemplateId =
await ChannelTemplateDefault.GetDefaultTemplateId(configElementRepository, cancellationToken);
Option<ChannelTemplate> maybeTemplate = await dbContext.ChannelTemplates
.AsNoTracking()
.SelectOneAsync(t => t.Id, t => t.Id == request.ChannelTemplateId, cancellationToken);
return maybeTemplate.Map(t => ChannelTemplateMapper.ProjectToResponseModel(t, defaultTemplateId));
}
}
@@ -0,0 +1,5 @@
using ErsatzTV.Core.Api.ChannelTemplates;
namespace ErsatzTV.Application.ChannelTemplates;
public record GetDefaultChannelTemplate : IRequest<Option<ChannelTemplateResponseModel>>;
@@ -0,0 +1,40 @@
using ErsatzTV.Core.Api.ChannelTemplates;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.ChannelTemplates;
public class GetDefaultChannelTemplateHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository)
: IRequestHandler<GetDefaultChannelTemplate, Option<ChannelTemplateResponseModel>>
{
public async Task<Option<ChannelTemplateResponseModel>> Handle(
GetDefaultChannelTemplate request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
int? defaultTemplateId =
await ChannelTemplateDefault.GetDefaultTemplateId(configElementRepository, cancellationToken);
foreach (int id in Optional(defaultTemplateId))
{
Option<ChannelTemplate> maybeConfigured = await dbContext.ChannelTemplates
.AsNoTracking()
.SelectOneAsync(t => t.Id, t => t.Id == id, cancellationToken);
foreach (ChannelTemplate template in maybeConfigured)
{
return ChannelTemplateMapper.ProjectToResponseModel(template, id);
}
}
ChannelTemplate fallback = await dbContext.ChannelTemplates
.AsNoTracking()
.Where(t => t.IsSystem)
.OrderBy(t => t.Name)
.FirstOrDefaultAsync(cancellationToken);
return Optional(fallback).Map(t => ChannelTemplateMapper.ProjectToResponseModel(t, t.Id));
}
}
@@ -0,0 +1,56 @@
using ErsatzTV.Application.Artworks;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Application.Channels;
public record CreateChannelFromLineup(
string Name,
string Number,
string Group,
string Categories,
ArtworkContentTypeModel Logo,
bool IsEnabled,
bool ShowInEpg,
int TemplateId,
CreateChannelFromLineupAdvancedOptions Advanced,
List<CreateChannelFromLineupItem> Lineup) : IRequest<Either<BaseError, CreateChannelFromLineupResponseModel>>;
public record CreateChannelFromLineupAdvancedOptions(
PlaybackOrder? PlaybackOrder = null,
int? FFmpegProfileId = null,
int? WatermarkId = null,
int? FallbackFillerId = null,
int? PreRollFillerId = null,
int? MidRollFillerId = null,
int? PostRollFillerId = null,
ChannelStreamSelectorMode? StreamSelectorMode = null,
string StreamSelector = null,
string PreferredAudioLanguageCode = null,
string PreferredAudioTitle = null,
ChannelPlayoutSource? PlayoutSource = null,
ChannelPlayoutMode? PlayoutMode = null,
StreamingMode? StreamingMode = null,
string PreferredSubtitleLanguageCode = null,
ChannelSubtitleMode? SubtitleMode = null,
ChannelMusicVideoCreditsMode? MusicVideoCreditsMode = null,
string MusicVideoCreditsTemplate = null,
ChannelSongVideoMode? SongVideoMode = null,
ChannelTranscodeMode? TranscodeMode = null,
ChannelIdleBehavior? IdleBehavior = null,
bool? ShuffleScheduleItems = null,
bool? RandomStartPoint = null,
FixedStartTimeBehavior? FixedStartTimeBehavior = null);
public record CreateChannelFromLineupItem(
LibraryBrowseMediaType MediaType,
CollectionType CollectionType,
int? CollectionId,
int? MultiCollectionId,
int? SmartCollectionId,
int? RerunCollectionId,
int? MediaItemId,
int? PlaylistId);
@@ -0,0 +1,754 @@
using System.Globalization;
using System.Text.RegularExpressions;
using System.Threading.Channels;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Channel = ErsatzTV.Core.Domain.Channel;
namespace ErsatzTV.Application.Channels;
public class CreateChannelFromLineupHandler(
ChannelWriter<IBackgroundServiceRequest> workerChannel,
IDbContextFactory<TvContext> dbContextFactory,
ISearchTargets searchTargets,
ILogger<CreateChannelFromLineupHandler> logger)
: IRequestHandler<CreateChannelFromLineup, Either<BaseError, CreateChannelFromLineupResponseModel>>
{
// The single system playlist group that holds every generated channel-lineup playlist.
// Matches the Trakt "Trakt Lists" precedent (DbInitializer + delete guards on IsSystem).
private const string SystemPlaylistGroupName = "Channel Lineups";
public async Task<Either<BaseError, CreateChannelFromLineupResponseModel>> Handle(
CreateChannelFromLineup request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Either<BaseError, PreparedCreate> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Match(
Left: error => Task.FromResult<Either<BaseError, CreateChannelFromLineupResponseModel>>(error),
Right: prepared => PersistAndDispatch(dbContext, prepared, cancellationToken));
}
private async Task<Either<BaseError, CreateChannelFromLineupResponseModel>> PersistAndDispatch(
TvContext dbContext,
PreparedCreate prepared,
CancellationToken cancellationToken)
{
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
try
{
dbContext.Channels.Add(prepared.Channel);
if (prepared.Playlist is not null)
{
dbContext.Playlists.Add(prepared.Playlist);
}
dbContext.ProgramSchedules.Add(prepared.ProgramSchedule);
dbContext.Playouts.Add(prepared.Playout);
await dbContext.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
}
catch (DbUpdateException ex)
{
await transaction.RollbackAsync(cancellationToken);
logger.LogError(ex, "Failed to persist channel created from lineup");
return BaseError.New("Unable to create channel from lineup");
}
searchTargets.SearchTargetsChanged();
await workerChannel.WriteAsync(
new BuildPlayout(prepared.Playout.Id, PlayoutBuildMode.Reset),
cancellationToken);
// Mirror CreateClassicPlayoutHandler: on-demand playouts must be time-shifted to "now" after build.
if (prepared.Channel.PlayoutMode is ChannelPlayoutMode.OnDemand)
{
await workerChannel.WriteAsync(
new TimeShiftOnDemandPlayout(prepared.Playout.Id, DateTimeOffset.Now, false),
cancellationToken);
}
await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
return new CreateChannelFromLineupResponseModel(
prepared.Channel.Id,
prepared.Playlist?.Id,
prepared.ProgramSchedule.Id,
prepared.Playout.Id);
}
private static async Task<Either<BaseError, PreparedCreate>> Validate(
TvContext dbContext,
CreateChannelFromLineup request,
CancellationToken cancellationToken)
{
string name = (request.Name ?? string.Empty).Trim();
string number = (request.Number ?? string.Empty).Trim();
string group = (request.Group ?? string.Empty).Trim();
string categories = (request.Categories ?? string.Empty).Trim();
CreateChannelFromLineupAdvancedOptions advanced = request.Advanced ?? new CreateChannelFromLineupAdvancedOptions();
if (string.IsNullOrWhiteSpace(name))
{
return BaseError.New("Channel name is required");
}
if (name.Length > 50)
{
return BaseError.New("Channel name must be 50 characters or fewer");
}
if (string.IsNullOrWhiteSpace(group))
{
return BaseError.New("Channel group is required");
}
if (!Regex.IsMatch(number, Channel.NumberValidator))
{
return BaseError.New("Invalid channel number; two decimals are allowed for subchannels");
}
if (await dbContext.Channels.AnyAsync(c => c.Number == number, cancellationToken))
{
return BaseError.New("Channel number must be unique");
}
if (!request.IsEnabled && request.ShowInEpg)
{
return BaseError.New("Disabled channels cannot be shown in EPG");
}
if (!string.IsNullOrWhiteSpace(request.Logo?.Path) &&
Uri.TryCreate(request.Logo.Path, UriKind.Absolute, out _) &&
!Artwork.IsExternalUrl(request.Logo.Path))
{
return BaseError.New("External logo url is invalid");
}
if (request.Lineup is null || request.Lineup.Count == 0)
{
return BaseError.New("Lineup must contain at least one item");
}
ChannelTemplate template = await dbContext.ChannelTemplates
.AsNoTracking()
.SingleOrDefaultAsync(t => t.Id == request.TemplateId, cancellationToken);
if (template is null)
{
return new NotFoundError($"Channel template {request.TemplateId} does not exist.");
}
int ffmpegProfileId = advanced.FFmpegProfileId ?? template.FFmpegProfileId;
int? fallbackFillerId = advanced.FallbackFillerId ?? template.FallbackFillerId;
int? preRollFillerId = advanced.PreRollFillerId ?? template.PreRollFillerId;
int? midRollFillerId = advanced.MidRollFillerId ?? template.MidRollFillerId;
int? postRollFillerId = advanced.PostRollFillerId ?? template.PostRollFillerId;
PlaybackOrder playbackOrder = advanced.PlaybackOrder ?? PlaybackOrder.Chronological;
ChannelPlayoutSource playoutSource = advanced.PlayoutSource ?? template.PlayoutSource;
// Mirror channels need special MirrorSourceChannelId plumbing (see CreateChannelHandler);
// this endpoint only builds generated playouts.
if (playoutSource is ChannelPlayoutSource.Mirror)
{
return BaseError.New("Mirror playout source is not supported by this endpoint");
}
Either<BaseError, Unit> referenceValidation = await ValidateReferences(
dbContext,
advanced,
template,
cancellationToken);
foreach (BaseError error in referenceValidation.LeftToSeq())
{
return error;
}
// Normalize + validate every lineup entry once, so validation and build see the same data.
var normalized = new List<NormalizedLineupItem>();
for (int i = 0; i < request.Lineup.Count; i++)
{
Either<BaseError, NormalizedLineupItem> itemValidation =
await NormalizeLineupItem(dbContext, request.Lineup[i], i, cancellationToken);
foreach (BaseError error in itemValidation.LeftToSeq())
{
return error;
}
foreach (NormalizedLineupItem item in itemValidation.RightToSeq())
{
normalized.Add(item);
}
}
bool multiItem = normalized.Count >= 2;
// MultiCollection entries only support Shuffle / ShuffleInOrder (mirrors PlayoutModeMustBeValid).
if (normalized.Any(i => i.CollectionType is CollectionType.MultiCollection) &&
playbackOrder is not (PlaybackOrder.Shuffle or PlaybackOrder.ShuffleInOrder))
{
return BaseError.New($"Invalid playback order for multi collection: '{playbackOrder}'");
}
if (multiItem)
{
// The generated playlist cannot express rerun collections or nested playlists
// (PlaylistItem + CollectionKey.ForPlaylistItem lack those fields).
for (int i = 0; i < normalized.Count; i++)
{
if (normalized[i].CollectionType is CollectionType.RerunFirstRun or CollectionType.Playlist)
{
return BaseError.New(
$"lineup[{normalized[i].Index}]: rerun collections and playlists are only " +
"supported as a single-item lineup");
}
}
}
Channel channel = BuildChannel(
request,
template,
advanced,
name,
number,
group,
categories,
ffmpegProfileId,
fallbackFillerId);
string scheduleName = await DeCollideName(
GeneratedName(number, name, "Schedule"),
(candidate, ct) => dbContext.ProgramSchedules.AnyAsync(ps => ps.Name == candidate, ct),
cancellationToken);
ProgramSchedule schedule = BuildProgramSchedule(scheduleName, template, advanced);
Playlist playlist = null;
ProgramScheduleItemFlood floodItem = BuildFloodBase(
playbackOrder,
advanced,
template,
fallbackFillerId,
preRollFillerId,
midRollFillerId,
postRollFillerId);
if (multiItem)
{
playlist = await BuildPlaylist(dbContext, number, name, normalized, playbackOrder, cancellationToken);
floodItem.CollectionType = CollectionType.Playlist;
floodItem.Playlist = playlist;
}
else
{
NormalizedLineupItem only = normalized[0];
floodItem.CollectionType = only.CollectionType;
floodItem.CollectionId = only.CollectionId;
floodItem.MultiCollectionId = only.MultiCollectionId;
floodItem.SmartCollectionId = only.SmartCollectionId;
floodItem.RerunCollectionId = only.RerunCollectionId;
floodItem.MediaItemId = only.MediaItemId;
floodItem.PlaylistId = only.PlaylistId;
}
schedule.Items = [floodItem];
var playout = new Playout
{
Channel = channel,
ProgramSchedule = schedule,
ScheduleKind = PlayoutScheduleKind.Classic
};
return new PreparedCreate(channel, playlist, schedule, playout);
}
private static async Task<Playlist> BuildPlaylist(
TvContext dbContext,
string channelNumber,
string channelName,
List<NormalizedLineupItem> lineup,
PlaybackOrder playbackOrder,
CancellationToken cancellationToken)
{
// Reuse the single system playlist group, creating it on first use.
PlaylistGroup playlistGroup = await dbContext.PlaylistGroups
.FirstOrDefaultAsync(pg => pg.IsSystem && pg.Name == SystemPlaylistGroupName, cancellationToken);
playlistGroup ??= new PlaylistGroup { Name = SystemPlaylistGroupName, IsSystem = true };
string playlistName = await DeCollideName(
GeneratedName(channelNumber, channelName, "Lineup"),
(candidate, ct) => dbContext.Playlists.AnyAsync(
p => p.PlaylistGroupId == playlistGroup.Id && p.Name == candidate,
ct),
cancellationToken);
var playlist = new Playlist
{
Name = playlistName,
IsSystem = true,
PlaylistGroup = playlistGroup,
PlaylistGroupId = playlistGroup.Id,
Items = []
};
int index = 1;
foreach (NormalizedLineupItem item in lineup)
{
playlist.Items.Add(new PlaylistItem
{
Playlist = playlist,
Index = index++,
CollectionType = item.CollectionType,
CollectionId = item.CollectionId,
MultiCollectionId = item.MultiCollectionId,
SmartCollectionId = item.SmartCollectionId,
MediaItemId = item.MediaItemId,
PlaybackOrder = playbackOrder,
// Play every item in each entry before advancing so lineup order is honored
// (PlaylistEnumerator round-robins one-per-entry unless PlayAll/Count).
PlayAll = true,
IncludeInProgramGuide = true
});
}
return playlist;
}
private static Channel BuildChannel(
CreateChannelFromLineup request,
ChannelTemplate template,
CreateChannelFromLineupAdvancedOptions advanced,
string name,
string number,
string group,
string categories,
int ffmpegProfileId,
int? fallbackFillerId)
{
var artwork = new List<Artwork>();
if (!string.IsNullOrWhiteSpace(request.Logo?.Path))
{
string logo = request.Logo.Path;
if (logo.StartsWith("iptv/logos/", StringComparison.Ordinal))
{
logo = logo.Replace("iptv/logos/", string.Empty);
}
artwork.Add(new Artwork
{
Path = logo,
ArtworkKind = ArtworkKind.Logo,
OriginalContentType = !string.IsNullOrEmpty(request.Logo.ContentType) ? request.Logo.ContentType : null,
DateAdded = DateTime.UtcNow,
DateUpdated = DateTime.UtcNow
});
}
return new Channel(Guid.NewGuid())
{
Name = name,
Number = number,
SortNumber = double.Parse(number, CultureInfo.InvariantCulture),
Group = group,
Categories = categories,
FFmpegProfileId = ffmpegProfileId,
SlugSeconds = null,
PlayoutSource = advanced.PlayoutSource ?? template.PlayoutSource,
PlayoutMode = advanced.PlayoutMode ?? template.PlayoutMode,
StreamingMode = advanced.StreamingMode ?? template.StreamingMode,
WatermarkId = advanced.WatermarkId ?? template.WatermarkId,
FallbackFillerId = fallbackFillerId,
Artwork = artwork,
StreamSelectorMode = advanced.StreamSelectorMode ?? template.StreamSelectorMode,
StreamSelector = advanced.StreamSelector ?? template.StreamSelector ?? string.Empty,
PreferredAudioLanguageCode =
advanced.PreferredAudioLanguageCode ?? template.PreferredAudioLanguageCode ?? string.Empty,
PreferredAudioTitle = advanced.PreferredAudioTitle ?? template.PreferredAudioTitle ?? string.Empty,
PreferredSubtitleLanguageCode =
advanced.PreferredSubtitleLanguageCode ?? template.PreferredSubtitleLanguageCode ?? string.Empty,
SubtitleMode = advanced.SubtitleMode ?? template.SubtitleMode,
MusicVideoCreditsMode = advanced.MusicVideoCreditsMode ?? template.MusicVideoCreditsMode,
MusicVideoCreditsTemplate =
advanced.MusicVideoCreditsTemplate ?? template.MusicVideoCreditsTemplate ?? string.Empty,
SongVideoMode = advanced.SongVideoMode ?? template.SongVideoMode,
TranscodeMode = advanced.TranscodeMode ?? template.TranscodeMode,
IdleBehavior = advanced.IdleBehavior ?? template.IdleBehavior,
IsEnabled = request.IsEnabled,
ShowInEpg = request.IsEnabled && request.ShowInEpg
};
}
private static ProgramSchedule BuildProgramSchedule(
string scheduleName,
ChannelTemplate template,
CreateChannelFromLineupAdvancedOptions advanced) =>
new()
{
Name = scheduleName,
KeepMultiPartEpisodesTogether = true,
TreatCollectionsAsShows = true,
ShuffleScheduleItems = advanced.ShuffleScheduleItems ?? template.ShuffleScheduleItems,
RandomStartPoint = advanced.RandomStartPoint ?? template.RandomStartPoint,
FixedStartTimeBehavior = advanced.FixedStartTimeBehavior ?? template.FixedStartTimeBehavior,
Items = []
};
private static ProgramScheduleItemFlood BuildFloodBase(
PlaybackOrder playbackOrder,
CreateChannelFromLineupAdvancedOptions advanced,
ChannelTemplate template,
int? fallbackFillerId,
int? preRollFillerId,
int? midRollFillerId,
int? postRollFillerId) =>
new()
{
Index = 1,
PlaybackOrder = playbackOrder,
GuideMode = GuideMode.Normal,
CustomTitle = string.Empty,
SearchTitle = string.Empty,
SearchQuery = string.Empty,
PreRollFillerId = preRollFillerId,
MidRollFillerId = midRollFillerId,
PostRollFillerId = postRollFillerId,
FallbackFillerId = fallbackFillerId,
PreferredAudioLanguageCode =
advanced.PreferredAudioLanguageCode ?? template.PreferredAudioLanguageCode ?? string.Empty,
PreferredAudioTitle = advanced.PreferredAudioTitle ?? template.PreferredAudioTitle ?? string.Empty,
PreferredSubtitleLanguageCode =
advanced.PreferredSubtitleLanguageCode ?? template.PreferredSubtitleLanguageCode ?? string.Empty,
SubtitleMode = advanced.SubtitleMode ?? template.SubtitleMode
};
private static string GeneratedName(string channelNumber, string channelName, string suffix)
{
string prefix = $"{channelNumber} {channelName}".Trim();
int maxPrefixLength = Math.Max(0, 50 - suffix.Length - 1);
if (prefix.Length > maxPrefixLength)
{
prefix = prefix[..maxPrefixLength].TrimEnd();
}
return $"{prefix} {suffix}".Trim();
}
// De-collide a generated (already <= 50 char) name against a unique index by appending " 2", " 3", ...
// rather than leaking a raw UNIQUE-constraint failure.
private static async Task<string> DeCollideName(
string baseName,
Func<string, CancellationToken, Task<bool>> exists,
CancellationToken cancellationToken)
{
if (!await exists(baseName, cancellationToken))
{
return baseName;
}
for (int n = 2; ; n++)
{
string suffix = $" {n}";
string candidate = baseName.Length + suffix.Length > 50
? baseName[..(50 - suffix.Length)].TrimEnd() + suffix
: baseName + suffix;
if (!await exists(candidate, cancellationToken))
{
return candidate;
}
}
}
private static async Task<Either<BaseError, Unit>> ValidateReferences(
TvContext dbContext,
CreateChannelFromLineupAdvancedOptions advanced,
ChannelTemplate template,
CancellationToken cancellationToken)
{
int ffmpegProfileId = advanced.FFmpegProfileId ?? template.FFmpegProfileId;
if (!await dbContext.FFmpegProfiles.AnyAsync(p => p.Id == ffmpegProfileId, cancellationToken))
{
return new NotFoundError($"FFmpegProfile {ffmpegProfileId} does not exist.");
}
Either<BaseError, Unit> channelReferences = await ValidateChannelReferences(
dbContext,
advanced.WatermarkId ?? template.WatermarkId,
advanced.FallbackFillerId ?? template.FallbackFillerId,
cancellationToken);
foreach (BaseError error in channelReferences.LeftToSeq())
{
return error;
}
Either<BaseError, Unit> itemFillers = await ValidateItemFillers(
dbContext,
advanced.PreRollFillerId ?? template.PreRollFillerId,
advanced.MidRollFillerId ?? template.MidRollFillerId,
advanced.PostRollFillerId ?? template.PostRollFillerId,
cancellationToken);
foreach (BaseError error in itemFillers.LeftToSeq())
{
return error;
}
return Unit.Default;
}
private static async Task<Either<BaseError, Unit>> ValidateChannelReferences(
TvContext dbContext,
int? watermarkId,
int? fallbackFillerId,
CancellationToken cancellationToken)
{
if (watermarkId.HasValue &&
!await dbContext.ChannelWatermarks.AnyAsync(w => w.Id == watermarkId.Value, cancellationToken))
{
return new NotFoundError($"Watermark {watermarkId.Value} does not exist.");
}
if (fallbackFillerId.HasValue && !await dbContext.FillerPresets.AnyAsync(
fp => fp.Id == fallbackFillerId.Value && fp.FillerKind == FillerKind.Fallback,
cancellationToken))
{
return new NotFoundError($"Fallback filler {fallbackFillerId.Value} does not exist.");
}
return Unit.Default;
}
private static async Task<Either<BaseError, Unit>> ValidateItemFillers(
TvContext dbContext,
int? preRollFillerId,
int? midRollFillerId,
int? postRollFillerId,
CancellationToken cancellationToken)
{
if (preRollFillerId.HasValue && !await FillerExists(dbContext, preRollFillerId.Value, FillerKind.PreRoll, cancellationToken))
{
return new NotFoundError($"Pre-roll filler {preRollFillerId.Value} does not exist.");
}
if (midRollFillerId.HasValue && !await FillerExists(dbContext, midRollFillerId.Value, FillerKind.MidRoll, cancellationToken))
{
return new NotFoundError($"Mid-roll filler {midRollFillerId.Value} does not exist.");
}
if (postRollFillerId.HasValue && !await FillerExists(dbContext, postRollFillerId.Value, FillerKind.PostRoll, cancellationToken))
{
return new NotFoundError($"Post-roll filler {postRollFillerId.Value} does not exist.");
}
return Unit.Default;
}
private static Task<bool> FillerExists(
TvContext dbContext,
int id,
FillerKind fillerKind,
CancellationToken cancellationToken) =>
dbContext.FillerPresets.AnyAsync(fp => fp.Id == id && fp.FillerKind == fillerKind, cancellationToken);
private static async Task<Either<BaseError, NormalizedLineupItem>> NormalizeLineupItem(
TvContext dbContext,
CreateChannelFromLineupItem item,
int index,
CancellationToken cancellationToken)
{
int providedIds = new int?[]
{
item.CollectionId,
item.MultiCollectionId,
item.SmartCollectionId,
item.RerunCollectionId,
item.MediaItemId,
item.PlaylistId
}.Count(id => id.HasValue);
if (providedIds != 1)
{
return BaseError.New($"lineup[{index}] must provide exactly one typed id.");
}
switch (item.MediaType)
{
case LibraryBrowseMediaType.Movie:
return await NormalizeMediaItem(
dbContext.Movies, item, CollectionType.Movie, index, "Movie", cancellationToken);
case LibraryBrowseMediaType.TelevisionShow:
return await NormalizeMediaItem(
dbContext.Shows, item, CollectionType.TelevisionShow, index, "TelevisionShow", cancellationToken);
case LibraryBrowseMediaType.TelevisionSeason:
return await NormalizeMediaItem(
dbContext.Seasons, item, CollectionType.TelevisionSeason, index, "TelevisionSeason", cancellationToken);
case LibraryBrowseMediaType.Artist:
return await NormalizeMediaItem(
dbContext.Artists, item, CollectionType.Artist, index, "Artist", cancellationToken);
case LibraryBrowseMediaType.Collection:
if (item.CollectionType != CollectionType.Collection)
{
return Mismatch(index, item);
}
if (!item.CollectionId.HasValue)
{
return WrongId(index, item.MediaType, "collectionId");
}
return await ExistsThen(
dbContext.Collections,
item.CollectionId.Value,
$"lineup[{index}] Collection",
new NormalizedLineupItem(index, CollectionType.Collection, CollectionId: item.CollectionId),
cancellationToken);
case LibraryBrowseMediaType.SmartCollection:
if (item.CollectionType != CollectionType.SmartCollection)
{
return Mismatch(index, item);
}
if (!item.SmartCollectionId.HasValue)
{
return WrongId(index, item.MediaType, "smartCollectionId");
}
return await ExistsThen(
dbContext.SmartCollections,
item.SmartCollectionId.Value,
$"lineup[{index}] SmartCollection",
new NormalizedLineupItem(index, CollectionType.SmartCollection, SmartCollectionId: item.SmartCollectionId),
cancellationToken);
case LibraryBrowseMediaType.MultiCollection:
if (item.CollectionType != CollectionType.MultiCollection)
{
return Mismatch(index, item);
}
if (!item.MultiCollectionId.HasValue)
{
return WrongId(index, item.MediaType, "multiCollectionId");
}
return await ExistsThen(
dbContext.MultiCollections,
item.MultiCollectionId.Value,
$"lineup[{index}] MultiCollection",
new NormalizedLineupItem(index, CollectionType.MultiCollection, MultiCollectionId: item.MultiCollectionId),
cancellationToken);
case LibraryBrowseMediaType.RerunCollection:
if (item.CollectionType != CollectionType.RerunFirstRun)
{
return Mismatch(index, item);
}
if (!item.RerunCollectionId.HasValue)
{
return WrongId(index, item.MediaType, "rerunCollectionId");
}
return await ExistsThen(
dbContext.RerunCollections,
item.RerunCollectionId.Value,
$"lineup[{index}] RerunCollection",
new NormalizedLineupItem(index, CollectionType.RerunFirstRun, RerunCollectionId: item.RerunCollectionId),
cancellationToken);
case LibraryBrowseMediaType.Playlist:
if (item.CollectionType != CollectionType.Playlist)
{
return Mismatch(index, item);
}
if (!item.PlaylistId.HasValue)
{
return WrongId(index, item.MediaType, "playlistId");
}
return await ExistsThen(
dbContext.Playlists,
item.PlaylistId.Value,
$"lineup[{index}] Playlist",
new NormalizedLineupItem(index, CollectionType.Playlist, PlaylistId: item.PlaylistId),
cancellationToken);
default:
return BaseError.New($"lineup[{index}] has an unsupported media type '{item.MediaType}'.");
}
}
private static async Task<Either<BaseError, NormalizedLineupItem>> NormalizeMediaItem<TEntity>(
DbSet<TEntity> set,
CreateChannelFromLineupItem item,
CollectionType expectedType,
int index,
string label,
CancellationToken cancellationToken)
where TEntity : class
{
if (item.CollectionType != expectedType)
{
return Mismatch(index, item);
}
if (!item.MediaItemId.HasValue)
{
return WrongId(index, item.MediaType, "mediaItemId");
}
return await ExistsThen(
set,
item.MediaItemId.Value,
$"lineup[{index}] {label}",
new NormalizedLineupItem(index, expectedType, MediaItemId: item.MediaItemId),
cancellationToken);
}
private static BaseError Mismatch(int index, CreateChannelFromLineupItem item) =>
BaseError.New(
$"lineup[{index}]: media type '{item.MediaType}' does not match collection type '{item.CollectionType}'.");
private static BaseError WrongId(int index, LibraryBrowseMediaType mediaType, string expectedField) =>
BaseError.New($"lineup[{index}]: media type '{mediaType}' requires a {expectedField}.");
private static async Task<Either<BaseError, NormalizedLineupItem>> ExistsThen<TEntity>(
DbSet<TEntity> set,
int id,
string label,
NormalizedLineupItem normalized,
CancellationToken cancellationToken)
where TEntity : class
{
bool exists = await set.AsNoTracking()
.AnyAsync(e => EF.Property<int>(e, "Id") == id, cancellationToken);
return exists
? normalized
: new NotFoundError($"{label} {id} does not exist.");
}
private sealed record NormalizedLineupItem(
int Index,
CollectionType CollectionType,
int? CollectionId = null,
int? MultiCollectionId = null,
int? SmartCollectionId = null,
int? RerunCollectionId = null,
int? MediaItemId = null,
int? PlaylistId = null);
private sealed record PreparedCreate(
Channel Channel,
Playlist Playlist,
ProgramSchedule ProgramSchedule,
Playout Playout);
}
@@ -0,0 +1,10 @@
using ErsatzTV.Core.Api.LibraryBrowse;
namespace ErsatzTV.Application.LibraryBrowse;
public record GetLibraryBrowseItems(
string Query,
int? LibraryId,
LibraryBrowseMediaType? MediaType,
int PageNum,
int PageSize) : IRequest<PagedLibraryBrowseItemsResponseModel>;
@@ -0,0 +1,953 @@
using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Jellyfin;
using ErsatzTV.Core.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Search;
using Flurl;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.LibraryBrowse;
public class GetLibraryBrowseItemsHandler(
ISearchIndex searchIndex,
IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetLibraryBrowseItems, PagedLibraryBrowseItemsResponseModel>
{
private const string LibraryIdField = "library_id";
public async Task<PagedLibraryBrowseItemsResponseModel> Handle(
GetLibraryBrowseItems request,
CancellationToken cancellationToken)
{
int offset = request.PageNum * request.PageSize;
SearchResult mediaResult = await SearchMedia(request, offset, request.PageSize, cancellationToken);
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
List<LibraryBrowseItemResponseModel> mediaItems = await HydrateMediaItems(
dbContext,
mediaResult.Items,
cancellationToken);
int collectionTotal = await CountCollections(dbContext, request, cancellationToken);
// Lucene total count can briefly include stale media ids until the async rescan catches up; collection
// paging may drift in that window, matching the staleness behavior accepted by the existing search UI.
int collectionSkip = Math.Max(0, offset - mediaResult.TotalCount);
int collectionTake = request.PageSize - mediaItems.Count;
List<LibraryBrowseItemResponseModel> collectionItems = collectionTake > 0
? await GetCollectionItems(dbContext, request, collectionSkip, collectionTake, cancellationToken)
: [];
return new PagedLibraryBrowseItemsResponseModel(
mediaResult.TotalCount + collectionTotal,
mediaItems.Concat(collectionItems).ToList());
}
private async Task<SearchResult> SearchMedia(
GetLibraryBrowseItems request,
int offset,
int pageSize,
CancellationToken cancellationToken)
{
List<string> typeFilters = MediaTypesFor(request.MediaType).ToList();
if (typeFilters.Count == 0)
{
return new SearchResult([], 0);
}
var clauses = new List<string>
{
typeFilters.Count == 1
? $"type:{typeFilters[0]}"
: $"({string.Join(" OR ", typeFilters.Map(t => $"type:{t}"))})"
};
if (request.LibraryId.HasValue)
{
clauses.Add($"{LibraryIdField}:{request.LibraryId.Value}");
}
if (!string.IsNullOrWhiteSpace(request.Query))
{
clauses.Add($"({request.Query})");
}
return await searchIndex.Search(
string.Join(" AND ", clauses),
string.Empty,
offset,
pageSize,
[LuceneSearchIndex.TitleAndYearSearchField],
cancellationToken);
}
private static IEnumerable<string> MediaTypesFor(LibraryBrowseMediaType? mediaType) =>
mediaType switch
{
LibraryBrowseMediaType.Movie => [LuceneSearchIndex.MovieType],
LibraryBrowseMediaType.TelevisionShow => [LuceneSearchIndex.ShowType],
LibraryBrowseMediaType.TelevisionSeason => [LuceneSearchIndex.SeasonType],
LibraryBrowseMediaType.Artist => [LuceneSearchIndex.ArtistType],
null =>
[
LuceneSearchIndex.MovieType,
LuceneSearchIndex.ShowType,
LuceneSearchIndex.SeasonType,
LuceneSearchIndex.ArtistType
],
_ => []
};
private static async Task<List<LibraryBrowseItemResponseModel>> HydrateMediaItems(
TvContext dbContext,
List<SearchItem> searchItems,
CancellationToken cancellationToken)
{
if (searchItems.Count == 0)
{
return [];
}
List<int> movieIds = searchItems.Where(i => i.Type == LuceneSearchIndex.MovieType).Select(i => i.Id).ToList();
List<int> showIds = searchItems.Where(i => i.Type == LuceneSearchIndex.ShowType).Select(i => i.Id).ToList();
List<int> seasonIds = searchItems.Where(i => i.Type == LuceneSearchIndex.SeasonType).Select(i => i.Id).ToList();
List<int> artistIds = searchItems.Where(i => i.Type == LuceneSearchIndex.ArtistType).Select(i => i.Id).ToList();
Dictionary<(string Type, int Id), LibraryBrowseItemResponseModel> hydrated = [];
foreach (LibraryBrowseItemResponseModel item in await GetMovies(dbContext, movieIds, cancellationToken))
{
hydrated[(LuceneSearchIndex.MovieType, item.Id)] = item;
}
foreach (LibraryBrowseItemResponseModel item in await GetShows(dbContext, showIds, cancellationToken))
{
hydrated[(LuceneSearchIndex.ShowType, item.Id)] = item;
}
foreach (LibraryBrowseItemResponseModel item in await GetSeasons(dbContext, seasonIds, cancellationToken))
{
hydrated[(LuceneSearchIndex.SeasonType, item.Id)] = item;
}
foreach (LibraryBrowseItemResponseModel item in await GetArtists(dbContext, artistIds, cancellationToken))
{
hydrated[(LuceneSearchIndex.ArtistType, item.Id)] = item;
}
return searchItems
.Where(i => hydrated.ContainsKey((i.Type, i.Id)))
.Select(i => hydrated[(i.Type, i.Id)])
.ToList();
}
private static async Task<List<LibraryBrowseItemResponseModel>> GetMovies(
TvContext dbContext,
List<int> ids,
CancellationToken cancellationToken)
{
if (ids.Count == 0)
{
return [];
}
return await dbContext.MovieMetadata
.AsNoTracking()
.Where(mm => ids.Contains(mm.MovieId))
.Include(mm => mm.Artwork)
.Include(mm => mm.Movie)
.ThenInclude(m => m.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(mm => mm.Movie)
.ThenInclude(m => m.MediaVersions)
.ToListAsync(cancellationToken)
.Map(list => list
.GroupBy(mm => mm.MovieId)
.Select(g => g.OrderBy(mm => mm.Id).First())
.Map(mm => new LibraryBrowseItemResponseModel(
mm.MovieId,
LibraryBrowseMediaType.Movie,
mm.Title ?? string.Empty,
mm.Movie.LibraryPath.LibraryId,
mm.Movie.LibraryPath.Library.Name,
Artwork(mm, ArtworkKind.Poster),
BestDuration(mm.Movie.MediaVersions),
1,
null,
CollectionType.Movie,
null,
null,
null,
null,
mm.MovieId,
null)).ToList());
}
private static async Task<List<LibraryBrowseItemResponseModel>> GetShows(
TvContext dbContext,
List<int> ids,
CancellationToken cancellationToken)
{
if (ids.Count == 0)
{
return [];
}
Dictionary<int, int> counts = await dbContext.Episodes
.AsNoTracking()
.Where(e => ids.Contains(e.Season.ShowId))
.GroupBy(e => e.Season.ShowId)
.Select(g => new { ShowId = g.Key, Count = g.Count() })
.ToDictionaryAsync(g => g.ShowId, g => g.Count, cancellationToken);
return await dbContext.ShowMetadata
.AsNoTracking()
.Where(sm => ids.Contains(sm.ShowId))
.Include(sm => sm.Artwork)
.Include(sm => sm.Show)
.ThenInclude(s => s.LibraryPath)
.ThenInclude(lp => lp.Library)
.ToListAsync(cancellationToken)
.Map(list => list
.GroupBy(sm => sm.ShowId)
.Select(g => g.OrderBy(sm => sm.Id).First())
.Map(sm => new LibraryBrowseItemResponseModel(
sm.ShowId,
LibraryBrowseMediaType.TelevisionShow,
sm.Title ?? string.Empty,
sm.Show.LibraryPath.LibraryId,
sm.Show.LibraryPath.Library.Name,
Artwork(sm, ArtworkKind.Poster),
null,
counts.TryGetValue(sm.ShowId, out int count) ? count : 0,
null,
CollectionType.TelevisionShow,
null,
null,
null,
null,
sm.ShowId,
null)).ToList());
}
private static async Task<List<LibraryBrowseItemResponseModel>> GetSeasons(
TvContext dbContext,
List<int> ids,
CancellationToken cancellationToken)
{
if (ids.Count == 0)
{
return [];
}
Dictionary<int, int> counts = await dbContext.Episodes
.AsNoTracking()
.Where(e => ids.Contains(e.SeasonId))
.GroupBy(e => e.SeasonId)
.Select(g => new { SeasonId = g.Key, Count = g.Count() })
.ToDictionaryAsync(g => g.SeasonId, g => g.Count, cancellationToken);
return await dbContext.SeasonMetadata
.AsNoTracking()
.Where(sm => ids.Contains(sm.SeasonId))
.Include(sm => sm.Artwork)
.Include(sm => sm.Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.Include(sm => sm.Season)
.ThenInclude(s => s.LibraryPath)
.ThenInclude(lp => lp.Library)
.ToListAsync(cancellationToken)
.Map(list => list
.GroupBy(sm => sm.SeasonId)
.Select(g => g.OrderBy(sm => sm.Id).First())
.Map(sm => new LibraryBrowseItemResponseModel(
sm.SeasonId,
LibraryBrowseMediaType.TelevisionSeason,
SeasonTitle(sm),
sm.Season.LibraryPath.LibraryId,
sm.Season.LibraryPath.Library.Name,
Artwork(sm, ArtworkKind.Poster),
null,
counts.TryGetValue(sm.SeasonId, out int count) ? count : 0,
null,
CollectionType.TelevisionSeason,
null,
null,
null,
null,
sm.SeasonId,
null)).ToList());
}
private static async Task<List<LibraryBrowseItemResponseModel>> GetArtists(
TvContext dbContext,
List<int> ids,
CancellationToken cancellationToken)
{
if (ids.Count == 0)
{
return [];
}
Dictionary<int, int> counts = await dbContext.MusicVideos
.AsNoTracking()
.Where(mv => ids.Contains(mv.ArtistId))
.GroupBy(mv => mv.ArtistId)
.Select(g => new { ArtistId = g.Key, Count = g.Count() })
.ToDictionaryAsync(g => g.ArtistId, g => g.Count, cancellationToken);
return await dbContext.ArtistMetadata
.AsNoTracking()
.Where(am => ids.Contains(am.ArtistId))
.Include(am => am.Artwork)
.Include(am => am.Artist)
.ThenInclude(a => a.LibraryPath)
.ThenInclude(lp => lp.Library)
.ToListAsync(cancellationToken)
.Map(list => list
.GroupBy(am => am.ArtistId)
.Select(g => g.OrderBy(am => am.Id).First())
.Map(am => new LibraryBrowseItemResponseModel(
am.ArtistId,
LibraryBrowseMediaType.Artist,
am.Title ?? string.Empty,
am.Artist.LibraryPath.LibraryId,
am.Artist.LibraryPath.Library.Name,
Artwork(am, ArtworkKind.Thumbnail),
null,
counts.TryGetValue(am.ArtistId, out int count) ? count : 0,
null,
CollectionType.Artist,
null,
null,
null,
null,
am.ArtistId,
null)).ToList());
}
private static async Task<int> CountCollections(
TvContext dbContext,
GetLibraryBrowseItems request,
CancellationToken cancellationToken)
{
int count = 0;
if (ShouldInclude(request.MediaType, LibraryBrowseMediaType.Collection))
{
count += await FilterCollections(dbContext.Collections.AsNoTracking(), request)
.CountAsync(cancellationToken);
}
if (!request.LibraryId.HasValue && ShouldInclude(request.MediaType, LibraryBrowseMediaType.SmartCollection))
{
count += await FilterByName(dbContext.SmartCollections.AsNoTracking(), request.Query)
.CountAsync(cancellationToken);
}
if (!request.LibraryId.HasValue && ShouldInclude(request.MediaType, LibraryBrowseMediaType.MultiCollection))
{
count += await FilterByName(dbContext.MultiCollections.AsNoTracking(), request.Query)
.CountAsync(cancellationToken);
}
if (!request.LibraryId.HasValue && ShouldInclude(request.MediaType, LibraryBrowseMediaType.RerunCollection))
{
count += await FilterByName(dbContext.RerunCollections.AsNoTracking(), request.Query)
.CountAsync(cancellationToken);
}
if (!request.LibraryId.HasValue && ShouldInclude(request.MediaType, LibraryBrowseMediaType.Playlist))
{
count += await FilterByName(dbContext.Playlists.AsNoTracking(), request.Query)
.CountAsync(cancellationToken);
}
return count;
}
private static async Task<List<LibraryBrowseItemResponseModel>> GetCollectionItems(
TvContext dbContext,
GetLibraryBrowseItems request,
int skip,
int take,
CancellationToken cancellationToken)
{
var results = new List<LibraryBrowseItemResponseModel>();
int remainingSkip = skip;
if (ShouldInclude(request.MediaType, LibraryBrowseMediaType.Collection))
{
IQueryable<Collection> collectionQuery = FilterCollections(dbContext.Collections.AsNoTracking(), request);
int count = await collectionQuery.CountAsync(cancellationToken);
int pageSkip = Math.Min(remainingSkip, count);
remainingSkip -= pageSkip;
List<Collection> collections = await collectionQuery
.OrderBy(c => c.Name)
.Skip(pageSkip)
.Take(take)
.ToListAsync(cancellationToken);
List<int> collectionIds = collections.Map(c => c.Id).ToList();
Dictionary<int, int> itemCounts = await dbContext.CollectionItems
.AsNoTracking()
.Where(ci => collectionIds.Contains(ci.CollectionId))
.GroupBy(ci => ci.CollectionId)
.Select(g => new { CollectionId = g.Key, Count = g.Count() })
.ToDictionaryAsync(g => g.CollectionId, g => g.Count, cancellationToken);
Dictionary<int, TimeSpan?> durations = await GetManualCollectionDurations(
dbContext,
collectionIds,
cancellationToken);
Dictionary<int, string> artwork = await GetManualCollectionArtwork(dbContext, collectionIds, cancellationToken);
results.AddRange(collections.Map(c => new LibraryBrowseItemResponseModel(
c.Id,
LibraryBrowseMediaType.Collection,
c.Name,
null,
null,
artwork.TryGetValue(c.Id, out string poster) ? poster : string.Empty,
durations.TryGetValue(c.Id, out TimeSpan? duration) ? duration : null,
itemCounts.TryGetValue(c.Id, out int itemCount) ? itemCount : 0,
"Manual",
CollectionType.Collection,
c.Id,
null,
null,
null,
null,
null)));
}
if (results.Count >= take || request.LibraryId.HasValue)
{
return results.Take(take).ToList();
}
remainingSkip = await AppendSmartCollections(dbContext, request, results, remainingSkip, take, cancellationToken);
remainingSkip = await AppendMultiCollections(dbContext, request, results, remainingSkip, take, cancellationToken);
remainingSkip = await AppendRerunCollections(dbContext, request, results, remainingSkip, take, cancellationToken);
await AppendPlaylists(dbContext, request, results, remainingSkip, take, cancellationToken);
return results.Take(take).ToList();
}
private static async Task<int> AppendSmartCollections(
TvContext dbContext,
GetLibraryBrowseItems request,
List<LibraryBrowseItemResponseModel> results,
int skip,
int take,
CancellationToken cancellationToken)
{
if (!ShouldInclude(request.MediaType, LibraryBrowseMediaType.SmartCollection) || results.Count >= take)
{
return skip;
}
IQueryable<SmartCollection> query = FilterByName(dbContext.SmartCollections.AsNoTracking(), request.Query);
int count = await query.CountAsync(cancellationToken);
int pageSkip = Math.Min(skip, count);
List<SmartCollection> page = await query
.OrderBy(c => c.Name)
.Skip(pageSkip)
.Take(take - results.Count)
.ToListAsync(cancellationToken);
results.AddRange(page.Map(c => new LibraryBrowseItemResponseModel(
c.Id,
LibraryBrowseMediaType.SmartCollection,
c.Name,
null,
null,
string.Empty,
null,
null,
"Smart",
CollectionType.SmartCollection,
null,
null,
c.Id,
null,
null,
null)));
return skip - pageSkip;
}
private static async Task<int> AppendMultiCollections(
TvContext dbContext,
GetLibraryBrowseItems request,
List<LibraryBrowseItemResponseModel> results,
int skip,
int take,
CancellationToken cancellationToken)
{
if (!ShouldInclude(request.MediaType, LibraryBrowseMediaType.MultiCollection) || results.Count >= take)
{
return skip;
}
IQueryable<MultiCollection> query = FilterByName(dbContext.MultiCollections.AsNoTracking(), request.Query);
int count = await query.CountAsync(cancellationToken);
int pageSkip = Math.Min(skip, count);
List<MultiCollection> page = await query
.OrderBy(c => c.Name)
.Skip(pageSkip)
.Take(take - results.Count)
.ToListAsync(cancellationToken);
List<int> ids = page.Map(c => c.Id).ToList();
Dictionary<int, int> collectionCounts = await dbContext.Set<MultiCollectionItem>()
.AsNoTracking()
.Where(i => ids.Contains(i.MultiCollectionId))
.GroupBy(i => i.MultiCollectionId)
.Select(g => new { MultiCollectionId = g.Key, Count = g.Count() })
.ToDictionaryAsync(g => g.MultiCollectionId, g => g.Count, cancellationToken);
Dictionary<int, int> smartCollectionCounts = await dbContext.Set<MultiCollectionSmartItem>()
.AsNoTracking()
.Where(i => ids.Contains(i.MultiCollectionId))
.GroupBy(i => i.MultiCollectionId)
.Select(g => new { MultiCollectionId = g.Key, Count = g.Count() })
.ToDictionaryAsync(g => g.MultiCollectionId, g => g.Count, cancellationToken);
results.AddRange(page.Map(c => new LibraryBrowseItemResponseModel(
c.Id,
LibraryBrowseMediaType.MultiCollection,
c.Name,
null,
null,
string.Empty,
null,
(collectionCounts.TryGetValue(c.Id, out int collectionCount) ? collectionCount : 0) +
(smartCollectionCounts.TryGetValue(c.Id, out int smartCollectionCount) ? smartCollectionCount : 0),
"Multi",
CollectionType.MultiCollection,
null,
c.Id,
null,
null,
null,
null)));
return skip - pageSkip;
}
private static async Task<int> AppendRerunCollections(
TvContext dbContext,
GetLibraryBrowseItems request,
List<LibraryBrowseItemResponseModel> results,
int skip,
int take,
CancellationToken cancellationToken)
{
if (!ShouldInclude(request.MediaType, LibraryBrowseMediaType.RerunCollection) || results.Count >= take)
{
return skip;
}
IQueryable<RerunCollection> query = FilterByName(dbContext.RerunCollections.AsNoTracking(), request.Query);
int count = await query.CountAsync(cancellationToken);
int pageSkip = Math.Min(skip, count);
List<RerunCollection> page = await query
.OrderBy(c => c.Name)
.Skip(pageSkip)
.Take(take - results.Count)
.ToListAsync(cancellationToken);
results.AddRange(page.Map(c => new LibraryBrowseItemResponseModel(
c.Id,
LibraryBrowseMediaType.RerunCollection,
c.Name,
null,
null,
string.Empty,
null,
null,
"Rerun",
// RerunFirstRun addresses the first-run side; the SPA can substitute RerunRerun for rerun schedule items.
CollectionType.RerunFirstRun,
null,
null,
null,
c.Id,
null,
null)));
return skip - pageSkip;
}
private static async Task AppendPlaylists(
TvContext dbContext,
GetLibraryBrowseItems request,
List<LibraryBrowseItemResponseModel> results,
int skip,
int take,
CancellationToken cancellationToken)
{
if (!ShouldInclude(request.MediaType, LibraryBrowseMediaType.Playlist) || results.Count >= take)
{
return;
}
IQueryable<Playlist> query = FilterByName(dbContext.Playlists.AsNoTracking(), request.Query);
int count = await query.CountAsync(cancellationToken);
int pageSkip = Math.Min(skip, count);
List<Playlist> page = await query
.OrderBy(c => c.Name)
.Skip(pageSkip)
.Take(take - results.Count)
.ToListAsync(cancellationToken);
List<int> ids = page.Map(c => c.Id).ToList();
Dictionary<int, int> itemCounts = await dbContext.PlaylistItems
.AsNoTracking()
.Where(i => ids.Contains(i.PlaylistId))
.GroupBy(i => i.PlaylistId)
.Select(g => new { PlaylistId = g.Key, Count = g.Count() })
.ToDictionaryAsync(g => g.PlaylistId, g => g.Count, cancellationToken);
results.AddRange(page.Map(c => new LibraryBrowseItemResponseModel(
c.Id,
LibraryBrowseMediaType.Playlist,
c.Name,
null,
null,
string.Empty,
null,
itemCounts.TryGetValue(c.Id, out int itemCount) ? itemCount : 0,
null,
CollectionType.Playlist,
null,
null,
null,
null,
null,
c.Id)));
}
private static IQueryable<Collection> FilterCollections(
IQueryable<Collection> query,
GetLibraryBrowseItems request)
{
query = FilterByName(query, request.Query);
if (request.LibraryId.HasValue)
{
query = query.Where(c => c.MediaItems.Any(mi => mi.LibraryPath.LibraryId == request.LibraryId.Value));
}
return query;
}
private static IQueryable<T> FilterByName<T>(IQueryable<T> query, string searchQuery)
where T : class
{
if (string.IsNullOrWhiteSpace(searchQuery))
{
return query;
}
return query.Where(c => EF.Functions.Like(EF.Property<string>(c, "Name"), $"%{EscapeLike(searchQuery)}%", "\\"));
}
private static bool ShouldInclude(LibraryBrowseMediaType? requestType, LibraryBrowseMediaType itemType) =>
requestType is null || requestType == itemType;
private static TimeSpan? BestDuration(IEnumerable<MediaVersion> versions)
{
TimeSpan duration = versions
.Select(v => v.Duration)
.Where(d => d > TimeSpan.Zero)
.DefaultIfEmpty()
.Max();
return duration > TimeSpan.Zero ? duration : null;
}
private static async Task<Dictionary<int, TimeSpan?>> GetManualCollectionDurations(
TvContext dbContext,
List<int> collectionIds,
CancellationToken cancellationToken)
{
List<CollectionMediaItem> items = await GetCollectionMediaItems(dbContext, collectionIds, cancellationToken);
List<int> mediaItemIds = items.Map(i => i.MediaItemId).Distinct().ToList();
Dictionary<int, TimeSpan> mediaItemDurations = [];
foreach (Movie movie in await dbContext.Movies
.AsNoTracking()
.Where(m => mediaItemIds.Contains(m.Id))
.Include(m => m.MediaVersions)
.ToListAsync(cancellationToken))
{
TimeSpan? duration = BestDuration(movie.MediaVersions);
if (duration.HasValue)
{
mediaItemDurations[movie.Id] = duration.Value;
}
}
foreach (Episode episode in await dbContext.Episodes
.AsNoTracking()
.Where(e => mediaItemIds.Contains(e.Id))
.Include(e => e.MediaVersions)
.ToListAsync(cancellationToken))
{
TimeSpan? duration = BestDuration(episode.MediaVersions);
if (duration.HasValue)
{
mediaItemDurations[episode.Id] = duration.Value;
}
}
foreach (MusicVideo musicVideo in await dbContext.MusicVideos
.AsNoTracking()
.Where(mv => mediaItemIds.Contains(mv.Id))
.Include(mv => mv.MediaVersions)
.ToListAsync(cancellationToken))
{
TimeSpan? duration = BestDuration(musicVideo.MediaVersions);
if (duration.HasValue)
{
mediaItemDurations[musicVideo.Id] = duration.Value;
}
}
foreach (OtherVideo otherVideo in await dbContext.OtherVideos
.AsNoTracking()
.Where(ov => mediaItemIds.Contains(ov.Id))
.Include(ov => ov.MediaVersions)
.ToListAsync(cancellationToken))
{
TimeSpan? duration = BestDuration(otherVideo.MediaVersions);
if (duration.HasValue)
{
mediaItemDurations[otherVideo.Id] = duration.Value;
}
}
foreach (Song song in await dbContext.Songs
.AsNoTracking()
.Where(s => mediaItemIds.Contains(s.Id))
.Include(s => s.MediaVersions)
.ToListAsync(cancellationToken))
{
TimeSpan? duration = BestDuration(song.MediaVersions);
if (duration.HasValue)
{
mediaItemDurations[song.Id] = duration.Value;
}
}
foreach (Image image in await dbContext.Images
.AsNoTracking()
.Where(i => mediaItemIds.Contains(i.Id))
.Include(i => i.MediaVersions)
.ToListAsync(cancellationToken))
{
TimeSpan? duration = BestDuration(image.MediaVersions);
if (duration.HasValue)
{
mediaItemDurations[image.Id] = duration.Value;
}
}
foreach (RemoteStream remoteStream in await dbContext.RemoteStreams
.AsNoTracking()
.Where(rs => mediaItemIds.Contains(rs.Id))
.Include(rs => rs.MediaVersions)
.ToListAsync(cancellationToken))
{
TimeSpan? duration = BestDuration(remoteStream.MediaVersions);
if (duration.HasValue)
{
mediaItemDurations[remoteStream.Id] = duration.Value;
}
}
return items
.Where(i => mediaItemDurations.ContainsKey(i.MediaItemId))
.GroupBy(i => i.CollectionId)
.ToDictionary(
g => g.Key,
g => (TimeSpan?)g.Aggregate(TimeSpan.Zero, (sum, item) => sum + mediaItemDurations[item.MediaItemId]));
}
private static async Task<Dictionary<int, string>> GetManualCollectionArtwork(
TvContext dbContext,
List<int> collectionIds,
CancellationToken cancellationToken)
{
List<CollectionMediaItem> items = await GetCollectionMediaItems(dbContext, collectionIds, cancellationToken);
List<int> mediaItemIds = items.Map(i => i.MediaItemId).Distinct().ToList();
Dictionary<int, string> mediaItemArtwork = [];
foreach (MovieMetadata metadata in await dbContext.MovieMetadata
.AsNoTracking()
.Where(mm => mediaItemIds.Contains(mm.MovieId))
.Include(mm => mm.Artwork)
.OrderBy(mm => mm.Id)
.ToListAsync(cancellationToken))
{
string poster = Artwork(metadata, ArtworkKind.Poster);
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.MovieId))
{
mediaItemArtwork[metadata.MovieId] = poster;
}
}
foreach (ShowMetadata metadata in await dbContext.ShowMetadata
.AsNoTracking()
.Where(sm => mediaItemIds.Contains(sm.ShowId))
.Include(sm => sm.Artwork)
.OrderBy(sm => sm.Id)
.ToListAsync(cancellationToken))
{
string poster = Artwork(metadata, ArtworkKind.Poster);
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.ShowId))
{
mediaItemArtwork[metadata.ShowId] = poster;
}
}
foreach (SeasonMetadata metadata in await dbContext.SeasonMetadata
.AsNoTracking()
.Where(sm => mediaItemIds.Contains(sm.SeasonId))
.Include(sm => sm.Artwork)
.OrderBy(sm => sm.Id)
.ToListAsync(cancellationToken))
{
string poster = Artwork(metadata, ArtworkKind.Poster);
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.SeasonId))
{
mediaItemArtwork[metadata.SeasonId] = poster;
}
}
foreach (OtherVideoMetadata metadata in await dbContext.OtherVideoMetadata
.AsNoTracking()
.Where(ovm => mediaItemIds.Contains(ovm.OtherVideoId))
.Include(ovm => ovm.Artwork)
.OrderBy(ovm => ovm.Id)
.ToListAsync(cancellationToken))
{
string poster = Artwork(metadata, ArtworkKind.Poster);
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.OtherVideoId))
{
mediaItemArtwork[metadata.OtherVideoId] = poster;
}
}
foreach (SongMetadata metadata in await dbContext.SongMetadata
.AsNoTracking()
.Where(sm => mediaItemIds.Contains(sm.SongId))
.Include(sm => sm.Artwork)
.OrderBy(sm => sm.Id)
.ToListAsync(cancellationToken))
{
string poster = Artwork(metadata, ArtworkKind.Poster);
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.SongId))
{
mediaItemArtwork[metadata.SongId] = poster;
}
}
foreach (ImageMetadata metadata in await dbContext.ImageMetadata
.AsNoTracking()
.Where(im => mediaItemIds.Contains(im.ImageId))
.Include(im => im.Artwork)
.OrderBy(im => im.Id)
.ToListAsync(cancellationToken))
{
string poster = Artwork(metadata, ArtworkKind.Poster);
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.ImageId))
{
mediaItemArtwork[metadata.ImageId] = poster;
}
}
foreach (RemoteStreamMetadata metadata in await dbContext.RemoteStreamMetadata
.AsNoTracking()
.Where(rsm => mediaItemIds.Contains(rsm.RemoteStreamId))
.Include(rsm => rsm.Artwork)
.OrderBy(rsm => rsm.Id)
.ToListAsync(cancellationToken))
{
string poster = Artwork(metadata, ArtworkKind.Poster);
if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.RemoteStreamId))
{
mediaItemArtwork[metadata.RemoteStreamId] = poster;
}
}
return items
.Where(i => mediaItemArtwork.ContainsKey(i.MediaItemId))
.GroupBy(i => i.CollectionId)
.ToDictionary(
g => g.Key,
g => mediaItemArtwork[g.OrderBy(i => i.CustomIndex ?? int.MaxValue).ThenBy(i => i.MediaItemId).First().MediaItemId]);
}
private static async Task<List<CollectionMediaItem>> GetCollectionMediaItems(
TvContext dbContext,
List<int> collectionIds,
CancellationToken cancellationToken) =>
await dbContext.CollectionItems
.AsNoTracking()
.Where(ci => collectionIds.Contains(ci.CollectionId))
.Select(ci => new CollectionMediaItem(ci.CollectionId, ci.MediaItemId, ci.CustomIndex))
.ToListAsync(cancellationToken);
private static string SeasonTitle(SeasonMetadata metadata)
{
string showTitle = metadata.Season.Show.ShowMetadata.HeadOrNone()
.Map(sm => sm.Title ?? string.Empty)
.IfNone(string.Empty);
string seasonTitle = metadata.Season.SeasonNumber == 0
? "Specials"
: $"Season {metadata.Season.SeasonNumber}";
return string.IsNullOrWhiteSpace(showTitle) ? seasonTitle : $"{showTitle} - {seasonTitle}";
}
private static string Artwork(Metadata metadata, ArtworkKind artworkKind)
{
string artwork = Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == artworkKind))
.Match(a => a.Path, string.Empty);
if (artwork.StartsWith("jellyfin://", StringComparison.OrdinalIgnoreCase))
{
Url url = JellyfinUrl.RelativeProxyForArtwork(artwork);
if (artworkKind is ArtworkKind.Poster or ArtworkKind.Thumbnail)
{
url.SetQueryParam("fillHeight", 440);
}
return url;
}
if (artwork.StartsWith("emby://", StringComparison.OrdinalIgnoreCase))
{
Url url = EmbyUrl.RelativeProxyForArtwork(artwork);
if (artworkKind is ArtworkKind.Poster or ArtworkKind.Thumbnail)
{
url.SetQueryParam("maxHeight", 440);
}
return url;
}
return artwork;
}
private static string EscapeLike(string searchQuery) =>
searchQuery
.Replace("\\", "\\\\", StringComparison.Ordinal)
.Replace("%", "\\%", StringComparison.Ordinal)
.Replace("_", "\\_", StringComparison.Ordinal);
private sealed record CollectionMediaItem(int CollectionId, int MediaItemId, int? CustomIndex);
}
@@ -0,0 +1,36 @@
#nullable enable
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Core.Api.ChannelTemplates;
public record ChannelTemplateResponseModel(
int Id,
string Name,
string Description,
bool IsSystem,
bool IsDefault,
int FFmpegProfileId,
int? WatermarkId,
int? FallbackFillerId,
int? PreRollFillerId,
int? MidRollFillerId,
int? PostRollFillerId,
ChannelStreamSelectorMode StreamSelectorMode,
string? StreamSelector,
string? PreferredAudioLanguageCode,
string? PreferredAudioTitle,
ChannelPlayoutSource PlayoutSource,
ChannelPlayoutMode PlayoutMode,
StreamingMode StreamingMode,
string? PreferredSubtitleLanguageCode,
ChannelSubtitleMode SubtitleMode,
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
string? MusicVideoCreditsTemplate,
ChannelSongVideoMode SongVideoMode,
ChannelTranscodeMode TranscodeMode,
ChannelIdleBehavior IdleBehavior,
bool ShuffleScheduleItems,
bool RandomStartPoint,
FixedStartTimeBehavior FixedStartTimeBehavior);
@@ -0,0 +1,9 @@
#nullable enable
namespace ErsatzTV.Core.Api.Channels;
public record CreateChannelFromLineupResponseModel(
int ChannelId,
int? PlaylistId,
int ProgramScheduleId,
int PlayoutId);
@@ -0,0 +1,22 @@
#nullable enable
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Api.LibraryBrowse;
public record LibraryBrowseItemResponseModel(
int Id,
LibraryBrowseMediaType MediaType,
string Title,
int? LibraryId,
string? LibraryName,
string Artwork,
TimeSpan? Duration,
int? ItemCount,
string? CollectionKind,
CollectionType CollectionType,
int? CollectionId,
int? MultiCollectionId,
int? SmartCollectionId,
int? RerunCollectionId,
int? MediaItemId,
int? PlaylistId);
@@ -0,0 +1,15 @@
#nullable enable
namespace ErsatzTV.Core.Api.LibraryBrowse;
public enum LibraryBrowseMediaType
{
Movie = 1,
TelevisionShow = 2,
TelevisionSeason = 3,
Artist = 4,
Collection = 5,
SmartCollection = 6,
MultiCollection = 7,
RerunCollection = 8,
Playlist = 9
}
@@ -0,0 +1,6 @@
#nullable enable
namespace ErsatzTV.Core.Api.LibraryBrowse;
public record PagedLibraryBrowseItemsResponseModel(
int TotalCount,
List<LibraryBrowseItemResponseModel> Page);
+41
View File
@@ -0,0 +1,41 @@
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Core.Domain;
public class ChannelTemplate
{
public int Id { get; set; }
public string Name { get; set; }
public string Description { get; set; }
public bool IsSystem { get; set; }
public int FFmpegProfileId { get; set; }
public FFmpegProfile FFmpegProfile { get; set; }
public int? WatermarkId { get; set; }
public ChannelWatermark Watermark { get; set; }
public int? FallbackFillerId { get; set; }
public FillerPreset FallbackFiller { get; set; }
public int? PreRollFillerId { get; set; }
public FillerPreset PreRollFiller { get; set; }
public int? MidRollFillerId { get; set; }
public FillerPreset MidRollFiller { get; set; }
public int? PostRollFillerId { get; set; }
public FillerPreset PostRollFiller { get; set; }
public ChannelStreamSelectorMode StreamSelectorMode { get; set; }
public string StreamSelector { get; set; }
public string PreferredAudioLanguageCode { get; set; }
public string PreferredAudioTitle { get; set; }
public ChannelPlayoutSource PlayoutSource { get; set; }
public ChannelPlayoutMode PlayoutMode { get; set; }
public StreamingMode StreamingMode { get; set; }
public string PreferredSubtitleLanguageCode { get; set; }
public ChannelSubtitleMode SubtitleMode { get; set; }
public ChannelMusicVideoCreditsMode MusicVideoCreditsMode { get; set; }
public string MusicVideoCreditsTemplate { get; set; }
public ChannelSongVideoMode SongVideoMode { get; set; }
public ChannelTranscodeMode TranscodeMode { get; set; }
public ChannelIdleBehavior IdleBehavior { get; set; }
public bool ShuffleScheduleItems { get; set; }
public bool RandomStartPoint { get; set; }
public FixedStartTimeBehavior FixedStartTimeBehavior { get; set; }
}
+1
View File
@@ -23,6 +23,7 @@ public class ConfigElementKey
public static ConfigElementKey FFmpegPreferredLanguageCode => new("ffmpeg.preferred_language_code");
public static ConfigElementKey FFmpegGlobalWatermarkId => new("ffmpeg.global_watermark_id");
public static ConfigElementKey FFmpegGlobalFallbackFillerId => new("ffmpeg.global_fallback_filler_id");
public static ConfigElementKey ChannelTemplatesDefaultTemplateId => new("channel_templates.default_template_id");
public static ConfigElementKey FFmpegSegmenterTimeout => new("ffmpeg.segmenter.timeout_seconds");
public static ConfigElementKey FFmpegWorkAheadSegmenters => new("ffmpeg.segmenter.work_ahead_limit");
public static ConfigElementKey FFmpegInitialSegmentCount => new("ffmpeg.segmenter.initial_segment_count");
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,140 @@
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class AddChannelTemplates : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ChannelTemplate",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
Name = table.Column<string>(type: "varchar(50)", maxLength: 50, nullable: false, collation: "utf8mb4_general_ci")
.Annotation("MySql:CharSet", "utf8mb4"),
Description = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: false, defaultValue: "")
.Annotation("MySql:CharSet", "utf8mb4"),
IsSystem = table.Column<bool>(type: "tinyint(1)", nullable: false),
FFmpegProfileId = table.Column<int>(type: "int", nullable: false),
WatermarkId = table.Column<int>(type: "int", nullable: true),
FallbackFillerId = table.Column<int>(type: "int", nullable: true),
PreRollFillerId = table.Column<int>(type: "int", nullable: true),
MidRollFillerId = table.Column<int>(type: "int", nullable: true),
PostRollFillerId = table.Column<int>(type: "int", nullable: true),
StreamSelectorMode = table.Column<int>(type: "int", nullable: false),
StreamSelector = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
PreferredAudioLanguageCode = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
PreferredAudioTitle = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
PlayoutSource = table.Column<int>(type: "int", nullable: false),
PlayoutMode = table.Column<int>(type: "int", nullable: false),
StreamingMode = table.Column<int>(type: "int", nullable: false),
PreferredSubtitleLanguageCode = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
SubtitleMode = table.Column<int>(type: "int", nullable: false),
MusicVideoCreditsMode = table.Column<int>(type: "int", nullable: false),
MusicVideoCreditsTemplate = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
SongVideoMode = table.Column<int>(type: "int", nullable: false),
TranscodeMode = table.Column<int>(type: "int", nullable: false),
IdleBehavior = table.Column<int>(type: "int", nullable: false),
ShuffleScheduleItems = table.Column<bool>(type: "tinyint(1)", nullable: false),
RandomStartPoint = table.Column<bool>(type: "tinyint(1)", nullable: false),
FixedStartTimeBehavior = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ChannelTemplate", x => x.Id);
table.ForeignKey(
name: "FK_ChannelTemplate_ChannelWatermark_WatermarkId",
column: x => x.WatermarkId,
principalTable: "ChannelWatermark",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_ChannelTemplate_FFmpegProfile_FFmpegProfileId",
column: x => x.FFmpegProfileId,
principalTable: "FFmpegProfile",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_ChannelTemplate_FillerPreset_FallbackFillerId",
column: x => x.FallbackFillerId,
principalTable: "FillerPreset",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_ChannelTemplate_FillerPreset_MidRollFillerId",
column: x => x.MidRollFillerId,
principalTable: "FillerPreset",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_ChannelTemplate_FillerPreset_PostRollFillerId",
column: x => x.PostRollFillerId,
principalTable: "FillerPreset",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_ChannelTemplate_FillerPreset_PreRollFillerId",
column: x => x.PreRollFillerId,
principalTable: "FillerPreset",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_ChannelTemplate_FallbackFillerId",
table: "ChannelTemplate",
column: "FallbackFillerId");
migrationBuilder.CreateIndex(
name: "IX_ChannelTemplate_FFmpegProfileId",
table: "ChannelTemplate",
column: "FFmpegProfileId");
migrationBuilder.CreateIndex(
name: "IX_ChannelTemplate_MidRollFillerId",
table: "ChannelTemplate",
column: "MidRollFillerId");
migrationBuilder.CreateIndex(
name: "IX_ChannelTemplate_Name",
table: "ChannelTemplate",
column: "Name",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ChannelTemplate_PostRollFillerId",
table: "ChannelTemplate",
column: "PostRollFillerId");
migrationBuilder.CreateIndex(
name: "IX_ChannelTemplate_PreRollFillerId",
table: "ChannelTemplate",
column: "PreRollFillerId");
migrationBuilder.CreateIndex(
name: "IX_ChannelTemplate_WatermarkId",
table: "ChannelTemplate",
column: "WatermarkId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ChannelTemplate");
}
}
}
@@ -403,6 +403,119 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.ToTable("Channel", (string)null);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ChannelTemplate", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Description")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(500)
.HasColumnType("varchar(500)")
.HasDefaultValue("");
b.Property<int>("FFmpegProfileId")
.HasColumnType("int");
b.Property<int?>("FallbackFillerId")
.HasColumnType("int");
b.Property<int>("FixedStartTimeBehavior")
.HasColumnType("int");
b.Property<int>("IdleBehavior")
.HasColumnType("int");
b.Property<bool>("IsSystem")
.HasColumnType("tinyint(1)");
b.Property<int?>("MidRollFillerId")
.HasColumnType("int");
b.Property<int>("MusicVideoCreditsMode")
.HasColumnType("int");
b.Property<string>("MusicVideoCreditsTemplate")
.HasColumnType("longtext");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)")
.UseCollation("utf8mb4_general_ci");
b.Property<int>("PlayoutMode")
.HasColumnType("int");
b.Property<int>("PlayoutSource")
.HasColumnType("int");
b.Property<int?>("PostRollFillerId")
.HasColumnType("int");
b.Property<int?>("PreRollFillerId")
.HasColumnType("int");
b.Property<string>("PreferredAudioLanguageCode")
.HasColumnType("longtext");
b.Property<string>("PreferredAudioTitle")
.HasColumnType("longtext");
b.Property<string>("PreferredSubtitleLanguageCode")
.HasColumnType("longtext");
b.Property<bool>("RandomStartPoint")
.HasColumnType("tinyint(1)");
b.Property<bool>("ShuffleScheduleItems")
.HasColumnType("tinyint(1)");
b.Property<int>("SongVideoMode")
.HasColumnType("int");
b.Property<string>("StreamSelector")
.HasColumnType("longtext");
b.Property<int>("StreamSelectorMode")
.HasColumnType("int");
b.Property<int>("StreamingMode")
.HasColumnType("int");
b.Property<int>("SubtitleMode")
.HasColumnType("int");
b.Property<int>("TranscodeMode")
.HasColumnType("int");
b.Property<int?>("WatermarkId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("FFmpegProfileId");
b.HasIndex("FallbackFillerId");
b.HasIndex("MidRollFillerId");
b.HasIndex("Name")
.IsUnique();
b.HasIndex("PostRollFillerId");
b.HasIndex("PreRollFillerId");
b.HasIndex("WatermarkId");
b.ToTable("ChannelTemplate", (string)null);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ChannelWatermark", b =>
{
b.Property<int>("Id")
@@ -4559,6 +4672,52 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Navigation("Watermark");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ChannelTemplate", b =>
{
b.HasOne("ErsatzTV.Core.Domain.FFmpegProfile", "FFmpegProfile")
.WithMany()
.HasForeignKey("FFmpegProfileId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.Filler.FillerPreset", "FallbackFiller")
.WithMany()
.HasForeignKey("FallbackFillerId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ErsatzTV.Core.Domain.Filler.FillerPreset", "MidRollFiller")
.WithMany()
.HasForeignKey("MidRollFillerId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ErsatzTV.Core.Domain.Filler.FillerPreset", "PostRollFiller")
.WithMany()
.HasForeignKey("PostRollFillerId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ErsatzTV.Core.Domain.Filler.FillerPreset", "PreRollFiller")
.WithMany()
.HasForeignKey("PreRollFillerId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ErsatzTV.Core.Domain.ChannelWatermark", "Watermark")
.WithMany()
.HasForeignKey("WatermarkId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("FFmpegProfile");
b.Navigation("FallbackFiller");
b.Navigation("MidRollFiller");
b.Navigation("PostRollFiller");
b.Navigation("PreRollFiller");
b.Navigation("Watermark");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.CollectionItem", b =>
{
b.HasOne("ErsatzTV.Core.Domain.Collection", "Collection")
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,131 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class AddChannelTemplates : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ChannelTemplate",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>(type: "varchar(50)", maxLength: 50, nullable: false, collation: "NOCASE"),
Description = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: false, defaultValue: ""),
IsSystem = table.Column<bool>(type: "INTEGER", nullable: false),
FFmpegProfileId = table.Column<int>(type: "INTEGER", nullable: false),
WatermarkId = table.Column<int>(type: "INTEGER", nullable: true),
FallbackFillerId = table.Column<int>(type: "INTEGER", nullable: true),
PreRollFillerId = table.Column<int>(type: "INTEGER", nullable: true),
MidRollFillerId = table.Column<int>(type: "INTEGER", nullable: true),
PostRollFillerId = table.Column<int>(type: "INTEGER", nullable: true),
StreamSelectorMode = table.Column<int>(type: "INTEGER", nullable: false),
StreamSelector = table.Column<string>(type: "TEXT", nullable: true),
PreferredAudioLanguageCode = table.Column<string>(type: "TEXT", nullable: true),
PreferredAudioTitle = table.Column<string>(type: "TEXT", nullable: true),
PlayoutSource = table.Column<int>(type: "INTEGER", nullable: false),
PlayoutMode = table.Column<int>(type: "INTEGER", nullable: false),
StreamingMode = table.Column<int>(type: "INTEGER", nullable: false),
PreferredSubtitleLanguageCode = table.Column<string>(type: "TEXT", nullable: true),
SubtitleMode = table.Column<int>(type: "INTEGER", nullable: false),
MusicVideoCreditsMode = table.Column<int>(type: "INTEGER", nullable: false),
MusicVideoCreditsTemplate = table.Column<string>(type: "TEXT", nullable: true),
SongVideoMode = table.Column<int>(type: "INTEGER", nullable: false),
TranscodeMode = table.Column<int>(type: "INTEGER", nullable: false),
IdleBehavior = table.Column<int>(type: "INTEGER", nullable: false),
ShuffleScheduleItems = table.Column<bool>(type: "INTEGER", nullable: false),
RandomStartPoint = table.Column<bool>(type: "INTEGER", nullable: false),
FixedStartTimeBehavior = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ChannelTemplate", x => x.Id);
table.ForeignKey(
name: "FK_ChannelTemplate_ChannelWatermark_WatermarkId",
column: x => x.WatermarkId,
principalTable: "ChannelWatermark",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_ChannelTemplate_FFmpegProfile_FFmpegProfileId",
column: x => x.FFmpegProfileId,
principalTable: "FFmpegProfile",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_ChannelTemplate_FillerPreset_FallbackFillerId",
column: x => x.FallbackFillerId,
principalTable: "FillerPreset",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_ChannelTemplate_FillerPreset_MidRollFillerId",
column: x => x.MidRollFillerId,
principalTable: "FillerPreset",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_ChannelTemplate_FillerPreset_PostRollFillerId",
column: x => x.PostRollFillerId,
principalTable: "FillerPreset",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_ChannelTemplate_FillerPreset_PreRollFillerId",
column: x => x.PreRollFillerId,
principalTable: "FillerPreset",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateIndex(
name: "IX_ChannelTemplate_FallbackFillerId",
table: "ChannelTemplate",
column: "FallbackFillerId");
migrationBuilder.CreateIndex(
name: "IX_ChannelTemplate_FFmpegProfileId",
table: "ChannelTemplate",
column: "FFmpegProfileId");
migrationBuilder.CreateIndex(
name: "IX_ChannelTemplate_MidRollFillerId",
table: "ChannelTemplate",
column: "MidRollFillerId");
migrationBuilder.CreateIndex(
name: "IX_ChannelTemplate_Name",
table: "ChannelTemplate",
column: "Name",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ChannelTemplate_PostRollFillerId",
table: "ChannelTemplate",
column: "PostRollFillerId");
migrationBuilder.CreateIndex(
name: "IX_ChannelTemplate_PreRollFillerId",
table: "ChannelTemplate",
column: "PreRollFillerId");
migrationBuilder.CreateIndex(
name: "IX_ChannelTemplate_WatermarkId",
table: "ChannelTemplate",
column: "WatermarkId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ChannelTemplate");
}
}
}
@@ -390,6 +390,117 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.ToTable("Channel", (string)null);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ChannelTemplate", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Description")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(500)
.HasColumnType("varchar(500)")
.HasDefaultValue("");
b.Property<int>("FFmpegProfileId")
.HasColumnType("INTEGER");
b.Property<int?>("FallbackFillerId")
.HasColumnType("INTEGER");
b.Property<int>("FixedStartTimeBehavior")
.HasColumnType("INTEGER");
b.Property<int>("IdleBehavior")
.HasColumnType("INTEGER");
b.Property<bool>("IsSystem")
.HasColumnType("INTEGER");
b.Property<int?>("MidRollFillerId")
.HasColumnType("INTEGER");
b.Property<int>("MusicVideoCreditsMode")
.HasColumnType("INTEGER");
b.Property<string>("MusicVideoCreditsTemplate")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)")
.UseCollation("NOCASE");
b.Property<int>("PlayoutMode")
.HasColumnType("INTEGER");
b.Property<int>("PlayoutSource")
.HasColumnType("INTEGER");
b.Property<int?>("PostRollFillerId")
.HasColumnType("INTEGER");
b.Property<int?>("PreRollFillerId")
.HasColumnType("INTEGER");
b.Property<string>("PreferredAudioLanguageCode")
.HasColumnType("TEXT");
b.Property<string>("PreferredAudioTitle")
.HasColumnType("TEXT");
b.Property<string>("PreferredSubtitleLanguageCode")
.HasColumnType("TEXT");
b.Property<bool>("RandomStartPoint")
.HasColumnType("INTEGER");
b.Property<bool>("ShuffleScheduleItems")
.HasColumnType("INTEGER");
b.Property<int>("SongVideoMode")
.HasColumnType("INTEGER");
b.Property<string>("StreamSelector")
.HasColumnType("TEXT");
b.Property<int>("StreamSelectorMode")
.HasColumnType("INTEGER");
b.Property<int>("StreamingMode")
.HasColumnType("INTEGER");
b.Property<int>("SubtitleMode")
.HasColumnType("INTEGER");
b.Property<int>("TranscodeMode")
.HasColumnType("INTEGER");
b.Property<int?>("WatermarkId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("FFmpegProfileId");
b.HasIndex("FallbackFillerId");
b.HasIndex("MidRollFillerId");
b.HasIndex("Name")
.IsUnique();
b.HasIndex("PostRollFillerId");
b.HasIndex("PreRollFillerId");
b.HasIndex("WatermarkId");
b.ToTable("ChannelTemplate", (string)null);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ChannelWatermark", b =>
{
b.Property<int>("Id")
@@ -4386,6 +4497,52 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Navigation("Watermark");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ChannelTemplate", b =>
{
b.HasOne("ErsatzTV.Core.Domain.FFmpegProfile", "FFmpegProfile")
.WithMany()
.HasForeignKey("FFmpegProfileId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.Filler.FillerPreset", "FallbackFiller")
.WithMany()
.HasForeignKey("FallbackFillerId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ErsatzTV.Core.Domain.Filler.FillerPreset", "MidRollFiller")
.WithMany()
.HasForeignKey("MidRollFillerId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ErsatzTV.Core.Domain.Filler.FillerPreset", "PostRollFiller")
.WithMany()
.HasForeignKey("PostRollFillerId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ErsatzTV.Core.Domain.Filler.FillerPreset", "PreRollFiller")
.WithMany()
.HasForeignKey("PreRollFillerId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ErsatzTV.Core.Domain.ChannelWatermark", "Watermark")
.WithMany()
.HasForeignKey("WatermarkId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("FFmpegProfile");
b.Navigation("FallbackFiller");
b.Navigation("MidRollFiller");
b.Navigation("PostRollFiller");
b.Navigation("PreRollFiller");
b.Navigation("Watermark");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.CollectionItem", b =>
{
b.HasOne("ErsatzTV.Core.Domain.Collection", "Collection")
@@ -0,0 +1,62 @@
using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ErsatzTV.Infrastructure.Data.Configurations;
public class ChannelTemplateConfiguration : IEntityTypeConfiguration<ChannelTemplate>
{
public void Configure(EntityTypeBuilder<ChannelTemplate> builder)
{
builder.ToTable("ChannelTemplate");
builder.HasIndex(t => t.Name)
.IsUnique();
builder.Property(t => t.Name)
.HasMaxLength(50)
.HasColumnType("varchar(50)")
.IsRequired();
builder.Property(t => t.Description)
.HasMaxLength(500)
.HasColumnType("varchar(500)")
.HasDefaultValue(string.Empty)
.IsRequired();
builder.HasOne(t => t.FFmpegProfile)
.WithMany()
.HasForeignKey(t => t.FFmpegProfileId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne(t => t.Watermark)
.WithMany()
.HasForeignKey(t => t.WatermarkId)
.OnDelete(DeleteBehavior.SetNull)
.IsRequired(false);
builder.HasOne(t => t.FallbackFiller)
.WithMany()
.HasForeignKey(t => t.FallbackFillerId)
.OnDelete(DeleteBehavior.SetNull)
.IsRequired(false);
builder.HasOne(t => t.PreRollFiller)
.WithMany()
.HasForeignKey(t => t.PreRollFillerId)
.OnDelete(DeleteBehavior.SetNull)
.IsRequired(false);
builder.HasOne(t => t.MidRollFiller)
.WithMany()
.HasForeignKey(t => t.MidRollFillerId)
.OnDelete(DeleteBehavior.SetNull)
.IsRequired(false);
builder.HasOne(t => t.PostRollFiller)
.WithMany()
.HasForeignKey(t => t.PostRollFillerId)
.OnDelete(DeleteBehavior.SetNull)
.IsRequired(false);
}
}
+171 -43
View File
@@ -2,6 +2,8 @@
using System.Reflection;
using Dapper;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Infrastructure.Data;
@@ -84,54 +86,180 @@ public static class DbInitializer
await context.SaveChangesAsync(cancellationToken);
}
if (context.Resolutions.Any(x => x.Width == 1920))
if (!context.Resolutions.Any(x => x.Width == 1920))
{
return Unit.Default;
var resolutions = new List<Resolution>
{
new() { Id = 1, Name = "720x480", Width = 720, Height = 480 },
new() { Id = 2, Name = "1280x720", Width = 1280, Height = 720 },
new() { Id = 3, Name = "1920x1080", Width = 1920, Height = 1080 },
new() { Id = 4, Name = "3840x2160", Width = 3840, Height = 2160 }
};
await context.Resolutions.AddRangeAsync(resolutions, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var resolutionConfig = new ConfigElement
{
Key = ConfigElementKey.FFmpegDefaultResolutionId.Key,
Value = "3" // 1920x1080
};
await context.ConfigElements.AddAsync(resolutionConfig, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var defaultProfile = FFmpegProfile.New("1920x1080 x264 aac", resolutions[2]);
await context.FFmpegProfiles.AddAsync(defaultProfile, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var profileConfig = new ConfigElement
{
Key = ConfigElementKey.FFmpegDefaultProfileId.Key,
Value = defaultProfile.Id.ToString(CultureInfo.InvariantCulture)
};
await context.ConfigElements.AddAsync(profileConfig, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var defaultChannel = new Channel(Guid.NewGuid())
{
Number = "1",
Name = "ErsatzTV",
FFmpegProfile = defaultProfile,
StreamingMode = StreamingMode.TransportStreamHybrid,
IsEnabled = true,
ShowInEpg = true
};
await context.Channels.AddAsync(defaultChannel, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
}
var resolutions = new List<Resolution>
{
new() { Id = 1, Name = "720x480", Width = 720, Height = 480 },
new() { Id = 2, Name = "1280x720", Width = 1280, Height = 720 },
new() { Id = 3, Name = "1920x1080", Width = 1920, Height = 1080 },
new() { Id = 4, Name = "3840x2160", Width = 3840, Height = 2160 }
};
await context.Resolutions.AddRangeAsync(resolutions, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var resolutionConfig = new ConfigElement
{
Key = ConfigElementKey.FFmpegDefaultResolutionId.Key,
Value = "3" // 1920x1080
};
await context.ConfigElements.AddAsync(resolutionConfig, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var defaultProfile = FFmpegProfile.New("1920x1080 x264 aac", resolutions[2]);
await context.FFmpegProfiles.AddAsync(defaultProfile, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var profileConfig = new ConfigElement
{
Key = ConfigElementKey.FFmpegDefaultProfileId.Key,
Value = defaultProfile.Id.ToString(CultureInfo.InvariantCulture)
};
await context.ConfigElements.AddAsync(profileConfig, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
var defaultChannel = new Channel(Guid.NewGuid())
{
Number = "1",
Name = "ErsatzTV",
FFmpegProfile = defaultProfile,
StreamingMode = StreamingMode.TransportStreamHybrid,
IsEnabled = true,
ShowInEpg = true
};
await context.Channels.AddAsync(defaultChannel, cancellationToken);
await context.SaveChangesAsync(cancellationToken);
await SeedChannelTemplates(context, cancellationToken);
// TODO: create looping static image that mentions configuring via web
return Unit.Default;
}
private static async Task SeedChannelTemplates(TvContext context, CancellationToken cancellationToken)
{
if (await context.ChannelTemplates.AnyAsync(t => t.Name == "Standard", cancellationToken) &&
await context.ChannelTemplates.AnyAsync(t => t.Name == "Music videos", cancellationToken))
{
await EnsureDefaultChannelTemplateConfig(context, cancellationToken);
return;
}
FFmpegProfile defaultProfile = await GetDefaultFFmpegProfile(context, cancellationToken);
if (defaultProfile is null)
{
return;
}
if (!await context.ChannelTemplates.AnyAsync(t => t.Name == "Standard", cancellationToken))
{
await context.ChannelTemplates.AddAsync(
NewSystemTemplate(
"Standard",
"Balanced defaults for generated channels.",
defaultProfile.Id,
ChannelMusicVideoCreditsMode.None,
ChannelSongVideoMode.Default,
shuffleScheduleItems: false,
randomStartPoint: false),
cancellationToken);
await context.SaveChangesAsync(cancellationToken);
}
if (!await context.ChannelTemplates.AnyAsync(t => t.Name == "Music videos", cancellationToken))
{
await context.ChannelTemplates.AddAsync(
NewSystemTemplate(
"Music videos",
"Defaults for shuffled music-video channels with generated credit subtitles.",
defaultProfile.Id,
ChannelMusicVideoCreditsMode.GenerateSubtitles,
ChannelSongVideoMode.WithProgress,
shuffleScheduleItems: true,
randomStartPoint: true),
cancellationToken);
await context.SaveChangesAsync(cancellationToken);
}
await EnsureDefaultChannelTemplateConfig(context, cancellationToken);
}
private static async Task<FFmpegProfile> GetDefaultFFmpegProfile(
TvContext context,
CancellationToken cancellationToken)
{
string defaultProfileConfigKey = ConfigElementKey.FFmpegDefaultProfileId.Key;
ConfigElement profileConfig = await context.ConfigElements
.AsNoTracking()
.FirstOrDefaultAsync(c => c.Key == defaultProfileConfigKey, cancellationToken);
if (profileConfig is not null &&
int.TryParse(profileConfig.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int profileId))
{
FFmpegProfile configuredProfile = await context.FFmpegProfiles
.FirstOrDefaultAsync(p => p.Id == profileId, cancellationToken);
if (configuredProfile is not null)
{
return configuredProfile;
}
}
return await context.FFmpegProfiles.OrderBy(p => p.Id).FirstOrDefaultAsync(cancellationToken);
}
private static async Task EnsureDefaultChannelTemplateConfig(TvContext context, CancellationToken cancellationToken)
{
string defaultTemplateConfigKey = ConfigElementKey.ChannelTemplatesDefaultTemplateId.Key;
bool configExists = await context.ConfigElements
.AnyAsync(c => c.Key == defaultTemplateConfigKey, cancellationToken);
if (configExists)
{
return;
}
ChannelTemplate standard = await context.ChannelTemplates
.AsNoTracking()
.FirstAsync(t => t.Name == "Standard", cancellationToken);
await context.ConfigElements.AddAsync(
new ConfigElement
{
Key = defaultTemplateConfigKey,
Value = standard.Id.ToString(CultureInfo.InvariantCulture)
},
cancellationToken);
await context.SaveChangesAsync(cancellationToken);
}
private static ChannelTemplate NewSystemTemplate(
string name,
string description,
int ffmpegProfileId,
ChannelMusicVideoCreditsMode musicVideoCreditsMode,
ChannelSongVideoMode songVideoMode,
bool shuffleScheduleItems,
bool randomStartPoint) =>
new()
{
Name = name,
Description = description,
IsSystem = true,
FFmpegProfileId = ffmpegProfileId,
StreamSelectorMode = ChannelStreamSelectorMode.Default,
StreamSelector = string.Empty,
PreferredAudioLanguageCode = string.Empty,
PreferredAudioTitle = string.Empty,
PlayoutSource = ChannelPlayoutSource.Generated,
PlayoutMode = ChannelPlayoutMode.Continuous,
StreamingMode = StreamingMode.TransportStreamHybrid,
PreferredSubtitleLanguageCode = string.Empty,
SubtitleMode = ChannelSubtitleMode.None,
MusicVideoCreditsMode = musicVideoCreditsMode,
MusicVideoCreditsTemplate = string.Empty,
SongVideoMode = songVideoMode,
TranscodeMode = ChannelTranscodeMode.OnDemand,
IdleBehavior = ChannelIdleBehavior.StopOnDisconnect,
ShuffleScheduleItems = shuffleScheduleItems,
RandomStartPoint = randomStartPoint,
FixedStartTimeBehavior = FixedStartTimeBehavior.Flexible
};
}
@@ -30,6 +30,7 @@ public class TvContext : DbContext
public DbSet<ConfigElement> ConfigElements { get; set; }
public DbSet<Channel> Channels { get; set; }
public DbSet<ChannelTemplate> ChannelTemplates { get; set; }
public DbSet<ChannelWatermark> ChannelWatermarks { get; set; }
public DbSet<MediaSource> MediaSources { get; set; }
public DbSet<LocalMediaSource> LocalMediaSources { get; set; }
@@ -160,6 +161,7 @@ public class TvContext : DbContext
modelBuilder.Entity<Block>().Property(b => b.Name).UseCollation(collation);
modelBuilder.Entity<BlockGroup>().Property(b => b.Name).UseCollation(collation);
modelBuilder.Entity<Channel>().Property(c => c.Name).UseCollation(collation);
modelBuilder.Entity<ChannelTemplate>().Property(c => c.Name).UseCollation(collation);
modelBuilder.Entity<ChannelWatermark>().Property(c => c.Name).UseCollation(collation);
modelBuilder.Entity<Collection>().Property(c => c.Name).UseCollation(collation);
modelBuilder.Entity<Deco>().Property(d => d.Name).UseCollation(collation);
@@ -0,0 +1,288 @@
using ErsatzTV.Application.ChannelTemplates;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.ChannelTemplates;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Data.Repositories;
using ErsatzTV.Tests.Support;
using LanguageExt;
using NUnit.Framework;
using Shouldly;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Tests.Application.ChannelTemplates;
[TestFixture]
public class ChannelTemplateHandlerTests
{
private InMemoryTvContext _db = null!;
private IConfigElementRepository _config = null!;
[SetUp]
public async Task SetUp()
{
_db = await InMemoryTvContext.CreateAsync();
_config = new ConfigElementRepository(_db.Factory);
}
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
[Test]
public async Task Create_Should_Persist_Template_With_Reference_Ids()
{
await SeedProfile(1);
await SeedFiller(2, FillerKind.PreRoll);
await SeedFiller(3, FillerKind.MidRoll);
await SeedFiller(4, FillerKind.PostRoll);
var handler = new CreateChannelTemplateHandler(_db.Factory, _config);
Either<BaseError, ChannelTemplateResponseModel> result =
await handler.Handle(MakeCreate("Custom"), CancellationToken.None);
ChannelTemplateResponseModel vm = RightOf(result);
vm.Id.ShouldBeGreaterThan(0);
vm.Name.ShouldBe("Custom");
vm.IsSystem.ShouldBeFalse();
vm.PreRollFillerId.ShouldBe(2);
vm.MidRollFillerId.ShouldBe(3);
vm.PostRollFillerId.ShouldBe(4);
await using TvContext context = _db.CreateContext();
context.ChannelTemplates.Single().Name.ShouldBe("Custom");
}
[Test]
public async Task Create_Should_Return_NotFoundError_When_Profile_Missing()
{
var handler = new CreateChannelTemplateHandler(_db.Factory, _config);
Either<BaseError, ChannelTemplateResponseModel> result =
await handler.Handle(MakeCreate("Missing", ffmpegProfileId: 99), CancellationToken.None);
LeftOf(result).ShouldBeOfType<NotFoundError>();
}
[Test]
public async Task Create_Should_Return_Error_When_Trimmed_Name_Already_Exists()
{
await SeedProfile(1);
await SeedTemplate(7, "Standard");
var handler = new CreateChannelTemplateHandler(_db.Factory, _config);
Either<BaseError, ChannelTemplateResponseModel> result =
await handler.Handle(MakeCreate("Standard "), CancellationToken.None);
LeftOf(result).Value.ShouldBe("Channel template name must be unique.");
}
[Test]
public async Task Create_Should_Validate_Filler_Kind()
{
await SeedProfile(1);
await SeedFiller(2, FillerKind.PostRoll);
var handler = new CreateChannelTemplateHandler(_db.Factory, _config);
Either<BaseError, ChannelTemplateResponseModel> result =
await handler.Handle(MakeCreate("Bad", preRollFillerId: 2), CancellationToken.None);
LeftOf(result).ShouldBeOfType<NotFoundError>();
}
[Test]
public async Task Update_Should_Reject_System_Template()
{
await SeedProfile(1);
await SeedTemplate(7, "Standard", isSystem: true);
var handler = new UpdateChannelTemplateHandler(_db.Factory, _config);
Either<BaseError, ChannelTemplateResponseModel> result =
await handler.Handle(MakeUpdate(7, "Changed"), CancellationToken.None);
LeftOf(result).Value.ShouldBe("System templates cannot be updated.");
}
[Test]
public async Task Delete_Should_Reject_Default_Template()
{
await SeedProfile(1);
await SeedTemplate(7, "Default");
await _config.Upsert(ConfigElementKey.ChannelTemplatesDefaultTemplateId, 7, CancellationToken.None);
var handler = new DeleteChannelTemplateHandler(_db.Factory, _config);
Either<BaseError, Unit> result = await handler.Handle(new DeleteChannelTemplate(7), CancellationToken.None);
LeftOf(result).Value.ShouldBe("Default channel template cannot be deleted.");
}
[Test]
public async Task SetDefault_Should_Update_Config_And_Return_Template()
{
await SeedProfile(1);
await SeedTemplate(7, "Default");
var handler = new SetDefaultChannelTemplateHandler(_db.Factory, _config);
Either<BaseError, ChannelTemplateResponseModel> result =
await handler.Handle(new SetDefaultChannelTemplate(7), CancellationToken.None);
ChannelTemplateResponseModel vm = RightOf(result);
vm.IsDefault.ShouldBeTrue();
Option<int> defaultId =
await _config.GetValue<int>(ConfigElementKey.ChannelTemplatesDefaultTemplateId, CancellationToken.None);
defaultId.IfNone(0).ShouldBe(7);
}
[Test]
public async Task GetAll_Should_Flag_Configured_Default()
{
await SeedProfile(1);
await SeedTemplate(7, "Standard");
await SeedTemplate(8, "Music videos");
await _config.Upsert(ConfigElementKey.ChannelTemplatesDefaultTemplateId, 8, CancellationToken.None);
var handler = new GetAllChannelTemplatesHandler(_db.Factory, _config);
List<ChannelTemplateResponseModel> result =
await handler.Handle(new GetAllChannelTemplates(), CancellationToken.None);
result.Single(t => t.Id == 7).IsDefault.ShouldBeFalse();
result.Single(t => t.Id == 8).IsDefault.ShouldBeTrue();
}
[Test]
public async Task GetDefault_Should_Fall_Back_To_First_System_Template_By_Name()
{
await SeedProfile(1);
await SeedTemplate(1, "Zulu", isSystem: true);
await SeedTemplate(99, "Alpha", isSystem: true);
var handler = new GetDefaultChannelTemplateHandler(_db.Factory, _config);
Option<ChannelTemplateResponseModel> result =
await handler.Handle(new GetDefaultChannelTemplate(), CancellationToken.None);
ChannelTemplateResponseModel vm = result.IfNone(() => throw new AssertionException("Expected a template"));
vm.Id.ShouldBe(99);
vm.Name.ShouldBe("Alpha");
vm.IsDefault.ShouldBeTrue();
}
private async Task SeedProfile(int id)
{
await using TvContext context = _db.CreateContext();
context.FFmpegProfiles.Add(new FFmpegProfile { Id = id, Name = $"profile-{id}" });
await context.SaveChangesAsync();
}
private async Task SeedFiller(int id, FillerKind fillerKind)
{
await using TvContext context = _db.CreateContext();
context.FillerPresets.Add(new FillerPreset
{
Id = id,
Name = $"filler-{id}",
FillerKind = fillerKind,
FillerMode = FillerMode.Count,
Count = 1,
CollectionType = CollectionType.Collection
});
await context.SaveChangesAsync();
}
private async Task SeedTemplate(int id, string name, bool isSystem = false)
{
await using TvContext context = _db.CreateContext();
context.ChannelTemplates.Add(new ChannelTemplate
{
Id = id,
Name = name,
Description = string.Empty,
IsSystem = isSystem,
FFmpegProfileId = 1,
StreamSelectorMode = ChannelStreamSelectorMode.Default,
StreamSelector = string.Empty,
PreferredAudioLanguageCode = string.Empty,
PreferredAudioTitle = string.Empty,
PlayoutSource = ChannelPlayoutSource.Generated,
PlayoutMode = ChannelPlayoutMode.Continuous,
StreamingMode = StreamingMode.TransportStreamHybrid,
PreferredSubtitleLanguageCode = string.Empty,
SubtitleMode = ChannelSubtitleMode.None,
MusicVideoCreditsMode = ChannelMusicVideoCreditsMode.None,
MusicVideoCreditsTemplate = string.Empty,
SongVideoMode = ChannelSongVideoMode.Default,
TranscodeMode = ChannelTranscodeMode.OnDemand,
IdleBehavior = ChannelIdleBehavior.StopOnDisconnect,
FixedStartTimeBehavior = FixedStartTimeBehavior.Flexible
});
await context.SaveChangesAsync();
}
private static CreateChannelTemplate MakeCreate(
string name,
int ffmpegProfileId = 1,
int? preRollFillerId = 2) =>
new(
name,
"Description",
ffmpegProfileId,
null,
null,
preRollFillerId,
3,
4,
ChannelStreamSelectorMode.Default,
string.Empty,
string.Empty,
string.Empty,
ChannelPlayoutSource.Generated,
ChannelPlayoutMode.Continuous,
StreamingMode.TransportStreamHybrid,
string.Empty,
ChannelSubtitleMode.None,
ChannelMusicVideoCreditsMode.None,
string.Empty,
ChannelSongVideoMode.Default,
ChannelTranscodeMode.OnDemand,
ChannelIdleBehavior.StopOnDisconnect,
true,
false,
FixedStartTimeBehavior.Flexible);
private static UpdateChannelTemplate MakeUpdate(int id, string name) =>
new(
id,
name,
"Description",
1,
null,
null,
null,
null,
null,
ChannelStreamSelectorMode.Default,
string.Empty,
string.Empty,
string.Empty,
ChannelPlayoutSource.Generated,
ChannelPlayoutMode.Continuous,
StreamingMode.TransportStreamHybrid,
string.Empty,
ChannelSubtitleMode.None,
ChannelMusicVideoCreditsMode.None,
string.Empty,
ChannelSongVideoMode.Default,
ChannelTranscodeMode.OnDemand,
ChannelIdleBehavior.StopOnDisconnect,
false,
false,
FixedStartTimeBehavior.Flexible);
private static BaseError LeftOf<TR>(Either<BaseError, TR> either) =>
either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result"));
private static TR RightOf<TR>(Either<BaseError, TR> either) =>
either.Match(Left: e => throw new AssertionException($"Expected a Right result but got {e.Value}"), Right: r => r);
}
@@ -0,0 +1,751 @@
using System.Threading.Channels;
using ErsatzTV.Application;
using ErsatzTV.Application.Artworks;
using ErsatzTV.Application.Channels;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using DomainChannel = ErsatzTV.Core.Domain.Channel;
using DomainPlaylistItem = ErsatzTV.Core.Domain.PlaylistItem;
namespace ErsatzTV.Tests.Application.Channels;
[TestFixture]
public class CreateChannelFromLineupHandlerTests
{
private Channel<IBackgroundServiceRequest> _background = null!;
private InMemoryTvContext _db = null!;
private ISearchTargets _searchTargets = null!;
[SetUp]
public async Task SetUp()
{
_background = System.Threading.Channels.Channel.CreateUnbounded<IBackgroundServiceRequest>();
_db = await InMemoryTvContext.CreateAsync();
_searchTargets = Substitute.For<ISearchTargets>();
}
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
[Test]
public async Task Should_Create_Single_Media_Item_Directly_Without_Playlist()
{
await SeedTemplateDependencies();
await SeedTemplate();
await SeedMovie(42);
Either<BaseError, CreateChannelFromLineupResponseModel> result =
await MakeHandler().Handle(MakeRequest(), CancellationToken.None);
CreateChannelFromLineupResponseModel response = RightOf(result);
response.ChannelId.ShouldBeGreaterThan(0);
response.PlaylistId.ShouldBeNull();
response.ProgramScheduleId.ShouldBeGreaterThan(0);
response.PlayoutId.ShouldBeGreaterThan(0);
await using TvContext context = _db.CreateContext();
// No generated playlist or collection for a single-item lineup.
(await context.Playlists.CountAsync()).ShouldBe(0);
(await context.Collections.CountAsync()).ShouldBe(0);
DomainChannel channel = await context.Channels.SingleAsync();
channel.Name.ShouldBe("Movies");
channel.Number.ShouldBe("12");
channel.Group.ShouldBe("Kids");
channel.FFmpegProfileId.ShouldBe(1);
channel.FallbackFillerId.ShouldBe(5);
channel.StreamingMode.ShouldBe(StreamingMode.HttpLiveStreamingSegmenter);
channel.ShowInEpg.ShouldBeTrue();
ProgramSchedule schedule = await context.ProgramSchedules.Include(ps => ps.Items).SingleAsync();
schedule.Name.ShouldBe("12 Movies Schedule");
schedule.ShuffleScheduleItems.ShouldBeTrue();
schedule.RandomStartPoint.ShouldBeTrue();
schedule.FixedStartTimeBehavior.ShouldBe(FixedStartTimeBehavior.Strict);
ProgramScheduleItem item = schedule.Items.Single();
item.ShouldBeOfType<ProgramScheduleItemFlood>();
item.CollectionType.ShouldBe(CollectionType.Movie);
item.MediaItemId.ShouldBe(42);
item.CollectionId.ShouldBeNull();
item.PlaylistId.ShouldBeNull();
item.PlaybackOrder.ShouldBe(PlaybackOrder.Shuffle);
item.PreRollFillerId.ShouldBe(2);
item.MidRollFillerId.ShouldBe(3);
item.PostRollFillerId.ShouldBe(4);
item.FallbackFillerId.ShouldBe(5);
Playout playout = await context.Playouts.SingleAsync();
playout.ChannelId.ShouldBe(channel.Id);
playout.ProgramScheduleId.ShouldBe(schedule.Id);
playout.ScheduleKind.ShouldBe(PlayoutScheduleKind.Classic);
_background.Reader.TryRead(out IBackgroundServiceRequest? buildRequest).ShouldBeTrue();
BuildPlayout buildPlayout = buildRequest.ShouldBeOfType<BuildPlayout>();
buildPlayout.PlayoutId.ShouldBe(playout.Id);
buildPlayout.Mode.ShouldBe(PlayoutBuildMode.Reset);
_background.Reader.TryRead(out IBackgroundServiceRequest? refreshRequest).ShouldBeTrue();
refreshRequest.ShouldBeOfType<RefreshChannelList>();
_searchTargets.Received(1).SearchTargetsChanged();
}
[Test]
public async Task Should_Create_Single_Collection_Item_Directly()
{
await SeedTemplateDependencies();
await SeedTemplate();
await SeedCollection(7);
Either<BaseError, CreateChannelFromLineupResponseModel> result =
await MakeHandler().Handle(MakeRequest(lineup: [CollectionItem(7)]), CancellationToken.None);
RightOf(result).PlaylistId.ShouldBeNull();
await using TvContext context = _db.CreateContext();
(await context.Playlists.CountAsync()).ShouldBe(0);
ProgramScheduleItem item = await context.ProgramScheduleItems.SingleAsync();
item.CollectionType.ShouldBe(CollectionType.Collection);
item.CollectionId.ShouldBe(7);
}
[Test]
public async Task Should_Create_Single_Playlist_Item_Directly()
{
await SeedTemplateDependencies();
await SeedTemplate();
await SeedExistingPlaylist(9);
Either<BaseError, CreateChannelFromLineupResponseModel> result =
await MakeHandler().Handle(MakeRequest(lineup: [PlaylistEntry(9)]), CancellationToken.None);
// The generated-playlist id is null for a single-item lineup, even when it references a playlist.
RightOf(result).PlaylistId.ShouldBeNull();
await using TvContext context = _db.CreateContext();
ProgramScheduleItem item = await context.ProgramScheduleItems.SingleAsync();
item.CollectionType.ShouldBe(CollectionType.Playlist);
item.PlaylistId.ShouldBe(9);
}
[Test]
public async Task Should_Create_Single_Rerun_Collection_As_First_Run()
{
await SeedTemplateDependencies();
await SeedTemplate();
await SeedRerunCollection(11);
Either<BaseError, CreateChannelFromLineupResponseModel> result =
await MakeHandler().Handle(MakeRequest(lineup: [RerunItem(11)]), CancellationToken.None);
RightOf(result).PlaylistId.ShouldBeNull();
await using TvContext context = _db.CreateContext();
ProgramScheduleItem item = await context.ProgramScheduleItems.SingleAsync();
item.CollectionType.ShouldBe(CollectionType.RerunFirstRun);
item.RerunCollectionId.ShouldBe(11);
}
[Test]
public async Task Should_Create_Multi_Item_Lineup_As_Generated_System_Playlist()
{
await SeedTemplateDependencies();
await SeedTemplate();
await SeedMovie(42);
await SeedShow(43);
await SeedCollection(7);
Either<BaseError, CreateChannelFromLineupResponseModel> result =
await MakeHandler().Handle(
MakeRequest(lineup: [MovieItem(42), ShowItem(43), CollectionItem(7)]),
CancellationToken.None);
CreateChannelFromLineupResponseModel response = RightOf(result);
response.PlaylistId.ShouldNotBeNull();
await using TvContext context = _db.CreateContext();
PlaylistGroup group = await context.PlaylistGroups.SingleAsync();
group.Name.ShouldBe("Channel Lineups");
group.IsSystem.ShouldBeTrue();
Playlist playlist = await context.Playlists.Include(p => p.Items).SingleAsync();
playlist.Id.ShouldBe(response.PlaylistId!.Value);
playlist.Name.ShouldBe("12 Movies Lineup");
playlist.IsSystem.ShouldBeTrue();
playlist.PlaylistGroupId.ShouldBe(group.Id);
List<DomainPlaylistItem> items = playlist.Items.OrderBy(i => i.Index).ToList();
items.Count.ShouldBe(3);
items.ShouldAllBe(i => i.PlayAll);
items.ShouldAllBe(i => i.IncludeInProgramGuide);
items.ShouldAllBe(i => i.PlaybackOrder == PlaybackOrder.Shuffle);
items[0].Index.ShouldBe(1);
items[0].CollectionType.ShouldBe(CollectionType.Movie);
items[0].MediaItemId.ShouldBe(42);
items[1].Index.ShouldBe(2);
items[1].CollectionType.ShouldBe(CollectionType.TelevisionShow);
items[1].MediaItemId.ShouldBe(43);
items[2].Index.ShouldBe(3);
items[2].CollectionType.ShouldBe(CollectionType.Collection);
items[2].CollectionId.ShouldBe(7);
// Exactly one flood schedule item, referencing the generated playlist.
ProgramScheduleItem scheduleItem = await context.ProgramScheduleItems.SingleAsync();
scheduleItem.ShouldBeOfType<ProgramScheduleItemFlood>();
scheduleItem.CollectionType.ShouldBe(CollectionType.Playlist);
scheduleItem.PlaylistId.ShouldBe(playlist.Id);
}
[Test]
public async Task Should_Reject_Rerun_Collection_In_Multi_Item_Lineup()
{
await SeedTemplateDependencies();
await SeedTemplate();
await SeedMovie(42);
await SeedRerunCollection(11);
Either<BaseError, CreateChannelFromLineupResponseModel> result =
await MakeHandler().Handle(
MakeRequest(lineup: [MovieItem(42), RerunItem(11)]),
CancellationToken.None);
BaseError error = LeftOf(result);
error.ShouldNotBeOfType<NotFoundError>();
error.Value.ShouldContain("single-item");
}
[Test]
public async Task Should_Reject_Playlist_In_Multi_Item_Lineup()
{
await SeedTemplateDependencies();
await SeedTemplate();
await SeedMovie(42);
await SeedExistingPlaylist(9);
Either<BaseError, CreateChannelFromLineupResponseModel> result =
await MakeHandler().Handle(
MakeRequest(lineup: [MovieItem(42), PlaylistEntry(9)]),
CancellationToken.None);
BaseError error = LeftOf(result);
error.ShouldNotBeOfType<NotFoundError>();
error.Value.ShouldContain("single-item");
}
[Test]
public async Task Advanced_Overrides_Should_Beat_Template_Defaults()
{
await SeedTemplateDependencies();
await SeedTemplate();
await SeedMovie(42);
await using (TvContext context = _db.CreateContext())
{
context.FFmpegProfiles.Add(new FFmpegProfile { Id = 2, Name = "advanced-profile" });
context.ChannelWatermarks.Add(new ChannelWatermark { Id = 21, Name = "wm", Image = "wm.png" });
context.FillerPresets.AddRange(
MakeFiller(6, FillerKind.Fallback),
MakeFiller(7, FillerKind.PreRoll));
await context.SaveChangesAsync();
}
var advanced = new CreateChannelFromLineupAdvancedOptions(
PlaybackOrder: PlaybackOrder.Shuffle,
FFmpegProfileId: 2,
WatermarkId: 21,
FallbackFillerId: 6,
PreRollFillerId: 7,
StreamingMode: StreamingMode.TransportStream);
Either<BaseError, CreateChannelFromLineupResponseModel> result =
await MakeHandler().Handle(MakeRequest(advanced: advanced), CancellationToken.None);
RightOf(result);
await using TvContext assert = _db.CreateContext();
DomainChannel channel = await assert.Channels.SingleAsync();
// Advanced wins over the template's values (template = profile 1, no watermark, HLS, fallback 5).
channel.FFmpegProfileId.ShouldBe(2);
channel.WatermarkId.ShouldBe(21);
channel.StreamingMode.ShouldBe(StreamingMode.TransportStream);
channel.FallbackFillerId.ShouldBe(6);
ProgramScheduleItem item = await assert.ProgramScheduleItems.SingleAsync();
item.PlaybackOrder.ShouldBe(PlaybackOrder.Shuffle);
item.PreRollFillerId.ShouldBe(7);
item.FallbackFillerId.ShouldBe(6);
// Not overridden in Advanced -> falls through to the template value.
item.MidRollFillerId.ShouldBe(3);
}
[Test]
public async Task Should_Return_Validation_Error_When_Not_Exactly_One_Id_Provided()
{
await SeedTemplateDependencies();
await SeedTemplate();
var item = new CreateChannelFromLineupItem(
LibraryBrowseMediaType.Movie, CollectionType.Movie, null, null, null, null, null, null);
Either<BaseError, CreateChannelFromLineupResponseModel> result =
await MakeHandler().Handle(MakeRequest(lineup: [item]), CancellationToken.None);
LeftOf(result).Value.ShouldContain("exactly one typed id");
}
[Test]
public async Task Should_Return_Validation_Error_When_Media_Type_Does_Not_Match_Collection_Type()
{
await SeedTemplateDependencies();
await SeedTemplate();
await SeedMovie(42);
var item = new CreateChannelFromLineupItem(
LibraryBrowseMediaType.Movie, CollectionType.Playlist, null, null, null, null, 42, null);
Either<BaseError, CreateChannelFromLineupResponseModel> result =
await MakeHandler().Handle(MakeRequest(lineup: [item]), CancellationToken.None);
BaseError error = LeftOf(result);
error.ShouldNotBeOfType<NotFoundError>();
error.Value.ShouldContain("does not match");
}
[Test]
public async Task Should_Reject_MultiCollection_With_Non_Shuffle_Playback_Order()
{
await SeedTemplateDependencies();
await SeedTemplate();
await SeedMultiCollection(15);
var advanced = new CreateChannelFromLineupAdvancedOptions(PlaybackOrder: PlaybackOrder.Chronological);
Either<BaseError, CreateChannelFromLineupResponseModel> result =
await MakeHandler().Handle(
MakeRequest(advanced: advanced, lineup: [MultiCollectionItem(15)]),
CancellationToken.None);
LeftOf(result).Value.ShouldContain("Invalid playback order for multi collection");
}
[Test]
public async Task Should_Reject_Mirror_Playout_Source()
{
await SeedTemplateDependencies();
await SeedTemplate();
await SeedMovie(42);
var advanced = new CreateChannelFromLineupAdvancedOptions(
PlaybackOrder: PlaybackOrder.Shuffle,
PlayoutSource: ChannelPlayoutSource.Mirror);
Either<BaseError, CreateChannelFromLineupResponseModel> result =
await MakeHandler().Handle(MakeRequest(advanced: advanced), CancellationToken.None);
LeftOf(result).Value.ShouldContain("Mirror playout source");
}
[Test]
public async Task OnDemand_Playout_Mode_Should_Queue_TimeShift_After_Build()
{
await SeedTemplateDependencies();
await SeedTemplate();
await SeedMovie(42);
var advanced = new CreateChannelFromLineupAdvancedOptions(
PlaybackOrder: PlaybackOrder.Shuffle,
PlayoutMode: ChannelPlayoutMode.OnDemand);
Either<BaseError, CreateChannelFromLineupResponseModel> result =
await MakeHandler().Handle(MakeRequest(advanced: advanced), CancellationToken.None);
CreateChannelFromLineupResponseModel response = RightOf(result);
_background.Reader.TryRead(out IBackgroundServiceRequest? first).ShouldBeTrue();
first.ShouldBeOfType<BuildPlayout>();
_background.Reader.TryRead(out IBackgroundServiceRequest? second).ShouldBeTrue();
TimeShiftOnDemandPlayout timeShift = second.ShouldBeOfType<TimeShiftOnDemandPlayout>();
timeShift.PlayoutId.ShouldBe(response.PlayoutId);
timeShift.Force.ShouldBeFalse();
_background.Reader.TryRead(out IBackgroundServiceRequest? third).ShouldBeTrue();
third.ShouldBeOfType<RefreshChannelList>();
}
[Test]
public async Task Should_Recreate_With_De_Collided_Names_After_Channel_Delete()
{
await SeedTemplateDependencies();
await SeedTemplate();
await SeedMovie(42);
await SeedShow(43);
// First create builds "12 Movies Schedule" + "12 Movies Lineup".
RightOf(await MakeHandler().Handle(
MakeRequest(lineup: [MovieItem(42), ShowItem(43)]),
CancellationToken.None));
// Delete the channel + playout but leave the generated schedule/playlist rows behind.
await using (TvContext context = _db.CreateContext())
{
context.Playouts.RemoveRange(await context.Playouts.ToListAsync());
context.Channels.RemoveRange(await context.Channels.ToListAsync());
await context.SaveChangesAsync();
}
// Second create with the same name must succeed via de-collided names.
CreateChannelFromLineupResponseModel response = RightOf(await MakeHandler().Handle(
MakeRequest(lineup: [MovieItem(42), ShowItem(43)]),
CancellationToken.None));
await using TvContext assert = _db.CreateContext();
bool scheduleExists = await assert.ProgramSchedules.AnyAsync(ps => ps.Name == "12 Movies Schedule 2");
scheduleExists.ShouldBeTrue();
Playlist newPlaylist = await assert.Playlists.SingleAsync(p => p.Id == response.PlaylistId!.Value);
newPlaylist.Name.ShouldBe("12 Movies Lineup 2");
// Only one system playlist group is ever created.
(await assert.PlaylistGroups.CountAsync(pg => pg.Name == "Channel Lineups")).ShouldBe(1);
}
[Test]
public async Task Should_Return_NotFound_For_Missing_Advanced_References()
{
await SeedTemplateDependencies();
await SeedTemplate();
await SeedMovie(42);
await AssertNotFound(
new CreateChannelFromLineupAdvancedOptions(FFmpegProfileId: 999),
"FFmpegProfile 999");
await AssertNotFound(
new CreateChannelFromLineupAdvancedOptions(WatermarkId: 999),
"Watermark 999");
await AssertNotFound(
new CreateChannelFromLineupAdvancedOptions(FallbackFillerId: 999),
"Fallback filler 999");
await AssertNotFound(
new CreateChannelFromLineupAdvancedOptions(PreRollFillerId: 999),
"Pre-roll filler 999");
await AssertNotFound(
new CreateChannelFromLineupAdvancedOptions(MidRollFillerId: 999),
"Mid-roll filler 999");
await AssertNotFound(
new CreateChannelFromLineupAdvancedOptions(PostRollFillerId: 999),
"Post-roll filler 999");
}
[Test]
public async Task Should_Roll_Back_When_Save_Fails()
{
await SeedTemplateDependencies();
await SeedTemplate();
await SeedMovie(42);
await SeedShow(43);
// A non-system group with the reserved name forces the handler's new system group insert
// to violate the unique Name index at save time (multi-item lineup path).
await using (TvContext context = _db.CreateContext())
{
context.PlaylistGroups.Add(new PlaylistGroup { Id = 99, Name = "Channel Lineups", IsSystem = false });
await context.SaveChangesAsync();
}
Either<BaseError, CreateChannelFromLineupResponseModel> result =
await MakeHandler().Handle(
MakeRequest(lineup: [MovieItem(42), ShowItem(43)]),
CancellationToken.None);
BaseError error = LeftOf(result);
error.ShouldNotBeOfType<NotFoundError>();
error.Value.ShouldBe("Unable to create channel from lineup");
await using TvContext assertContext = _db.CreateContext();
(await assertContext.Channels.CountAsync()).ShouldBe(0);
(await assertContext.Playlists.CountAsync()).ShouldBe(0);
(await assertContext.ProgramSchedules.CountAsync()).ShouldBe(0);
(await assertContext.Playouts.CountAsync()).ShouldBe(0);
_background.Reader.TryRead(out _).ShouldBeFalse();
}
[Test]
public async Task Should_Return_NotFound_When_Template_Is_Missing()
{
await SeedTemplateDependencies();
await SeedMovie(42);
Either<BaseError, CreateChannelFromLineupResponseModel> result =
await MakeHandler().Handle(MakeRequest(templateId: 999), CancellationToken.None);
LeftOf(result).ShouldBeOfType<NotFoundError>();
}
[Test]
public async Task Should_Return_Validation_Error_When_Channel_Number_Already_Exists_After_Trim()
{
await SeedTemplateDependencies();
await SeedTemplate();
await SeedMovie(42);
await using (TvContext context = _db.CreateContext())
{
context.Channels.Add(new DomainChannel(Guid.NewGuid())
{
Number = "12",
Name = "Existing",
Group = "Kids",
Categories = string.Empty,
FFmpegProfileId = 1,
StreamSelector = string.Empty,
PreferredAudioLanguageCode = string.Empty,
PreferredAudioTitle = string.Empty,
PreferredSubtitleLanguageCode = string.Empty,
MusicVideoCreditsTemplate = string.Empty
});
await context.SaveChangesAsync();
}
Either<BaseError, CreateChannelFromLineupResponseModel> result =
await MakeHandler().Handle(MakeRequest(number: " 12 "), CancellationToken.None);
BaseError error = LeftOf(result);
error.ShouldNotBeOfType<NotFoundError>();
error.Value.ShouldContain("Channel number must be unique");
}
[Test]
public async Task Should_Return_Validation_Error_When_Disabled_But_Shown_In_Epg()
{
await SeedTemplateDependencies();
await SeedTemplate();
await SeedMovie(42);
Either<BaseError, CreateChannelFromLineupResponseModel> result =
await MakeHandler().Handle(
MakeRequest(isEnabled: false, showInEpg: true),
CancellationToken.None);
BaseError error = LeftOf(result);
error.ShouldNotBeOfType<NotFoundError>();
error.Value.ShouldContain("Disabled channels cannot be shown in EPG");
}
[Test]
public async Task Should_Return_Validation_Error_For_Invalid_External_Logo()
{
await SeedTemplateDependencies();
await SeedTemplate();
await SeedMovie(42);
var logo = new ArtworkContentTypeModel("ftp://example.com/logo.png", string.Empty);
Either<BaseError, CreateChannelFromLineupResponseModel> result =
await MakeHandler().Handle(MakeRequest(logo: logo), CancellationToken.None);
LeftOf(result).Value.ShouldContain("External logo url is invalid");
}
[Test]
public async Task Should_Return_NotFound_When_Lineup_Target_Is_Missing()
{
await SeedTemplateDependencies();
await SeedTemplate();
Either<BaseError, CreateChannelFromLineupResponseModel> result =
await MakeHandler().Handle(MakeRequest(), CancellationToken.None);
NotFoundError error = LeftOf(result).ShouldBeOfType<NotFoundError>();
error.Value.ShouldContain("lineup[0]");
error.Value.ShouldContain("Movie 42");
}
private async Task AssertNotFound(CreateChannelFromLineupAdvancedOptions advanced, string expectedFragment)
{
Either<BaseError, CreateChannelFromLineupResponseModel> result =
await MakeHandler().Handle(MakeRequest(advanced: advanced), CancellationToken.None);
NotFoundError error = LeftOf(result).ShouldBeOfType<NotFoundError>();
error.Value.ShouldContain(expectedFragment);
}
private CreateChannelFromLineupHandler MakeHandler() =>
new(
_background.Writer,
_db.Factory,
_searchTargets,
NullLogger<CreateChannelFromLineupHandler>.Instance);
private async Task SeedTemplateDependencies()
{
await using TvContext context = _db.CreateContext();
context.FFmpegProfiles.Add(new FFmpegProfile { Id = 1, Name = "profile" });
context.FillerPresets.AddRange(
MakeFiller(2, FillerKind.PreRoll),
MakeFiller(3, FillerKind.MidRoll),
MakeFiller(4, FillerKind.PostRoll),
MakeFiller(5, FillerKind.Fallback));
await context.SaveChangesAsync();
}
private async Task SeedTemplate()
{
await using TvContext context = _db.CreateContext();
context.ChannelTemplates.Add(new ChannelTemplate
{
Id = 10,
Name = "Template",
Description = string.Empty,
FFmpegProfileId = 1,
FallbackFillerId = 5,
PreRollFillerId = 2,
MidRollFillerId = 3,
PostRollFillerId = 4,
StreamSelectorMode = ChannelStreamSelectorMode.Default,
StreamSelector = string.Empty,
PreferredAudioLanguageCode = string.Empty,
PreferredAudioTitle = string.Empty,
PlayoutSource = ChannelPlayoutSource.Generated,
PlayoutMode = ChannelPlayoutMode.Continuous,
StreamingMode = StreamingMode.HttpLiveStreamingSegmenter,
PreferredSubtitleLanguageCode = string.Empty,
SubtitleMode = ChannelSubtitleMode.None,
MusicVideoCreditsMode = ChannelMusicVideoCreditsMode.None,
MusicVideoCreditsTemplate = string.Empty,
SongVideoMode = ChannelSongVideoMode.Default,
TranscodeMode = ChannelTranscodeMode.OnDemand,
IdleBehavior = ChannelIdleBehavior.StopOnDisconnect,
ShuffleScheduleItems = true,
RandomStartPoint = true,
FixedStartTimeBehavior = FixedStartTimeBehavior.Strict
});
await context.SaveChangesAsync();
}
private async Task SeedMovie(int id)
{
await using TvContext context = _db.CreateContext();
context.Movies.Add(new Movie { Id = id });
await context.SaveChangesAsync();
}
private async Task SeedShow(int id)
{
await using TvContext context = _db.CreateContext();
context.Shows.Add(new Show { Id = id });
await context.SaveChangesAsync();
}
private async Task SeedCollection(int id)
{
await using TvContext context = _db.CreateContext();
context.Collections.Add(new Collection { Id = id, Name = $"Collection {id}" });
await context.SaveChangesAsync();
}
private async Task SeedMultiCollection(int id)
{
await using TvContext context = _db.CreateContext();
context.MultiCollections.Add(new MultiCollection { Id = id, Name = $"Multi {id}" });
await context.SaveChangesAsync();
}
private async Task SeedRerunCollection(int id)
{
await using TvContext context = _db.CreateContext();
context.RerunCollections.Add(new RerunCollection
{
Id = id,
Name = $"Rerun {id}",
CollectionType = CollectionType.Collection
});
await context.SaveChangesAsync();
}
private async Task SeedExistingPlaylist(int id)
{
await using TvContext context = _db.CreateContext();
context.PlaylistGroups.Add(new PlaylistGroup { Id = 500, Name = "User Group", IsSystem = false });
context.Playlists.Add(new Playlist { Id = id, Name = $"Playlist {id}", PlaylistGroupId = 500, IsSystem = false });
await context.SaveChangesAsync();
}
private static FillerPreset MakeFiller(int id, FillerKind kind) =>
new()
{
Id = id,
Name = $"filler-{id}",
FillerKind = kind,
FillerMode = FillerMode.Count,
Count = 1,
CollectionType = CollectionType.Collection
};
private static CreateChannelFromLineupItem MovieItem(int id) =>
new(LibraryBrowseMediaType.Movie, CollectionType.Movie, null, null, null, null, id, null);
private static CreateChannelFromLineupItem ShowItem(int id) =>
new(LibraryBrowseMediaType.TelevisionShow, CollectionType.TelevisionShow, null, null, null, null, id, null);
private static CreateChannelFromLineupItem CollectionItem(int id) =>
new(LibraryBrowseMediaType.Collection, CollectionType.Collection, id, null, null, null, null, null);
private static CreateChannelFromLineupItem MultiCollectionItem(int id) =>
new(LibraryBrowseMediaType.MultiCollection, CollectionType.MultiCollection, null, id, null, null, null, null);
private static CreateChannelFromLineupItem RerunItem(int id) =>
new(LibraryBrowseMediaType.RerunCollection, CollectionType.RerunFirstRun, null, null, null, id, null, null);
private static CreateChannelFromLineupItem PlaylistEntry(int id) =>
new(LibraryBrowseMediaType.Playlist, CollectionType.Playlist, null, null, null, null, null, id);
private static CreateChannelFromLineup MakeRequest(
string number = "12",
string name = "Movies",
int templateId = 10,
bool isEnabled = true,
bool showInEpg = true,
ArtworkContentTypeModel logo = null,
CreateChannelFromLineupAdvancedOptions advanced = null,
List<CreateChannelFromLineupItem> lineup = null) =>
new(
name,
number,
"Kids",
string.Empty,
logo ?? ArtworkContentTypeModel.None,
isEnabled,
showInEpg,
templateId,
advanced ?? new CreateChannelFromLineupAdvancedOptions(PlaybackOrder.Shuffle),
lineup ?? [MovieItem(42)]);
private static BaseError LeftOf<TR>(Either<BaseError, TR> either) =>
either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result"));
private static TR RightOf<TR>(Either<BaseError, TR> either) =>
either.Match(Left: e => throw new AssertionException($"Expected a Right result, got {e.Value}"), Right: r => r);
}
@@ -0,0 +1,665 @@
using ErsatzTV.Application.LibraryBrowse;
using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Search;
using ErsatzTV.Tests.Support;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.LibraryBrowse;
[TestFixture]
public class GetLibraryBrowseItemsHandlerTests
{
private InMemoryTvContext _db = null!;
private ISearchIndex _searchIndex = null!;
[SetUp]
public async Task SetUp()
{
_db = await InMemoryTvContext.CreateAsync();
_searchIndex = Substitute.For<ISearchIndex>();
}
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
[Test]
public async Task Handle_Should_Search_Index_With_Type_Library_And_Title_Filters()
{
_searchIndex.Search(
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<int>(),
Arg.Any<int>(),
Arg.Any<List<string>>(),
Arg.Any<CancellationToken>())
.Returns(new SearchResult([], 0));
var handler = new GetLibraryBrowseItemsHandler(_searchIndex, _db.Factory);
await handler.Handle(
new GetLibraryBrowseItems("star", 5, LibraryBrowseMediaType.Movie, 2, 25),
CancellationToken.None);
await _searchIndex.Received(1).Search(
Arg.Is<string>(q => q.Contains("type:movie") && q.Contains("library_id:5") && q.Contains("star")),
string.Empty,
50,
25,
Arg.Is<List<string>>(fields => fields.SequenceEqual(new[] { LuceneSearchIndex.TitleAndYearSearchField })),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Handle_Should_Hydrate_Media_Items_In_Search_Order_With_Artwork_Duration_And_Counts()
{
await SeedLibraryGraph();
_searchIndex.Search(
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<int>(),
Arg.Any<int>(),
Arg.Any<List<string>>(),
Arg.Any<CancellationToken>())
.Returns(new SearchResult(
[
new SearchItem(LuceneSearchIndex.ShowType, 20),
new SearchItem(LuceneSearchIndex.MovieType, 10)
],
2));
var handler = new GetLibraryBrowseItemsHandler(_searchIndex, _db.Factory);
PagedLibraryBrowseItemsResponseModel result = await handler.Handle(
new GetLibraryBrowseItems("", null, null, 0, 10),
CancellationToken.None);
result.TotalCount.ShouldBe(2);
result.Page[0].MediaType.ShouldBe(LibraryBrowseMediaType.TelevisionShow);
result.Page[0].Title.ShouldBe("Collision Show");
result.Page[0].ItemCount.ShouldBe(1);
result.Page[0].MediaItemId.ShouldBe(20);
result.Page[1].MediaType.ShouldBe(LibraryBrowseMediaType.Movie);
result.Page[1].Title.ShouldBe("Collision Movie");
result.Page[1].Artwork.ShouldBe("movie-poster.jpg");
result.Page[1].Duration.ShouldBe(TimeSpan.FromMinutes(95));
result.Page[1].MediaItemId.ShouldBe(10);
}
[Test]
public async Task Handle_Should_Return_Typed_Collection_And_Playlist_Picker_Targets()
{
await SeedCollectionGraph();
_searchIndex.Search(
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<int>(),
Arg.Any<int>(),
Arg.Any<List<string>>(),
Arg.Any<CancellationToken>())
.Returns(new SearchResult([], 0));
var handler = new GetLibraryBrowseItemsHandler(_searchIndex, _db.Factory);
PagedLibraryBrowseItemsResponseModel result = await handler.Handle(
new GetLibraryBrowseItems("", null, null, 0, 10),
CancellationToken.None);
result.TotalCount.ShouldBe(5);
result.Page.ShouldContain(i =>
i.MediaType == LibraryBrowseMediaType.Collection &&
i.CollectionKind == "Manual" &&
i.CollectionType == CollectionType.Collection &&
i.CollectionId == 20 &&
i.ItemCount == 1);
result.Page.ShouldContain(i =>
i.MediaType == LibraryBrowseMediaType.SmartCollection &&
i.CollectionKind == "Smart" &&
i.CollectionType == CollectionType.SmartCollection &&
i.SmartCollectionId == 30);
result.Page.ShouldContain(i =>
i.MediaType == LibraryBrowseMediaType.MultiCollection &&
i.CollectionKind == "Multi" &&
i.CollectionType == CollectionType.MultiCollection &&
i.MultiCollectionId == 40);
result.Page.ShouldContain(i =>
i.MediaType == LibraryBrowseMediaType.RerunCollection &&
i.CollectionKind == "Rerun" &&
i.CollectionType == CollectionType.RerunFirstRun &&
i.RerunCollectionId == 50);
result.Page.ShouldContain(i =>
i.MediaType == LibraryBrowseMediaType.Playlist &&
i.CollectionType == CollectionType.Playlist &&
i.PlaylistId == 60);
}
[Test]
public async Task Handle_Should_Page_Across_Media_Manual_Collections_And_Smart_Collections()
{
await SeedMediaAndCollectionPagingGraph();
_searchIndex.Search(
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Is<int>(offset => offset == 0),
Arg.Any<int>(),
Arg.Any<List<string>>(),
Arg.Any<CancellationToken>())
.Returns(new SearchResult(
[
new SearchItem(LuceneSearchIndex.MovieType, 101),
new SearchItem(LuceneSearchIndex.MovieType, 102)
],
2));
_searchIndex.Search(
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Is<int>(offset => offset > 0),
Arg.Any<int>(),
Arg.Any<List<string>>(),
Arg.Any<CancellationToken>())
.Returns(new SearchResult([], 2));
var handler = new GetLibraryBrowseItemsHandler(_searchIndex, _db.Factory);
PagedLibraryBrowseItemsResponseModel page0 = await handler.Handle(
new GetLibraryBrowseItems("", null, null, 0, 3),
CancellationToken.None);
PagedLibraryBrowseItemsResponseModel page1 = await handler.Handle(
new GetLibraryBrowseItems("", null, null, 1, 3),
CancellationToken.None);
PagedLibraryBrowseItemsResponseModel page2 = await handler.Handle(
new GetLibraryBrowseItems("", null, null, 2, 3),
CancellationToken.None);
page0.TotalCount.ShouldBe(7);
page0.Page.Select(i => i.Title).ShouldBe(["Movie One", "Movie Two", "Manual A"]);
page1.TotalCount.ShouldBe(7);
page1.Page.Select(i => i.Title).ShouldBe(["Manual B", "Manual C", "Smart A"]);
page2.TotalCount.ShouldBe(7);
page2.Page.Select(i => i.Title).ShouldBe(["Smart B"]);
}
[Test]
public async Task Handle_Should_Hydrate_Manual_Collection_Artwork_And_Total_Duration()
{
await SeedManualCollectionMetadataGraph();
_searchIndex.Search(
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<int>(),
Arg.Any<int>(),
Arg.Any<List<string>>(),
Arg.Any<CancellationToken>())
.Returns(new SearchResult([], 0));
var handler = new GetLibraryBrowseItemsHandler(_searchIndex, _db.Factory);
PagedLibraryBrowseItemsResponseModel result = await handler.Handle(
new GetLibraryBrowseItems("", null, LibraryBrowseMediaType.Collection, 0, 10),
CancellationToken.None);
result.TotalCount.ShouldBe(1);
result.Page.Count.ShouldBe(1);
result.Page[0].Title.ShouldBe("Manual Metadata");
result.Page[0].Artwork.ShouldBe("first-poster.jpg");
result.Page[0].Duration.ShouldBe(TimeSpan.FromMinutes(75));
result.Page[0].ItemCount.ShouldBe(2);
}
[Test]
public async Task Handle_Should_Hydrate_Seasons_With_Composed_Titles_And_Episode_Counts()
{
await SeedSeasonGraph();
_searchIndex.Search(
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<int>(),
Arg.Any<int>(),
Arg.Any<List<string>>(),
Arg.Any<CancellationToken>())
.Returns(new SearchResult(
[
new SearchItem(LuceneSearchIndex.SeasonType, 301),
new SearchItem(LuceneSearchIndex.SeasonType, 302)
],
2));
var handler = new GetLibraryBrowseItemsHandler(_searchIndex, _db.Factory);
PagedLibraryBrowseItemsResponseModel result = await handler.Handle(
new GetLibraryBrowseItems("", null, LibraryBrowseMediaType.TelevisionSeason, 0, 10),
CancellationToken.None);
result.TotalCount.ShouldBe(2);
result.Page[0].Title.ShouldBe("Season Show - Season 2");
result.Page[0].ItemCount.ShouldBe(2);
result.Page[0].CollectionType.ShouldBe(CollectionType.TelevisionSeason);
result.Page[0].MediaItemId.ShouldBe(301);
result.Page[1].Title.ShouldBe("Season Show - Specials");
result.Page[1].ItemCount.ShouldBe(1);
result.Page[1].MediaItemId.ShouldBe(302);
}
[Test]
public async Task Handle_Should_Hydrate_Artists_With_Music_Video_Counts()
{
await SeedArtistGraph();
_searchIndex.Search(
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<int>(),
Arg.Any<int>(),
Arg.Any<List<string>>(),
Arg.Any<CancellationToken>())
.Returns(new SearchResult([new SearchItem(LuceneSearchIndex.ArtistType, 401)], 1));
var handler = new GetLibraryBrowseItemsHandler(_searchIndex, _db.Factory);
PagedLibraryBrowseItemsResponseModel result = await handler.Handle(
new GetLibraryBrowseItems("", null, LibraryBrowseMediaType.Artist, 0, 10),
CancellationToken.None);
result.TotalCount.ShouldBe(1);
result.Page[0].Title.ShouldBe("Counted Artist");
result.Page[0].ItemCount.ShouldBe(2);
result.Page[0].Artwork.ShouldBe("artist-thumb.jpg");
result.Page[0].CollectionType.ShouldBe(CollectionType.Artist);
result.Page[0].MediaItemId.ShouldBe(401);
}
[Test]
public async Task Handle_Should_Apply_Library_Filter_To_Manual_Collections_Only()
{
await SeedLibraryFilteredCollectionsGraph();
_searchIndex.Search(
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<int>(),
Arg.Any<int>(),
Arg.Any<List<string>>(),
Arg.Any<CancellationToken>())
.Returns(new SearchResult([], 0));
var handler = new GetLibraryBrowseItemsHandler(_searchIndex, _db.Factory);
PagedLibraryBrowseItemsResponseModel result = await handler.Handle(
new GetLibraryBrowseItems("", 501, null, 0, 10),
CancellationToken.None);
result.TotalCount.ShouldBe(1);
result.Page.Count.ShouldBe(1);
result.Page[0].Title.ShouldBe("Included Manual");
result.Page[0].MediaType.ShouldBe(LibraryBrowseMediaType.Collection);
result.Page[0].CollectionId.ShouldBe(510);
}
private async Task SeedLibraryGraph()
{
await using TvContext context = _db.CreateContext();
var library = new LocalLibrary
{
Id = 1,
Name = "Local Movies",
MediaKind = LibraryMediaKind.Movies,
Paths = []
};
var path = new LibraryPath
{
Id = 1,
Path = "/media",
Library = library,
LibraryFolders = [],
MediaItems = []
};
library.Paths.Add(path);
var movie = new Movie
{
Id = 10,
LibraryPath = path,
Collections = [],
CollectionItems = [],
TraktListItems = [],
MediaVersions = [new MediaVersion { Duration = TimeSpan.FromMinutes(95) }],
MovieMetadata =
[
MakeMovieMetadata("Collision Movie", "movie-poster.jpg")
]
};
var show = new Show
{
Id = 20,
LibraryPath = path,
Collections = [],
CollectionItems = [],
TraktListItems = [],
Seasons = [],
ShowMetadata = [MakeShowMetadata("Collision Show", "show-poster.jpg")]
};
var season = new Season
{
Id = 11,
LibraryPath = path,
Show = show,
Collections = [],
CollectionItems = [],
TraktListItems = [],
Episodes = [],
SeasonMetadata = []
};
var episode = new Episode
{
Id = 12,
LibraryPath = path,
Season = season,
Collections = [],
CollectionItems = [],
TraktListItems = [],
EpisodeMetadata = [],
MediaVersions = [new MediaVersion { Duration = TimeSpan.FromMinutes(25) }]
};
season.Episodes.Add(episode);
show.Seasons.Add(season);
path.MediaItems.AddRange([movie, show, season, episode]);
context.LocalLibraries.Add(library);
context.Movies.Add(movie);
context.Shows.Add(show);
context.Seasons.Add(season);
context.Episodes.Add(episode);
await context.SaveChangesAsync();
}
private async Task SeedCollectionGraph()
{
await using TvContext context = _db.CreateContext();
var collection = new Collection { Id = 20, Name = "Manual", MediaItems = [], CollectionItems = [] };
var movie = new Movie
{
Id = 21,
Collections = [collection],
CollectionItems = [],
TraktListItems = [],
MovieMetadata = [],
MediaVersions = []
};
collection.MediaItems.Add(movie);
var smart = new SmartCollection { Id = 30, Name = "Smart", Query = "tag:kids" };
var multi = new MultiCollection
{
Id = 40,
Name = "Multi",
Collections = [collection],
SmartCollections = [smart]
};
var rerun = new RerunCollection { Id = 50, Name = "Rerun", CollectionType = CollectionType.Collection };
var playlist = new Playlist
{
Id = 60,
Name = "Playlist",
Items = [new PlaylistItem { CollectionType = CollectionType.Collection, CollectionId = 20 }]
};
context.Collections.Add(collection);
context.Movies.Add(movie);
context.SmartCollections.Add(smart);
context.MultiCollections.Add(multi);
context.RerunCollections.Add(rerun);
context.Playlists.Add(playlist);
await context.SaveChangesAsync();
}
private async Task SeedMediaAndCollectionPagingGraph()
{
await using TvContext context = _db.CreateContext();
(LocalLibrary library, LibraryPath path) = MakeLibrary(100, "Paging Library");
var movie1 = MakeMovie(101, path, "Movie One", "movie-one.jpg", TimeSpan.FromMinutes(10));
var movie2 = MakeMovie(102, path, "Movie Two", "movie-two.jpg", TimeSpan.FromMinutes(20));
var manualA = MakeCollection(201, "Manual A");
var manualB = MakeCollection(202, "Manual B");
var manualC = MakeCollection(203, "Manual C");
context.LocalLibraries.Add(library);
context.Movies.AddRange(movie1, movie2);
context.Collections.AddRange(manualA, manualB, manualC);
context.SmartCollections.AddRange(
new SmartCollection { Id = 301, Name = "Smart A", Query = "tag:a", MultiCollections = [], MultiCollectionSmartItems = [] },
new SmartCollection { Id = 302, Name = "Smart B", Query = "tag:b", MultiCollections = [], MultiCollectionSmartItems = [] });
await context.SaveChangesAsync();
}
private async Task SeedManualCollectionMetadataGraph()
{
await using TvContext context = _db.CreateContext();
(LocalLibrary library, LibraryPath path) = MakeLibrary(200, "Manual Metadata Library");
var collection = MakeCollection(210, "Manual Metadata");
var first = MakeMovie(211, path, "First", "first-poster.jpg", TimeSpan.FromMinutes(30));
var second = MakeMovie(212, path, "Second", "second-poster.jpg", TimeSpan.FromMinutes(45));
collection.MediaItems.AddRange([first, second]);
context.LocalLibraries.Add(library);
context.Collections.Add(collection);
context.Movies.AddRange(first, second);
await context.SaveChangesAsync();
}
private async Task SeedSeasonGraph()
{
await using TvContext context = _db.CreateContext();
(LocalLibrary library, LibraryPath path) = MakeLibrary(300, "Season Library");
var show = new Show
{
Id = 300,
LibraryPath = path,
Collections = [],
CollectionItems = [],
TraktListItems = [],
Seasons = [],
ShowMetadata = [MakeShowMetadata("Season Show", "show.jpg")]
};
var season = MakeSeason(301, path, show, 2);
var specials = MakeSeason(302, path, show, 0);
season.Episodes.AddRange([
MakeEpisode(311, path, season),
MakeEpisode(312, path, season)
]);
specials.Episodes.Add(MakeEpisode(313, path, specials));
show.Seasons.AddRange([season, specials]);
path.MediaItems.AddRange([show, season, specials, .. season.Episodes, .. specials.Episodes]);
context.LocalLibraries.Add(library);
context.Shows.Add(show);
context.Seasons.AddRange(season, specials);
await context.SaveChangesAsync();
}
private async Task SeedArtistGraph()
{
await using TvContext context = _db.CreateContext();
(LocalLibrary library, LibraryPath path) = MakeLibrary(400, "Artist Library");
var artist = new Artist
{
Id = 401,
LibraryPath = path,
Collections = [],
CollectionItems = [],
TraktListItems = [],
MusicVideos = [],
ArtistMetadata = [MakeArtistMetadata("Counted Artist", "artist-thumb.jpg")]
};
var musicVideo1 = MakeMusicVideo(411, path, artist);
var musicVideo2 = MakeMusicVideo(412, path, artist);
artist.MusicVideos.AddRange([musicVideo1, musicVideo2]);
path.MediaItems.AddRange([artist, musicVideo1, musicVideo2]);
context.LocalLibraries.Add(library);
context.Artists.Add(artist);
context.MusicVideos.AddRange(musicVideo1, musicVideo2);
await context.SaveChangesAsync();
}
private async Task SeedLibraryFilteredCollectionsGraph()
{
await using TvContext context = _db.CreateContext();
(LocalLibrary includedLibrary, LibraryPath includedPath) = MakeLibrary(501, "Included Library");
(LocalLibrary excludedLibrary, LibraryPath excludedPath) = MakeLibrary(502, "Excluded Library");
var includedCollection = MakeCollection(510, "Included Manual");
var excludedCollection = MakeCollection(511, "Excluded Manual");
var includedMovie = MakeMovie(520, includedPath, "Included Movie", "included.jpg", TimeSpan.FromMinutes(5));
var excludedMovie = MakeMovie(521, excludedPath, "Excluded Movie", "excluded.jpg", TimeSpan.FromMinutes(5));
includedCollection.MediaItems.Add(includedMovie);
excludedCollection.MediaItems.Add(excludedMovie);
context.LocalLibraries.AddRange(includedLibrary, excludedLibrary);
context.Movies.AddRange(includedMovie, excludedMovie);
context.Collections.AddRange(includedCollection, excludedCollection);
context.SmartCollections.Add(new SmartCollection { Id = 530, Name = "Smart", Query = "tag", MultiCollections = [], MultiCollectionSmartItems = [] });
context.MultiCollections.Add(new MultiCollection { Id = 540, Name = "Multi", Collections = [], SmartCollections = [], MultiCollectionItems = [], MultiCollectionSmartItems = [] });
context.RerunCollections.Add(new RerunCollection { Id = 550, Name = "Rerun", CollectionType = CollectionType.Collection });
context.Playlists.Add(new Playlist { Id = 560, Name = "Playlist", Items = [] });
await context.SaveChangesAsync();
}
private static (LocalLibrary Library, LibraryPath Path) MakeLibrary(int id, string name)
{
var library = new LocalLibrary
{
Id = id,
Name = name,
MediaKind = LibraryMediaKind.Movies,
Paths = []
};
var path = new LibraryPath
{
Id = id,
Path = $"/media/{id}",
Library = library,
LibraryFolders = [],
MediaItems = []
};
library.Paths.Add(path);
return (library, path);
}
private static Collection MakeCollection(int id, string name) =>
new()
{
Id = id,
Name = name,
MediaItems = [],
CollectionItems = [],
MultiCollections = [],
MultiCollectionItems = []
};
private static Movie MakeMovie(int id, LibraryPath path, string title, string poster, TimeSpan duration) =>
new()
{
Id = id,
LibraryPath = path,
Collections = [],
CollectionItems = [],
TraktListItems = [],
MovieMetadata = [MakeMovieMetadata(title, poster)],
MediaVersions = [new MediaVersion { Duration = duration }]
};
private static Season MakeSeason(int id, LibraryPath path, Show show, int seasonNumber) =>
new()
{
Id = id,
LibraryPath = path,
Show = show,
SeasonNumber = seasonNumber,
Collections = [],
CollectionItems = [],
TraktListItems = [],
Episodes = [],
SeasonMetadata = [MakeSeasonMetadata()]
};
private static Episode MakeEpisode(int id, LibraryPath path, Season season) =>
new()
{
Id = id,
LibraryPath = path,
Season = season,
Collections = [],
CollectionItems = [],
TraktListItems = [],
EpisodeMetadata = [],
MediaVersions = []
};
private static MusicVideo MakeMusicVideo(int id, LibraryPath path, Artist artist) =>
new()
{
Id = id,
LibraryPath = path,
Artist = artist,
Collections = [],
CollectionItems = [],
TraktListItems = [],
MusicVideoMetadata = [],
MediaVersions = []
};
private static MovieMetadata MakeMovieMetadata(string title, string poster) =>
new()
{
Title = title,
SortTitle = title,
Artwork = [new Artwork { Path = poster, ArtworkKind = ArtworkKind.Poster }],
Genres = [],
Tags = [],
Studios = [],
Actors = [],
Guids = [],
Subtitles = [],
Directors = [],
Writers = []
};
private static ShowMetadata MakeShowMetadata(string title, string poster) =>
new()
{
Title = title,
SortTitle = title,
Artwork = [new Artwork { Path = poster, ArtworkKind = ArtworkKind.Poster }],
Genres = [],
Tags = [],
Studios = [],
Actors = [],
Guids = [],
Subtitles = []
};
private static SeasonMetadata MakeSeasonMetadata() =>
new()
{
Title = string.Empty,
SortTitle = string.Empty,
Artwork = [],
Genres = [],
Tags = [],
Studios = [],
Actors = [],
Guids = [],
Subtitles = []
};
private static ArtistMetadata MakeArtistMetadata(string title, string thumbnail) =>
new()
{
Title = title,
SortTitle = title,
Artwork = [new Artwork { Path = thumbnail, ArtworkKind = ArtworkKind.Thumbnail }],
Genres = [],
Tags = [],
Studios = [],
Actors = [],
Guids = [],
Subtitles = [],
Styles = [],
Moods = []
};
}
@@ -13,6 +13,8 @@ public class ApiErrorResponseMetadataTests
[TestCase(typeof(ChannelController), nameof(ChannelController.GetById), StatusCodes.Status404NotFound)]
[TestCase(typeof(ChannelController), nameof(ChannelController.Create), StatusCodes.Status404NotFound)]
[TestCase(typeof(ChannelController), nameof(ChannelController.Create), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(ChannelController), nameof(ChannelController.CreateFromLineup), StatusCodes.Status404NotFound)]
[TestCase(typeof(ChannelController), nameof(ChannelController.CreateFromLineup), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(ChannelController), nameof(ChannelController.Update), StatusCodes.Status404NotFound)]
[TestCase(typeof(ChannelController), nameof(ChannelController.Update), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(ChannelController), nameof(ChannelController.Delete), StatusCodes.Status404NotFound)]
@@ -24,6 +26,16 @@ public class ApiErrorResponseMetadataTests
[TestCase(typeof(ChannelController), nameof(ChannelController.BulkDelete), StatusCodes.Status404NotFound)]
[TestCase(typeof(ChannelController), nameof(ChannelController.BulkDelete), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(ChannelController), nameof(ChannelController.ResetPlayout), StatusCodes.Status404NotFound)]
[TestCase(typeof(ChannelTemplateController), nameof(ChannelTemplateController.GetDefault), StatusCodes.Status404NotFound)]
[TestCase(typeof(ChannelTemplateController), nameof(ChannelTemplateController.SetDefault), StatusCodes.Status404NotFound)]
[TestCase(typeof(ChannelTemplateController), nameof(ChannelTemplateController.SetDefault), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(ChannelTemplateController), nameof(ChannelTemplateController.GetById), StatusCodes.Status404NotFound)]
[TestCase(typeof(ChannelTemplateController), nameof(ChannelTemplateController.Create), StatusCodes.Status404NotFound)]
[TestCase(typeof(ChannelTemplateController), nameof(ChannelTemplateController.Create), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(ChannelTemplateController), nameof(ChannelTemplateController.Update), StatusCodes.Status404NotFound)]
[TestCase(typeof(ChannelTemplateController), nameof(ChannelTemplateController.Update), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(ChannelTemplateController), nameof(ChannelTemplateController.Delete), StatusCodes.Status404NotFound)]
[TestCase(typeof(ChannelTemplateController), nameof(ChannelTemplateController.Delete), StatusCodes.Status422UnprocessableEntity)]
[TestCase(typeof(CollectionController), nameof(CollectionController.GetById), StatusCodes.Status404NotFound)]
[TestCase(typeof(CollectionController), nameof(CollectionController.Create), StatusCodes.Status404NotFound)]
[TestCase(typeof(CollectionController), nameof(CollectionController.Create), StatusCodes.Status422UnprocessableEntity)]
@@ -7,6 +7,7 @@ using ErsatzTV.Controllers.Api;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Scheduling;
@@ -98,6 +99,54 @@ public class ChannelControllerTests
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
[Test]
public async Task CreateFromLineup_Should_Return_201_With_Location_And_Body()
{
var response = new CreateChannelFromLineupResponseModel(5, 6, 7, 8);
_mediator.Send(Arg.Any<CreateChannelFromLineup>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, CreateChannelFromLineupResponseModel>(response));
IActionResult result = await _controller.CreateFromLineup(MakeLineupRequest(), CancellationToken.None);
var created = result.ShouldBeOfType<CreatedResult>();
created.StatusCode.ShouldBe(201);
created.Location.ShouldBe("/api/channels/5");
created.Value.ShouldBe(response);
}
[Test]
public async Task CreateFromLineup_Should_Map_Request_To_Command()
{
_mediator.Send(Arg.Any<CreateChannelFromLineup>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, CreateChannelFromLineupResponseModel>(
new CreateChannelFromLineupResponseModel(5, 6, 7, 8)));
await _controller.CreateFromLineup(
MakeLineupRequest(number: "12", name: "Movies", templateId: 9),
CancellationToken.None);
await _mediator.Received(1).Send(
Arg.Is<CreateChannelFromLineup>(c =>
c.Number == "12" &&
c.Name == "Movies" &&
c.TemplateId == 9 &&
c.Lineup.Count == 1 &&
c.Lineup[0].CollectionType == CollectionType.Movie &&
c.Lineup[0].MediaItemId == 42),
Arg.Any<CancellationToken>());
}
[Test]
public async Task CreateFromLineup_Should_Return_404_For_NotFoundError()
{
_mediator.Send(Arg.Any<CreateChannelFromLineup>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, CreateChannelFromLineupResponseModel>(new NotFoundError("missing")));
IActionResult result = await _controller.CreateFromLineup(MakeLineupRequest(), CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
}
[Test]
public async Task Update_Should_Return_200_And_Map_Route_Id()
{
@@ -413,6 +462,30 @@ public class ChannelControllerTests
true,
false);
private static CreateChannelFromLineupRequest MakeLineupRequest(
string number = "5",
string name = "Test",
int templateId = 1) =>
new(
name,
number,
"ErsatzTV",
string.Empty,
ArtworkContentTypeModel.None,
true,
true,
templateId,
null,
[new CreateChannelFromLineupItemRequest(
LibraryBrowseMediaType.Movie,
CollectionType.Movie,
null,
null,
null,
null,
42,
null)]);
private static UpdateChannelRequest MakeUpdateRequest(string number = "5", string name = "Test") =>
new(
name,
@@ -0,0 +1,276 @@
using System.Reflection;
using ErsatzTV.Application.ChannelTemplates;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.ChannelTemplates;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Scheduling;
using LanguageExt;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Routing;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using static LanguageExt.Prelude;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Tests.Controllers;
[TestFixture]
public class ChannelTemplateControllerTests
{
private ChannelTemplateController _controller = null!;
private IMediator _mediator = null!;
[SetUp]
public void SetUp()
{
_mediator = Substitute.For<IMediator>();
_controller = new ChannelTemplateController(_mediator);
}
[Test]
public void Controller_Should_Expose_Named_Rest_Routes()
{
ShouldHaveActionRoute(
nameof(ChannelTemplateController.GetAll),
"GET",
"/api/channel-templates",
"GetChannelTemplates");
ShouldHaveActionRoute(
nameof(ChannelTemplateController.GetDefault),
"GET",
"/api/channel-templates/default",
"GetDefaultChannelTemplate");
ShouldHaveActionRoute(
nameof(ChannelTemplateController.SetDefault),
"PUT",
"/api/channel-templates/default/{id:int}",
"SetDefaultChannelTemplate");
ShouldHaveActionRoute(
nameof(ChannelTemplateController.GetById),
"GET",
"/api/channel-templates/{id:int}",
"GetChannelTemplateById");
ShouldHaveActionRoute(
nameof(ChannelTemplateController.Create),
"POST",
"/api/channel-templates",
"CreateChannelTemplate");
ShouldHaveActionRoute(
nameof(ChannelTemplateController.Update),
"PUT",
"/api/channel-templates/{id:int}",
"UpdateChannelTemplate");
ShouldHaveActionRoute(
nameof(ChannelTemplateController.Delete),
"DELETE",
"/api/channel-templates/{id:int}",
"DeleteChannelTemplate");
}
[Test]
public async Task GetAll_Should_Return_ChannelTemplates()
{
List<ChannelTemplateResponseModel> models = [MakeResponse(1, "Standard", isDefault: true)];
_mediator.Send(Arg.Any<GetAllChannelTemplates>(), Arg.Any<CancellationToken>())
.Returns(models);
List<ChannelTemplateResponseModel> result = await _controller.GetAll(CancellationToken.None);
result.ShouldBe(models);
}
[Test]
public async Task GetDefault_Should_Return_404_When_Default_Missing()
{
_mediator.Send(Arg.Any<GetDefaultChannelTemplate>(), Arg.Any<CancellationToken>())
.Returns(Option<ChannelTemplateResponseModel>.None);
IActionResult result = await _controller.GetDefault(CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
}
[Test]
public async Task Create_Should_Return_201_With_Location_And_Body()
{
ChannelTemplateResponseModel vm = MakeResponse(7, "Custom");
_mediator.Send(Arg.Any<CreateChannelTemplate>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, ChannelTemplateResponseModel>(vm));
IActionResult result = await _controller.Create(MakeCreateRequest("Custom"), CancellationToken.None);
var created = result.ShouldBeOfType<CreatedResult>();
created.Location.ShouldBe("/api/channel-templates/7");
created.Value.ShouldBe(vm);
}
[Test]
public async Task Create_Should_Map_Request_To_Command()
{
_mediator.Send(Arg.Any<CreateChannelTemplate>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, ChannelTemplateResponseModel>(MakeResponse(7, "Custom")));
await _controller.Create(MakeCreateRequest("Custom"), CancellationToken.None);
await _mediator.Received(1).Send(
Arg.Is<CreateChannelTemplate>(c =>
c.Name == "Custom" &&
c.FFmpegProfileId == 1 &&
c.PreRollFillerId == 2 &&
c.MidRollFillerId == 3 &&
c.PostRollFillerId == 4 &&
c.ShuffleScheduleItems),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Update_Should_Return_404_For_NotFoundError()
{
_mediator.Send(Arg.Any<UpdateChannelTemplate>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, ChannelTemplateResponseModel>(new NotFoundError("missing")));
IActionResult result = await _controller.Update(99, MakeUpdateRequest("Missing"), CancellationToken.None);
result.ShouldBeOfType<NotFoundObjectResult>();
}
[Test]
public async Task SetDefault_Should_Return_200_With_Default_Template()
{
ChannelTemplateResponseModel vm = MakeResponse(8, "Default", isDefault: true);
_mediator.Send(Arg.Any<SetDefaultChannelTemplate>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, ChannelTemplateResponseModel>(vm));
IActionResult result = await _controller.SetDefault(8, CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(vm);
await _mediator.Received(1).Send(
Arg.Is<SetDefaultChannelTemplate>(c => c.ChannelTemplateId == 8),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Delete_Should_Return_204_On_Success()
{
_mediator.Send(Arg.Any<DeleteChannelTemplate>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default));
IActionResult result = await _controller.Delete(9, CancellationToken.None);
result.ShouldBeOfType<NoContentResult>();
}
[Test]
public async Task Delete_Should_Return_422_When_System_Template()
{
_mediator.Send(Arg.Any<DeleteChannelTemplate>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, Unit>(BaseError.New("System templates cannot be deleted.")));
IActionResult result = await _controller.Delete(9, CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route, string name)
{
MethodInfo action = typeof(ChannelTemplateController).GetMethod(actionName)
?? throw new AssertionException($"Missing action {actionName}");
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
attribute.HttpMethods.ShouldContain(httpMethod);
attribute.Template.ShouldBe(route);
attribute.Name.ShouldBe(name);
}
private static CreateChannelTemplateRequest MakeCreateRequest(string name) =>
new(
name,
"Description",
1,
null,
null,
2,
3,
4,
ChannelStreamSelectorMode.Default,
string.Empty,
string.Empty,
string.Empty,
ChannelPlayoutSource.Generated,
ChannelPlayoutMode.Continuous,
StreamingMode.TransportStreamHybrid,
string.Empty,
ChannelSubtitleMode.None,
ChannelMusicVideoCreditsMode.None,
string.Empty,
ChannelSongVideoMode.Default,
ChannelTranscodeMode.OnDemand,
ChannelIdleBehavior.StopOnDisconnect,
true,
false,
FixedStartTimeBehavior.Flexible);
private static UpdateChannelTemplateRequest MakeUpdateRequest(string name) =>
new(
name,
"Description",
1,
null,
null,
null,
null,
null,
ChannelStreamSelectorMode.Default,
string.Empty,
string.Empty,
string.Empty,
ChannelPlayoutSource.Generated,
ChannelPlayoutMode.Continuous,
StreamingMode.TransportStreamHybrid,
string.Empty,
ChannelSubtitleMode.None,
ChannelMusicVideoCreditsMode.None,
string.Empty,
ChannelSongVideoMode.Default,
ChannelTranscodeMode.OnDemand,
ChannelIdleBehavior.StopOnDisconnect,
false,
false,
FixedStartTimeBehavior.Flexible);
private static ChannelTemplateResponseModel MakeResponse(int id, string name, bool isDefault = false) =>
new(
id,
name,
"Description",
false,
isDefault,
1,
null,
null,
null,
null,
null,
ChannelStreamSelectorMode.Default,
string.Empty,
string.Empty,
string.Empty,
ChannelPlayoutSource.Generated,
ChannelPlayoutMode.Continuous,
StreamingMode.TransportStreamHybrid,
string.Empty,
ChannelSubtitleMode.None,
ChannelMusicVideoCreditsMode.None,
string.Empty,
ChannelSongVideoMode.Default,
ChannelTranscodeMode.OnDemand,
ChannelIdleBehavior.StopOnDisconnect,
false,
false,
FixedStartTimeBehavior.Flexible);
}
@@ -0,0 +1,92 @@
using System.Reflection;
using ErsatzTV.Application.LibraryBrowse;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Domain;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Routing;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Controllers;
[TestFixture]
public class LibraryBrowseControllerTests
{
private LibraryBrowseController _controller = null!;
private IMediator _mediator = null!;
[SetUp]
public void SetUp()
{
_mediator = Substitute.For<IMediator>();
_controller = new LibraryBrowseController(_mediator);
}
[Test]
public void Controller_Should_Expose_Browse_Route_With_Stable_Operation_Name()
{
MethodInfo action = typeof(LibraryBrowseController).GetMethod(nameof(LibraryBrowseController.Browse))
?? throw new AssertionException($"Missing action {nameof(LibraryBrowseController.Browse)}");
var attribute = action.GetCustomAttributes<HttpGetAttribute>().Single();
attribute.Template.ShouldBe("/api/library/browse");
attribute.Name.ShouldBe("BrowseLibrary");
}
[Test]
public async Task Browse_Should_Clamp_Paging_And_Send_Query()
{
_mediator.Send(Arg.Any<GetLibraryBrowseItems>(), Arg.Any<CancellationToken>())
.Returns(new PagedLibraryBrowseItemsResponseModel(0, []));
await _controller.Browse(
"star",
123,
LibraryBrowseMediaType.Movie,
-1,
500,
CancellationToken.None);
await _mediator.Received(1).Send(
Arg.Is<GetLibraryBrowseItems>(q =>
q.Query == "star" &&
q.LibraryId == 123 &&
q.MediaType == LibraryBrowseMediaType.Movie &&
q.PageNum == 0 &&
q.PageSize == 100),
Arg.Any<CancellationToken>());
}
[Test]
public async Task Browse_Should_Return_Paged_Response()
{
var item = new LibraryBrowseItemResponseModel(
10,
LibraryBrowseMediaType.Movie,
"Movie",
3,
"Movies",
"/artwork/posters/movie.jpg",
TimeSpan.FromMinutes(90),
1,
null,
CollectionType.Movie,
null,
null,
null,
null,
10,
null);
_mediator.Send(Arg.Any<GetLibraryBrowseItems>(), Arg.Any<CancellationToken>())
.Returns(new PagedLibraryBrowseItemsResponseModel(1, [item]));
PagedLibraryBrowseItemsResponseModel result = await _controller.Browse(
cancellationToken: CancellationToken.None);
result.TotalCount.ShouldBe(1);
result.Page.ShouldBe([item]);
}
}
@@ -102,6 +102,8 @@ public class OpenApiErrorResponseContractTests
[TestCase("/api/channels/{id}", "get", "404")]
[TestCase("/api/channels", "post", "404")]
[TestCase("/api/channels", "post", "422")]
[TestCase("/api/channels/from-lineup", "post", "404")]
[TestCase("/api/channels/from-lineup", "post", "422")]
[TestCase("/api/channels/{id}", "put", "404")]
[TestCase("/api/channels/{id}", "put", "422")]
[TestCase("/api/channels/{id}", "delete", "404")]
@@ -113,6 +115,16 @@ public class OpenApiErrorResponseContractTests
[TestCase("/api/channels/bulk/delete", "post", "404")]
[TestCase("/api/channels/bulk/delete", "post", "422")]
[TestCase("/api/channels/{channelNumber}/playout/reset", "post", "404")]
[TestCase("/api/channel-templates/default", "get", "404")]
[TestCase("/api/channel-templates/default/{id}", "put", "404")]
[TestCase("/api/channel-templates/default/{id}", "put", "422")]
[TestCase("/api/channel-templates/{id}", "get", "404")]
[TestCase("/api/channel-templates", "post", "404")]
[TestCase("/api/channel-templates", "post", "422")]
[TestCase("/api/channel-templates/{id}", "put", "404")]
[TestCase("/api/channel-templates/{id}", "put", "422")]
[TestCase("/api/channel-templates/{id}", "delete", "404")]
[TestCase("/api/channel-templates/{id}", "delete", "422")]
[TestCase("/api/collections/{id}", "get", "404")]
[TestCase("/api/collections", "post", "404")]
[TestCase("/api/collections", "post", "422")]
@@ -0,0 +1,51 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Infrastructure;
[TestFixture]
public class DbInitializerChannelTemplateTests
{
private InMemoryTvContext _db = null!;
[SetUp]
public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
[Test]
public async Task Initialize_Should_Seed_System_Channel_Templates_And_Default_Config()
{
await using TvContext context = _db.CreateContext();
await DbInitializer.Initialize(context, CancellationToken.None);
List<ChannelTemplate> templates = context.ChannelTemplates.OrderBy(t => t.Name).ToList();
templates.Count.ShouldBe(2);
templates.ShouldContain(t => t.Name == "Music videos" && t.IsSystem);
ChannelTemplate standard = templates.Single(t => t.Name == "Standard");
standard.IsSystem.ShouldBeTrue();
ConfigElement config = context.ConfigElements.Single(
c => c.Key == ConfigElementKey.ChannelTemplatesDefaultTemplateId.Key);
config.Value.ShouldBe(standard.Id.ToString());
}
[Test]
public async Task Initialize_Should_Not_Clobber_Existing_System_Template()
{
await using TvContext context = _db.CreateContext();
await DbInitializer.Initialize(context, CancellationToken.None);
ChannelTemplate standard = context.ChannelTemplates.Single(t => t.Name == "Standard");
standard.Description = "User adjusted description";
await context.SaveChangesAsync();
await DbInitializer.Initialize(context, CancellationToken.None);
context.ChannelTemplates.Single(t => t.Name == "Standard").Description.ShouldBe("User adjusted description");
}
}
@@ -79,6 +79,32 @@ public class ChannelController(ChannelWriter<IBackgroundServiceRequest> workerCh
});
}
[HttpPost("/api/channels/from-lineup", Name = "CreateChannelFromLineup")]
[Tags("Channels")]
[EndpointSummary("Create a channel from a library lineup")]
[EndpointDescription(
"Atomically creates the channel, program schedule, a classic playout, and (for multi-item lineups) a " +
"generated system playlist. A single-item lineup produces one flood schedule item that references the " +
"target directly (movie, show, season, artist, collection, smart/multi collection, rerun collection, or " +
"playlist) and no generated playlist. A lineup with two or more items produces one generated system " +
"playlist whose entries play in the given order (each entry played in full before the next) referenced by " +
"one flood schedule item; only movies, shows, seasons, artists, collections, smart collections and multi " +
"collections are allowed there (rerun collections and playlists are single-item only). playbackOrder sets " +
"how items within each lineup entry are ordered. Template defaults are stamped at create time; advanced " +
"overrides win.")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(CreateChannelFromLineupResponseModel), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> CreateFromLineup(
[Required] [FromBody] CreateChannelFromLineupRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, CreateChannelFromLineupResponseModel> result =
await mediator.Send(request.ToCommand(), cancellationToken);
return result.ToCreatedResult(response => $"/api/channels/{response.ChannelId}", response => response);
}
[HttpPut("/api/channels/{id:int}")]
[Tags("Channels")]
[EndpointSummary("Update a channel")]
@@ -0,0 +1,109 @@
using System.ComponentModel.DataAnnotations;
using ErsatzTV.Application.ChannelTemplates;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.ChannelTemplates;
using ErsatzTV.Extensions;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
public class ChannelTemplateController(IMediator mediator) : ControllerBase
{
[HttpGet("/api/channel-templates", Name = "GetChannelTemplates")]
[Tags("Channel Templates")]
[EndpointSummary("Get all channel templates")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(List<ChannelTemplateResponseModel>), StatusCodes.Status200OK)]
public async Task<List<ChannelTemplateResponseModel>> GetAll(CancellationToken cancellationToken) =>
await mediator.Send(new GetAllChannelTemplates(), cancellationToken);
[HttpGet("/api/channel-templates/default", Name = "GetDefaultChannelTemplate")]
[Tags("Channel Templates")]
[EndpointSummary("Get the default channel template")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(ChannelTemplateResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetDefault(CancellationToken cancellationToken)
{
Option<ChannelTemplateResponseModel> result =
await mediator.Send(new GetDefaultChannelTemplate(), cancellationToken);
return result.ToGetResult();
}
[HttpPut("/api/channel-templates/default/{id:int}", Name = "SetDefaultChannelTemplate")]
[Tags("Channel Templates")]
[EndpointSummary("Set the default channel template")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(ChannelTemplateResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> SetDefault(int id, CancellationToken cancellationToken)
{
Either<BaseError, ChannelTemplateResponseModel> result =
await mediator.Send(new SetDefaultChannelTemplate(id), cancellationToken);
return result.ToUpdatedResult();
}
[HttpGet("/api/channel-templates/{id:int}", Name = "GetChannelTemplateById")]
[Tags("Channel Templates")]
[EndpointSummary("Get a channel template by id")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(ChannelTemplateResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
{
Option<ChannelTemplateResponseModel> result =
await mediator.Send(new GetChannelTemplateById(id), cancellationToken);
return result.ToGetResult();
}
[HttpPost("/api/channel-templates", Name = "CreateChannelTemplate")]
[Tags("Channel Templates")]
[EndpointSummary("Create a channel template")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(ChannelTemplateResponseModel), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Create(
[Required] [FromBody] CreateChannelTemplateRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, ChannelTemplateResponseModel> result =
await mediator.Send(request.ToCommand(), cancellationToken);
return result.ToCreatedResult(vm => $"/api/channel-templates/{vm.Id}", vm => vm);
}
[HttpPut("/api/channel-templates/{id:int}", Name = "UpdateChannelTemplate")]
[Tags("Channel Templates")]
[EndpointSummary("Update a channel template")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(ChannelTemplateResponseModel), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Update(
int id,
[Required] [FromBody] UpdateChannelTemplateRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, ChannelTemplateResponseModel> result =
await mediator.Send(request.ToCommand(id), cancellationToken);
return result.ToUpdatedResult();
}
[HttpDelete("/api/channel-templates/{id:int}", Name = "DeleteChannelTemplate")]
[Tags("Channel Templates")]
[EndpointSummary("Delete a channel template")]
[EndpointGroupName("general")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> Delete(int id, CancellationToken cancellationToken)
{
Either<BaseError, Unit> result = await mediator.Send(new DeleteChannelTemplate(id), cancellationToken);
return result.ToDeletedResult();
}
}
@@ -0,0 +1,33 @@
using ErsatzTV.Application.LibraryBrowse;
using ErsatzTV.Core.Api.LibraryBrowse;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers.Api;
[ApiController]
public class LibraryBrowseController(IMediator mediator) : ControllerBase
{
private const int MaxPageSize = 100;
[HttpGet("/api/library/browse", Name = "BrowseLibrary")]
[Tags("Libraries")]
[EndpointSummary("Browse and search library items")]
[EndpointGroupName("general")]
[ProducesResponseType(typeof(PagedLibraryBrowseItemsResponseModel), StatusCodes.Status200OK)]
public async Task<PagedLibraryBrowseItemsResponseModel> Browse(
[FromQuery] string query = "",
[FromQuery] int? libraryId = null,
[FromQuery] LibraryBrowseMediaType? mediaType = null,
[FromQuery] int pageNum = 0,
[FromQuery] int pageSize = 100,
CancellationToken cancellationToken = default)
{
int clampedPageNum = Math.Max(0, pageNum);
int clampedPageSize = Math.Clamp(pageSize, 1, MaxPageSize);
return await mediator.Send(
new GetLibraryBrowseItems(query, libraryId, mediaType, clampedPageNum, clampedPageSize),
cancellationToken);
}
}
@@ -0,0 +1,117 @@
#nullable enable
using ErsatzTV.Application.Artworks;
using ErsatzTV.Application.Channels;
using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Controllers.Api.Requests;
/// <summary>
/// Composite request to create a channel, its generated schedule/playlist and a classic playout in one call.
/// Template defaults are stamped at create time; any value set in <see cref="Advanced" /> overrides the template.
/// A single-item lineup references its target directly; a multi-item lineup is played in order via a generated
/// system playlist.
/// </summary>
public record CreateChannelFromLineupRequest(
string Name,
string Number,
string Group,
string Categories,
ArtworkContentTypeModel Logo,
bool IsEnabled,
bool ShowInEpg,
int TemplateId,
CreateChannelFromLineupAdvancedOptionsRequest? Advanced,
List<CreateChannelFromLineupItemRequest> Lineup)
{
public CreateChannelFromLineup ToCommand() =>
new(
Name,
Number,
Group,
Categories,
Logo,
IsEnabled,
ShowInEpg,
TemplateId,
Advanced?.ToCommand() ?? new CreateChannelFromLineupAdvancedOptions(),
Lineup.Map(i => i.ToCommand()).ToList());
}
public record CreateChannelFromLineupAdvancedOptionsRequest(
PlaybackOrder? PlaybackOrder = null,
int? FFmpegProfileId = null,
int? WatermarkId = null,
int? FallbackFillerId = null,
int? PreRollFillerId = null,
int? MidRollFillerId = null,
int? PostRollFillerId = null,
ChannelStreamSelectorMode? StreamSelectorMode = null,
string? StreamSelector = null,
string? PreferredAudioLanguageCode = null,
string? PreferredAudioTitle = null,
ChannelPlayoutSource? PlayoutSource = null,
ChannelPlayoutMode? PlayoutMode = null,
StreamingMode? StreamingMode = null,
string? PreferredSubtitleLanguageCode = null,
ChannelSubtitleMode? SubtitleMode = null,
ChannelMusicVideoCreditsMode? MusicVideoCreditsMode = null,
string? MusicVideoCreditsTemplate = null,
ChannelSongVideoMode? SongVideoMode = null,
ChannelTranscodeMode? TranscodeMode = null,
ChannelIdleBehavior? IdleBehavior = null,
bool? ShuffleScheduleItems = null,
bool? RandomStartPoint = null,
FixedStartTimeBehavior? FixedStartTimeBehavior = null)
{
public CreateChannelFromLineupAdvancedOptions ToCommand() =>
new(
PlaybackOrder,
FFmpegProfileId,
WatermarkId,
FallbackFillerId,
PreRollFillerId,
MidRollFillerId,
PostRollFillerId,
StreamSelectorMode,
StreamSelector,
PreferredAudioLanguageCode,
PreferredAudioTitle,
PlayoutSource,
PlayoutMode,
StreamingMode,
PreferredSubtitleLanguageCode,
SubtitleMode,
MusicVideoCreditsMode,
MusicVideoCreditsTemplate,
SongVideoMode,
TranscodeMode,
IdleBehavior,
ShuffleScheduleItems,
RandomStartPoint,
FixedStartTimeBehavior);
}
public record CreateChannelFromLineupItemRequest(
LibraryBrowseMediaType MediaType,
CollectionType CollectionType,
int? CollectionId,
int? MultiCollectionId,
int? SmartCollectionId,
int? RerunCollectionId,
int? MediaItemId,
int? PlaylistId)
{
public CreateChannelFromLineupItem ToCommand() =>
new(
MediaType,
CollectionType,
CollectionId,
MultiCollectionId,
SmartCollectionId,
RerunCollectionId,
MediaItemId,
PlaylistId);
}
@@ -0,0 +1,63 @@
#nullable enable
using ErsatzTV.Application.ChannelTemplates;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Controllers.Api.Requests;
public record CreateChannelTemplateRequest(
string Name,
string Description,
int FFmpegProfileId,
int? WatermarkId,
int? FallbackFillerId,
int? PreRollFillerId,
int? MidRollFillerId,
int? PostRollFillerId,
ChannelStreamSelectorMode StreamSelectorMode,
string? StreamSelector,
string? PreferredAudioLanguageCode,
string? PreferredAudioTitle,
ChannelPlayoutSource PlayoutSource,
ChannelPlayoutMode PlayoutMode,
StreamingMode StreamingMode,
string? PreferredSubtitleLanguageCode,
ChannelSubtitleMode SubtitleMode,
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
string? MusicVideoCreditsTemplate,
ChannelSongVideoMode SongVideoMode,
ChannelTranscodeMode TranscodeMode,
ChannelIdleBehavior IdleBehavior,
bool ShuffleScheduleItems,
bool RandomStartPoint,
FixedStartTimeBehavior FixedStartTimeBehavior)
{
public CreateChannelTemplate ToCommand() =>
new(
Name,
Description,
FFmpegProfileId,
WatermarkId,
FallbackFillerId,
PreRollFillerId,
MidRollFillerId,
PostRollFillerId,
StreamSelectorMode,
StreamSelector,
PreferredAudioLanguageCode,
PreferredAudioTitle,
PlayoutSource,
PlayoutMode,
StreamingMode,
PreferredSubtitleLanguageCode,
SubtitleMode,
MusicVideoCreditsMode,
MusicVideoCreditsTemplate,
SongVideoMode,
TranscodeMode,
IdleBehavior,
ShuffleScheduleItems,
RandomStartPoint,
FixedStartTimeBehavior);
}
@@ -0,0 +1,64 @@
#nullable enable
using ErsatzTV.Application.ChannelTemplates;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Controllers.Api.Requests;
public record UpdateChannelTemplateRequest(
string Name,
string Description,
int FFmpegProfileId,
int? WatermarkId,
int? FallbackFillerId,
int? PreRollFillerId,
int? MidRollFillerId,
int? PostRollFillerId,
ChannelStreamSelectorMode StreamSelectorMode,
string? StreamSelector,
string? PreferredAudioLanguageCode,
string? PreferredAudioTitle,
ChannelPlayoutSource PlayoutSource,
ChannelPlayoutMode PlayoutMode,
StreamingMode StreamingMode,
string? PreferredSubtitleLanguageCode,
ChannelSubtitleMode SubtitleMode,
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
string? MusicVideoCreditsTemplate,
ChannelSongVideoMode SongVideoMode,
ChannelTranscodeMode TranscodeMode,
ChannelIdleBehavior IdleBehavior,
bool ShuffleScheduleItems,
bool RandomStartPoint,
FixedStartTimeBehavior FixedStartTimeBehavior)
{
public UpdateChannelTemplate ToCommand(int id) =>
new(
id,
Name,
Description,
FFmpegProfileId,
WatermarkId,
FallbackFillerId,
PreRollFillerId,
MidRollFillerId,
PostRollFillerId,
StreamSelectorMode,
StreamSelector,
PreferredAudioLanguageCode,
PreferredAudioTitle,
PlayoutSource,
PlayoutMode,
StreamingMode,
PreferredSubtitleLanguageCode,
SubtitleMode,
MusicVideoCreditsMode,
MusicVideoCreditsTemplate,
SongVideoMode,
TranscodeMode,
IdleBehavior,
ShuffleScheduleItems,
RandomStartPoint,
FixedStartTimeBehavior);
}
+61
View File
@@ -0,0 +1,61 @@
using ErsatzTV.Infrastructure;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using Microsoft.Extensions.Logging.Abstractions;
namespace ErsatzTV;
public class TvContextDesignTimeFactory : IDesignTimeDbContextFactory<TvContext>
{
public TvContext CreateDbContext(string[] args)
{
string provider = GetProvider(args);
var optionsBuilder = new DbContextOptionsBuilder<TvContext>();
if (provider.Equals("MySql", StringComparison.OrdinalIgnoreCase))
{
TvContext.IsSqlite = false;
TvContext.LastInsertedRowId = "last_insert_id()";
TvContext.CaseInsensitiveCollation = "utf8mb4_general_ci";
string connectionString =
Environment.GetEnvironmentVariable("MySql__ConnectionString") ??
"Server=localhost;Database=ersatztv_design_time;User=root;Password=ersatztv;";
optionsBuilder.UseMySql(
connectionString,
new MySqlServerVersion(new Version(8, 0, 36)),
builder => builder.MigrationsAssembly("ErsatzTV.Infrastructure.MySql"));
}
else
{
TvContext.IsSqlite = true;
TvContext.LastInsertedRowId = "last_insert_rowid()";
TvContext.CaseInsensitiveCollation = "NOCASE";
string configFolder = Environment.GetEnvironmentVariable("ETV_CONFIG_FOLDER");
string databasePath = string.IsNullOrWhiteSpace(configFolder)
? Path.Combine(Path.GetTempPath(), "ersatztv-design-time.sqlite3")
: Path.Combine(configFolder, "ersatztv.sqlite3");
optionsBuilder.UseSqlite(
$"Data Source={databasePath}",
builder => builder.MigrationsAssembly("ErsatzTV.Infrastructure.Sqlite"));
}
return new TvContext(
optionsBuilder.Options,
NullLoggerFactory.Instance,
new SlowQueryInterceptor(NullLogger<SlowQueryInterceptor>.Instance));
}
private static string GetProvider(string[] args)
{
for (int index = 0; index < args.Length - 1; index++)
{
if (args[index].Equals("--provider", StringComparison.OrdinalIgnoreCase))
{
return args[index + 1];
}
}
return "Sqlite";
}
}
File diff suppressed because it is too large Load Diff
+205 -109
View File
@@ -5,131 +5,227 @@ UPDATING THIS FILE in place (rewrite the state section and the queue for the nex
always holds the current handoff. History: created 2026-07-02 after the plan audit (#59 epic)
filed backend gap issues #100#111; a 79-way parallel workflow build once exhausted RAM, so
builds are limited to 23 concurrent, never wide fan-outs. Backend gaps all landed by
2026-07-04 (#105/#108/#100+#101+#107+#110/#103+#106/#104/#111/#102 PRs #113#119). The
MERGE PASS (2026-07-04) then landed the whole SPA stack on main via PR #120.
2026-07-04 (PRs #113#119); merge pass PR #120; live-data screens: #109 Dashboard (PR #123),
#84 Channels (PR #124), #86 Schedule editor (PR #125), #87 Playouts (PR #127), #88 Libraries
(PR #128), #85 Guide/EPG (PR #129); #62 prerequisites: #65 library browse (PR #130), #64
channel templates (PR #133).
**Session state (2026-07-04, post-#121 merge)**: main = cc6ffcb8 (PR #121 "#99 direct
stream session tracking"): design-system/ + web/ SPA foundation (#78#83), backend
#96/#98/#97, Dockerfile hotfix #122, and direct-stream session tracker #99 support are all
on main. #97 is closed; #99 remains open for the final `/api/channels/state` wiring only
(combine `IFFmpegSegmenterService.IsActive` with `IDirectStreamSessionTracker`). Test
baselines on main after #121: ErsatzTV.Tests **368**, Core.Tests **493** (+1 skip); web/:
`npm run typecheck` + `build` clean (outputs to ErsatzTV/wwwroot/app/, gitignored; SPA
types = web/src/api/generated/v1.d.ts, regen via `npm run generate:api`). Post-merge main
run 224 is fully green, including `Build & push image (amd64)`.
- **#99 / PR #121 state**: merged after Fable APPROVE. Round-2 fixes removed
`DirectStreamSessionTracker` empty-dictionary pruning to avoid orphaned live sessions and
bypassed direct-session tracking for HEAD probes in `TrackedFileStreamResult`.
- Worktrees: .worktrees/issue-99-session-tracking can be removed after this doc update;
issue-97/feat-102 removed. The main checkout still sits on docs/59-ui-redesign-brief —
fully merged now, safe to switch to main.
**PROCESS (2026-07-05, binding)**: Fable writes each Codex prompt AND performs the PR review
(via review subagents — parallel lenses: correctness fork + cheaper contract/tests +
design-system agents; plus a fork verification pass over any fix diff). Codex implements.
Every Codex prompt MUST mandate: (a) subagents where appropriate at fitting effort/model
levels, (b) `npm ci` in each fresh worktree. Review-driven fixes are applied by fitting
subagents too (NITS: Fable fixes on the branch; SUBSTANTIAL: back to Codex verbatim or fixed
in-session — user decides). Merges need explicit user consent (standing consent for this
stretch: merge when reviewers are happy and tests pass).
**RELEASE CHECKPOINT (standing, added 2026-07-06)**: prod cutover to the fork is DONE —
prod container `ersatztv` on bumblebee runs `192.168.1.95:3000/timothy/ersatztv:prod`
(= v26.3.1, app-identical to upstream 26.3.0); `ersatztv-test` tracks `:latest` (main).
Prod only advances on `v*` tags. At every milestone merge, FLAG THE USER: is this slice
worth tagging **v26.4.0** (reserved for the first app-change release)? Latest sensible tag
point is #91 (cutover); earlier if a stable API slice should reach prod sooner. Tagging
needs explicit user consent; NEVER `[skip ci]` a commit you'll tag.
**Session state (2026-07-06, post-#64)**: main = 4854c45a (PR #133 merged): `ChannelTemplate`
domain entity — composition-by-reference (FFmpegProfileId + optional watermark/fallback/
pre/mid/post-roll filler refs) plus stream/audio/playout/subtitle/music-video/transcode/idle/
schedule defaults; `IsSystem` built-ins ("Standard", "Music videos") seeded idempotently in
DbInitializer (user edits survive restarts, verified by test); default-template selection via
ConfigElement `channel_templates.default_template_id`; 7 endpoints under /api/channel-templates
(list/get/get-default/set-default/create/update/delete; delete rejects system + active
default). FIRST FORK SCHEMA CHANGE: dual migrations `AddChannelTemplates` (SQLite
20260706155532 / MySQL 20260706155538) + first `IDesignTimeDbContextFactory`
(ErsatzTV/TvContextDesignTimeFactory.cs — lets `dotnet ef` run without a live MySQL).
Fable review found 3 CONFIRMED (untrimmed-name uniqueness check → DbUpdateException/500;
dead `.OrderBy(Name)` before SelectOneAsync in the default fallback; flat folder layout vs
contributing §2) + nits; Codex fixed all in 1a61b89f (+ shared ChannelTemplateDefault helper,
ConfigElementKey regrouped); deferred: Validation.Apply conversion (would erase NotFoundError
→ breaks 404 mapping, stays early-return Option<BaseError>). #64 CLOSED. Templates are
applied at channel-create time by #63 — NOT live-linked to channels (so #68 unblocked).
Baselines: ErsatzTV.Tests **419**, Core.Tests **493** (+1 skip); web tests **80**. Main
checkout sits on docs/59-ui-redesign-brief; .worktrees/issue-64-channel-templates is merged
(remove it).
**Lessons for all remaining prompts** (accumulated):
- The image-build job runs ONLY on main pushes — PR CI cannot catch Dockerfile breakage.
Any Dockerfile change: verify base tags exist (hub.docker.com API) and, where docker is
available, `docker build --target <stage>` locally before merging. (`node:22-noble` cost
a red main run; node official images have no `-noble` variant.)
- DTO records in ErsatzTV.Core/Api MUST get file-scoped `#nullable enable` — else the spec
emits `["null","string"]` unions and SPA types get needless `| null`. #96/#98 DTOs were
retrofitted in 8d89ab16; ChannelStateResponseModel complied from the start. NOTE:
ErsatzTV.Application has NO nullable context — `string?` there trips CS8632.
- NSubstitute + ConfigElementKey: `ConfigElementKey.X` is a fresh instance per access —
stub with `Arg.Any<ConfigElementKey>()` disambiguated by the generic `<T>`.
- `Option<T>.ToNullable()` doesn't exist here; use `MatchUnsafe(v => (T?)v, () => null)`.
- ./scripts/update-openapi.sh only runs `-t:GenerateOpenApiDocuments` — do a normal
`dotnet build ErsatzTV/ErsatzTV.csproj` FIRST or it fails with MSB3680.
- Id-taking child-collection GETs 404 on unknown parent via pre-check + ApiResults
(precedent: ScheduleController.GetItems) + an OpenApiErrorResponseContractTests entry.
- Backlog nits (unfiled): unclamped pageSize on paged endpoints; PlayoutController
Create/Delete lack route Name=; PlayoutController.GetItems existence pre-check is heavy;
>30 MB uploads return bare Kestrel 413; artwork content-type trusted from client (#66);
schedule duration estimator: expression/zero Count → null estimate, materializes each
referenced collection per GET. From #102 (also on #85): GET /api/guide runs the full
21-include eager-load per channel per request — trim projection or add caching before the
EPG grid polls it; `fillerKind` is almost always `None` (projector merges filler into
adjacent programmes) — discrete filler entries need a JSON-only projection mode.
- `SelectOneAsync` re-applies `.OrderBy(keySelector)` INTERNALLY, which REPLACES any ordering
the caller composed before it (#133) — never pre-`OrderBy` into SelectOneAsync; write the
explicit `.Where(...).OrderBy(...).FirstOrDefaultAsync(...)` when ordering matters.
- Normalize user input ONCE (#133): validate uniqueness/lengths against the SAME normalized
(e.g. trimmed) value you persist, or a whitespace variant slips past validation and dies on
the unique index as an unhandled 500.
- Application command/query records + handlers live in `<Domain>/Commands/` and
`<Domain>/Queries/` subfolders (contributing §2); namespace stays
`ErsatzTV.Application.<Domain>` regardless of subfolder (#133).
- Deferral wording must ENUMERATE what is deferred (#130): "aggregate collection metadata is
deferred" quietly swallowed manual collections, which are a cheap direct join — the review
had to split the deferral. Cheap-vs-expensive is per collection kind, not per feature.
- Merged-source paging pattern (#130): Lucene supplies media ids+total, EF supplies
collection-likes; page = media first, then a skip cascade through each collection type
(`remainingSkip`/`take` threading). Stale Lucene entries can drift collection paging for a
scan window — accepted, documented in-code. Any similar dual-source endpoint should copy
the GetLibraryBrowseItemsHandler pattern AND its multi-type-overflow paging test.
- User text into BOTH Lucene and SQL needs per-side treatment (#130): raw query text is the
established Lucene idiom (parser falls back to escaped-literal on ParseException — malformed
input degrades to empty/literal results, never throws), but the same text in EF LIKE needs
`%`/`_`/escape-char escaping or semantics diverge between the two halves.
- Direct `*Metadata` DbSet queries need a deterministic winner (#130): items can carry >1
metadata row; either go through the navigation + HeadOrNone() idiom or
GroupBy(itemId).OrderBy(Id).First().
- NULL FIELDS ARE OMITTED ON THE WIRE (#129): Startup.cs sets Newtonsoft
`NullValueHandling.Ignore` globally, so any null DTO property is ABSENT from the JSON →
`undefined` in the browser, even though generated types say `| null`. Frontend guards must
use truthiness (`!x`), NEVER `=== null`; fixtures for null cases must OMIT the key (test
precedent: "renders the Guide screen when an on-air channel omits nowPlaying").
- Cross-endpoint correlation needs shared ids (#129): /api/guide titles are show-only
(ChannelGuideMetadata.GetTitle) while /api/channels/state nowPlaying uses GetDisplayTitle
("Show - s01e01 - Ep") — string matching across endpoints can never work for episodes.
Live match is now timestamps-only; the real fix is a shared programme/playout-item id on
both endpoints (backlog).
- Fixture fidelity (#109/#127/#128): fixtures must be what the actually-called endpoint
returns UNDER THE QUERY THE CLIENT SENDS. `percent` is a 01 fraction despite its name.
Enum-with-None fields are never truthiness-checked. Verify UNITS/scale of numeric wire
fields against the producing code, not the field name.
- Trigger≠started (#128): a 202/200 on a trigger endpoint means QUEUED; poll while
pendingactive nonempty with a grace window; drain grace on persistent errors.
- setState updaters must be PURE — no fetches (#127), no ref mutations (#128); StrictMode
double-invokes updaters and the test renderer doesn't, so reviewers must catch it.
- OpenAPI can UNDER-report the wire (#125/#126); check the serializer before widening types.
- Every new multi-column grid → the @media (max-width: 980px) collapse block; var() fallback
= the token's resolved value; verify the token EXISTS (--ctv-surface-1, --text-faint don't).
- Prototype affordances: implemented or VISIBLY deferred — never silently dropped.
- Actionable = visible (#84); disable all mutation triggers while mutating; ref-based
double-submit guards; mutations never refetch the world (#125/#127).
- Honest tests: no scenarios the backend can't produce (no exception middleware → failures
are BARE 4xx/5xx unless the controller returns ProblemDetails); mount call-counts assert
the DELTA across navigation; status-dot state never color-only (StatusDot has a label).
- The image-build job runs ONLY on main pushes. DTO records in Core/Api need `#nullable
enable`; Application has NO nullable context. NSubstitute+ConfigElementKey:
`Arg.Any<ConfigElementKey>()` + `<T>`. `Option<T>.ToNullable()` → `MatchUnsafe`.
update-openapi.sh needs a prior normal build. Child GETs 404 unknown parents via pre-check.
Validation.Apply ERASES NotFoundError subtypes (#44 gotcha) — multi-check validation that
must 404 stays early-return.
- Backlog nits (unfiled): unclamped pageSize (browse is clamped; older endpoints aren't);
PlayoutController Create/Delete lack Name=; heavy GetItems pre-check; >30 MB uploads →
bare 413; artwork content-type trusted (#66); schedule estimator materializes collections
per GET; /api/health TTL cache; `LibraryScanStatusResponseModel.percent` 01 under a
percent name; no Dialog/Modal component yet (needed by #89); 1 pre-existing --text-faint
usage in shell.css. From #129: shared programme/playout-item id on /api/guide +
/api/channels/state; extract the duplicated channel-state poll loop into a shared helper;
EPG grid re-renders unmemoized on every tick; /api/guide 21-include eager-load untrimmed.
From #130: the two manual-collection metadata helpers each fetch CollectionItems (share
one fetch); very large manual collections make the browse duration sum heavy. Filed: #126
(OpenAPI polymorphism gap).
---
# PROMPT FOR CODEX — #109: Dashboard follow-up — replace stubbed data with real sources
# PROMPT FOR CODEX — #63: composite "create channel from lineup" endpoint
You are Codex working solo in /Users/timothy/ersatztv (ErsatzTV fork; the React SPA
"ChicoryTV" lives in web/ — Vite + TS, typed client in web/src/api/, generated types
web/src/api/generated/v1.d.ts). Read CLAUDE.md and docs/contributing.md first; follow the
SPA foundation's existing patterns (#78#83) — match, don't invent.
You are Codex, the IMPLEMENTER, in /Users/timothy/ersatztv (ErsatzTV fork; .NET 10,
CQRS/MediatR, LanguageExt, EF Core dual-provider). Fable (Claude) reviews your PR read-only
afterwards — do NOT merge. Read CLAUDE.md and docs/contributing.md first; follow the REST
API conventions from the #2a foundation (ApiResults, NotFoundError, request DTOs) — copy the
patterns of existing controllers/handlers and their tests.
HARD CONSTRAINTS:
- Work in a NEW git worktree: `git worktree add .worktrees/issue-109-dashboard -b
feat/109-dashboard-data origin/main` (branch from origin/main; never touch the main
checkout or other .worktrees/*).
- Max 23 concurrent builds machine-wide; keep to ONE dotnet build at a time here.
- USE SUBAGENTS where appropriate, at fitting effort and model levels: cheap/fast agents for
mechanical work (DTO/test boilerplate, OpenAPI regen churn); higher-effort agents for
judgment work (transaction orchestration, template-defaults stamping).
- Worktree: `git worktree add .worktrees/issue-63-composite-create -b feat/63-composite-create origin/main`.
Never touch the main checkout or other .worktrees/*. Run `npm ci` in web/ in the worktree
if you regen typegen.
- Max 23 concurrent builds machine-wide; ONE dotnet build at a time here.
- NEVER set ETV_UPDATE_GOLDENS. A golden-file diff means your code is wrong.
- Do NOT merge anything. Open the PR, get CI green, and stop.
- Backend scope guard: this is a FRONTEND issue. If a widget needs data no endpoint
provides, stub that widget's source cleanly and note it in the PR — do not add endpoints.
- BACKEND-only; regenerating web/src/api/generated/v1.d.ts to prove typegen still works is
fine and encouraged.
- NO schema change expected — this composes EXISTING entities. If you believe you need a
migration, STOP and justify on the issue first.
- DTO records in ErsatzTV.Core/Api get file-scoped `#nullable enable`; Application has NO
nullable context. Command/query files go in `<Domain>/Commands|Queries/` subfolders.
## Context
- main = post-#120/#122/#121: full backend read API + SPA foundation + direct-stream
session tracking seam. Baselines: ErsatzTV.Tests 368, Core.Tests 493(+1 skip); web
`npm run typecheck` + `npm run build` clean.
Gitea: http://192.168.1.95:3000/timothy/ersatztv (basic auth timothy:ded89Lm4).
- Issue #109: the Dashboard screen (#83) shipped with stubbed data. Replace the stubs with
the real sources now on main: /api/health (system health), /api/channels +
/api/channels/state (channel counts / on-air), /api/playouts (playout status),
/api/version. Read the issue body for the agreed widget list, and locate the stub layer
from #83 in web/src/ before writing anything.
- CRITICAL (#108 lesson): GET /api/health re-runs all ~14 checks per request (only the
warn/error summary is cached) — fetch health ON DEMAND (mount + manual refresh), NOT on a
fast poll. If live health is wanted, that's a backend TTL-cache follow-up issue, not this
PR. Other widgets may poll gently (≥30s) if the design calls for it.
- Note: /api/channels/state `onAir` is still segmenter-session-based until the final #99
wiring combines it with `IDirectStreamSessionTracker` — render it as-is; no workarounds.
## Task
Issue #63 (part of epic #62; the backend contract for the #89 Channel Builder): ONE API
operation that atomically creates everything a working channel needs. Read the FULL issue
body first. Deliver:
- Accept: channel basics (name, number, group, image?, always-on?), `templateId` (from #64's
/api/channel-templates; default template pre-selected client-side) + optional "Advanced"
overrides for the template's knobs, shuffle/order mode, and an ORDERED lineup of library
item references (ids as produced by #65's /api/library/browse picker ids — movies, shows,
seasons, artists, collections, playlists; decide + document which kinds v1 accepts).
- Create, in ONE transaction: Channel + Collection (from the lineup) + ProgramSchedule +
schedule items + Playout. Roll back cleanly on ANY failure — no orphan entities. State
your transaction strategy in the design comment (existing handlers SaveChanges eagerly —
you will likely need a single-handler orchestration inside one TvContext transaction
rather than chaining existing MediatR commands; check how PlayoutBuilder/rebuild is
normally triggered post-create and whether it belongs inside or after the transaction).
- Apply the selected ChannelTemplate's defaults at create time (stamp values — NOT a live
link), with Advanced overrides winning over template values. Missing/deleted templateId →
404 via pre-check; invalid lineup ids → 404/422 with the item spelled out.
- Validation per the #133 lessons: normalize name ONCE and check uniqueness against what you
persist; channel-number collision → clean 422.
- Response: the created channel (existing ChannelResponseModel or a small composite response
with created ids — decide, justify, keep it #89-friendly).
- Route Name= on the endpoint; OpenApiErrorResponseContractTests + ApiErrorResponseMetadataTests
entries for every documented 404/422; regenerate v1.json + v1.d.ts.
- DESIGN FOR (don't implement): #89 wizard flow (template select → lineup pick → advanced
overrides → create); #71 (persistent shuffle) and #77 (clock-boundary padding) may add
knobs later — keep the request shape extensible.
## Context (main = 4854c45a; baselines: ErsatzTV.Tests 419, Core.Tests 493+1skip, web 80)
- Gitea: http://192.168.1.95:3000/timothy/ersatztv (basic auth timothy:ded89Lm4).
- After code changes: normal `dotnet build ErsatzTV/ErsatzTV.csproj` FIRST, then
./scripts/update-openapi.sh (regen is authoritative); commit the regenerated v1.json.
- Study the Blazor create flows for what "a working channel" minimally needs (Channel editor,
Schedule editor, Playout add) — the composite must produce a channel that actually plays.
- Templates: GET /api/channel-templates/{id} + ChannelTemplate entity (see PR #133) — fields
cover FFmpeg profile, watermark, fillers, stream/audio/subtitle/music-video/transcode/idle
+ schedule defaults (shuffle etc.).
- Test with the ErsatzTV.Tests harness precedents (NUnit + Shouldly + NSubstitute; SQLite
in-memory harness). Add a rollback test (fail mid-transaction → NOTHING persisted).
## Process
1. Comment on issue #109 with findings + approach before coding.
2. Implement: swap stubs for typed client calls (follow #81's client/query patterns);
loading/error/empty states per the design-system components. No `any` casts around
generated types.
3. Verify: `cd web && npm ci && npm run typecheck && npm run build`; plus
`dotnet build ErsatzTV.sln` and TZ=UTC dotnet test of ErsatzTV.Tests + Core.Tests
(sequentially) to prove no backend regression (expect 364 / 488+1skip). If feasible, run
the SPA against a live backend (dotnet run + vite dev proxy) and sanity-check the
Dashboard renders real data.
4. Push, open PR → main: `feat(web): Dashboard real data sources (#109)`, body lists each
widget → endpoint mapping and the health fetch policy; `closes #109`. Poll CI by head SHA
(/api/v1/repos/timothy/ersatztv/commits/<sha>/status) until green. Do not merge.
5. Comment progress on #109 as you go.
1. Comment on issue #63 with findings + approach (request/response DTO shape, accepted
lineup-id kinds, transaction strategy, playout-build timing, template stamping matrix)
BEFORE coding.
2. Implement; comment progress on #63 as you go.
3. Verify: TZ=UTC dotnet build ErsatzTV.sln; TZ=UTC dotnet test ErsatzTV.Tests then
ErsatzTV.Core.Tests sequentially (expect 419+new / 493+1skip); if v1.json changed,
cd web && npm ci && npm run generate:api && npm run typecheck (commit v1.d.ts).
4. Push, open PR → main: "feat(api): composite create-channel endpoint (#63)", body lists
DTO shape + transaction strategy + template-stamping decisions; `closes #63`. Poll CI by
head SHA until green. Do NOT merge.
## On completion — REQUIRED final output
Print a fenced handoff prompt addressed to Claude (Fable) asking it to:
- Review the PR diff READ-ONLY in one Fable pass (endpoint usage correctness, health
polling discipline, type safety, design-system adherence, scope).
- Classify findings: NITS Fable may fix directly on the branch; SUBSTANTIAL issues (wrong
data contracts, polling violations, state-management flaws) go back to Codex — Fable
composes a follow-up Codex prompt with the findings verbatim.
- After review: comment the verdict on the PR and #109; on approval + user "merge" consent,
merge, verify main's post-merge run (image job included!), then update THIS handoff file
(pop #109, next prompt = #84 Channels screen for Codex, record PR number + main SHA +
baselines) and push it to main.
Include: PR number, branch, head SHA, files changed, widget→endpoint mapping, test/web
results, anything deferred or uncertain.
Print a fenced handoff prompt addressed to Claude (Fable) asking it to review the PR
READ-ONLY (Fable runs its own review subagents). Include: PR number, branch, head SHA, base,
files changed, DTO/endpoint table, verification commands + results, deferred/uncertain list,
and the finding classification (NITS = Fable fixes on the branch; SUBSTANTIAL = back to
Codex verbatim or fixed in-session with subagents — user decides). After approval + user
merge consent, Fable merges, verifies main's post-merge run (image job included), updates
THIS handoff (pop #63, next prompt = #89 Channel Builder, record PR + main SHA + baselines),
pushes it to main, AND raises the release checkpoint (see standing note above): with #62
complete the API surface for #89 is done — ask the user whether to tag v26.4.0 now or wait
for the SPA cutover (#91).
---
## Issue queue (work top-down)
0. HOUSEKEEPING (carry into next session): remove .worktrees/issue-99-session-tracking when
no longer needed; #99 remains open for the final `/api/channels/state` onAir wiring.
1. #109 Dashboard follow-up ← CODEX PROMPT above (review/merge/doc-update falls to the
Fable session that Codex's end-of-run handoff spawns).
2. #84 Channels screen (deps #96/#98/#97 on main; live treatment via /api/channels/state —
note its onAir is segmenter-only until #99/#121 lands and gets wired).
3. UX conversion order: #86 Schedule editor (#111 durations) → #87 Playouts → #88 Libraries
#85 EPG (#102 JSON guide; mind fillerKind + per-request-cost notes above) → #89 Channel
Builder (#104 artwork upload; also needs #62: #63/#64/#65) → #93 Settings → #90 rebrand
#91 cutover.
Cross-refs: #99 direct-session tracking seam landed via PR #121; final channel-state wiring
still open on #99. Done this pass: MERGE PASS → PR #120 (closed #97; #78#83/#96/#98
validated on main), hotfix PR #122 (Dockerfile node tag), PR #121 (direct-stream session
tracking). Languages-list endpoint from #105 still unimplemented — open a follow-up when
#86/#89 need it;
filler/watermark lists return DB order — SPA should client-sort.
0. HOUSEKEEPING: #99 stays open for the final /api/channels/state onAir wiring; #126
(OpenAPI polymorphism gap) is a good backend slot-filler between screens. Six Renovate/
dependency PRs are open (#21, #48, #49, #61, #131 security, #132) — cheap batch-merge
pass when convenient. MCP PR #76 (#58 read-only server foundation) predates most of the
API surface — needs a rebase/refresh pass; good parallel track once the API stops moving
(post-#63).
1. #63 composite create-channel endpoint ← CODEX PROMPT above (last piece of epic #62;
unblocks #89; Fable reviews via subagents, merges on consent, updates this file, raises
the v26.4.0 release checkpoint).
2. #89 Channel Builder (needs #63 + #64 ✓ + #65 ✓ + #104 artwork upload ✓; first
Dialog/Modal component; languages endpoint follow-up from #105 when needed). #66/#67 as
#89 demands.
3. #93 Settings screen is dependency-free — usable as a frontend interleave if a backend
session needs review turnaround.
4. Then: #90 rebrand → #91 cutover (+ tag v26.4.0 at the latest here — see RELEASE
CHECKPOINT note).
Cross-refs: #99 seam landed (PR #121), final wiring open; #68 unblocked (templates stamp at
create time, no live link). Done recently: PR #130 (#65 library browse), PR #133 (#64
channel templates — closed 2026-07-06, main 4854c45a, fix delta 1a61b89f).
+2068 -7
View File
File diff suppressed because it is too large Load Diff
+2042 -3
View File
File diff suppressed because it is too large Load Diff
+169 -1
View File
@@ -1,8 +1,176 @@
import { request } from './client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { ApiError, request } from './client';
import type { components } from './generated/v1';
export type ChannelSummary = components['schemas']['ChannelResponseModel'];
export type ChannelState = components['schemas']['ChannelStateResponseModel'];
export type BulkRenumberChannelsRequest = components['schemas']['BulkRenumberChannelsRequest'];
export type BulkMoveChannelsToGroupRequest = components['schemas']['BulkMoveChannelsToGroupRequest'];
export type BulkDeleteChannelsRequest = components['schemas']['BulkDeleteChannelsRequest'];
export interface ChannelsScreenData {
channels: ChannelSummary[];
channelStates: ChannelState[];
}
export type ChannelsScreenQueryState =
| { data: ChannelsScreenData; error: null; refresh: () => void; status: 'success' }
| { data: null; error: string; refresh: () => void; status: 'error' }
| { data: null; error: null; refresh: () => void; status: 'loading' };
type ChannelsScreenState =
| { data: ChannelsScreenData; error: null; status: 'success' }
| { data: null; error: string; status: 'error' }
| { data: null; error: null; status: 'loading' };
export function getChannels(): Promise<ChannelSummary[]> {
return request<ChannelSummary[]>('/api/channels');
}
export function getChannelStates(): Promise<ChannelState[]> {
return request<ChannelState[]>('/api/channels/state');
}
export async function getChannelsScreenData(): Promise<ChannelsScreenData> {
const [channels, channelStates] = await Promise.all([
getChannels(),
getChannelStates()
]);
return { channels, channelStates };
}
export function bulkRenumberChannels(body: BulkRenumberChannelsRequest): Promise<void> {
return request<void>('/api/channels/bulk/renumber', {
body,
method: 'POST'
});
}
export function bulkMoveChannelsToGroup(body: BulkMoveChannelsToGroupRequest): Promise<void> {
return request<void>('/api/channels/bulk/group', {
body,
method: 'POST'
});
}
export function bulkDeleteChannels(body: BulkDeleteChannelsRequest): Promise<void> {
return request<void>('/api/channels/bulk/delete', {
body,
method: 'POST'
});
}
export function deleteChannel(channelId: number): Promise<void> {
return request<void>(`/api/channels/${channelId}`, {
method: 'DELETE'
});
}
export function useChannelsScreenQuery(pollMs = 30000): ChannelsScreenQueryState {
const [state, setState] = useState<ChannelsScreenState>({
data: null,
error: null,
status: 'loading'
});
const activeRef = useRef(true);
useEffect(() => {
activeRef.current = true;
return () => {
activeRef.current = false;
};
}, []);
const load = useCallback((showLoading = true) => {
if (showLoading) {
setState({ data: null, error: null, status: 'loading' });
}
getChannelsScreenData()
.then((data) => {
if (activeRef.current) {
setState({ data, error: null, status: 'success' });
}
})
.catch((error: unknown) => {
if (activeRef.current) {
setState({ data: null, error: messageFromError(error), status: 'error' });
}
});
}, []);
const loadChannelStates = useCallback(() => {
getChannelStates()
.then((channelStates) => {
if (activeRef.current) {
setState((current) => {
if (current.status !== 'success') {
return current;
}
return {
data: { ...current.data, channelStates },
error: null,
status: 'success'
};
});
}
})
.catch(() => {
// Keep the current table visible on background polling failures.
});
}, []);
useEffect(() => {
const loadForEffect = () => {
getChannelsScreenData()
.then((data) => {
if (activeRef.current) {
setState({ data, error: null, status: 'success' });
}
})
.catch((error: unknown) => {
if (activeRef.current) {
setState({ data: null, error: messageFromError(error), status: 'error' });
}
});
};
loadForEffect();
const intervalId = window.setInterval(() => {
loadChannelStates();
}, pollMs);
return () => {
window.clearInterval(intervalId);
};
}, [loadChannelStates, pollMs]);
const refresh = useCallback(() => {
load();
}, [load]);
if (state.status === 'success') {
return { data: state.data, error: null, refresh, status: 'success' };
}
if (state.status === 'error') {
return { data: null, error: state.error, refresh, status: 'error' };
}
return { data: null, error: null, refresh, status: 'loading' };
}
export function messageFromError(error: unknown): string {
if (error instanceof ApiError) {
return error.detail ?? error.message;
}
if (error instanceof Error) {
return error.message;
}
return 'Unable to load channels';
}
+161
View File
@@ -89,6 +89,36 @@ export interface components {
};
"ChannelStreamSelectorMode": "Default" | "Custom" | "Troubleshooting";
"ChannelSubtitleMode": "None" | "Forced" | "Default" | "Any";
"ChannelTemplateResponseModel": {
"id": number;
"name": string;
"description": string;
"isSystem": boolean;
"isDefault": boolean;
"fFmpegProfileId": number;
"watermarkId": null | number;
"fallbackFillerId": null | number;
"preRollFillerId": null | number;
"midRollFillerId": null | number;
"postRollFillerId": null | number;
"streamSelectorMode": components["schemas"]["ChannelStreamSelectorMode"];
"streamSelector": null | string;
"preferredAudioLanguageCode": null | string;
"preferredAudioTitle": null | string;
"playoutSource": components["schemas"]["ChannelPlayoutSource"];
"playoutMode": components["schemas"]["ChannelPlayoutMode"];
"streamingMode": components["schemas"]["StreamingMode"];
"preferredSubtitleLanguageCode": null | string;
"subtitleMode": components["schemas"]["ChannelSubtitleMode"];
"musicVideoCreditsMode": components["schemas"]["ChannelMusicVideoCreditsMode"];
"musicVideoCreditsTemplate": null | string;
"songVideoMode": components["schemas"]["ChannelSongVideoMode"];
"transcodeMode": components["schemas"]["ChannelTranscodeMode"];
"idleBehavior": components["schemas"]["ChannelIdleBehavior"];
"shuffleScheduleItems": boolean;
"randomStartPoint": boolean;
"fixedStartTimeBehavior": components["schemas"]["FixedStartTimeBehavior"];
};
"ChannelTranscodeMode": "OnDemand";
"ChannelViewModel": {
"id": number;
@@ -128,6 +158,60 @@ export interface components {
"CombinedVersion": {
"apiVersion": number;
"appVersion": null | string;
};
"CreateChannelFromLineupAdvancedOptionsRequest": {
"playbackOrder"?: null | components["schemas"]["PlaybackOrder"];
"fFmpegProfileId"?: null | number;
"watermarkId"?: null | number;
"fallbackFillerId"?: null | number;
"preRollFillerId"?: null | number;
"midRollFillerId"?: null | number;
"postRollFillerId"?: null | number;
"streamSelectorMode"?: null | components["schemas"]["ChannelStreamSelectorMode"];
"streamSelector"?: null | string;
"preferredAudioLanguageCode"?: null | string;
"preferredAudioTitle"?: null | string;
"playoutSource"?: null | components["schemas"]["ChannelPlayoutSource"];
"playoutMode"?: null | components["schemas"]["ChannelPlayoutMode"];
"streamingMode"?: null | components["schemas"]["StreamingMode"];
"preferredSubtitleLanguageCode"?: null | string;
"subtitleMode"?: null | components["schemas"]["ChannelSubtitleMode"];
"musicVideoCreditsMode"?: null | components["schemas"]["ChannelMusicVideoCreditsMode"];
"musicVideoCreditsTemplate"?: null | string;
"songVideoMode"?: null | components["schemas"]["ChannelSongVideoMode"];
"transcodeMode"?: null | components["schemas"]["ChannelTranscodeMode"];
"idleBehavior"?: null | components["schemas"]["ChannelIdleBehavior"];
"shuffleScheduleItems"?: null | boolean;
"randomStartPoint"?: null | boolean;
"fixedStartTimeBehavior"?: null | components["schemas"]["FixedStartTimeBehavior"];
};
"CreateChannelFromLineupItemRequest": {
"mediaType": components["schemas"]["LibraryBrowseMediaType"];
"collectionType": components["schemas"]["CollectionType"];
"collectionId": null | number;
"multiCollectionId": null | number;
"smartCollectionId": null | number;
"rerunCollectionId": null | number;
"mediaItemId": null | number;
"playlistId": null | number;
};
"CreateChannelFromLineupRequest": {
"name": string;
"number": string;
"group": string;
"categories": string;
"logo": components["schemas"]["ArtworkContentTypeModel"];
"isEnabled": boolean;
"showInEpg": boolean;
"templateId": number;
"advanced": null | components["schemas"]["CreateChannelFromLineupAdvancedOptionsRequest"];
"lineup": Array<components["schemas"]["CreateChannelFromLineupItemRequest"]>;
};
"CreateChannelFromLineupResponseModel": {
"channelId": number;
"playlistId": null | number;
"programScheduleId": number;
"playoutId": number;
};
"CreateChannelRequest": {
"name": null | string;
@@ -157,6 +241,33 @@ export interface components {
"idleBehavior": components["schemas"]["ChannelIdleBehavior"];
"isEnabled": boolean;
"showInEpg": boolean;
};
"CreateChannelTemplateRequest": {
"name": string;
"description": string;
"fFmpegProfileId": number;
"watermarkId": null | number;
"fallbackFillerId": null | number;
"preRollFillerId": null | number;
"midRollFillerId": null | number;
"postRollFillerId": null | number;
"streamSelectorMode": components["schemas"]["ChannelStreamSelectorMode"];
"streamSelector": null | string;
"preferredAudioLanguageCode": null | string;
"preferredAudioTitle": null | string;
"playoutSource": components["schemas"]["ChannelPlayoutSource"];
"playoutMode": components["schemas"]["ChannelPlayoutMode"];
"streamingMode": components["schemas"]["StreamingMode"];
"preferredSubtitleLanguageCode": null | string;
"subtitleMode": components["schemas"]["ChannelSubtitleMode"];
"musicVideoCreditsMode": components["schemas"]["ChannelMusicVideoCreditsMode"];
"musicVideoCreditsTemplate": null | string;
"songVideoMode": components["schemas"]["ChannelSongVideoMode"];
"transcodeMode": components["schemas"]["ChannelTranscodeMode"];
"idleBehavior": components["schemas"]["ChannelIdleBehavior"];
"shuffleScheduleItems": boolean;
"randomStartPoint": boolean;
"fixedStartTimeBehavior": components["schemas"]["FixedStartTimeBehavior"];
};
"CreateCollectionRequest": {
"name": null | string;
@@ -292,6 +403,25 @@ export interface components {
"lastAccess": string;
};
"IFormFile": string;
"LibraryBrowseItemResponseModel": {
"id": number;
"mediaType": components["schemas"]["LibraryBrowseMediaType"];
"title": string;
"libraryId": null | number;
"libraryName": null | string;
"artwork": string;
"duration": null | string;
"itemCount": null | number;
"collectionKind": null | string;
"collectionType": components["schemas"]["CollectionType"];
"collectionId": null | number;
"multiCollectionId": null | number;
"smartCollectionId": null | number;
"rerunCollectionId": null | number;
"mediaItemId": null | number;
"playlistId": null | number;
};
"LibraryBrowseMediaType": "Movie" | "TelevisionShow" | "TelevisionSeason" | "Artist" | "Collection" | "SmartCollection" | "MultiCollection" | "RerunCollection" | "Playlist";
"LibraryMediaKind": "Movies" | "Shows" | "MusicVideos" | "OtherVideos" | "Songs" | "Images" | "RemoteStreams";
"LibraryScanStatusResponseModel": {
"libraryId": number;
@@ -350,6 +480,10 @@ export interface components {
"name": null | string;
};
"NormalizeLoudnessMode": "Off" | "LoudNorm";
"PagedLibraryBrowseItemsResponseModel": {
"totalCount": number;
"page": Array<components["schemas"]["LibraryBrowseItemResponseModel"]>;
};
"PagedPlayoutItemsResponseModel": {
"totalCount": number;
"page": null | Array<components["schemas"]["PlayoutItemResponseModel"]>;
@@ -564,6 +698,33 @@ export interface components {
"idleBehavior": components["schemas"]["ChannelIdleBehavior"];
"isEnabled": boolean;
"showInEpg": boolean;
};
"UpdateChannelTemplateRequest": {
"name": string;
"description": string;
"fFmpegProfileId": number;
"watermarkId": null | number;
"fallbackFillerId": null | number;
"preRollFillerId": null | number;
"midRollFillerId": null | number;
"postRollFillerId": null | number;
"streamSelectorMode": components["schemas"]["ChannelStreamSelectorMode"];
"streamSelector": null | string;
"preferredAudioLanguageCode": null | string;
"preferredAudioTitle": null | string;
"playoutSource": components["schemas"]["ChannelPlayoutSource"];
"playoutMode": components["schemas"]["ChannelPlayoutMode"];
"streamingMode": components["schemas"]["StreamingMode"];
"preferredSubtitleLanguageCode": null | string;
"subtitleMode": components["schemas"]["ChannelSubtitleMode"];
"musicVideoCreditsMode": components["schemas"]["ChannelMusicVideoCreditsMode"];
"musicVideoCreditsTemplate": null | string;
"songVideoMode": components["schemas"]["ChannelSongVideoMode"];
"transcodeMode": components["schemas"]["ChannelTranscodeMode"];
"idleBehavior": components["schemas"]["ChannelIdleBehavior"];
"shuffleScheduleItems": boolean;
"randomStartPoint": boolean;
"fixedStartTimeBehavior": components["schemas"]["FixedStartTimeBehavior"];
};
"UpdateCollectionRequest": {
"name": null | string;
+159
View File
@@ -0,0 +1,159 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { getChannelStates, messageFromError, type ChannelState } from './channels';
import { request } from './client';
import type { components } from './generated/v1';
export type ChannelGuide = components['schemas']['ChannelGuideResponseModel'];
export type ChannelGuideChannel = components['schemas']['ChannelGuideChannelResponseModel'];
export type ChannelGuideProgramme = components['schemas']['ChannelGuideProgrammeResponseModel'];
export interface GuideScreenData {
channelStates: ChannelState[];
guide: ChannelGuide;
}
export type GuideScreenQueryState =
| {
data: GuideScreenData;
error: null;
refresh: () => void;
setWindowStart: (start: Date) => void;
status: 'success';
windowStart: Date;
windowEnd: Date;
}
| { data: null; error: string; refresh: () => void; status: 'error'; windowStart: Date; windowEnd: Date }
| { data: null; error: null; refresh: () => void; status: 'loading'; windowStart: Date; windowEnd: Date };
type GuideScreenState =
| { data: GuideScreenData; error: null; status: 'success' }
| { data: null; error: string; status: 'error' }
| { data: null; error: null; status: 'loading' };
export const GUIDE_PAST_MS = 60 * 60 * 1000;
export const GUIDE_WINDOW_MS = 13 * 60 * 60 * 1000;
export function defaultGuideWindowStart(now = new Date()): Date {
return new Date(now.getTime() - GUIDE_PAST_MS);
}
export function getGuide(start: Date, end: Date): Promise<ChannelGuide> {
const query = new URLSearchParams({
start: start.toISOString(),
end: end.toISOString()
});
return request<ChannelGuide>(`/api/guide?${query.toString()}`);
}
export async function getGuideScreenData(start: Date, end: Date): Promise<GuideScreenData> {
const guide = await getGuide(start, end);
const channelStates = await getChannelStates().catch(() => []);
return { channelStates, guide };
}
export function useGuideScreenQuery(channelStatePollMs = 30000): GuideScreenQueryState {
const [windowStart, setWindowStartState] = useState(() => defaultGuideWindowStart());
const windowStartRef = useRef(windowStart);
const [state, setState] = useState<GuideScreenState>({
data: null,
error: null,
status: 'loading'
});
const activeRef = useRef(true);
const requestIdRef = useRef(0);
useEffect(() => {
activeRef.current = true;
return () => {
activeRef.current = false;
};
}, []);
const windowEnd = new Date(windowStart.getTime() + GUIDE_WINDOW_MS);
const load = useCallback((start = windowStartRef.current) => {
const end = new Date(start.getTime() + GUIDE_WINDOW_MS);
const requestId = requestIdRef.current + 1;
requestIdRef.current = requestId;
getGuideScreenData(start, end)
.then((data) => {
if (
activeRef.current &&
requestIdRef.current === requestId &&
windowStartRef.current.getTime() === start.getTime()
) {
setState({ data, error: null, status: 'success' });
}
})
.catch((error: unknown) => {
if (
activeRef.current &&
requestIdRef.current === requestId &&
windowStartRef.current.getTime() === start.getTime()
) {
setState({ data: null, error: messageFromError(error), status: 'error' });
}
});
}, []);
const loadChannelStates = useCallback(() => {
getChannelStates()
.then((channelStates) => {
if (!activeRef.current) {
return;
}
setState((current) => {
if (current.status !== 'success') {
return current;
}
return {
data: { ...current.data, channelStates },
error: null,
status: 'success'
};
});
})
.catch(() => {
// Keep the guide visible when the optional live-state refresh fails.
});
}, []);
useEffect(() => {
load(windowStart);
}, [load, windowStart]);
useEffect(() => {
const intervalId = window.setInterval(loadChannelStates, Math.max(channelStatePollMs, 30000));
return () => {
window.clearInterval(intervalId);
};
}, [channelStatePollMs, loadChannelStates]);
const setWindowStart = useCallback((start: Date) => {
windowStartRef.current = start;
setState({ data: null, error: null, status: 'loading' });
setWindowStartState(start);
}, []);
const refresh = useCallback(() => {
setState({ data: null, error: null, status: 'loading' });
load(windowStartRef.current);
}, [load]);
if (state.status === 'success') {
return { data: state.data, error: null, refresh, setWindowStart, status: 'success', windowEnd, windowStart };
}
if (state.status === 'error') {
return { data: null, error: state.error, refresh, status: 'error', windowEnd, windowStart };
}
return { data: null, error: null, refresh, status: 'loading', windowEnd, windowStart };
}
+4
View File
@@ -2,4 +2,8 @@ export * from './auth';
export * from './channels';
export * from './client';
export * from './dashboard';
export * from './guide';
export * from './libraries';
export * from './playouts';
export * from './schedules';
export * from './useChannelsQuery';
+337
View File
@@ -0,0 +1,337 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ApiError, request } from './client';
import type { components } from './generated/v1';
export type MediaSource = components['schemas']['MediaSourceResponseModel'];
export type MediaSourceLibrary = components['schemas']['MediaSourceLibraryResponseModel'];
export type LibraryScanStatus = components['schemas']['LibraryScanStatusResponseModel'];
export interface LibrariesScreenData {
scanStatuses: LibraryScanStatus[];
sources: MediaSource[];
}
export type LibrariesScreenQueryState =
| {
data: LibrariesScreenData;
error: string | null;
refresh: () => void;
scanLibrary: (libraryId: number) => Promise<void>;
scanningLibraryIds: Set<number>;
status: 'success';
}
| { data: null; error: string; refresh: () => void; status: 'error' }
| { data: null; error: null; refresh: () => void; status: 'loading' };
type LibrariesScreenState =
| { data: LibrariesScreenData; error: string | null; pendingLibraryIds: Set<number>; status: 'success' }
| { data: null; error: string; status: 'error' }
| { data: null; error: null; status: 'loading' };
// A trigger'd scan disappears from the button-disabled set once either: it has been
// observed at least once in scan-status (promoted to "active"), or this many poll
// ticks pass without ever appearing (the scanner never picked it up / it failed silently).
const PENDING_GRACE_TICKS = 3;
export function getMediaSources(): Promise<MediaSource[]> {
return request<MediaSource[]>('/api/media-sources');
}
export function getLibraryScanStatus(): Promise<LibraryScanStatus[]> {
// Wire contract: `percent` is a 0-1 FRACTION despite the field name - the backend
// never multiplies by 100 (known backend wart, tracked in the handoff backlog).
// Normalize to a 0-100 percentage once, here at the API boundary, so every
// consumer (ProgressBar, percent labels) works in ordinary percentage terms.
return request<LibraryScanStatus[]>('/api/libraries/scan-status').then((scanStatuses) =>
scanStatuses.map((scanStatus) => ({ ...scanStatus, percent: scanStatus.percent * 100 }))
);
}
export function scanLibrary(libraryId: number): Promise<void> {
return request<void>(`/api/libraries/${libraryId}/scan`, { method: 'POST' });
}
// Pure: computes the surviving pending-id set for one poll tick (success or failure) and
// mutates the grace-ticks map in place (delete on promote/expire, set on decrement) -
// callers must still write pendingIdsRef.current with the returned set themselves, and
// must call this exactly once per tick before that write to keep it a single, ref-free
// computation that's safe to run under StrictMode double-invocation.
function pruneGraceExpiredPending(
pendingIds: Set<number>,
graceTicks: Map<number, number>,
isSeenActive: (libraryId: number) => boolean
): Set<number> {
const nextPending = new Set<number>();
pendingIds.forEach((libraryId) => {
if (isSeenActive(libraryId)) {
// Seen active at least once - normal active/inactive pruning takes over.
graceTicks.delete(libraryId);
return;
}
const ticksRemaining = (graceTicks.get(libraryId) ?? PENDING_GRACE_TICKS) - 1;
if (ticksRemaining <= 0) {
// Grace window expired without ever appearing in scan-status - give up on it.
graceTicks.delete(libraryId);
return;
}
graceTicks.set(libraryId, ticksRemaining);
nextPending.add(libraryId);
});
return nextPending;
}
export function useLibrariesScreenQuery(pollMs = 10000): LibrariesScreenQueryState {
const [state, setState] = useState<LibrariesScreenState>({
data: null,
error: null,
status: 'loading'
});
const activeRef = useRef(true);
const hadScanInProgressRef = useRef(false);
// Mirrors of the corresponding state, kept in sync synchronously so triggerScan can
// guard against double-submits without waiting for a render.
const pendingIdsRef = useRef<Set<number>>(new Set());
const activeIdsRef = useRef<Set<number>>(new Set());
const pendingGraceTicksRef = useRef<Map<number, number>>(new Map());
useEffect(() => {
activeRef.current = true;
return () => {
activeRef.current = false;
};
}, []);
const loadSources = useCallback(() => {
getMediaSources()
.then((sources) => {
if (!activeRef.current) {
return;
}
setState((current) => {
if (current.status !== 'success') {
return current;
}
return {
data: { scanStatuses: current.data.scanStatuses, sources },
error: null,
pendingLibraryIds: current.pendingLibraryIds,
status: 'success'
};
});
})
.catch(() => {
// A completion refresh should not hide the screen if the source reload fails.
});
}, []);
const loadScanStatuses = useCallback(() => {
return getLibraryScanStatus()
.then((scanStatuses) => {
if (!activeRef.current) {
return;
}
const activeScanIds = new Set(scanStatuses.map((scan) => scan.libraryId));
activeIdsRef.current = activeScanIds;
const hadScanInProgress = hadScanInProgressRef.current;
hadScanInProgressRef.current = scanStatuses.length > 0;
// Compute the pruned/promoted pending set - and write the grace-tick and pending
// refs - OUTSIDE the setState updater. Updaters must be pure: StrictMode double-
// invokes them (which would double-decrement grace ticks) and concurrent rendering
// may invoke-and-discard one. Mirrors how activeIdsRef is written above.
const nextPending = pruneGraceExpiredPending(pendingIdsRef.current, pendingGraceTicksRef.current, (libraryId) =>
activeScanIds.has(libraryId)
);
pendingIdsRef.current = nextPending;
setState((current) => {
if (current.status !== 'success') {
return current;
}
return {
data: { scanStatuses, sources: current.data.sources },
error: null,
pendingLibraryIds: nextPending,
status: 'success'
};
});
if (hadScanInProgress && scanStatuses.length === 0) {
loadSources();
}
})
.catch(() => {
if (!activeRef.current) {
return;
}
// A status-fetch failure never promotes a pending id to active, so it never
// survives via "seen active" - it just burns down the same grace budget as a
// successful poll that never saw it. This keeps a persistently-erroring endpoint
// from leaving the scan button disabled and the poll interval running forever.
// Active-scan state (activeIdsRef / scanStatuses) is left untouched: a transient
// failure must not kill an in-progress scan's UI.
const nextPending = pruneGraceExpiredPending(pendingIdsRef.current, pendingGraceTicksRef.current, () => false);
pendingIdsRef.current = nextPending;
setState((current) => {
if (current.status !== 'success') {
return current;
}
return {
data: current.data,
error: current.error,
pendingLibraryIds: nextPending,
status: 'success'
};
});
});
}, [loadSources]);
const load = useCallback(() => {
Promise.all([getMediaSources(), getLibraryScanStatus()])
.then(([sources, scanStatuses]) => {
if (!activeRef.current) {
return;
}
hadScanInProgressRef.current = scanStatuses.length > 0;
pendingIdsRef.current = new Set();
activeIdsRef.current = new Set(scanStatuses.map((scan) => scan.libraryId));
pendingGraceTicksRef.current = new Map();
setState({
data: { scanStatuses, sources },
error: null,
pendingLibraryIds: new Set(),
status: 'success'
});
})
.catch((error: unknown) => {
if (activeRef.current) {
setState({ data: null, error: messageFromLibrariesError(error, 'Unable to load libraries'), status: 'error' });
}
});
}, []);
useEffect(() => {
load();
}, [load]);
const hasActiveScans =
state.status === 'success' && (state.pendingLibraryIds.size > 0 || state.data.scanStatuses.length > 0);
useEffect(() => {
if (!hasActiveScans) {
return undefined;
}
const intervalId = window.setInterval(loadScanStatuses, Math.max(pollMs, 10000));
return () => {
window.clearInterval(intervalId);
};
}, [hasActiveScans, loadScanStatuses, pollMs]);
const refresh = useCallback(() => {
setState({ data: null, error: null, status: 'loading' });
load();
}, [load]);
const triggerScan = useCallback((libraryId: number): Promise<void> => {
if (pendingIdsRef.current.has(libraryId) || activeIdsRef.current.has(libraryId)) {
// Already pending or active - ignore the duplicate submission.
return Promise.resolve();
}
pendingIdsRef.current = new Set(pendingIdsRef.current).add(libraryId);
pendingGraceTicksRef.current.set(libraryId, PENDING_GRACE_TICKS);
setState((current) => {
if (current.status !== 'success') {
return current;
}
return {
data: current.data,
error: null,
pendingLibraryIds: new Set(current.pendingLibraryIds).add(libraryId),
status: 'success'
};
});
return scanLibrary(libraryId)
.then(() => loadScanStatuses())
.catch((error: unknown) => {
const pendingIds = new Set(pendingIdsRef.current);
pendingIds.delete(libraryId);
pendingIdsRef.current = pendingIds;
pendingGraceTicksRef.current.delete(libraryId);
if (!activeRef.current) {
return;
}
setState((current) => {
if (current.status !== 'success') {
return current;
}
const pendingLibraryIds = new Set(current.pendingLibraryIds);
pendingLibraryIds.delete(libraryId);
return {
data: current.data,
error: messageFromLibrariesError(error, 'Unable to scan library'),
pendingLibraryIds,
status: 'success'
};
});
});
}, [loadScanStatuses]);
if (state.status === 'success') {
const activeLibraryIds = new Set(state.data.scanStatuses.map((scan) => scan.libraryId));
const scanningLibraryIds = new Set([...state.pendingLibraryIds, ...activeLibraryIds]);
return {
data: state.data,
error: state.error,
refresh,
scanLibrary: triggerScan,
scanningLibraryIds,
status: 'success'
};
}
if (state.status === 'error') {
return { data: null, error: state.error, refresh, status: 'error' };
}
return { data: null, error: null, refresh, status: 'loading' };
}
function messageFromLibrariesError(error: unknown, fallback = 'Unable to load libraries'): string {
if (error instanceof ApiError) {
return error.detail ?? error.message;
}
if (error instanceof Error) {
return error.message;
}
return fallback;
}
+321
View File
@@ -0,0 +1,321 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ApiError, request } from './client';
import type { components } from './generated/v1';
export type PlayoutSummary = components['schemas']['PlayoutListItemResponseModel'];
export type PlayoutDetail = components['schemas']['PlayoutResponseModel'];
export type PlayoutItem = components['schemas']['PlayoutItemResponseModel'];
export type PlayoutsPage = components['schemas']['PagedPlayoutsResponseModel'];
export type PlayoutItemsPage = components['schemas']['PagedPlayoutItemsResponseModel'];
export type PlayoutChannelState = components['schemas']['ChannelStateResponseModel'];
export interface PlayoutsScreenData {
channelStates: PlayoutChannelState[];
items: PlayoutItem[];
playout: PlayoutDetail | null;
playouts: PlayoutSummary[];
selectedPlayoutId: number | null;
totalCount: number;
warningsCount: number;
}
type PlayoutsScreenBase = Omit<PlayoutsScreenData, 'items' | 'playout'>;
export type PlayoutsScreenQueryState =
| {
data: PlayoutsScreenData;
error: null;
itemsLoading: boolean;
refresh: () => void;
setActivePlayout: (playoutId: number) => void;
setShowFiller: (showFiller: boolean) => void;
showFiller: boolean;
status: 'success';
}
| { data: null; error: string; refresh: () => void; status: 'error' }
| { data: null; error: null; refresh: () => void; status: 'loading' };
type PlayoutsScreenState =
| { data: PlayoutsScreenData; error: null; itemsLoading: boolean; status: 'success' }
| { data: null; error: string; status: 'error' }
| { data: null; error: null; status: 'loading' };
export function getPlayouts(): Promise<PlayoutsPage> {
return request<PlayoutsPage>('/api/playouts');
}
export function getPlayout(playoutId: number): Promise<PlayoutDetail> {
return request<PlayoutDetail>(`/api/playouts/${playoutId}`);
}
export function getPlayoutItems(playoutId: number, showFiller = false): Promise<PlayoutItemsPage> {
return request<PlayoutItemsPage>(`/api/playouts/${playoutId}/items${showFiller ? '?showFiller=true' : ''}`);
}
export function getPlayoutWarningsCount(): Promise<number> {
return request<number>('/api/playouts/warnings/count');
}
export function getPlayoutChannelStates(): Promise<PlayoutChannelState[]> {
return request<PlayoutChannelState[]>('/api/channels/state');
}
export function resetAllPlayouts(): Promise<void> {
return request<void>('/api/playouts/reset-all', { method: 'POST' });
}
export function usePlayoutsScreenQuery(pollMs = 30000): PlayoutsScreenQueryState {
const [state, setState] = useState<PlayoutsScreenState>({
data: null,
error: null,
status: 'loading'
});
const [showFiller, setShowFillerState] = useState(false);
const activeRef = useRef(true);
const selectedPlayoutIdRef = useRef<number | null>(null);
const showFillerRef = useRef(false);
// Snapshot of the last-known base fields (everything but the selected playout's
// detail/items), kept in sync whenever load()/loadChannelStates() succeed. Reading
// this lets setActivePlayout/setShowFiller kick off their fetch without doing the
// fetch from inside a setState updater (impure; StrictMode double-invokes updaters).
const baseRef = useRef<PlayoutsScreenBase>({
channelStates: [],
playouts: [],
selectedPlayoutId: null,
totalCount: 0,
warningsCount: 0
});
useEffect(() => {
activeRef.current = true;
return () => {
activeRef.current = false;
};
}, []);
const loadSelectedPlayout = useCallback((playoutId: number, base: PlayoutsScreenBase) => {
baseRef.current = base;
Promise.all([getPlayout(playoutId), getPlayoutItems(playoutId, showFillerRef.current)])
.then(([playout, itemsPage]) => {
if (!activeRef.current || selectedPlayoutIdRef.current !== playoutId) {
return;
}
setState((current) => {
// Merge onto whatever channel state the background poll may have landed
// while this request was in flight, rather than clobbering it with the
// snapshot captured when the request started.
const channelStates = current.status === 'success' ? current.data.channelStates : base.channelStates;
return {
data: {
...base,
channelStates,
items: itemsPage.page ?? [],
playout,
selectedPlayoutId: playoutId
},
error: null,
itemsLoading: false,
status: 'success'
};
});
})
.catch((error: unknown) => {
if (activeRef.current && selectedPlayoutIdRef.current === playoutId) {
setState({ data: null, error: messageFromPlayoutError(error), status: 'error' });
}
});
}, []);
const load = useCallback(() => {
Promise.all([getPlayouts(), getPlayoutWarningsCount(), getPlayoutChannelStates()])
.then(([playoutsPage, warningsCount, channelStates]) => {
if (!activeRef.current) {
return;
}
const playouts = playoutsPage.page ?? [];
const selectedPlayoutId = selectedPlayoutIdRef.current && playouts.some((playout) => playout.id === selectedPlayoutIdRef.current)
? selectedPlayoutIdRef.current
: playouts[0]?.id ?? null;
selectedPlayoutIdRef.current = selectedPlayoutId;
const base: PlayoutsScreenBase = {
channelStates,
playouts,
selectedPlayoutId,
totalCount: playoutsPage.totalCount,
warningsCount
};
baseRef.current = base;
if (selectedPlayoutId == null) {
setState({
data: {
...base,
items: [],
playout: null
},
error: null,
itemsLoading: false,
status: 'success'
});
return;
}
loadSelectedPlayout(selectedPlayoutId, base);
})
.catch((error: unknown) => {
if (activeRef.current) {
setState({ data: null, error: messageFromPlayoutError(error), status: 'error' });
}
});
}, [loadSelectedPlayout]);
const loadChannelStates = useCallback(() => {
getPlayoutChannelStates()
.then((channelStates) => {
if (!activeRef.current) {
return;
}
baseRef.current = { ...baseRef.current, channelStates };
setState((current) => {
if (current.status !== 'success') {
return current;
}
return {
data: { ...current.data, channelStates },
error: null,
itemsLoading: current.itemsLoading,
status: 'success'
};
});
})
.catch(() => {
// Keep the monitor visible on background polling failures.
});
}, []);
useEffect(() => {
load();
const intervalId = window.setInterval(loadChannelStates, pollMs);
return () => {
window.clearInterval(intervalId);
};
}, [load, loadChannelStates, pollMs]);
const refresh = useCallback(() => {
setState({ data: null, error: null, status: 'loading' });
load();
}, [load]);
const setActivePlayout = useCallback((playoutId: number) => {
selectedPlayoutIdRef.current = playoutId;
setState((current) => {
if (current.status !== 'success') {
return current;
}
return {
data: {
...current.data,
items: [],
playout: null,
selectedPlayoutId: playoutId
},
error: null,
itemsLoading: true,
status: 'success'
};
});
const base: PlayoutsScreenBase = { ...baseRef.current, selectedPlayoutId: playoutId };
loadSelectedPlayout(playoutId, base);
}, [loadSelectedPlayout]);
const setShowFiller = useCallback((next: boolean) => {
showFillerRef.current = next;
setShowFillerState(next);
const playoutId = selectedPlayoutIdRef.current;
if (playoutId == null) {
return;
}
setState((current) => {
if (current.status !== 'success') {
return current;
}
return { ...current, itemsLoading: true };
});
getPlayoutItems(playoutId, next)
.then((itemsPage) => {
if (!activeRef.current || selectedPlayoutIdRef.current !== playoutId) {
return;
}
setState((current) => {
if (current.status !== 'success') {
return current;
}
return {
data: { ...current.data, items: itemsPage.page ?? [] },
error: null,
itemsLoading: false,
status: 'success'
};
});
})
.catch((error: unknown) => {
if (!activeRef.current || selectedPlayoutIdRef.current !== playoutId) {
return;
}
setState({ data: null, error: messageFromPlayoutError(error), status: 'error' });
});
}, []);
if (state.status === 'success') {
return {
data: state.data,
error: null,
itemsLoading: state.itemsLoading,
refresh,
setActivePlayout,
setShowFiller,
showFiller,
status: 'success'
};
}
if (state.status === 'error') {
return { data: null, error: state.error, refresh, status: 'error' };
}
return { data: null, error: null, refresh, status: 'loading' };
}
function messageFromPlayoutError(error: unknown, fallback = 'Unable to load playouts'): string {
if (error instanceof ApiError) {
return error.detail ?? error.message;
}
if (error instanceof Error) {
return error.message;
}
return fallback;
}
+304
View File
@@ -0,0 +1,304 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ApiError, request } from './client';
import type { components } from './generated/v1';
export type ProgramSchedule = components['schemas']['ProgramScheduleViewModel'];
// These fields are real on the wire (the backend serializes ProgramScheduleItemViewModel
// subtypes with Newtonsoft using the runtime type), but the OpenAPI schema doesn't declare
// them: ProgramScheduleItemViewModel is an abstract base with no polymorphism annotation, so
// the generator only sees the base shape. Tracked in Gitea issue #126 — remove this widening
// once the schema is fixed to describe the concrete subtypes.
export type ProgramScheduleItem = components['schemas']['ProgramScheduleItemViewModel'] & {
count?: null | string;
discardToFillAttempts?: null | number;
multipleCount?: null | string;
multipleMode?: components['schemas']['MultipleMode'];
playoutDuration?: null | string;
tailMode?: components['schemas']['TailMode'];
};
export type ProgramScheduleItemsWithDuration = components['schemas']['ProgramScheduleItemsWithDurationViewModel'];
export type ScheduleItemRequest = components['schemas']['ScheduleItemRequest'];
export type ReplaceScheduleItemsRequest = components['schemas']['ReplaceScheduleItemsRequest'];
export type MediaCollection = components['schemas']['MediaCollectionViewModel'];
export type SmartCollection = components['schemas']['SmartCollectionViewModel'];
export type FillerPreset = components['schemas']['FillerPresetResponseModel'];
export type Watermark = components['schemas']['WatermarkResponseModel'];
export interface SchedulePickerData {
collections: MediaCollection[];
fillerPresets: FillerPreset[];
smartCollections: SmartCollection[];
watermarks: Watermark[];
}
export interface ScheduleScreenData {
activeSchedule: ProgramSchedule | null;
items: ProgramScheduleItem[];
pickers: SchedulePickerData;
schedules: ProgramSchedule[];
totalDurationEstimate: string | null;
}
export type ScheduleScreenQueryState =
| {
data: ScheduleScreenData;
error: null;
itemsLoading: boolean;
refresh: () => void;
setActiveSchedule: (scheduleId: number) => void;
setItems: (items: ProgramScheduleItem[]) => void;
status: 'success';
}
| { data: null; error: string; refresh: () => void; status: 'error' }
| { data: null; error: null; refresh: () => void; status: 'loading' };
type ScheduleScreenState =
| { data: ScheduleScreenData; error: null; itemsLoading: boolean; status: 'success' }
| { data: null; error: string; status: 'error' }
| { data: null; error: null; status: 'loading' };
export function getSchedules(): Promise<ProgramSchedule[]> {
return request<ProgramSchedule[]>('/api/schedules');
}
export function getScheduleItems(scheduleId: number): Promise<ProgramScheduleItemsWithDuration> {
return request<ProgramScheduleItemsWithDuration>(`/api/schedules/${scheduleId}/items`);
}
export function getCollections(): Promise<MediaCollection[]> {
return request<MediaCollection[]>('/api/collections');
}
export function getSmartCollections(): Promise<SmartCollection[]> {
return request<SmartCollection[]>('/api/smart-collections');
}
export function getFillerPresets(): Promise<FillerPreset[]> {
return request<FillerPreset[]>('/api/filler-presets');
}
export function getWatermarks(): Promise<Watermark[]> {
return request<Watermark[]>('/api/watermarks');
}
export function addScheduleItem(scheduleId: number, body: ScheduleItemRequest): Promise<ProgramScheduleItem> {
return request<ProgramScheduleItem>(`/api/schedules/${scheduleId}/items`, {
body,
method: 'POST'
});
}
export function replaceScheduleItems(
scheduleId: number,
body: ReplaceScheduleItemsRequest
): Promise<ProgramScheduleItem[]> {
return request<ProgramScheduleItem[]>(`/api/schedules/${scheduleId}/items`, {
body,
method: 'PUT'
});
}
export function deleteScheduleItem(scheduleId: number, itemId: number): Promise<void> {
return request<void>(`/api/schedules/${scheduleId}/items/${itemId}`, {
method: 'DELETE'
});
}
export async function getScheduleScreenData(): Promise<ScheduleScreenData> {
const schedules = await getSchedules();
const activeSchedule = schedules[0] ?? null;
if (!activeSchedule) {
return {
activeSchedule: null,
items: [],
pickers: emptyPickerData(),
schedules,
totalDurationEstimate: null
};
}
const [itemsEnvelope, pickers] = await Promise.all([
getScheduleItems(activeSchedule.id),
getSchedulePickerData()
]);
return {
activeSchedule,
items: (itemsEnvelope.items ?? []) as ProgramScheduleItem[],
pickers,
schedules,
totalDurationEstimate: itemsEnvelope.totalDurationEstimate
};
}
export function useScheduleScreenQuery(): ScheduleScreenQueryState {
const [state, setState] = useState<ScheduleScreenState>({
data: null,
error: null,
status: 'loading'
});
const activeRef = useRef(true);
const activeScheduleIdRef = useRef<number | null>(null);
useEffect(() => {
activeRef.current = true;
return () => {
activeRef.current = false;
};
}, []);
const load = useCallback(() => {
getScheduleScreenData()
.then((data) => {
if (activeRef.current) {
activeScheduleIdRef.current = data.activeSchedule?.id ?? null;
setState({ data, error: null, itemsLoading: false, status: 'success' });
}
})
.catch((error: unknown) => {
if (activeRef.current) {
setState({ data: null, error: messageFromScheduleError(error, 'Unable to load schedules'), status: 'error' });
}
});
}, []);
useEffect(() => {
load();
}, [load]);
const refresh = useCallback(() => {
setState({ data: null, error: null, status: 'loading' });
load();
}, [load]);
const setActiveSchedule = useCallback((scheduleId: number) => {
activeScheduleIdRef.current = scheduleId;
setState((current) => {
if (current.status !== 'success') {
return current;
}
const nextActiveSchedule = current.data.schedules.find((schedule) => schedule.id === scheduleId)
?? current.data.activeSchedule;
return {
data: { ...current.data, activeSchedule: nextActiveSchedule },
error: null,
itemsLoading: true,
status: 'success'
};
});
getScheduleItems(scheduleId)
.then((itemsEnvelope) => {
if (!activeRef.current || activeScheduleIdRef.current !== scheduleId) {
return;
}
setState((current) => {
if (current.status !== 'success') {
return current;
}
return {
data: {
...current.data,
items: (itemsEnvelope.items ?? []) as ProgramScheduleItem[],
totalDurationEstimate: itemsEnvelope.totalDurationEstimate
},
error: null,
itemsLoading: false,
status: 'success'
};
});
})
.catch((error: unknown) => {
if (!activeRef.current || activeScheduleIdRef.current !== scheduleId) {
return;
}
setState({
data: null,
error: messageFromScheduleError(error, 'Unable to load schedule items'),
status: 'error'
});
});
}, []);
const setItems = useCallback((items: ProgramScheduleItem[]) => {
setState((current) => {
if (current.status !== 'success') {
return current;
}
return {
data: { ...current.data, items },
error: null,
itemsLoading: false,
status: 'success'
};
});
}, []);
if (state.status === 'success') {
return {
data: state.data,
error: null,
itemsLoading: state.itemsLoading,
refresh,
setActiveSchedule,
setItems,
status: 'success'
};
}
if (state.status === 'error') {
return { data: null, error: state.error, refresh, status: 'error' };
}
return { data: null, error: null, refresh, status: 'loading' };
}
async function getSchedulePickerData(): Promise<SchedulePickerData> {
const [collections, smartCollections, fillerPresets, watermarks] = await Promise.all([
getCollections(),
getSmartCollections(),
getFillerPresets(),
getWatermarks()
]);
return {
collections,
fillerPresets: sortByName(fillerPresets),
smartCollections,
watermarks: sortByName(watermarks)
};
}
function emptyPickerData(): SchedulePickerData {
return {
collections: [],
fillerPresets: [],
smartCollections: [],
watermarks: []
};
}
function sortByName<T extends { name: null | string }>(items: T[]): T[] {
return [...items].sort((left, right) => (left.name ?? '').localeCompare(right.name ?? ''));
}
function messageFromScheduleError(error: unknown, fallback = 'Unable to load schedules'): string {
if (error instanceof ApiError) {
return error.detail ?? error.message;
}
if (error instanceof Error) {
return error.message;
}
return fallback;
}
+1499
View File
File diff suppressed because it is too large Load Diff