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