Compare commits
70
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73455aae28 | ||
|
|
e66f49e54a | ||
|
|
07e9d9838d | ||
|
|
f01ffc6254 | ||
|
|
6057bcea7d | ||
|
|
7b903d4bb4 | ||
|
|
7c1a1d5672 | ||
|
|
1891498563 | ||
|
|
7484df1e5b | ||
|
|
52bfdbcdce | ||
|
|
6b3a698283 | ||
|
|
32c0d6a6d3 | ||
|
|
97b6c4bee7 | ||
|
|
032b9d8116 | ||
|
|
6e6fd67fea | ||
|
|
5fb3bb2ebc | ||
|
|
d1cc12e065 | ||
|
|
e6df6ac10a | ||
|
|
e282bcecfa | ||
|
|
b183c6a154 | ||
|
|
c0b20a0ac5 | ||
|
|
482396497e | ||
|
|
4229717575 | ||
|
|
0826198d21 | ||
|
|
a2d2f5930c | ||
|
|
45ade1fd78 | ||
|
|
e170b2dc3c | ||
|
|
183f6c5d2a | ||
|
|
5560790bcb | ||
|
|
f25cfdb593 | ||
|
|
527332a3ac | ||
|
|
cd20dfb2aa | ||
|
|
8e6379a51b | ||
|
|
4aec234e20 | ||
|
|
861827dcbc | ||
|
|
30ecd9a8a7 | ||
|
|
b53b06c615 | ||
|
|
4f8b3042db | ||
|
|
b552905799 | ||
|
|
0d101e79b1 | ||
|
|
c113142a3d | ||
|
|
7c646a6829 | ||
|
|
82f3a1e633 | ||
|
|
2f306ce59f | ||
|
|
fb3f2856da | ||
|
|
8f7d240fc8 | ||
|
|
12e6a07e3c | ||
|
|
d22688a730 | ||
|
|
fe4cd39070 | ||
|
|
38e743d3cf | ||
|
|
7dfd4c37bb | ||
|
|
a638b4f54c | ||
|
|
421710499c | ||
|
|
969a23909a | ||
|
|
2edf3dab49 | ||
|
|
307da32f34 | ||
|
|
194d3fdf34 | ||
|
|
c788ae57f1 | ||
|
|
f2d0a36ed6 | ||
|
|
f1ff39954f | ||
|
|
2430927b40 | ||
|
|
74815da024 | ||
|
|
01647b6e50 | ||
|
|
97bf50cb5b | ||
|
|
350ca3b4b6 | ||
|
|
a5cbf0fbf7 | ||
|
|
d9cdff8154 | ||
|
|
2fa79edfdc | ||
|
|
77a28fcefc | ||
|
|
0e5e6ebc72 |
@@ -104,3 +104,19 @@ ij_json_wrap_long_lines = false
|
||||
[*.cs]
|
||||
# disable CA1848: Use the LoggerMessage delegates`
|
||||
dotnet_diagnostic.ca1848.severity = none
|
||||
|
||||
# --- Static-analysis pack adoption (ersatztv#15) ---
|
||||
# Roslynator / SonarAnalyzer / Meziantou / AsyncFixer are referenced centrally
|
||||
# (Directory.Build.targets). Default every analyzer diagnostic to `suggestion` so the new
|
||||
# packs don't fail the TreatWarningsAsErrors build; high-value rules get promoted to
|
||||
# warning/error one at a time (see ersatztv#15 / docs/contributing.md). Explicit per-rule
|
||||
# severities (e.g. ca1848 above) still take precedence over this bulk default.
|
||||
dotnet_analyzer_diagnostic.severity = suggestion
|
||||
|
||||
# Blazor components: analyzers run on .razor/.cshtml @code too, and TWAE would otherwise
|
||||
# turn their default-severity findings into build errors — keep them at suggestion as well.
|
||||
[*.razor]
|
||||
dotnet_analyzer_diagnostic.severity = suggestion
|
||||
|
||||
[*.cshtml]
|
||||
dotnet_analyzer_diagnostic.severity = suggestion
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
name: Dependency vulnerability scan
|
||||
|
||||
# Scheduled NuGet advisory scan — a Gitea-native stand-in for Dependabot (ersatztv#14).
|
||||
# Surfaces vulnerable direct/transitive packages on a schedule instead of only when a
|
||||
# `dotnet restore` happens to break. This is DETECTION ONLY; automated update PRs are
|
||||
# tracked separately (self-hosted Renovate — server-management#484).
|
||||
#
|
||||
# Scans the FULL solution (including the Scanner project, which the image build strips)
|
||||
# so coverage isn't narrower than the code we ship.
|
||||
#
|
||||
# NOTE: Gitea runs `schedule` triggers only from the default branch (main); the workflow
|
||||
# must be merged to main before the cron registers. Use `workflow_dispatch` to run on demand.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
# Mondays 06:00 UTC
|
||||
- cron: '0 6 * * 1'
|
||||
|
||||
# Independent of the build pipeline's concurrency group; a stale scan can be cancelled.
|
||||
concurrency:
|
||||
group: ersatztv-depscan
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
scan:
|
||||
name: NuGet vulnerable packages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore ErsatzTV.sln
|
||||
|
||||
- name: Scan for vulnerable packages (direct + transitive)
|
||||
# bash + `set -euo pipefail` so a failing `dotnet list` (e.g. the audit source
|
||||
# is unreachable while restore served from cache) fails the job instead of
|
||||
# falling through to a false "no vulnerable packages" green.
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "Running: dotnet list package --vulnerable --include-transitive"
|
||||
dotnet list ErsatzTV.sln package --vulnerable --include-transitive 2>&1 | tee depscan.txt
|
||||
# `dotnet list package --vulnerable` exits 0 even when advisories exist, so detect
|
||||
# findings by the report marker and fail the run if any are present. Expect this to
|
||||
# be RED until ersatztv#8 clears the current NCalcSync / SQLitePCLRaw advisories;
|
||||
# after that, a red run means a NEW advisory has appeared.
|
||||
if grep -q "has the following vulnerable packages" depscan.txt; then
|
||||
echo "::error::Vulnerable NuGet packages detected — see report above (tracked: ersatztv#8)."
|
||||
exit 1
|
||||
fi
|
||||
echo "No vulnerable packages found."
|
||||
@@ -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
|
||||
|
||||
@@ -59,10 +90,79 @@ jobs:
|
||||
- name: Test
|
||||
run: dotnet test --configuration Release --no-build --blame-hang-timeout "2m" --verbosity normal
|
||||
|
||||
migrations:
|
||||
name: EF migration integrity (SQLite + MySql)
|
||||
runs-on: ubuntu-latest
|
||||
# Independent gate (not a 'needs' of build yet) so the new MySql-service dependency
|
||||
# can't block image builds until it's proven reliable on the runner. Promote to a
|
||||
# required check / build dependency once green. (ersatztv#13)
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:8.4
|
||||
env:
|
||||
MYSQL_ROOT_PASSWORD: ersatztv
|
||||
MYSQL_DATABASE: ersatztv_migrations
|
||||
ports:
|
||||
- 3306:3306
|
||||
options: >-
|
||||
--health-cmd="mysqladmin ping -h 127.0.0.1 -uroot -persatztv --silent"
|
||||
--health-interval=5s
|
||||
--health-timeout=5s
|
||||
--health-retries=30
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore
|
||||
|
||||
- name: Build
|
||||
run: dotnet build --configuration Release --no-restore
|
||||
|
||||
- name: Install dotnet-ef
|
||||
run: dotnet tool install --global dotnet-ef --version 9.0.12
|
||||
|
||||
# SQLite is the prod provider; both checks validated locally.
|
||||
- name: SQLite — model drift + apply all migrations to a fresh DB
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export PATH="$PATH:$HOME/.dotnet/tools"
|
||||
echo "::group::SQLite model drift (has-pending-model-changes)"
|
||||
dotnet ef migrations has-pending-model-changes --no-build --configuration Release \
|
||||
--context TvContext --startup-project ErsatzTV --project ErsatzTV.Infrastructure.Sqlite -- --provider Sqlite
|
||||
echo "::endgroup::"
|
||||
echo "::group::SQLite apply all migrations to a fresh DB"
|
||||
export ETV_CONFIG_FOLDER="$(mktemp -d)" ETV_TRANSCODE_FOLDER="$(mktemp -d)"
|
||||
dotnet ef database update --no-build --configuration Release \
|
||||
--context TvContext --startup-project ErsatzTV --project ErsatzTV.Infrastructure.Sqlite -- --provider Sqlite
|
||||
echo "::endgroup::"
|
||||
|
||||
# MySql uses ServerVersion.AutoDetect (connects at config time), so it runs against the
|
||||
# service container above. MySql__ConnectionString maps to config key "MySql:ConnectionString".
|
||||
- name: MySql — model drift + apply all migrations to a fresh DB
|
||||
env:
|
||||
MySql__ConnectionString: "Server=mysql;Port=3306;Database=ersatztv_migrations;Uid=root;Pwd=ersatztv;"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export PATH="$PATH:$HOME/.dotnet/tools"
|
||||
echo "::group::MySql model drift (has-pending-model-changes)"
|
||||
dotnet ef migrations has-pending-model-changes --no-build --configuration Release \
|
||||
--context TvContext --startup-project ErsatzTV --project ErsatzTV.Infrastructure.MySql -- --provider MySql
|
||||
echo "::endgroup::"
|
||||
echo "::group::MySql apply all migrations to a fresh DB"
|
||||
dotnet ef database update --no-build --configuration Release \
|
||||
--context TvContext --startup-project ErsatzTV --project ErsatzTV.Infrastructure.MySql -- --provider MySql
|
||||
echo "::endgroup::"
|
||||
|
||||
build:
|
||||
name: Build & push image (amd64)
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
needs: [test, migrations]
|
||||
if: github.event_name != 'pull_request'
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -122,7 +222,7 @@ jobs:
|
||||
cache-from: type=registry,ref=192.168.1.95:3000/timothy/ersatztv:buildcache
|
||||
cache-to: type=registry,ref=192.168.1.95:3000/timothy/ersatztv:buildcache,mode=max,ignore-error=true
|
||||
|
||||
- name: Smoke test (HTTP readiness)
|
||||
- name: Smoke + IPTV E2E (assert key endpoints)
|
||||
if: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') }}
|
||||
run: |
|
||||
IMG="${IMAGE}:${{ steps.meta.outputs.short }}"
|
||||
@@ -154,11 +254,29 @@ jobs:
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "===== container logs (tail) ====="
|
||||
docker logs "$NAME" 2>&1 | tail -n 40 || true
|
||||
echo "================================="
|
||||
if [ "$ok" != "1" ]; then
|
||||
echo "===== container logs (tail) ====="; docker logs "$NAME" 2>&1 | tail -n 40 || true
|
||||
echo "Smoke test FAILED: ErsatzTV did not serve HTTP on :8409"
|
||||
exit 1
|
||||
fi
|
||||
echo "Smoke test passed: ErsatzTV served HTTP on :8409"
|
||||
echo "HTTP ready; asserting key IPTV endpoints (ersatztv#16)"
|
||||
# E2E: assert the real Jellyfin-facing surfaces serve a valid playlist + guide, not just
|
||||
# that the app answers HTTP. xmltv.xml needs channels.xml, which the scheduler writes a
|
||||
# few seconds after boot, so poll each endpoint until it returns 2xx with the right shape.
|
||||
# urlopen() returns only on 2xx (raises on 4xx/5xx), so reaching sys.exit means status OK.
|
||||
check() {
|
||||
local path="$1" needle="$2" i
|
||||
for i in $(seq 1 20); do
|
||||
if docker exec "$NAME" python3 -c "import urllib.request,sys; b=urllib.request.urlopen('http://localhost:8409$path',timeout=5).read(512).decode('utf-8','replace'); sys.exit(0 if '$needle' in b else 1)" 2>/dev/null; then
|
||||
echo " OK $path (2xx, contains '$needle')"; return 0
|
||||
fi
|
||||
sleep 3
|
||||
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"
|
||||
else
|
||||
echo "===== container logs (tail) ====="; docker logs "$NAME" 2>&1 | tail -n 40 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
name: Renovate
|
||||
|
||||
# Self-hosted Renovate for the ErsatzTV fork (server-management#484).
|
||||
#
|
||||
# Opens dependency-update PRs against this repo (managers: nuget via CPM, github-actions).
|
||||
# Runs on the shared Gitea act_runner (bumblebee). It supersedes the *proposing* half that
|
||||
# the dependency-scan.yml (ersatztv#14) deliberately left out — that scan stays as a cheap
|
||||
# in-repo detector for now.
|
||||
#
|
||||
# Config: repo-root renovate.json (package rules, grouping, automerge policy).
|
||||
# Bot identity + tokens are injected from repo Actions secrets:
|
||||
# RENOVATE_TOKEN — PAT of the dedicated `renovate` Gitea bot (write:repository,
|
||||
# read:user, write:issue, read:organization)
|
||||
# GH_COM_TOKEN — no-scope github.com PAT for changelog/release-note fetching
|
||||
# (Renovate needs this on non-GitHub platforms; optional, degrades
|
||||
# gracefully to anonymous if unset). Named GH_, not GITHUB_, because
|
||||
# Gitea reserves the GITHUB_ secret-name prefix.
|
||||
#
|
||||
# NOTE: Gitea runs `schedule` triggers ONLY from the default branch (main); this file must
|
||||
# be on main before the cron registers. Use workflow_dispatch to run on demand — it defaults
|
||||
# to a DRY RUN (logs only, no PRs); dispatch with "Dry run" cleared to create real PRs.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dryRun:
|
||||
description: 'Dry run (full = log only, no PRs; clear for a live run)'
|
||||
type: choice
|
||||
options:
|
||||
- 'full'
|
||||
- ''
|
||||
default: 'full'
|
||||
logLevel:
|
||||
description: 'Log level'
|
||||
type: choice
|
||||
options:
|
||||
- 'info'
|
||||
- 'debug'
|
||||
default: 'info'
|
||||
schedule:
|
||||
# Mondays 03:00 UTC — ahead of the 06:00 vulnerability scan
|
||||
- cron: '0 3 * * 1'
|
||||
|
||||
concurrency:
|
||||
group: ersatztv-renovate
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
renovate:
|
||||
name: Renovate
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: renovate/renovate:43
|
||||
steps:
|
||||
- name: Run Renovate
|
||||
env:
|
||||
RENOVATE_PLATFORM: gitea
|
||||
RENOVATE_ENDPOINT: http://192.168.1.95:3000/api/v1
|
||||
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
||||
RENOVATE_GITHUB_COM_TOKEN: ${{ secrets.GH_COM_TOKEN }}
|
||||
RENOVATE_REPOSITORIES: timothy/ersatztv
|
||||
RENOVATE_AUTODISCOVER: 'false'
|
||||
RENOVATE_GIT_AUTHOR: 'Renovate Bot <renovate@tblindustries.be>'
|
||||
# Let the dockerfile manager query our HTTP-only Gitea container registry for the
|
||||
# ersatztv-ffmpeg base image. Creds (reused from the image-push secrets) + insecureRegistry
|
||||
# live here, NOT in renovate.json, so they stay out of the committed config.
|
||||
RENOVATE_HOST_RULES: '[{"matchHost":"192.168.1.95:3000","hostType":"docker","username":"${{ secrets.REGISTRY_USER }}","password":"${{ secrets.REGISTRY_PASSWORD }}","insecureRegistry":true}]'
|
||||
RENOVATE_DRY_RUN: ${{ inputs.dryRun }}
|
||||
LOG_LEVEL: ${{ inputs.logLevel || 'info' }}
|
||||
run: renovate
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,10 +53,14 @@ 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.
|
||||
- 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
|
||||
- Test with xUnit (existing test projects)
|
||||
- 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.
|
||||
- **Renovate** is live (`.gitea/workflows/renovate.yml`, weekly + `workflow_dispatch`): opens dependency-update + OSV vuln-fix PRs and a Dependency Dashboard issue; patch bumps to test/dev-only packages auto-merge once `Build & test` passes, the rest are manual. Cross-repo rollout: server-management#484. See `docs/ci-cd.md` → Dependency management.
|
||||
- **Versioning**: release tags are `vYY.<release-seq>.<patch>` (year · sequential release-within-year · patch) — inherited from upstream, **not** year.month. `v26.3.1` = our infra rebuild of upstream 26.3.0 (no app changes); `v26.4.0` is reserved for the first release with app changes. Never `[skip ci]` a commit you'll tag (it suppresses the release build). Full policy: `docs/ci-cd.md` → Versioning & releases.
|
||||
- Backlog tracked via [Gitea Issues](http://192.168.1.95:3000/timothy/ersatztv/issues)
|
||||
|
||||
|
||||
+27
-1
@@ -6,10 +6,36 @@
|
||||
<ItemGroup>
|
||||
<PackageReference
|
||||
Include="Microsoft.VisualStudio.Threading.Analyzers"
|
||||
Version="17.14.15"
|
||||
Condition="'$(EnableThreadingAnalyzers)' == 'true'">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Curated static-analysis packs (ersatztv#15), applied to every project. Versions are
|
||||
central (Directory.Packages.props / CPM). Guarded on CPM so the gitignored .mcp tool
|
||||
(which opts out of CPM) doesn't pull versionless references. They start at `suggestion`
|
||||
severity in .editorconfig so they don't fail the TreatWarningsAsErrors build; high-value
|
||||
rules are promoted to warning/error incrementally. -->
|
||||
<ItemGroup Condition="'$(ManagePackageVersionsCentrally)' == 'true'">
|
||||
<PackageReference Include="Roslynator.Analyzers">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="SonarAnalyzer.CSharp">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Meziantou.Analyzer">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<!-- StyleCop.Analyzers intentionally omitted: its latest stable (1.1.118) crashes
|
||||
(AD0001) on C# records and its rules overlap the existing .editorconfig/Roslynator.
|
||||
Revisit via the record-compatible 1.2.0-beta if StyleCop is specifically wanted. (#15) -->
|
||||
<PackageReference Include="AsyncFixer">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
|
||||
<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="Blurhash.SkiaSharp" Version="2.0.0" />
|
||||
<PackageVersion Include="Chronic.Core" Version="0.4.0" />
|
||||
<PackageVersion Include="CliWrap" Version="3.10.0" />
|
||||
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageVersion Include="Dapper" Version="2.1.66" />
|
||||
<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="FluentValidation" Version="12.1.1" />
|
||||
<PackageVersion Include="FluentValidation.AspNetCore" Version="11.3.1" />
|
||||
<PackageVersion Include="Flurl" Version="4.0.0" />
|
||||
<PackageVersion Include="Hardware.Info" Version="101.1.1.1" />
|
||||
<PackageVersion Include="Heron.MudCalendar" Version="3.4.0" />
|
||||
<PackageVersion Include="HtmlSanitizer" Version="9.0.892" />
|
||||
<PackageVersion Include="Humanizer.Core" Version="3.0.1" />
|
||||
<PackageVersion Include="Jint" Version="4.5.0" />
|
||||
<PackageVersion Include="JsonSchema.Net" Version="9.0.0" />
|
||||
<PackageVersion Include="LanguageExt.Core" Version="4.4.9" />
|
||||
<PackageVersion Include="LanguageExt.Transformers" Version="4.4.8" />
|
||||
<PackageVersion Include="Lennox.NvEncSharp" Version="2.0.0" />
|
||||
<PackageVersion Include="Lucene.Net" Version="4.8.0-beta00017" />
|
||||
<PackageVersion Include="Lucene.Net.Analysis.Common" Version="4.8.0-beta00017" />
|
||||
<PackageVersion Include="Lucene.Net.QueryParser" Version="4.8.0-beta00017" />
|
||||
<PackageVersion Include="Markdig" Version="0.44.0" />
|
||||
<PackageVersion Include="MediatR" Version="[12.5.0]" />
|
||||
<PackageVersion Include="MediatR.Courier.DependencyInjection" Version="5.0.0" />
|
||||
<PackageVersion Include="Meziantou.Analyzer" Version="3.0.115" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="[9.0.12,10)" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="[9.0.12,10)" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="[9.0.12,10)" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="[9.0.12,10)" />
|
||||
<PackageVersion Include="Microsoft.Extensions.ApiDescription.Server" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyModel" Version="[8.0.2]" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Debug" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||
<PackageVersion Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.14.15" />
|
||||
<PackageVersion Include="MudBlazor" Version="8.15.0" />
|
||||
<PackageVersion Include="NaturalSort.Extension" Version="4.4.1" />
|
||||
<PackageVersion Include="NCalcSync" Version="6.3.2" />
|
||||
<PackageVersion Include="NetArchTest.eNhancedEdition" Version="1.4.5" />
|
||||
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<PackageVersion Include="Newtonsoft.Json.Schema" Version="4.0.1" />
|
||||
<PackageVersion Include="NSubstitute" Version="5.3.0" />
|
||||
<PackageVersion Include="NUnit" Version="4.4.0" />
|
||||
<PackageVersion Include="NUnit.Analyzers" Version="4.11.2" />
|
||||
<PackageVersion Include="NUnit3TestAdapter" Version="6.1.0" />
|
||||
<PackageVersion Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
|
||||
<PackageVersion Include="Refit" Version="9.0.2" />
|
||||
<PackageVersion Include="Refit.HttpClientFactory" Version="9.0.2" />
|
||||
<PackageVersion Include="Refit.Newtonsoft.Json" Version="9.0.2" />
|
||||
<PackageVersion Include="Refit.Xml" Version="9.0.2" />
|
||||
<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="Serilog" Version="4.3.0" />
|
||||
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||
<PackageVersion Include="Serilog.Extensions.Hosting" Version="10.0.0" />
|
||||
<PackageVersion Include="Serilog.Extensions.Logging" Version="10.0.0" />
|
||||
<PackageVersion Include="Serilog.Formatting.Compact" Version="3.0.0" />
|
||||
<PackageVersion Include="Serilog.Formatting.Compact.Reader" Version="4.0.0" />
|
||||
<PackageVersion Include="Serilog.Settings.Configuration" Version="10.0.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||
<PackageVersion Include="Serilog.Sinks.Debug" Version="3.0.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||
<PackageVersion Include="Shouldly" Version="4.3.0" />
|
||||
<PackageVersion Include="SixLabors.ImageSharp" Version="3.1.12" />
|
||||
<PackageVersion Include="SkiaSharp" Version="3.119.1" />
|
||||
<PackageVersion Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="3.119.1" />
|
||||
<PackageVersion Include="SonarAnalyzer.CSharp" Version="10.27.0.140913" />
|
||||
<!-- Direct pin to override EF Core 9's transitive SQLitePCLRaw 2.1.10 (vulnerable
|
||||
bundled SQLite, GHSA-2m69-gcr7-jv3q). The 3.x line ships the patched native
|
||||
(lib.e_sqlite3 3.50.3); core 3.0.3 satisfies Microsoft.Data.Sqlite's `>= 2.1.10`. (#8) -->
|
||||
<PackageVersion Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.3" />
|
||||
<PackageVersion Include="System.CommandLine" Version="2.0.2" />
|
||||
<PackageVersion Include="TagLibSharp" Version="2.3.0" />
|
||||
<PackageVersion Include="Testably.Abstractions" Version="10.0.0" />
|
||||
<PackageVersion Include="Testably.Abstractions.Testing" Version="5.1.0" />
|
||||
<PackageVersion Include="TimeSpanParserUtil" Version="1.2.0" />
|
||||
<PackageVersion Include="TimeZoneConverter" Version="7.2.0" />
|
||||
<PackageVersion Include="VueCliMiddleware" Version="6.0.0" />
|
||||
<PackageVersion Include="WebMarkupMin.Core" Version="2.20.1" />
|
||||
<PackageVersion Include="Winista.MimeDetect" Version="1.1.0" />
|
||||
<PackageVersion Include="YamlDotNet" Version="16.3.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -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,60 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Validation rules shared by <see cref="CreateChannelHandler" /> and
|
||||
/// <see cref="UpdateChannelHandler" />. These were previously enforced only by the Blazor page
|
||||
/// (<c>ChannelEditViewModelValidator</c>); porting them into the handlers makes them apply to
|
||||
/// the REST API as well. Failures map to HTTP 422.
|
||||
/// </summary>
|
||||
internal static class ChannelValidations
|
||||
{
|
||||
/// <summary>
|
||||
/// A channel must belong to a non-empty group; the value is used as the M3U
|
||||
/// <c>group-title</c>. Mirrors the Blazor rule <c>RuleFor(x => x.Group).NotEmpty()</c>.
|
||||
/// </summary>
|
||||
internal static Validation<BaseError, string> ValidateGroup(string group)
|
||||
{
|
||||
// Use explicit returns (not a ternary): BaseError has an implicit string conversion, so a
|
||||
// ternary would collapse both branches to BaseError and always produce a Fail.
|
||||
if (string.IsNullOrWhiteSpace(group))
|
||||
{
|
||||
return BaseError.New("Channel group is required");
|
||||
}
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
/// <summary>A disabled channel may not be shown in the EPG.</summary>
|
||||
internal static Validation<BaseError, bool> ValidateShowInEpg(bool isEnabled, bool showInEpg)
|
||||
{
|
||||
if (!isEnabled && showInEpg)
|
||||
{
|
||||
return BaseError.New("Disabled channels cannot be shown in EPG");
|
||||
}
|
||||
|
||||
return showInEpg;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A logo path that is an absolute URI must be a valid external (http/https) url.
|
||||
/// Relative/local logo paths and empty values are allowed.
|
||||
/// </summary>
|
||||
internal static Validation<BaseError, string> ValidateLogo(string logoPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(logoPath))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
bool isAbsoluteUri = Uri.TryCreate(logoPath, UriKind.Absolute, out _);
|
||||
if (isAbsoluteUri && !Artwork.IsExternalUrl(logoPath))
|
||||
{
|
||||
return BaseError.New("External logo url is invalid");
|
||||
}
|
||||
|
||||
return logoPath;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.Channels.ChannelValidations;
|
||||
using Channel = ErsatzTV.Core.Domain.Channel;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
@@ -36,18 +37,23 @@ public class CreateChannelHandler(
|
||||
return new CreateChannelResult(channel.Id);
|
||||
}
|
||||
|
||||
private static async Task<Validation<BaseError, Channel>> Validate(TvContext dbContext, CreateChannel request, CancellationToken cancellationToken) =>
|
||||
(ValidateName(request), await ValidateNumber(dbContext, request, cancellationToken),
|
||||
private static async Task<Validation<BaseError, Channel>> Validate(TvContext dbContext, CreateChannel request, CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, Channel> channelValidation = (ValidateName(request), await ValidateNumber(dbContext, request, cancellationToken),
|
||||
await FFmpegProfileMustExist(dbContext, request, cancellationToken),
|
||||
await WatermarkMustExist(dbContext, request, cancellationToken),
|
||||
await FillerPresetMustExist(dbContext, request, cancellationToken),
|
||||
await MirrorSourceMustBeValid(dbContext, request, cancellationToken))
|
||||
await MirrorSourceMustBeValid(dbContext, request, cancellationToken),
|
||||
ValidateShowInEpg(request.IsEnabled, request.ShowInEpg),
|
||||
ValidateLogo(request.Logo?.Path))
|
||||
.Apply((
|
||||
name,
|
||||
number,
|
||||
ffmpegProfileId,
|
||||
watermarkId,
|
||||
fillerPresetId,
|
||||
_,
|
||||
_,
|
||||
_) =>
|
||||
{
|
||||
var artwork = new List<Artwork>();
|
||||
@@ -125,6 +131,11 @@ public class CreateChannelHandler(
|
||||
return channel;
|
||||
});
|
||||
|
||||
// combine the page-only Group rule with the channel validation (keeps tuple arity within
|
||||
// LanguageExt's supported applicative range while still accumulating all errors)
|
||||
return (ValidateGroup(request.Group), channelValidation).Apply((_, channel) => channel);
|
||||
}
|
||||
|
||||
private static Validation<BaseError, string> ValidateName(CreateChannel createChannel) =>
|
||||
createChannel.NotEmpty(c => c.Name)
|
||||
.Bind(_ => createChannel.NotLongerThan(50)(c => c.Name));
|
||||
@@ -149,16 +160,20 @@ public class CreateChannelHandler(
|
||||
});
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, int>> FFmpegProfileMustExist(
|
||||
private static async Task<Validation<BaseError, int>> FFmpegProfileMustExist(
|
||||
TvContext dbContext,
|
||||
CreateChannel createChannel,
|
||||
CancellationToken cancellationToken) =>
|
||||
dbContext.FFmpegProfiles
|
||||
.CountAsync(p => p.Id == createChannel.FFmpegProfileId, cancellationToken)
|
||||
.Map(Optional)
|
||||
.Filter(c => c > 0)
|
||||
.MapT(_ => createChannel.FFmpegProfileId)
|
||||
.Map(o => o.ToValidation<BaseError>($"FFmpegProfile {createChannel.FFmpegProfileId} does not exist."));
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
bool exists = await dbContext.FFmpegProfiles
|
||||
.AnyAsync(p => p.Id == createChannel.FFmpegProfileId, cancellationToken);
|
||||
if (exists)
|
||||
{
|
||||
return createChannel.FFmpegProfileId;
|
||||
}
|
||||
|
||||
return BaseError.New($"FFmpegProfile {createChannel.FFmpegProfileId} does not exist.");
|
||||
}
|
||||
|
||||
private static async Task<Validation<BaseError, Option<int>>> WatermarkMustExist(
|
||||
TvContext dbContext,
|
||||
@@ -170,12 +185,14 @@ public class CreateChannelHandler(
|
||||
return Option<int>.None;
|
||||
}
|
||||
|
||||
return await dbContext.ChannelWatermarks
|
||||
.CountAsync(w => w.Id == createChannel.WatermarkId, cancellationToken)
|
||||
.Map(Optional)
|
||||
.Filter(c => c > 0)
|
||||
.MapT(_ => Optional(createChannel.WatermarkId))
|
||||
.Map(o => o.ToValidation<BaseError>($"Watermark {createChannel.WatermarkId} does not exist."));
|
||||
bool exists = await dbContext.ChannelWatermarks
|
||||
.AnyAsync(w => w.Id == createChannel.WatermarkId, cancellationToken);
|
||||
if (exists)
|
||||
{
|
||||
return Optional(createChannel.WatermarkId);
|
||||
}
|
||||
|
||||
return BaseError.New($"Watermark {createChannel.WatermarkId} does not exist.");
|
||||
}
|
||||
|
||||
private static async Task<Validation<BaseError, Option<int>>> FillerPresetMustExist(
|
||||
@@ -188,14 +205,15 @@ public class CreateChannelHandler(
|
||||
return Option<int>.None;
|
||||
}
|
||||
|
||||
return await dbContext.FillerPresets
|
||||
bool exists = await dbContext.FillerPresets
|
||||
.Filter(fp => fp.FillerKind == FillerKind.Fallback)
|
||||
.CountAsync(w => w.Id == createChannel.FallbackFillerId, cancellationToken)
|
||||
.Map(Optional)
|
||||
.Filter(c => c > 0)
|
||||
.MapT(_ => Optional(createChannel.FallbackFillerId))
|
||||
.Map(o => o.ToValidation<BaseError>(
|
||||
$"Fallback filler {createChannel.FallbackFillerId} does not exist."));
|
||||
.AnyAsync(w => w.Id == createChannel.FallbackFillerId, cancellationToken);
|
||||
if (exists)
|
||||
{
|
||||
return Optional(createChannel.FallbackFillerId);
|
||||
}
|
||||
|
||||
return BaseError.New($"Fallback filler {createChannel.FallbackFillerId} does not exist.");
|
||||
}
|
||||
|
||||
private static async Task<Validation<BaseError, Unit>> MirrorSourceMustBeValid(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 ErsatzTV.Infrastructure.Extensions;
|
||||
@@ -31,8 +32,17 @@ public class DeleteChannelHandler : IRequestHandler<DeleteChannel, Either<BaseEr
|
||||
public async Task<Either<BaseError, Unit>> Handle(DeleteChannel request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Channel> validation = await ChannelMustExist(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(c => DoDeletion(dbContext, c, cancellationToken));
|
||||
Option<Channel> maybeChannel = await dbContext.Channels
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == request.ChannelId, cancellationToken);
|
||||
|
||||
return await maybeChannel.Match(
|
||||
Some: async channel =>
|
||||
{
|
||||
await DoDeletion(dbContext, channel, cancellationToken);
|
||||
return Right<BaseError, Unit>(Unit.Default);
|
||||
},
|
||||
None: () => Task.FromResult(
|
||||
Left<BaseError, Unit>(new NotFoundError($"Channel {request.ChannelId} does not exist."))));
|
||||
}
|
||||
|
||||
private async Task<Unit> DoDeletion(TvContext dbContext, Channel channel, CancellationToken cancellationToken)
|
||||
@@ -54,14 +64,4 @@ public class DeleteChannelHandler : IRequestHandler<DeleteChannel, Either<BaseEr
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private static async Task<Validation<BaseError, Channel>> ChannelMustExist(
|
||||
TvContext dbContext,
|
||||
DeleteChannel deleteChannel,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<Channel> maybeChannel = await dbContext.Channels
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == deleteChannel.ChannelId, cancellationToken);
|
||||
return maybeChannel.ToValidation<BaseError>($"Channel {deleteChannel.ChannelId} does not exist.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,13 @@ using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Subtitles;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.Channels.ChannelValidations;
|
||||
using static ErsatzTV.Application.Channels.Mapper;
|
||||
using Channel = ErsatzTV.Core.Domain.Channel;
|
||||
|
||||
@@ -24,8 +27,23 @@ public class UpdateChannelHandler(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Channel> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request, cancellationToken));
|
||||
|
||||
Option<Channel> maybeChannel = await dbContext.Channels
|
||||
.Include(c => c.Artwork)
|
||||
.Include(c => c.Watermark)
|
||||
.Include(c => c.Playouts)
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == request.ChannelId, cancellationToken);
|
||||
|
||||
return await maybeChannel.Match(
|
||||
Some: async channel =>
|
||||
{
|
||||
Validation<BaseError, Channel> validation =
|
||||
await Validate(dbContext, request, channel, cancellationToken);
|
||||
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request, cancellationToken));
|
||||
},
|
||||
None: () => Task.FromResult(
|
||||
Left<BaseError, ChannelViewModel>(
|
||||
new NotFoundError($"Channel {request.ChannelId} does not exist."))));
|
||||
}
|
||||
|
||||
private async Task<ChannelViewModel> ApplyUpdateRequest(
|
||||
@@ -162,23 +180,82 @@ public class UpdateChannelHandler(
|
||||
private static async Task<Validation<BaseError, Channel>> Validate(
|
||||
TvContext dbContext,
|
||||
UpdateChannel request,
|
||||
CancellationToken cancellationToken) =>
|
||||
(await ChannelMustExist(dbContext, request, cancellationToken),
|
||||
ValidateName(request),
|
||||
Channel channel,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, Channel> channelValidation = (ValidateName(request),
|
||||
await ValidateNumber(dbContext, request, cancellationToken),
|
||||
await MirrorSourceMustBeValid(dbContext, request, cancellationToken))
|
||||
.Apply((channelToUpdate, _, _, _) => channelToUpdate);
|
||||
await MirrorSourceMustBeValid(dbContext, request, cancellationToken),
|
||||
ValidateShowInEpg(request.IsEnabled, request.ShowInEpg),
|
||||
ValidateLogo(request.Logo?.Path))
|
||||
.Apply((_, _, _, _, _) => channel);
|
||||
|
||||
private static Task<Validation<BaseError, Channel>> ChannelMustExist(
|
||||
// combine the page-only Group rule plus the FK existence checks (FFmpeg profile / watermark /
|
||||
// fallback filler) with the channel validation; splitting keeps tuple arity within
|
||||
// LanguageExt's supported applicative range while still accumulating all errors
|
||||
return (ValidateGroup(request.Group),
|
||||
await FFmpegProfileMustExist(dbContext, request, cancellationToken),
|
||||
await WatermarkMustExist(dbContext, request, cancellationToken),
|
||||
await FillerPresetMustExist(dbContext, request, cancellationToken),
|
||||
channelValidation)
|
||||
.Apply((_, _, _, _, c) => c);
|
||||
}
|
||||
|
||||
private static async Task<Validation<BaseError, int>> FFmpegProfileMustExist(
|
||||
TvContext dbContext,
|
||||
UpdateChannel updateChannel,
|
||||
CancellationToken cancellationToken) =>
|
||||
dbContext.Channels
|
||||
.Include(c => c.Artwork)
|
||||
.Include(c => c.Watermark)
|
||||
.Include(c => c.Playouts)
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == updateChannel.ChannelId, cancellationToken)
|
||||
.Map(o => o.ToValidation<BaseError>("Channel does not exist."));
|
||||
UpdateChannel request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
bool exists = await dbContext.FFmpegProfiles
|
||||
.AnyAsync(p => p.Id == request.FFmpegProfileId, cancellationToken);
|
||||
if (exists)
|
||||
{
|
||||
return request.FFmpegProfileId;
|
||||
}
|
||||
|
||||
return BaseError.New($"FFmpegProfile {request.FFmpegProfileId} does not exist.");
|
||||
}
|
||||
|
||||
private static async Task<Validation<BaseError, Option<int>>> WatermarkMustExist(
|
||||
TvContext dbContext,
|
||||
UpdateChannel request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.WatermarkId is null)
|
||||
{
|
||||
return Option<int>.None;
|
||||
}
|
||||
|
||||
bool exists = await dbContext.ChannelWatermarks
|
||||
.AnyAsync(w => w.Id == request.WatermarkId, cancellationToken);
|
||||
if (exists)
|
||||
{
|
||||
return Optional(request.WatermarkId);
|
||||
}
|
||||
|
||||
return BaseError.New($"Watermark {request.WatermarkId} does not exist.");
|
||||
}
|
||||
|
||||
private static async Task<Validation<BaseError, Option<int>>> FillerPresetMustExist(
|
||||
TvContext dbContext,
|
||||
UpdateChannel request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.FallbackFillerId is null)
|
||||
{
|
||||
return Option<int>.None;
|
||||
}
|
||||
|
||||
bool exists = await dbContext.FillerPresets
|
||||
.Filter(fp => fp.FillerKind == FillerKind.Fallback)
|
||||
.AnyAsync(w => w.Id == request.FallbackFillerId, cancellationToken);
|
||||
if (exists)
|
||||
{
|
||||
return Optional(request.FallbackFillerId);
|
||||
}
|
||||
|
||||
return BaseError.New($"Fallback filler {request.FallbackFillerId} does not exist.");
|
||||
}
|
||||
|
||||
private static async Task<Validation<BaseError, Unit>> MirrorSourceMustBeValid(
|
||||
TvContext dbContext,
|
||||
|
||||
@@ -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,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);
|
||||
}
|
||||
@@ -10,15 +10,15 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CliWrap" Version="3.10.0" />
|
||||
<PackageReference Include="Humanizer.Core" Version="3.0.1" />
|
||||
<PackageReference Include="MediatR" Version="[12.5.0]" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.2" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<PackageReference Include="Serilog.Formatting.Compact.Reader" Version="4.0.0" />
|
||||
<PackageReference Include="WebMarkupMin.Core" Version="2.20.1" />
|
||||
<PackageReference Include="Winista.MimeDetect" Version="1.1.0" />
|
||||
<PackageReference Include="CliWrap" />
|
||||
<PackageReference Include="Humanizer.Core" />
|
||||
<PackageReference Include="MediatR" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" />
|
||||
<PackageReference Include="Newtonsoft.Json" />
|
||||
<PackageReference Include="Serilog.Formatting.Compact.Reader" />
|
||||
<PackageReference Include="WebMarkupMin.Core" />
|
||||
<PackageReference Include="Winista.MimeDetect" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
@@ -24,8 +25,15 @@ public class CreateFFmpegProfileHandler :
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, FFmpegProfile> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(profile => PersistFFmpegProfile(dbContext, profile));
|
||||
Option<int> maybeResolutionId = await ResolutionMustExist(dbContext, request, cancellationToken);
|
||||
return await maybeResolutionId.Match(
|
||||
Some: async resolutionId =>
|
||||
{
|
||||
Validation<BaseError, FFmpegProfile> validation = Validate(request, resolutionId);
|
||||
return await validation.Apply(profile => PersistFFmpegProfile(dbContext, profile));
|
||||
},
|
||||
None: () => Task.FromResult<Either<BaseError, CreateFFmpegProfileResult>>(
|
||||
new NotFoundError($"[Resolution] {request.ResolutionId} does not exist")));
|
||||
}
|
||||
|
||||
private async Task<CreateFFmpegProfileResult> PersistFFmpegProfile(
|
||||
@@ -38,13 +46,11 @@ public class CreateFFmpegProfileHandler :
|
||||
return new CreateFFmpegProfileResult(ffmpegProfile.Id);
|
||||
}
|
||||
|
||||
private static async Task<Validation<BaseError, FFmpegProfile>> Validate(
|
||||
TvContext dbContext,
|
||||
private static Validation<BaseError, FFmpegProfile> Validate(
|
||||
CreateFFmpegProfile request,
|
||||
CancellationToken cancellationToken) =>
|
||||
(ValidateName(request), ValidateThreadCount(request),
|
||||
await ResolutionMustExist(dbContext, request, cancellationToken))
|
||||
.Apply((name, threadCount, resolutionId) =>
|
||||
int resolutionId) =>
|
||||
(ValidateName(request), ValidateThreadCount(request))
|
||||
.Apply((name, threadCount) =>
|
||||
{
|
||||
var hwAccel = request.NormalizeVideo
|
||||
? request.HardwareAcceleration
|
||||
@@ -110,12 +116,11 @@ public class CreateFFmpegProfileHandler :
|
||||
private static Validation<BaseError, int> ValidateThreadCount(CreateFFmpegProfile createFFmpegProfile) =>
|
||||
createFFmpegProfile.AtLeast(0)(p => p.ThreadCount);
|
||||
|
||||
private static Task<Validation<BaseError, int>> ResolutionMustExist(
|
||||
private static Task<Option<int>> ResolutionMustExist(
|
||||
TvContext dbContext,
|
||||
CreateFFmpegProfile createFFmpegProfile,
|
||||
CancellationToken cancellationToken) =>
|
||||
dbContext.Resolutions
|
||||
.SelectOneAsync(r => r.Id, r => r.Id == createFFmpegProfile.ResolutionId, cancellationToken)
|
||||
.MapT(r => r.Id)
|
||||
.Map(o => o.ToValidation<BaseError>($"[Resolution] {createFFmpegProfile.ResolutionId} does not exist"));
|
||||
.MapT(r => r.Id);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
@@ -19,8 +20,19 @@ public class DeleteFFmpegProfileHandler(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, FFmpegProfile> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(p => DoDeletion(dbContext, p));
|
||||
Option<FFmpegProfile> maybeProfile = await FFmpegProfileMustExist(dbContext, request, cancellationToken);
|
||||
return await maybeProfile.Match(
|
||||
Some: async profile =>
|
||||
{
|
||||
Validation<BaseError, FFmpegProfile> validation = await Validate(
|
||||
dbContext,
|
||||
request,
|
||||
profile,
|
||||
cancellationToken);
|
||||
return await validation.Apply(p => DoDeletion(dbContext, p));
|
||||
},
|
||||
None: () => Task.FromResult<Either<BaseError, Unit>>(
|
||||
new NotFoundError($"FFmpegProfile {request.FFmpegProfileId} does not exist")));
|
||||
}
|
||||
|
||||
private async Task<Unit> DoDeletion(TvContext dbContext, FFmpegProfile ffmpegProfile)
|
||||
@@ -34,19 +46,18 @@ public class DeleteFFmpegProfileHandler(
|
||||
private async Task<Validation<BaseError, FFmpegProfile>> Validate(
|
||||
TvContext dbContext,
|
||||
DeleteFFmpegProfile request,
|
||||
FFmpegProfile profile,
|
||||
CancellationToken cancellationToken) =>
|
||||
(await FFmpegProfileMustNotBeUsed(dbContext, request, cancellationToken),
|
||||
await FFmpegProfileMustNotBeDefault(request, cancellationToken),
|
||||
await FFmpegProfileMustExist(dbContext, request, cancellationToken))
|
||||
.Apply((_, _, ffmpegProfile) => ffmpegProfile);
|
||||
await FFmpegProfileMustNotBeDefault(request, cancellationToken))
|
||||
.Apply((_, _) => profile);
|
||||
|
||||
private static Task<Validation<BaseError, FFmpegProfile>> FFmpegProfileMustExist(
|
||||
private static Task<Option<FFmpegProfile>> FFmpegProfileMustExist(
|
||||
TvContext dbContext,
|
||||
DeleteFFmpegProfile request,
|
||||
CancellationToken cancellationToken) =>
|
||||
dbContext.FFmpegProfiles
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == request.FFmpegProfileId, cancellationToken)
|
||||
.Map(o => o.ToValidation<BaseError>($"FFmpegProfile {request.FFmpegProfileId} does not exist"));
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == request.FFmpegProfileId, cancellationToken);
|
||||
|
||||
private static async Task<Validation<BaseError, Unit>> FFmpegProfileMustNotBeUsed(
|
||||
TvContext dbContext,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.FFmpeg.Preset;
|
||||
@@ -17,8 +18,22 @@ public class UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFa
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, FFmpegProfile> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(p => ApplyUpdateRequest(dbContext, p, request, cancellationToken));
|
||||
Option<FFmpegProfile> maybeProfile = await FFmpegProfileMustExist(dbContext, request, cancellationToken);
|
||||
return await maybeProfile.Match(
|
||||
Some: async profile =>
|
||||
{
|
||||
Option<int> maybeResolutionId = await ResolutionMustExist(dbContext, request, cancellationToken);
|
||||
return await maybeResolutionId.Match(
|
||||
Some: async _ =>
|
||||
{
|
||||
Validation<BaseError, FFmpegProfile> validation = await Validate(dbContext, request, profile);
|
||||
return await validation.Apply(p => ApplyUpdateRequest(dbContext, p, request, cancellationToken));
|
||||
},
|
||||
None: () => Task.FromResult<Either<BaseError, UpdateFFmpegProfileResult>>(
|
||||
new NotFoundError($"[Resolution] {request.ResolutionId} does not exist")));
|
||||
},
|
||||
None: () => Task.FromResult<Either<BaseError, UpdateFFmpegProfileResult>>(
|
||||
new NotFoundError("FFmpegProfile does not exist.")));
|
||||
}
|
||||
|
||||
private async Task<UpdateFFmpegProfileResult> ApplyUpdateRequest(
|
||||
@@ -109,20 +124,16 @@ public class UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFa
|
||||
private static async Task<Validation<BaseError, FFmpegProfile>> Validate(
|
||||
TvContext dbContext,
|
||||
UpdateFFmpegProfile request,
|
||||
CancellationToken cancellationToken) =>
|
||||
(await FFmpegProfileMustExist(dbContext, request, cancellationToken),
|
||||
await ValidateName(dbContext, request),
|
||||
ValidateThreadCount(request),
|
||||
await ResolutionMustExist(dbContext, request, cancellationToken))
|
||||
.Apply((ffmpegProfileToUpdate, _, _, _) => ffmpegProfileToUpdate);
|
||||
FFmpegProfile profile) =>
|
||||
(await ValidateName(dbContext, request), ValidateThreadCount(request))
|
||||
.Apply((_, _) => profile);
|
||||
|
||||
private static Task<Validation<BaseError, FFmpegProfile>> FFmpegProfileMustExist(
|
||||
private static Task<Option<FFmpegProfile>> FFmpegProfileMustExist(
|
||||
TvContext dbContext,
|
||||
UpdateFFmpegProfile updateFFmpegProfile,
|
||||
CancellationToken cancellationToken) =>
|
||||
dbContext.FFmpegProfiles
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == updateFFmpegProfile.FFmpegProfileId, cancellationToken)
|
||||
.Map(o => o.ToValidation<BaseError>("FFmpegProfile does not exist."));
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == updateFFmpegProfile.FFmpegProfileId, cancellationToken);
|
||||
|
||||
private static async Task<Validation<BaseError, string>> ValidateName(
|
||||
TvContext dbContext,
|
||||
@@ -147,12 +158,11 @@ public class UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFa
|
||||
private static Validation<BaseError, int> ValidateThreadCount(UpdateFFmpegProfile updateFFmpegProfile) =>
|
||||
updateFFmpegProfile.AtLeast(0)(p => p.ThreadCount);
|
||||
|
||||
private static Task<Validation<BaseError, int>> ResolutionMustExist(
|
||||
private static Task<Option<int>> ResolutionMustExist(
|
||||
TvContext dbContext,
|
||||
UpdateFFmpegProfile updateFFmpegProfile,
|
||||
CancellationToken cancellationToken) =>
|
||||
dbContext.Resolutions
|
||||
.SelectOneAsync(r => r.Id, r => r.Id == updateFFmpegProfile.ResolutionId, cancellationToken)
|
||||
.MapT(r => r.Id)
|
||||
.Map(o => o.ToValidation<BaseError>($"[Resolution] {updateFFmpegProfile.ResolutionId} does not exist"));
|
||||
.MapT(r => r.Id);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Application.Search;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
@@ -42,8 +43,15 @@ public class AddItemsToCollectionHandler :
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Collection> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(c => ApplyAddItemsRequest(dbContext, c, request, cancellationToken));
|
||||
Option<Collection> maybeCollection = await CollectionMustExist(dbContext, request, cancellationToken);
|
||||
return await maybeCollection.Match(
|
||||
Some: async collection =>
|
||||
{
|
||||
Validation<BaseError, Collection> validation = await Validate(dbContext, request, collection, cancellationToken);
|
||||
return await validation.Apply(c => ApplyAddItemsRequest(dbContext, c, request, cancellationToken));
|
||||
},
|
||||
None: () => Task.FromResult<Either<BaseError, Unit>>(
|
||||
new NotFoundError($"Collection {request.CollectionId} does not exist.")));
|
||||
}
|
||||
|
||||
private async Task<Unit> ApplyAddItemsRequest(
|
||||
@@ -89,22 +97,23 @@ public class AddItemsToCollectionHandler :
|
||||
private async Task<Validation<BaseError, Collection>> Validate(
|
||||
TvContext dbContext,
|
||||
AddItemsToCollection request,
|
||||
Collection collection,
|
||||
CancellationToken cancellationToken) =>
|
||||
(await CollectionMustExist(dbContext, request, cancellationToken),
|
||||
await ValidateMovies(request),
|
||||
(await ValidateMovies(request),
|
||||
await ValidateShows(request),
|
||||
await ValidateSeasons(request),
|
||||
await ValidateEpisodes(request))
|
||||
.Apply((collection, _, _, _, _) => collection);
|
||||
await ValidateEpisodes(request),
|
||||
await ValidateMediaItems(dbContext, request, cancellationToken))
|
||||
.Apply((_, _, _, _, _) => collection);
|
||||
|
||||
private static Task<Validation<BaseError, Collection>> CollectionMustExist(
|
||||
private static Task<Option<Collection>> CollectionMustExist(
|
||||
TvContext dbContext,
|
||||
AddItemsToCollection request,
|
||||
CancellationToken cancellationToken) =>
|
||||
dbContext.Collections
|
||||
.Include(c => c.MediaItems)
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == request.CollectionId, cancellationToken)
|
||||
.Map(o => o.ToValidation<BaseError>("Collection does not exist."));
|
||||
.Map(identity);
|
||||
|
||||
private Task<Validation<BaseError, Unit>> ValidateMovies(AddItemsToCollection request) =>
|
||||
_movieRepository.AllMoviesExist(request.MovieIds)
|
||||
@@ -133,4 +142,30 @@ public class AddItemsToCollectionHandler :
|
||||
.Filter(v => v == true)
|
||||
.MapT(_ => Unit.Default)
|
||||
.Map(v => v.ToValidation<BaseError>("Episode does not exist"));
|
||||
|
||||
private static async Task<Validation<BaseError, Unit>> ValidateMediaItems(
|
||||
TvContext dbContext,
|
||||
AddItemsToCollection request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<int> ids = GetRequestedMediaItemIds(request).Distinct().ToList();
|
||||
int existingCount = await dbContext.MediaItems
|
||||
.CountAsync(mi => ids.Contains(mi.Id), cancellationToken);
|
||||
|
||||
return existingCount == ids.Count
|
||||
? Unit.Default
|
||||
: BaseError.New("Media item does not exist");
|
||||
}
|
||||
|
||||
private static IEnumerable<int> GetRequestedMediaItemIds(AddItemsToCollection request) =>
|
||||
request.MovieIds
|
||||
.Append(request.ShowIds)
|
||||
.Append(request.SeasonIds)
|
||||
.Append(request.EpisodeIds)
|
||||
.Append(request.ArtistIds)
|
||||
.Append(request.MusicVideoIds)
|
||||
.Append(request.OtherVideoIds)
|
||||
.Append(request.SongIds)
|
||||
.Append(request.ImageIds)
|
||||
.Append(request.RemoteStreamIds);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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,11 @@ public class DeleteCollectionHandler : IRequestHandler<DeleteCollection, Either<
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Collection> validation = await CollectionMustExist(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(c => DoDeletion(dbContext, c, cancellationToken));
|
||||
Option<Collection> maybeCollection = await CollectionMustExist(dbContext, request, cancellationToken);
|
||||
return await maybeCollection.Match(
|
||||
Some: collection => DoDeletion(dbContext, collection, cancellationToken).Map(Right<BaseError, Unit>),
|
||||
None: () => Task.FromResult<Either<BaseError, Unit>>(
|
||||
new NotFoundError($"Collection {request.CollectionId} does not exist.")));
|
||||
}
|
||||
|
||||
private async Task<Unit> DoDeletion(TvContext dbContext, Collection collection, CancellationToken cancellationToken)
|
||||
@@ -35,11 +39,11 @@ public class DeleteCollectionHandler : IRequestHandler<DeleteCollection, Either<
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, Collection>> CollectionMustExist(
|
||||
private static Task<Option<Collection>> CollectionMustExist(
|
||||
TvContext dbContext,
|
||||
DeleteCollection request,
|
||||
CancellationToken cancellationToken) =>
|
||||
dbContext.Collections
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == request.CollectionId, cancellationToken)
|
||||
.Map(o => o.ToValidation<BaseError>($"Collection {request.CollectionId} does not exist."));
|
||||
.Map(identity);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
@@ -29,11 +30,14 @@ public class DeleteSmartCollectionHandler : IRequestHandler<DeleteSmartCollectio
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, SmartCollection> validation = await SmartCollectionMustExist(
|
||||
Option<SmartCollection> maybeSmartCollection = await SmartCollectionMustExist(
|
||||
dbContext,
|
||||
request,
|
||||
cancellationToken);
|
||||
return await validation.Apply(c => DoDeletion(dbContext, c, cancellationToken));
|
||||
return await maybeSmartCollection.Match(
|
||||
Some: smartCollection => DoDeletion(dbContext, smartCollection, cancellationToken).Map(Right<BaseError, Unit>),
|
||||
None: () => Task.FromResult<Either<BaseError, Unit>>(
|
||||
new NotFoundError($"SmartCollection {request.SmartCollectionId} does not exist.")));
|
||||
}
|
||||
|
||||
private async Task<Unit> DoDeletion(
|
||||
@@ -48,11 +52,11 @@ public class DeleteSmartCollectionHandler : IRequestHandler<DeleteSmartCollectio
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, SmartCollection>> SmartCollectionMustExist(
|
||||
private static Task<Option<SmartCollection>> SmartCollectionMustExist(
|
||||
TvContext dbContext,
|
||||
DeleteSmartCollection request,
|
||||
CancellationToken cancellationToken) =>
|
||||
dbContext.SmartCollections
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == request.SmartCollectionId, cancellationToken)
|
||||
.Map(o => o.ToValidation<BaseError>($"SmartCollection {request.SmartCollectionId} does not exist."));
|
||||
.Map(identity);
|
||||
}
|
||||
|
||||
+16
-12
@@ -3,6 +3,7 @@ using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Application.Search;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
@@ -35,20 +36,29 @@ public class RemoveItemsFromCollectionHandler : IRequestHandler<RemoveItemsFromC
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Collection> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(c => ApplyRemoveItemsRequest(dbContext, request, c, cancellationToken));
|
||||
Option<Collection> maybeCollection = await CollectionMustExist(dbContext, request, cancellationToken);
|
||||
return await maybeCollection.Match(
|
||||
Some: collection => ApplyRemoveItemsRequest(dbContext, request, collection, cancellationToken),
|
||||
None: () => Task.FromResult<Either<BaseError, Unit>>(
|
||||
new NotFoundError($"Collection {request.MediaCollectionId} does not exist.")));
|
||||
}
|
||||
|
||||
private async Task<Unit> ApplyRemoveItemsRequest(
|
||||
private async Task<Either<BaseError, Unit>> ApplyRemoveItemsRequest(
|
||||
TvContext dbContext,
|
||||
RemoveItemsFromCollection request,
|
||||
Collection collection,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<int> requestedIds = request.MediaItemIds.Distinct().ToList();
|
||||
var itemsToRemove = collection.MediaItems
|
||||
.Filter(m => request.MediaItemIds.Contains(m.Id))
|
||||
.Filter(m => requestedIds.Contains(m.Id))
|
||||
.ToList();
|
||||
|
||||
if (itemsToRemove.Count != requestedIds.Count)
|
||||
{
|
||||
return new NotFoundError("Collection item does not exist.");
|
||||
}
|
||||
|
||||
itemsToRemove.ForEach(m => collection.MediaItems.Remove(m));
|
||||
|
||||
if (itemsToRemove.Count != 0 && await dbContext.SaveChangesAsync(cancellationToken) > 0)
|
||||
@@ -67,18 +77,12 @@ public class RemoveItemsFromCollectionHandler : IRequestHandler<RemoveItemsFromC
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, Collection>> Validate(
|
||||
TvContext dbContext,
|
||||
RemoveItemsFromCollection request,
|
||||
CancellationToken cancellationToken) =>
|
||||
CollectionMustExist(dbContext, request, cancellationToken);
|
||||
|
||||
private static Task<Validation<BaseError, Collection>> CollectionMustExist(
|
||||
private static Task<Option<Collection>> CollectionMustExist(
|
||||
TvContext dbContext,
|
||||
RemoveItemsFromCollection request,
|
||||
CancellationToken cancellationToken) =>
|
||||
dbContext.Collections
|
||||
.Include(c => c.MediaItems)
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == request.MediaCollectionId, cancellationToken)
|
||||
.Map(o => o.ToValidation<BaseError>("Collection does not exist."));
|
||||
.Map(identity);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
@@ -35,8 +36,15 @@ public class UpdateCollectionHandler : IRequestHandler<UpdateCollection, Either<
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Collection> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request, cancellationToken));
|
||||
Option<Collection> maybeCollection = await CollectionMustExist(dbContext, request, cancellationToken);
|
||||
return await maybeCollection.Match(
|
||||
Some: async collection =>
|
||||
{
|
||||
Validation<BaseError, Collection> validation = await Validate(dbContext, request, collection);
|
||||
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request, cancellationToken));
|
||||
},
|
||||
None: () => Task.FromResult<Either<BaseError, Unit>>(
|
||||
new NotFoundError($"Collection {request.CollectionId} does not exist.")));
|
||||
}
|
||||
|
||||
private async Task<Unit> ApplyUpdateRequest(
|
||||
@@ -69,17 +77,16 @@ public class UpdateCollectionHandler : IRequestHandler<UpdateCollection, Either<
|
||||
private static async Task<Validation<BaseError, Collection>> Validate(
|
||||
TvContext dbContext,
|
||||
UpdateCollection request,
|
||||
CancellationToken cancellationToken) =>
|
||||
(await CollectionMustExist(dbContext, request, cancellationToken), await ValidateName(dbContext, request))
|
||||
.Apply((collectionToUpdate, _) => collectionToUpdate);
|
||||
Collection collection) =>
|
||||
(await ValidateName(dbContext, request)).Map(_ => collection);
|
||||
|
||||
private static Task<Validation<BaseError, Collection>> CollectionMustExist(
|
||||
private static Task<Option<Collection>> CollectionMustExist(
|
||||
TvContext dbContext,
|
||||
UpdateCollection updateCollection,
|
||||
CancellationToken cancellationToken) =>
|
||||
dbContext.Collections
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == updateCollection.CollectionId, cancellationToken)
|
||||
.Map(o => o.ToValidation<BaseError>("Collection does not exist."));
|
||||
.Map(identity);
|
||||
|
||||
private static async Task<Validation<BaseError, string>> ValidateName(
|
||||
TvContext dbContext,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
@@ -41,8 +42,18 @@ public class
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, SmartCollection> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request, cancellationToken));
|
||||
Option<SmartCollection> maybeSmartCollection = await SmartCollectionMustExist(
|
||||
dbContext,
|
||||
request,
|
||||
cancellationToken);
|
||||
return await maybeSmartCollection.Match(
|
||||
Some: async smartCollection =>
|
||||
{
|
||||
Validation<BaseError, SmartCollection> validation = await Validate(dbContext, request, smartCollection);
|
||||
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request, cancellationToken));
|
||||
},
|
||||
None: () => Task.FromResult<Either<BaseError, UpdateSmartCollectionResult>>(
|
||||
new NotFoundError($"SmartCollection {request.Id} does not exist.")));
|
||||
}
|
||||
|
||||
private async Task<UpdateSmartCollectionResult> ApplyUpdateRequest(
|
||||
@@ -73,16 +84,16 @@ public class
|
||||
private static Task<Validation<BaseError, SmartCollection>> Validate(
|
||||
TvContext dbContext,
|
||||
UpdateSmartCollection request,
|
||||
CancellationToken cancellationToken) => ValidateName(dbContext, request)
|
||||
.BindT(_ => SmartCollectionMustExist(dbContext, request, cancellationToken));
|
||||
SmartCollection smartCollection) => ValidateName(dbContext, request)
|
||||
.MapT(_ => smartCollection);
|
||||
|
||||
private static Task<Validation<BaseError, SmartCollection>> SmartCollectionMustExist(
|
||||
private static Task<Option<SmartCollection>> SmartCollectionMustExist(
|
||||
TvContext dbContext,
|
||||
UpdateSmartCollection updateCollection,
|
||||
CancellationToken cancellationToken) =>
|
||||
dbContext.SmartCollections
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == updateCollection.Id, cancellationToken)
|
||||
.Map(o => o.ToValidation<BaseError>("SmartCollection does not exist."));
|
||||
.Map(identity);
|
||||
|
||||
private static async Task<Validation<BaseError, string>> ValidateName(
|
||||
TvContext dbContext,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
@@ -28,8 +29,23 @@ public class CreateClassicPlayoutHandler : IRequestHandler<CreateClassicPlayout,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Playout> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(playout => PersistPlayout(dbContext, playout));
|
||||
Option<Channel> maybeChannel = await GetChannel(dbContext, request, cancellationToken);
|
||||
return await maybeChannel.Match(
|
||||
Some: async channel =>
|
||||
{
|
||||
Option<ProgramSchedule> maybeProgramSchedule =
|
||||
await GetProgramSchedule(dbContext, request, cancellationToken);
|
||||
return await maybeProgramSchedule.Match(
|
||||
Some: async programSchedule =>
|
||||
{
|
||||
Validation<BaseError, Playout> validation = Validate(request, channel, programSchedule);
|
||||
return await validation.Apply(playout => PersistPlayout(dbContext, playout));
|
||||
},
|
||||
None: () => Task.FromResult<Either<BaseError, CreatePlayoutResponse>>(
|
||||
new NotFoundError("Program schedule does not exist")));
|
||||
},
|
||||
None: () => Task.FromResult<Either<BaseError, CreatePlayoutResponse>>(
|
||||
new NotFoundError("Channel does not exist")));
|
||||
}
|
||||
|
||||
private async Task<CreatePlayoutResponse> PersistPlayout(TvContext dbContext, Playout playout)
|
||||
@@ -46,12 +62,12 @@ public class CreateClassicPlayoutHandler : IRequestHandler<CreateClassicPlayout,
|
||||
return new CreatePlayoutResponse(playout.Id);
|
||||
}
|
||||
|
||||
private static async Task<Validation<BaseError, Playout>> Validate(
|
||||
TvContext dbContext,
|
||||
private static Validation<BaseError, Playout> Validate(
|
||||
CreateClassicPlayout request,
|
||||
CancellationToken cancellationToken) =>
|
||||
(await ValidateChannel(dbContext, request, cancellationToken),
|
||||
await ValidateProgramSchedule(dbContext, request, cancellationToken),
|
||||
Channel channel,
|
||||
ProgramSchedule programSchedule) =>
|
||||
(ChannelMustNotHavePlayouts(channel),
|
||||
ProgramScheduleMustHaveItems(programSchedule),
|
||||
ValidateScheduleKind(request))
|
||||
.Apply((channel, programSchedule, scheduleKind) => new Playout
|
||||
{
|
||||
@@ -60,15 +76,13 @@ public class CreateClassicPlayoutHandler : IRequestHandler<CreateClassicPlayout,
|
||||
ScheduleKind = scheduleKind
|
||||
});
|
||||
|
||||
private static Task<Validation<BaseError, Channel>> ValidateChannel(
|
||||
private static Task<Option<Channel>> GetChannel(
|
||||
TvContext dbContext,
|
||||
CreateClassicPlayout createClassicPlayout,
|
||||
CancellationToken cancellationToken) =>
|
||||
dbContext.Channels
|
||||
.Include(c => c.Playouts)
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == createClassicPlayout.ChannelId, cancellationToken)
|
||||
.Map(o => o.ToValidation<BaseError>("Channel does not exist"))
|
||||
.BindT(ChannelMustNotHavePlayouts);
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == createClassicPlayout.ChannelId, cancellationToken);
|
||||
|
||||
private static Validation<BaseError, Channel> ChannelMustNotHavePlayouts(Channel channel) =>
|
||||
Optional(channel.Playouts.Count)
|
||||
@@ -76,15 +90,13 @@ public class CreateClassicPlayoutHandler : IRequestHandler<CreateClassicPlayout,
|
||||
.Map(_ => channel)
|
||||
.ToValidation<BaseError>("Channel already has one playout");
|
||||
|
||||
private static Task<Validation<BaseError, ProgramSchedule>> ValidateProgramSchedule(
|
||||
private static Task<Option<ProgramSchedule>> GetProgramSchedule(
|
||||
TvContext dbContext,
|
||||
CreateClassicPlayout createClassicPlayout,
|
||||
CancellationToken cancellationToken) =>
|
||||
dbContext.ProgramSchedules
|
||||
.Include(ps => ps.Items)
|
||||
.SelectOneAsync(ps => ps.Id, ps => ps.Id == createClassicPlayout.ProgramScheduleId, cancellationToken)
|
||||
.Map(o => o.ToValidation<BaseError>("Program schedule does not exist"))
|
||||
.BindT(ProgramScheduleMustHaveItems);
|
||||
.SelectOneAsync(ps => ps.Id, ps => ps.Id == createClassicPlayout.ProgramScheduleId, cancellationToken);
|
||||
|
||||
private static Validation<BaseError, ProgramSchedule> ProgramScheduleMustHaveItems(
|
||||
ProgramSchedule programSchedule) =>
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Notifications;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
@@ -45,6 +46,6 @@ public class DeletePlayoutHandler(
|
||||
|
||||
return maybePlayout
|
||||
.Map(_ => Unit.Default)
|
||||
.ToEither(BaseError.New($"Playout {request.PlayoutId} does not exist."));
|
||||
.ToEither<BaseError>(new NotFoundError($"Playout {request.PlayoutId} does not exist."));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -28,8 +29,16 @@ public class AddProgramScheduleItemHandler : ProgramScheduleItemCommandBase,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, ProgramSchedule> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(ps => PersistItem(dbContext, request, ps, cancellationToken));
|
||||
Option<ProgramSchedule> maybeProgramSchedule =
|
||||
await ProgramScheduleMustExist(dbContext, request.ProgramScheduleId, cancellationToken);
|
||||
return await maybeProgramSchedule.Match(
|
||||
Some: async programSchedule =>
|
||||
{
|
||||
Validation<BaseError, ProgramSchedule> validation = await Validate(dbContext, request, programSchedule);
|
||||
return await validation.Apply(ps => PersistItem(dbContext, request, ps, cancellationToken));
|
||||
},
|
||||
None: () => Task.FromResult<Either<BaseError, ProgramScheduleItemViewModel>>(
|
||||
new NotFoundError("[ProgramScheduleId] does not exist.")));
|
||||
}
|
||||
|
||||
private async Task<ProgramScheduleItemViewModel> PersistItem(
|
||||
@@ -54,10 +63,25 @@ public class AddProgramScheduleItemHandler : ProgramScheduleItemCommandBase,
|
||||
return ProjectToViewModel(item);
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, ProgramSchedule>> Validate(
|
||||
private static async Task<Validation<BaseError, ProgramSchedule>> Validate(
|
||||
TvContext dbContext,
|
||||
AddProgramScheduleItem request,
|
||||
CancellationToken cancellationToken) =>
|
||||
ProgramScheduleMustExist(dbContext, request.ProgramScheduleId, cancellationToken)
|
||||
.BindT(programSchedule => PlayoutModeMustBeValid(request, programSchedule));
|
||||
ProgramSchedule programSchedule)
|
||||
{
|
||||
Validation<BaseError, ProgramSchedule> validation =
|
||||
PlayoutModeMustBeValid(request, programSchedule)
|
||||
.Bind(programSchedule => CollectionTypeMustBeValid(request, programSchedule));
|
||||
|
||||
return await validation.ToEither().Match(
|
||||
Left: error => Task.FromResult<Validation<BaseError, ProgramSchedule>>(
|
||||
Fail<BaseError, ProgramSchedule>(error)),
|
||||
Right: async validProgramSchedule =>
|
||||
{
|
||||
Either<BaseError, ProgramSchedule> fillerResult = await FillerConfigurationMustBeValid(
|
||||
dbContext,
|
||||
request,
|
||||
validProgramSchedule);
|
||||
return fillerResult.ToValidation();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -18,11 +19,14 @@ public class DeleteProgramScheduleHandler : IRequestHandler<DeleteProgramSchedul
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, ProgramSchedule> validation = await ProgramScheduleMustExist(
|
||||
Option<ProgramSchedule> maybeProgramSchedule = await ProgramScheduleMustExist(
|
||||
dbContext,
|
||||
request,
|
||||
cancellationToken);
|
||||
return await validation.Apply(ps => DoDeletion(dbContext, ps));
|
||||
return await maybeProgramSchedule.Match(
|
||||
Some: programSchedule => DoDeletion(dbContext, programSchedule).Map(Right<BaseError, Unit>),
|
||||
None: () => Task.FromResult<Either<BaseError, Unit>>(
|
||||
new NotFoundError($"ProgramSchedule {request.ProgramScheduleId} does not exist.")));
|
||||
}
|
||||
|
||||
private static Task<Unit> DoDeletion(TvContext dbContext, ProgramSchedule programSchedule)
|
||||
@@ -31,11 +35,11 @@ public class DeleteProgramScheduleHandler : IRequestHandler<DeleteProgramSchedul
|
||||
return dbContext.SaveChangesAsync().ToUnit();
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, ProgramSchedule>> ProgramScheduleMustExist(
|
||||
private static Task<Option<ProgramSchedule>> ProgramScheduleMustExist(
|
||||
TvContext dbContext,
|
||||
DeleteProgramSchedule request,
|
||||
CancellationToken cancellationToken) =>
|
||||
dbContext.ProgramSchedules
|
||||
.SelectOneAsync(ps => ps.Id, ps => ps.Id == request.ProgramScheduleId, cancellationToken)
|
||||
.Map(o => o.ToValidation<BaseError>($"ProgramSchedule {request.ProgramScheduleId} does not exist."));
|
||||
.Map(identity);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.ProgramSchedules;
|
||||
|
||||
public record DeleteProgramScheduleItem(int ProgramScheduleId, int ProgramScheduleItemId)
|
||||
: IRequest<Either<BaseError, Unit>>;
|
||||
@@ -0,0 +1,46 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.ProgramSchedules;
|
||||
|
||||
public class DeleteProgramScheduleItemHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ChannelWriter<IBackgroundServiceRequest> channel) : IRequestHandler<DeleteProgramScheduleItem, Either<BaseError, Unit>>
|
||||
{
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
DeleteProgramScheduleItem request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
ProgramScheduleItem item = await dbContext.ProgramScheduleItems
|
||||
.Include(i => i.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Playouts)
|
||||
.SingleOrDefaultAsync(
|
||||
i => i.Id == request.ProgramScheduleItemId && i.ProgramScheduleId == request.ProgramScheduleId,
|
||||
cancellationToken);
|
||||
|
||||
if (item is null)
|
||||
{
|
||||
return new NotFoundError(
|
||||
$"ProgramScheduleItem {request.ProgramScheduleItemId} does not exist on schedule {request.ProgramScheduleId}.");
|
||||
}
|
||||
|
||||
List<Playout> playouts = item.ProgramSchedule.Playouts;
|
||||
dbContext.ProgramScheduleItems.Remove(item);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
foreach (Playout playout in playouts)
|
||||
{
|
||||
await channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Refresh), cancellationToken);
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -9,7 +10,7 @@ namespace ErsatzTV.Application.ProgramSchedules;
|
||||
|
||||
public abstract class ProgramScheduleItemCommandBase
|
||||
{
|
||||
protected static Task<Validation<BaseError, ProgramSchedule>> ProgramScheduleMustExist(
|
||||
protected static Task<Option<ProgramSchedule>> ProgramScheduleMustExist(
|
||||
TvContext dbContext,
|
||||
int programScheduleId,
|
||||
CancellationToken cancellationToken) =>
|
||||
@@ -17,7 +18,7 @@ public abstract class ProgramScheduleItemCommandBase
|
||||
.Include(ps => ps.Items)
|
||||
.Include(ps => ps.Playouts)
|
||||
.SelectOneAsync(ps => ps.Id, ps => ps.Id == programScheduleId, cancellationToken)
|
||||
.Map(o => o.ToValidation<BaseError>("[ProgramScheduleId] does not exist."));
|
||||
.Map(identity);
|
||||
|
||||
protected static async Task<Either<BaseError, ProgramSchedule>> FillerConfigurationMustBeValid(
|
||||
TvContext dbContext,
|
||||
@@ -352,9 +353,11 @@ public abstract class ProgramScheduleItemCommandBase
|
||||
_ => throw new NotSupportedException($"Unsupported playout mode {item.PlayoutMode}")
|
||||
};
|
||||
|
||||
result.ProgramScheduleItemWatermarks = [];
|
||||
result.ProgramScheduleItemGraphicsElements = [];
|
||||
|
||||
foreach (int watermarkId in item.WatermarkIds)
|
||||
{
|
||||
result.ProgramScheduleItemWatermarks ??= [];
|
||||
result.ProgramScheduleItemWatermarks.Add(
|
||||
new ProgramScheduleItemWatermark
|
||||
{
|
||||
@@ -365,7 +368,6 @@ public abstract class ProgramScheduleItemCommandBase
|
||||
|
||||
foreach (int graphicsElementId in item.GraphicsElementIds)
|
||||
{
|
||||
result.ProgramScheduleItemGraphicsElements ??= [];
|
||||
result.ProgramScheduleItemGraphicsElements.Add(
|
||||
new ProgramScheduleItemGraphicsElement
|
||||
{
|
||||
|
||||
+23
-9
@@ -2,6 +2,7 @@
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -19,8 +20,16 @@ public class ReplaceProgramScheduleItemsHandler(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, ProgramSchedule> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(ps => PersistItems(dbContext, request, ps, cancellationToken));
|
||||
Option<ProgramSchedule> maybeProgramSchedule =
|
||||
await ProgramScheduleMustExist(dbContext, request.ProgramScheduleId, cancellationToken);
|
||||
return await maybeProgramSchedule.Match(
|
||||
Some: async programSchedule =>
|
||||
{
|
||||
Validation<BaseError, ProgramSchedule> validation = await Validate(dbContext, request, programSchedule);
|
||||
return await validation.Apply(ps => PersistItems(dbContext, request, ps, cancellationToken));
|
||||
},
|
||||
None: () => Task.FromResult<Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>>>(
|
||||
new NotFoundError("[ProgramScheduleId] does not exist.")));
|
||||
}
|
||||
|
||||
private async Task<IEnumerable<ProgramScheduleItemViewModel>> PersistItems(
|
||||
@@ -50,15 +59,20 @@ public class ReplaceProgramScheduleItemsHandler(
|
||||
return programSchedule.Items.Map(ProjectToViewModel);
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, ProgramSchedule>> Validate(
|
||||
private static async Task<Validation<BaseError, ProgramSchedule>> Validate(
|
||||
TvContext dbContext,
|
||||
ReplaceProgramScheduleItems request,
|
||||
CancellationToken cancellationToken) =>
|
||||
ProgramScheduleMustExist(dbContext, request.ProgramScheduleId, cancellationToken)
|
||||
.BindT(programSchedule => PlayoutModesMustBeValid(request, programSchedule))
|
||||
.BindT(programSchedule => CollectionTypesMustBeValid(request, programSchedule))
|
||||
.BindT(programSchedule => PlaybackOrdersMustBeValid(request, programSchedule))
|
||||
.BindT(programSchedule => FillerConfigurationsMustBeValid(dbContext, request, programSchedule));
|
||||
ProgramSchedule programSchedule)
|
||||
{
|
||||
Validation<BaseError, ProgramSchedule> validation = PlayoutModesMustBeValid(request, programSchedule)
|
||||
.Bind(programSchedule => CollectionTypesMustBeValid(request, programSchedule))
|
||||
.Bind(programSchedule => PlaybackOrdersMustBeValid(request, programSchedule));
|
||||
|
||||
return await validation.ToEither().Match(
|
||||
Left: error => Task.FromResult<Validation<BaseError, ProgramSchedule>>(
|
||||
Fail<BaseError, ProgramSchedule>(error)),
|
||||
Right: validProgramSchedule => FillerConfigurationsMustBeValid(dbContext, request, validProgramSchedule));
|
||||
}
|
||||
|
||||
private static Validation<BaseError, ProgramSchedule> PlayoutModesMustBeValid(
|
||||
ReplaceProgramScheduleItems request,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
@@ -20,8 +21,16 @@ public class UpdateProgramScheduleHandler(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, ProgramSchedule> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(ps => ApplyUpdateRequest(dbContext, ps, request));
|
||||
Option<ProgramSchedule> maybeProgramSchedule =
|
||||
await ProgramScheduleMustExist(dbContext, request, cancellationToken);
|
||||
return await maybeProgramSchedule.Match(
|
||||
Some: async programSchedule =>
|
||||
{
|
||||
Validation<BaseError, ProgramSchedule> validation = await Validate(dbContext, request, programSchedule, cancellationToken);
|
||||
return await validation.Apply(ps => ApplyUpdateRequest(dbContext, ps, request));
|
||||
},
|
||||
None: () => Task.FromResult<Either<BaseError, UpdateProgramScheduleResult>>(
|
||||
new NotFoundError("Schedule does not exist")));
|
||||
}
|
||||
|
||||
private async Task<UpdateProgramScheduleResult> ApplyUpdateRequest(
|
||||
@@ -66,17 +75,17 @@ public class UpdateProgramScheduleHandler(
|
||||
private static async Task<Validation<BaseError, ProgramSchedule>> Validate(
|
||||
TvContext dbContext,
|
||||
UpdateProgramSchedule request,
|
||||
ProgramSchedule programSchedule,
|
||||
CancellationToken cancellationToken) =>
|
||||
(await ProgramScheduleMustExist(dbContext, request, cancellationToken), await ValidateName(dbContext, request, cancellationToken))
|
||||
.Apply((programSchedule, _) => programSchedule);
|
||||
(await ValidateName(dbContext, request, cancellationToken)).Map(_ => programSchedule);
|
||||
|
||||
private static Task<Validation<BaseError, ProgramSchedule>> ProgramScheduleMustExist(
|
||||
private static Task<Option<ProgramSchedule>> ProgramScheduleMustExist(
|
||||
TvContext dbContext,
|
||||
UpdateProgramSchedule request,
|
||||
CancellationToken cancellationToken) =>
|
||||
dbContext.ProgramSchedules
|
||||
.SelectOneAsync(ps => ps.Id, ps => ps.Id == request.ProgramScheduleId, cancellationToken)
|
||||
.Map(o => o.ToValidation<BaseError>("Schedule does not exist"));
|
||||
.Map(identity);
|
||||
|
||||
private static async Task<Validation<BaseError, string>> ValidateName(
|
||||
TvContext dbContext,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<NoWarn>VSTHRD200,CA1873</NoWarn>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="NetArchTest.eNhancedEdition" />
|
||||
<PackageReference Include="NUnit" />
|
||||
<PackageReference Include="NUnit3TestAdapter" />
|
||||
<PackageReference Include="NUnit.Analyzers">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Shouldly" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Reference every layer so its assembly lands in the test output for NetArchTest to analyze. -->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ErsatzTV.Core\ErsatzTV.Core.csproj" />
|
||||
<ProjectReference Include="..\ErsatzTV.FFmpeg\ErsatzTV.FFmpeg.csproj" />
|
||||
<ProjectReference Include="..\ErsatzTV.Application\ErsatzTV.Application.csproj" />
|
||||
<ProjectReference Include="..\ErsatzTV.Infrastructure\ErsatzTV.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\ErsatzTV.Infrastructure.Sqlite\ErsatzTV.Infrastructure.Sqlite.csproj" />
|
||||
<ProjectReference Include="..\ErsatzTV.Infrastructure.MySql\ErsatzTV.Infrastructure.MySql.csproj" />
|
||||
<ProjectReference Include="..\ErsatzTV.Scanner\ErsatzTV.Scanner.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,104 @@
|
||||
using System.Reflection;
|
||||
using NetArchTest.Rules;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Architecture.Tests;
|
||||
|
||||
// Enforces the dependency direction of the solution's layers (see docs/ci-cd.md / contributors guide).
|
||||
// NetArchTest analyses the compiled assemblies, so a "just import it here" violation fails the build
|
||||
// instead of eroding the architecture over time. Rules below reflect the intended layering and the
|
||||
// current project-reference graph:
|
||||
// FFmpeg (lowest) <- Core <- Infrastructure <- {Infrastructure.Sqlite, Infrastructure.MySql}
|
||||
// Core <- Application; everything <- ErsatzTV (host / composition root)
|
||||
[TestFixture]
|
||||
public class LayeringTests
|
||||
{
|
||||
private static Assembly Core => Assembly.Load("ErsatzTV.Core");
|
||||
private static Assembly FFmpeg => Assembly.Load("ErsatzTV.FFmpeg");
|
||||
private static Assembly Application => Assembly.Load("ErsatzTV.Application");
|
||||
private static Assembly Infrastructure => Assembly.Load("ErsatzTV.Infrastructure");
|
||||
|
||||
[Test]
|
||||
public void Core_should_not_depend_on_outer_layers()
|
||||
{
|
||||
TestResult result = Types.InAssembly(Core)
|
||||
.Should()
|
||||
.NotHaveDependencyOnAny(
|
||||
"ErsatzTV.Application",
|
||||
"ErsatzTV.Infrastructure",
|
||||
"ErsatzTV.Scanner")
|
||||
.GetResult();
|
||||
|
||||
result.IsSuccessful.ShouldBeTrue(Describe(result));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Core_should_not_depend_on_a_database_or_orm()
|
||||
{
|
||||
TestResult result = Types.InAssembly(Core)
|
||||
.Should()
|
||||
.NotHaveDependencyOnAny(
|
||||
"Microsoft.EntityFrameworkCore",
|
||||
"Pomelo.EntityFrameworkCore",
|
||||
"Microsoft.Data.Sqlite",
|
||||
"Dapper")
|
||||
.GetResult();
|
||||
|
||||
result.IsSuccessful.ShouldBeTrue(Describe(result));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void FFmpeg_should_not_depend_on_other_layers()
|
||||
{
|
||||
TestResult result = Types.InAssembly(FFmpeg)
|
||||
.Should()
|
||||
.NotHaveDependencyOnAny(
|
||||
"ErsatzTV.Core",
|
||||
"ErsatzTV.Application",
|
||||
"ErsatzTV.Infrastructure",
|
||||
"ErsatzTV.Scanner")
|
||||
.GetResult();
|
||||
|
||||
result.IsSuccessful.ShouldBeTrue(Describe(result));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Application_should_not_depend_on_concrete_database_providers()
|
||||
{
|
||||
TestResult result = Types.InAssembly(Application)
|
||||
.Should()
|
||||
.NotHaveDependencyOnAny(
|
||||
"ErsatzTV.Infrastructure.Sqlite",
|
||||
"ErsatzTV.Infrastructure.MySql")
|
||||
.GetResult();
|
||||
|
||||
result.IsSuccessful.ShouldBeTrue(Describe(result));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Infrastructure_should_not_depend_on_application_or_concrete_providers()
|
||||
{
|
||||
TestResult result = Types.InAssembly(Infrastructure)
|
||||
.Should()
|
||||
.NotHaveDependencyOnAny(
|
||||
"ErsatzTV.Application",
|
||||
"ErsatzTV.Infrastructure.Sqlite",
|
||||
"ErsatzTV.Infrastructure.MySql")
|
||||
.GetResult();
|
||||
|
||||
result.IsSuccessful.ShouldBeTrue(Describe(result));
|
||||
}
|
||||
|
||||
private static string Describe(TestResult result)
|
||||
{
|
||||
if (result.IsSuccessful)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
IEnumerable<string> names = result.FailingTypes?.Select(t => t.FullName ?? t.Name)
|
||||
?? Enumerable.Empty<string>();
|
||||
return "Layering violation — offending types:\n " + string.Join("\n ", names);
|
||||
}
|
||||
}
|
||||
@@ -7,28 +7,34 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CliWrap" Version="3.10.0" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.4.9" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||
<PackageReference Include="NSubstitute" Version="5.3.0" />
|
||||
<PackageReference Include="NUnit" Version="4.4.0" />
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="6.1.0" />
|
||||
<PackageReference Include="Serilog" Version="4.3.0" />
|
||||
<PackageReference Include="Serilog.Extensions.Logging" Version="10.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Debug" Version="3.0.0" />
|
||||
<PackageReference Include="Shouldly" Version="4.3.0" />
|
||||
<PackageReference Include="Testably.Abstractions.Testing" Version="5.1.0" />
|
||||
<PackageReference Include="CliWrap" />
|
||||
<PackageReference Include="LanguageExt.Core" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Debug" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="NSubstitute" />
|
||||
<PackageReference Include="NUnit" />
|
||||
<PackageReference Include="NUnit3TestAdapter" />
|
||||
<PackageReference Include="Serilog" />
|
||||
<PackageReference Include="Serilog.Extensions.Logging" />
|
||||
<PackageReference Include="Serilog.Sinks.Debug" />
|
||||
<PackageReference Include="Shouldly" />
|
||||
<PackageReference Include="Testably.Abstractions.Testing" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ErsatzTV.Application\ErsatzTV.Application.csproj" />
|
||||
<ProjectReference Include="..\ErsatzTV.Core\ErsatzTV.Core.csproj" />
|
||||
<ProjectReference Include="..\ErsatzTV.Infrastructure.Sqlite\ErsatzTV.Infrastructure.Sqlite.csproj" />
|
||||
<ProjectReference Include="..\ErsatzTV.Infrastructure\ErsatzTV.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="..\ErsatzTV\Resources\Fonts\Sen.ttf" Link="Resources\Fonts\Sen.ttf" CopyToOutputDirectory="PreserveNewest" />
|
||||
<None Include="..\ErsatzTV\wwwroot\images\ersatztv-500.png" Link="Resources\Images\ersatztv-500.png" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -571,7 +571,8 @@ public class CustomStreamSelectorTests
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Content_Condition_Fail()
|
||||
[SetCulture("en-US")]
|
||||
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Content_Condition_Fail_SundayFirstCulture()
|
||||
{
|
||||
const string YAML =
|
||||
"""
|
||||
@@ -608,7 +609,8 @@ public class CustomStreamSelectorTests
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Content_Condition_Match()
|
||||
[SetCulture("en-US")]
|
||||
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Content_Condition_Match_SundayFirstCulture()
|
||||
{
|
||||
const string YAML =
|
||||
"""
|
||||
@@ -651,7 +653,8 @@ public class CustomStreamSelectorTests
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Fail_Before()
|
||||
[SetCulture("en-US")]
|
||||
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Fail_Before_SundayFirstCulture()
|
||||
{
|
||||
// saturday from 9pm-11pm
|
||||
const string YAML =
|
||||
@@ -689,7 +692,8 @@ public class CustomStreamSelectorTests
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Fail_After()
|
||||
[SetCulture("en-US")]
|
||||
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Fail_After_SundayFirstCulture()
|
||||
{
|
||||
// saturday from 9pm-11pm
|
||||
const string YAML =
|
||||
@@ -727,7 +731,8 @@ public class CustomStreamSelectorTests
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Fail_Wrong_Day()
|
||||
[SetCulture("en-US")]
|
||||
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Fail_Wrong_Day_SundayFirstCulture()
|
||||
{
|
||||
// saturday from 9pm-11pm
|
||||
const string YAML =
|
||||
@@ -765,7 +770,8 @@ public class CustomStreamSelectorTests
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Match()
|
||||
[SetCulture("en-US")]
|
||||
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Match_SundayFirstCulture()
|
||||
{
|
||||
// saturday from 9pm-11pm
|
||||
const string YAML =
|
||||
@@ -810,7 +816,7 @@ public class CustomStreamSelectorTests
|
||||
|
||||
[Test]
|
||||
[SetCulture("fr-FR")]
|
||||
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Match_France()
|
||||
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Match_MondayFirstCulture()
|
||||
{
|
||||
// saturday from 9pm-11pm
|
||||
const string YAML =
|
||||
@@ -853,6 +859,45 @@ public class CustomStreamSelectorTests
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
[SetCulture("fr-FR")]
|
||||
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Fail_Wrong_Day_MondayFirstCulture()
|
||||
{
|
||||
// In a Monday-first culture, saturday is day 5 and sunday is day 6.
|
||||
const string YAML =
|
||||
"""
|
||||
---
|
||||
items:
|
||||
- audio_language: ["ja"]
|
||||
subtitle_language: ["eng"]
|
||||
content_condition: "day_of_week = 5 and (time_of_day_seconds >= 75600 and time_of_day_seconds < 82800)"
|
||||
|
||||
- audio_language: ["eng"]
|
||||
disable_subtitles: true
|
||||
""";
|
||||
|
||||
var fileSystem = new MockFileSystem();
|
||||
fileSystem.Initialize()
|
||||
.WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
|
||||
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
|
||||
|
||||
var tz = TZConvert.GetTimeZoneInfo("America/Chicago");
|
||||
var start = new DateTime(2026, 1, 11, 22, 0, 0, DateTimeKind.Unspecified); // sunday at 10:00pm
|
||||
var dto = new DateTimeOffset(start, tz.GetUtcOffset(start));
|
||||
|
||||
StreamSelectorResult result = await streamSelector.SelectStreams(_channel, dto, _audioVersion, _subtitles);
|
||||
|
||||
result.AudioStream.IsSome.ShouldBeTrue();
|
||||
|
||||
foreach (MediaStream audioStream in result.AudioStream)
|
||||
{
|
||||
audioStream.Index.ShouldBe(1);
|
||||
audioStream.Language.ShouldBe("eng");
|
||||
}
|
||||
|
||||
result.Subtitle.IsSome.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Ignore_Blocked_Audio_Title()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
using ErsatzTV.Core.Images;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using SkiaSharp;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.Images;
|
||||
|
||||
[TestFixture]
|
||||
public class ChannelLogoGeneratorTests
|
||||
{
|
||||
[TestCase("News", 100, 200)]
|
||||
[TestCase("A Very Long Channel Name That Should Force Font Shrinking", 100, 200)]
|
||||
[TestCase("Tall Logo", 180, 120)]
|
||||
public void GenerateChannelLogo_WithInjectedAssets_ReturnsValidPngWithRequestedDimensions(
|
||||
string text,
|
||||
int logoHeight,
|
||||
int logoWidth)
|
||||
{
|
||||
var generator = new ChannelLogoGenerator(
|
||||
NullLogger<ChannelLogoGenerator>.Instance,
|
||||
TestAssetPath("Resources", "Images", "ersatztv-500.png"),
|
||||
TestAssetPath("Resources", "Fonts", "Sen.ttf"));
|
||||
|
||||
byte[] bytes = generator.GenerateChannelLogo(text, logoHeight, logoWidth, CancellationToken.None)
|
||||
.Match(
|
||||
Left: error => throw new AssertionException(error.Value),
|
||||
Right: identity);
|
||||
|
||||
using var stream = new MemoryStream(bytes);
|
||||
using var codec = SKCodec.Create(stream);
|
||||
|
||||
codec.ShouldNotBeNull();
|
||||
codec.EncodedFormat.ShouldBe(SKEncodedImageFormat.Png);
|
||||
codec.Info.Width.ShouldBe(logoWidth);
|
||||
codec.Info.Height.ShouldBe(logoHeight);
|
||||
}
|
||||
|
||||
private static string TestAssetPath(params string[] segments) =>
|
||||
Path.Combine([TestContext.CurrentContext.TestDirectory, .. segments]);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.RegularExpressions;
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Iptv;
|
||||
using ErsatzTV.Infrastructure;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.IO;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using MockFileSystem = Testably.Abstractions.Testing.MockFileSystem;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.Iptv;
|
||||
|
||||
// Golden-file tests that lock the XMLTV guide output GetChannelGuideHandler produces — the surface
|
||||
// Jellyfin/clients consume for EPG + channel/programme artwork. The handler reads pre-built cache
|
||||
// fragments (channels.xml + {number}.xml), substitutes {RequestBase}/{AccessTokenUri}, strips
|
||||
// internal etv: tags, hides ShowInEpg=false channels, then assembles via ChannelGuide.ToXml().
|
||||
//
|
||||
// This is the XMLTV counterpart to ChannelPlaylistGoldenTests (#11) and, like it, is the regression
|
||||
// net for the {RequestBase} host substitution at the heart of #1. Goldens live under Goldens/ and are
|
||||
// regenerated via the Regenerate_goldens test or ETV_UPDATE_GOLDENS=1 — review the diff before committing.
|
||||
//
|
||||
// The goldens faithfully enshrine current behaviour, including the raw unescaped "&text=" ampersand the
|
||||
// real _channel.sbntxt template emits (technically not well-formed XML). That is why the comparison is
|
||||
// string equality rather than XDocument.Parse — a parse guard would throw on today's legitimate output.
|
||||
[TestFixture]
|
||||
public class ChannelGuideGoldenTests
|
||||
{
|
||||
private const string Scheme = "https";
|
||||
private const string Host = "tv.example.com";
|
||||
|
||||
// channels.xml is NOT filtered by ShowInEpg — every channel's <channel> def stays here; only the
|
||||
// per-channel programme *data* fragment of a hidden channel is dropped. Channels 2 and 10 are visible,
|
||||
// channel 3 is hidden. (2 vs 10 also proves ChannelGuide.ToXml orders fragments by DECIMAL, not string.)
|
||||
private const string ChannelsXml =
|
||||
"""<channel id="C2.etv"><display-name>2 News</display-name><display-name>2</display-name><display-name>News</display-name><category lang="en">General</category><icon src="{RequestBase}/iptv/logos/news.jpg{AccessTokenUri}" /></channel><channel id="C10.etv"><display-name>10 Movies</display-name><display-name>10</display-name><display-name>Movies</display-name><icon src="{RequestBase}/iptv/logos/gen{AccessTokenUri}&text=Movies" /></channel><channel id="C3.etv"><display-name>3 Hidden</display-name><display-name>3</display-name><display-name>Hidden</display-name><icon src="{RequestBase}/iptv/logos/hidden.jpg{AccessTokenUri}" /></channel>""";
|
||||
|
||||
// Visible (channel 2) — exercises {RequestBase}/{AccessTokenUri} substitution on the programme artwork
|
||||
// <icon> (which must SURVIVE) AND etv:-tag stripping. etv: nodes are the documented optional graphics-
|
||||
// engine feature (paired like <etv:episode_number_key>…</etv:episode_number_key>); a trailing self-
|
||||
// closing <etv:…/> exercises the regex's second alternative. Both must be gone from client output.
|
||||
private const string Channel2Xml =
|
||||
"""<programme start="20260101080000 +0000" stop="20260101090000 +0000" channel="C2.etv"><title lang="en">Morning News</title><desc lang="en">Today's headlines.</desc><category lang="en">News</category><icon src="{RequestBase}/iptv/artwork/posters/abc.jpg{AccessTokenUri}" /><etv:episode_number_key>5</etv:episode_number_key><etv:marker /><previously-shown /></programme>""";
|
||||
|
||||
// Visible (channel 10) — uses an EXTERNAL icon URL with no placeholder, proving external URLs pass
|
||||
// through verbatim. Its decimal key (10) must sort AFTER channel 2 despite string order putting "10" first.
|
||||
private const string Channel10Xml =
|
||||
"""<programme start="20260101090000 +0000" stop="20260101110000 +0000" channel="C10.etv"><title lang="en">Matinee</title><desc lang="en">A film.</desc><category lang="en">Movie</category><icon src="https://cdn.example.com/movie.jpg" /></programme>""";
|
||||
|
||||
// Hidden (channel 3, ShowInEpg=false) — its programme data must NOT appear in the output.
|
||||
private const string Channel3Xml =
|
||||
"""<programme start="20260101080000 +0000" stop="20260101090000 +0000" channel="C3.etv"><title lang="en">Hidden Show</title></programme>""";
|
||||
|
||||
private SqliteConnection _connection;
|
||||
private IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
[OneTimeSetUp]
|
||||
public async Task SetUpDatabase()
|
||||
{
|
||||
// Shared in-memory SQLite: the connection must stay open for the DB to live across contexts.
|
||||
// Foreign keys are disabled — we only read Channel.Number/ShowInEpg, so referential integrity
|
||||
// (e.g. the FFmpegProfile FK) is irrelevant and would only complicate seeding.
|
||||
_connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=False");
|
||||
await _connection.OpenAsync();
|
||||
|
||||
DbContextOptions<TvContext> options = new DbContextOptionsBuilder<TvContext>()
|
||||
.UseSqlite(_connection)
|
||||
.Options;
|
||||
|
||||
_dbContextFactory = new TestTvContextFactory(options);
|
||||
|
||||
await using TvContext context = _dbContextFactory.CreateDbContext();
|
||||
|
||||
// EnsureCreated builds the schema from the model directly — sufficient here and far cheaper than
|
||||
// replaying every migration just to read two Channel columns.
|
||||
await context.Database.EnsureCreatedAsync();
|
||||
await context.Database.ExecuteSqlRawAsync("PRAGMA foreign_keys = OFF;");
|
||||
|
||||
context.Channels.Add(NewChannel("2", "News", showInEpg: true));
|
||||
context.Channels.Add(NewChannel("10", "Movies", showInEpg: true));
|
||||
context.Channels.Add(NewChannel("3", "Hidden", showInEpg: false));
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
[OneTimeTearDown]
|
||||
public void TearDownDatabase() => _connection?.Dispose();
|
||||
|
||||
[Test]
|
||||
public Task Guide_without_access_token() =>
|
||||
Verify("guide-no-token.xml", new GetChannelGuide(Scheme, Host, BaseUrl: "", AccessToken: null));
|
||||
|
||||
[Test]
|
||||
public Task Guide_with_access_token() =>
|
||||
Verify("guide-with-token.xml", new GetChannelGuide(Scheme, Host, BaseUrl: "", AccessToken: "SECRET-TOKEN"));
|
||||
|
||||
[Test]
|
||||
public Task Guide_with_base_url() =>
|
||||
Verify("guide-base-url.xml", new GetChannelGuide(Scheme, Host, BaseUrl: "/etv", AccessToken: null));
|
||||
|
||||
// --- harness ---
|
||||
|
||||
private async Task Verify(string goldenName, GetChannelGuide request)
|
||||
{
|
||||
MockFileSystem fileSystem = BuildCacheFileSystem();
|
||||
|
||||
// ListFiles returns channels.xml too (the handler skips it via Contains("channels")) and in a
|
||||
// deliberately non-decimal order, so the test proves ChannelGuide.ToXml re-sorts by decimal key.
|
||||
var localFileSystem = Substitute.For<ILocalFileSystem>();
|
||||
localFileSystem
|
||||
.ListFiles(FileSystemLayout.ChannelGuideCacheFolder)
|
||||
.Returns(new[]
|
||||
{
|
||||
FragmentPath(fileSystem, "channels.xml"),
|
||||
FragmentPath(fileSystem, "10.xml"),
|
||||
FragmentPath(fileSystem, "3.xml"),
|
||||
FragmentPath(fileSystem, "2.xml")
|
||||
});
|
||||
|
||||
var handler = new GetChannelGuideHandler(
|
||||
_dbContextFactory,
|
||||
new RecyclableMemoryStreamManager(),
|
||||
fileSystem,
|
||||
localFileSystem);
|
||||
|
||||
Either<BaseError, ChannelGuide> result = await handler.Handle(request, CancellationToken.None);
|
||||
|
||||
string actual = Normalize(
|
||||
result.Match(
|
||||
Right: guide => guide.ToXml(),
|
||||
Left: error => throw new AssertionException($"Handler returned error: {error.Value}")));
|
||||
|
||||
string path = Path.Combine(GoldenDir(), goldenName);
|
||||
|
||||
if (Environment.GetEnvironmentVariable("ETV_UPDATE_GOLDENS") == "1")
|
||||
{
|
||||
Directory.CreateDirectory(GoldenDir());
|
||||
await File.WriteAllTextAsync(path, actual);
|
||||
Assert.Inconclusive($"Wrote golden '{goldenName}'. Review it and re-run to verify.");
|
||||
return;
|
||||
}
|
||||
|
||||
// A missing golden is a hard failure (not a silent skip) so an un-committed baseline can't pass CI.
|
||||
File.Exists(path).ShouldBeTrue(
|
||||
$"Missing golden '{goldenName}'. Run Regenerate_goldens (or ETV_UPDATE_GOLDENS=1) and commit it.");
|
||||
|
||||
string expected = Canonicalize(await File.ReadAllTextAsync(path));
|
||||
actual.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Explicit("Regenerates all XMLTV goldens from current output; review the diff before committing.")]
|
||||
public async Task Regenerate_goldens()
|
||||
{
|
||||
Environment.SetEnvironmentVariable("ETV_UPDATE_GOLDENS", "1");
|
||||
try
|
||||
{
|
||||
foreach (Func<Task> regen in new Func<Task>[]
|
||||
{
|
||||
Guide_without_access_token, Guide_with_access_token, Guide_with_base_url
|
||||
})
|
||||
{
|
||||
try
|
||||
{
|
||||
await regen();
|
||||
}
|
||||
catch (InconclusiveException)
|
||||
{
|
||||
// expected — each Verify writes its golden then reports inconclusive
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("ETV_UPDATE_GOLDENS", null);
|
||||
}
|
||||
}
|
||||
|
||||
private static MockFileSystem BuildCacheFileSystem()
|
||||
{
|
||||
var fileSystem = new MockFileSystem();
|
||||
fileSystem.Directory.CreateDirectory(FileSystemLayout.ChannelGuideCacheFolder);
|
||||
fileSystem.File.WriteAllText(FragmentPath(fileSystem, "channels.xml"), ChannelsXml);
|
||||
fileSystem.File.WriteAllText(FragmentPath(fileSystem, "2.xml"), Channel2Xml);
|
||||
fileSystem.File.WriteAllText(FragmentPath(fileSystem, "10.xml"), Channel10Xml);
|
||||
fileSystem.File.WriteAllText(FragmentPath(fileSystem, "3.xml"), Channel3Xml);
|
||||
return fileSystem;
|
||||
}
|
||||
|
||||
private static string FragmentPath(MockFileSystem fileSystem, string name) =>
|
||||
fileSystem.Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, name);
|
||||
|
||||
// The XMLTV cache-buster (?v={channels.xml last-write ticks}) is the only volatile token; pin it so
|
||||
// the golden is deterministic. Then canonicalize to a single trailing newline (Canonicalize) so the
|
||||
// golden satisfies .editorconfig insert_final_newline and a formatting pass can't break the test.
|
||||
private static string Normalize(string xml) =>
|
||||
Canonicalize(Regex.Replace(xml, @"\?v=\d+", "?v=MTIME"));
|
||||
|
||||
// Strip the UTF-8 BOM that XmlWriter emits as a preamble (the load-bearing strip — the golden files
|
||||
// themselves have no BOM), normalize line endings, and force exactly one trailing newline.
|
||||
private static string Canonicalize(string xml) =>
|
||||
xml.TrimStart('').ReplaceLineEndings("\n").TrimEnd('\n') + "\n";
|
||||
|
||||
private static Channel NewChannel(string number, string name, bool showInEpg) =>
|
||||
new(Guid.NewGuid())
|
||||
{
|
||||
Number = number,
|
||||
Name = name,
|
||||
Group = "ErsatzTV",
|
||||
Categories = string.Empty,
|
||||
StreamSelector = string.Empty,
|
||||
PreferredAudioLanguageCode = string.Empty,
|
||||
PreferredAudioTitle = string.Empty,
|
||||
PreferredSubtitleLanguageCode = string.Empty,
|
||||
MusicVideoCreditsTemplate = string.Empty,
|
||||
ShowInEpg = showInEpg
|
||||
};
|
||||
|
||||
private static string GoldenDir([CallerFilePath] string thisFile = "") =>
|
||||
Path.Combine(Path.GetDirectoryName(thisFile) ?? ".", "Goldens");
|
||||
|
||||
private sealed class TestTvContextFactory(DbContextOptions<TvContext> options) : IDbContextFactory<TvContext>
|
||||
{
|
||||
public TvContext CreateDbContext() =>
|
||||
new(options, NullLoggerFactory.Instance, new SlowQueryInterceptor(NullLogger<SlowQueryInterceptor>.Instance));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Iptv;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.Iptv;
|
||||
|
||||
// Golden-file tests that lock the M3U output shape Jellyfin/Kodi consume (ChannelPlaylist.ToM3U).
|
||||
// Baselines capture TODAY's behaviour so a regression — e.g. while implementing the paused #1
|
||||
// (tvg-logo base URL) — fails CI instead of only surfacing as Jellyfin misbehaving in prod.
|
||||
//
|
||||
// Goldens live next to this file under Goldens/ and are located via [CallerFilePath], so they
|
||||
// resolve from the checked-out source in CI and locally without embedded-resource plumbing.
|
||||
// To regenerate after an INTENTIONAL output change, run the (explicit) Regenerate_goldens test
|
||||
// or set ETV_UPDATE_GOLDENS=1, then review and commit the diff.
|
||||
[TestFixture]
|
||||
public class ChannelPlaylistGoldenTests
|
||||
{
|
||||
private const string Scheme = "https";
|
||||
private const string Host = "tv.example.com";
|
||||
private const string BaseUrl = "";
|
||||
|
||||
[SetUp]
|
||||
public void GuardVolatileEnvironment() =>
|
||||
// tvg-id (ChannelIdentifier.FromNumber) appends SystemEnvironment.InstanceId when set;
|
||||
// the goldens assume it is unset (the CI/default case). Skip rather than falsely fail.
|
||||
Assume.That(
|
||||
string.IsNullOrEmpty(SystemEnvironment.InstanceId),
|
||||
"ETV_INSTANCE_ID is set; M3U goldens assume it is unset.");
|
||||
|
||||
[Test]
|
||||
public void Simple_channel_generated_logo() =>
|
||||
Verify(
|
||||
"simple-generated-logo.m3u",
|
||||
new ChannelPlaylist(
|
||||
Scheme,
|
||||
Host,
|
||||
BaseUrl,
|
||||
[BuildChannel(1, "1", "News", StreamingMode.HttpLiveStreamingDirect)],
|
||||
userAgent: "VLC/3.0",
|
||||
accessToken: null));
|
||||
|
||||
[Test]
|
||||
public void Channel_with_internal_logo() =>
|
||||
Verify(
|
||||
"internal-logo.m3u",
|
||||
new ChannelPlaylist(
|
||||
Scheme,
|
||||
Host,
|
||||
BaseUrl,
|
||||
[WithLogo(BuildChannel(2, "2", "Movies", StreamingMode.HttpLiveStreamingSegmenter), "abc123def")],
|
||||
userAgent: "VLC/3.0",
|
||||
accessToken: null));
|
||||
|
||||
[Test]
|
||||
public void Channel_with_external_logo() =>
|
||||
Verify(
|
||||
"external-logo.m3u",
|
||||
new ChannelPlaylist(
|
||||
Scheme,
|
||||
Host,
|
||||
BaseUrl,
|
||||
[WithLogo(BuildChannel(3, "3", "Music", StreamingMode.TransportStream), "https://cdn.example.com/music.png")],
|
||||
userAgent: "VLC/3.0",
|
||||
accessToken: null));
|
||||
|
||||
[Test]
|
||||
public void Multi_channel_sorted_by_number() =>
|
||||
Verify(
|
||||
"multi-channel.m3u",
|
||||
new ChannelPlaylist(
|
||||
Scheme,
|
||||
Host,
|
||||
BaseUrl,
|
||||
[
|
||||
BuildChannel(10, "10", "Ten", StreamingMode.HttpLiveStreamingDirect),
|
||||
BuildChannel(2, "2", "Two", StreamingMode.TransportStreamHybrid),
|
||||
BuildChannel(1, "1.5", "OnePointFive", StreamingMode.HttpLiveStreamingSegmenter)
|
||||
],
|
||||
userAgent: "VLC/3.0",
|
||||
accessToken: null));
|
||||
|
||||
[Test]
|
||||
public void Kodi_user_agent_adds_kodiprops() =>
|
||||
Verify(
|
||||
"kodi-user-agent.m3u",
|
||||
new ChannelPlaylist(
|
||||
Scheme,
|
||||
Host,
|
||||
BaseUrl,
|
||||
[BuildChannel(4, "4", "Kids", StreamingMode.TransportStream)],
|
||||
userAgent: "Kodi/21.0",
|
||||
accessToken: null));
|
||||
|
||||
[Test]
|
||||
public void With_access_token_and_base_url() =>
|
||||
Verify(
|
||||
"access-token-base-url.m3u",
|
||||
new ChannelPlaylist(
|
||||
Scheme,
|
||||
Host,
|
||||
baseUrl: "/etv",
|
||||
[WithLogo(BuildChannel(5, "5", "Docs", StreamingMode.HttpLiveStreamingDirect), "logo5")],
|
||||
userAgent: "VLC/3.0",
|
||||
accessToken: "SECRET-TOKEN"));
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
private static Channel BuildChannel(int seed, string number, string name, StreamingMode mode) =>
|
||||
new(new Guid($"00000000-0000-0000-0000-{seed:D12}"))
|
||||
{
|
||||
Number = number,
|
||||
Name = name,
|
||||
Group = "ErsatzTV",
|
||||
StreamingMode = mode,
|
||||
Artwork = [],
|
||||
FFmpegProfile = new FFmpegProfile
|
||||
{
|
||||
VideoFormat = FFmpegProfileVideoFormat.H264,
|
||||
AudioFormat = FFmpegProfileAudioFormat.Aac
|
||||
}
|
||||
};
|
||||
|
||||
private static Channel WithLogo(Channel channel, string path)
|
||||
{
|
||||
channel.Artwork = [new Artwork { ArtworkKind = ArtworkKind.Logo, Path = path }];
|
||||
return channel;
|
||||
}
|
||||
|
||||
private static void Verify(string goldenName, ChannelPlaylist playlist)
|
||||
{
|
||||
string actual = playlist.ToM3U().ReplaceLineEndings("\n");
|
||||
string path = Path.Combine(GoldenDir(), goldenName);
|
||||
|
||||
if (Environment.GetEnvironmentVariable("ETV_UPDATE_GOLDENS") == "1")
|
||||
{
|
||||
Directory.CreateDirectory(GoldenDir());
|
||||
File.WriteAllText(path, actual);
|
||||
Assert.Inconclusive($"Wrote golden '{goldenName}'. Review it and re-run to verify.");
|
||||
return;
|
||||
}
|
||||
|
||||
// A missing golden is a hard failure (not a silent skip) so an un-committed baseline
|
||||
// can't pass CI. Create it with the Regenerate_goldens test or ETV_UPDATE_GOLDENS=1.
|
||||
File.Exists(path).ShouldBeTrue(
|
||||
$"Missing golden '{goldenName}'. Run Regenerate_goldens (or ETV_UPDATE_GOLDENS=1) and commit it.");
|
||||
|
||||
string expected = File.ReadAllText(path).ReplaceLineEndings("\n");
|
||||
actual.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Explicit("Regenerates all M3U goldens from current output; review the diff before committing.")]
|
||||
public void Regenerate_goldens()
|
||||
{
|
||||
Environment.SetEnvironmentVariable("ETV_UPDATE_GOLDENS", "1");
|
||||
try
|
||||
{
|
||||
Simple_channel_generated_logo();
|
||||
Channel_with_internal_logo();
|
||||
Channel_with_external_logo();
|
||||
Multi_channel_sorted_by_number();
|
||||
Kodi_user_agent_adds_kodiprops();
|
||||
With_access_token_and_base_url();
|
||||
}
|
||||
catch (InconclusiveException)
|
||||
{
|
||||
// expected — each Verify writes its golden then reports inconclusive
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("ETV_UPDATE_GOLDENS", null);
|
||||
}
|
||||
}
|
||||
|
||||
private static string GoldenDir([CallerFilePath] string thisFile = "") =>
|
||||
Path.Combine(Path.GetDirectoryName(thisFile) ?? ".", "Goldens");
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
#EXTM3U url-tvg="https://tv.example.com/etv/iptv/xmltv.xml?access_token=SECRET-TOKEN" x-tvg-url="https://tv.example.com/etv/iptv/xmltv.xml?access_token=SECRET-TOKEN"
|
||||
#EXTINF:0 tvg-id="C5.149.ersatztv.org" channel-id="AAAAAAAAAAAAAAAAAAAABQ" channel-number="5" CUID="AAAAAAAAAAAAAAAAAAAABQ" tvg-chno="5" tvg-name="Docs" tvg-logo="https://tv.example.com/etv/iptv/logos/logo5.jpg?access_token=SECRET-TOKEN" group-title="ErsatzTV" tvc-stream-vcodec="h264" tvc-stream-acodec="aac", Docs
|
||||
https://tv.example.com/etv/iptv/channel/5.m3u8?mode=hls-direct&access_token=SECRET-TOKEN
|
||||
@@ -0,0 +1,3 @@
|
||||
#EXTM3U url-tvg="https://tv.example.com/iptv/xmltv.xml" x-tvg-url="https://tv.example.com/iptv/xmltv.xml"
|
||||
#EXTINF:0 tvg-id="C3.147.ersatztv.org" channel-id="AAAAAAAAAAAAAAAAAAAAAw" channel-number="3" CUID="AAAAAAAAAAAAAAAAAAAAAw" tvg-chno="3" tvg-name="Music" tvg-logo="https://cdn.example.com/music.png" group-title="ErsatzTV" tvc-stream-vcodec="h264" tvc-stream-acodec="aac", Music
|
||||
https://tv.example.com/iptv/channel/3.ts?mode=ts-legacy
|
||||
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><tv generator-info-name="ersatztv"><channel id="C2.etv"><display-name>2 News</display-name><display-name>2</display-name><display-name>News</display-name><category lang="en">General</category><icon src="https://tv.example.com/etv/iptv/logos/news.jpg?v=MTIME" /></channel><channel id="C10.etv"><display-name>10 Movies</display-name><display-name>10</display-name><display-name>Movies</display-name><icon src="https://tv.example.com/etv/iptv/logos/gen?v=MTIME&text=Movies" /></channel><channel id="C3.etv"><display-name>3 Hidden</display-name><display-name>3</display-name><display-name>Hidden</display-name><icon src="https://tv.example.com/etv/iptv/logos/hidden.jpg?v=MTIME" /></channel><programme start="20260101080000 +0000" stop="20260101090000 +0000" channel="C2.etv"><title lang="en">Morning News</title><desc lang="en">Today's headlines.</desc><category lang="en">News</category><icon src="https://tv.example.com/etv/iptv/artwork/posters/abc.jpg?v=MTIME" /><previously-shown /></programme><programme start="20260101090000 +0000" stop="20260101110000 +0000" channel="C10.etv"><title lang="en">Matinee</title><desc lang="en">A film.</desc><category lang="en">Movie</category><icon src="https://cdn.example.com/movie.jpg" /></programme></tv>
|
||||
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><tv generator-info-name="ersatztv"><channel id="C2.etv"><display-name>2 News</display-name><display-name>2</display-name><display-name>News</display-name><category lang="en">General</category><icon src="https://tv.example.com/iptv/logos/news.jpg?v=MTIME" /></channel><channel id="C10.etv"><display-name>10 Movies</display-name><display-name>10</display-name><display-name>Movies</display-name><icon src="https://tv.example.com/iptv/logos/gen?v=MTIME&text=Movies" /></channel><channel id="C3.etv"><display-name>3 Hidden</display-name><display-name>3</display-name><display-name>Hidden</display-name><icon src="https://tv.example.com/iptv/logos/hidden.jpg?v=MTIME" /></channel><programme start="20260101080000 +0000" stop="20260101090000 +0000" channel="C2.etv"><title lang="en">Morning News</title><desc lang="en">Today's headlines.</desc><category lang="en">News</category><icon src="https://tv.example.com/iptv/artwork/posters/abc.jpg?v=MTIME" /><previously-shown /></programme><programme start="20260101090000 +0000" stop="20260101110000 +0000" channel="C10.etv"><title lang="en">Matinee</title><desc lang="en">A film.</desc><category lang="en">Movie</category><icon src="https://cdn.example.com/movie.jpg" /></programme></tv>
|
||||
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><tv generator-info-name="ersatztv"><channel id="C2.etv"><display-name>2 News</display-name><display-name>2</display-name><display-name>News</display-name><category lang="en">General</category><icon src="https://tv.example.com/iptv/logos/news.jpg?v=MTIME&access_token=SECRET-TOKEN" /></channel><channel id="C10.etv"><display-name>10 Movies</display-name><display-name>10</display-name><display-name>Movies</display-name><icon src="https://tv.example.com/iptv/logos/gen?v=MTIME&access_token=SECRET-TOKEN&text=Movies" /></channel><channel id="C3.etv"><display-name>3 Hidden</display-name><display-name>3</display-name><display-name>Hidden</display-name><icon src="https://tv.example.com/iptv/logos/hidden.jpg?v=MTIME&access_token=SECRET-TOKEN" /></channel><programme start="20260101080000 +0000" stop="20260101090000 +0000" channel="C2.etv"><title lang="en">Morning News</title><desc lang="en">Today's headlines.</desc><category lang="en">News</category><icon src="https://tv.example.com/iptv/artwork/posters/abc.jpg?v=MTIME&access_token=SECRET-TOKEN" /><previously-shown /></programme><programme start="20260101090000 +0000" stop="20260101110000 +0000" channel="C10.etv"><title lang="en">Matinee</title><desc lang="en">A film.</desc><category lang="en">Movie</category><icon src="https://cdn.example.com/movie.jpg" /></programme></tv>
|
||||
@@ -0,0 +1,3 @@
|
||||
#EXTM3U url-tvg="https://tv.example.com/iptv/xmltv.xml" x-tvg-url="https://tv.example.com/iptv/xmltv.xml"
|
||||
#EXTINF:0 tvg-id="C2.146.ersatztv.org" channel-id="AAAAAAAAAAAAAAAAAAAAAg" channel-number="2" CUID="AAAAAAAAAAAAAAAAAAAAAg" tvg-chno="2" tvg-name="Movies" tvg-logo="https://tv.example.com/iptv/logos/abc123def.jpg" group-title="ErsatzTV" tvc-stream-vcodec="h264" tvc-stream-acodec="aac", Movies
|
||||
https://tv.example.com/iptv/channel/2.m3u8?mode=segmenter
|
||||
@@ -0,0 +1,6 @@
|
||||
#EXTM3U url-tvg="https://tv.example.com/iptv/xmltv.xml" x-tvg-url="https://tv.example.com/iptv/xmltv.xml"
|
||||
#KODIPROP:inputstream=inputstream.ffmpegdirect
|
||||
#KODIPROP:mimetype=video/mp2t
|
||||
#KODIPROP:inputstream.ffmpegdirect.open_mode=ffmpeg
|
||||
#EXTINF:0 tvg-id="C4.148.ersatztv.org" channel-id="AAAAAAAAAAAAAAAAAAAABA" channel-number="4" CUID="AAAAAAAAAAAAAAAAAAAABA" tvg-chno="4" tvg-name="Kids" tvg-logo="https://tv.example.com/iptv/logos/gen?text=Kids" group-title="ErsatzTV" tvc-stream-vcodec="h264" tvc-stream-acodec="aac", Kids
|
||||
https://tv.example.com/iptv/channel/4.ts?mode=ts-legacy
|
||||
@@ -0,0 +1,7 @@
|
||||
#EXTM3U url-tvg="https://tv.example.com/iptv/xmltv.xml" x-tvg-url="https://tv.example.com/iptv/xmltv.xml"
|
||||
#EXTINF:0 tvg-id="C1.5.150.ersatztv.org" channel-id="AAAAAAAAAAAAAAAAAAAAAQ" channel-number="1.5" CUID="AAAAAAAAAAAAAAAAAAAAAQ" tvg-chno="1.5" tvg-name="OnePointFive" tvg-logo="https://tv.example.com/iptv/logos/gen?text=OnePointFive" group-title="ErsatzTV" tvc-stream-vcodec="h264" tvc-stream-acodec="aac", OnePointFive
|
||||
https://tv.example.com/iptv/channel/1.5.m3u8?mode=segmenter
|
||||
#EXTINF:0 tvg-id="C2.146.ersatztv.org" channel-id="AAAAAAAAAAAAAAAAAAAAAg" channel-number="2" CUID="AAAAAAAAAAAAAAAAAAAAAg" tvg-chno="2" tvg-name="Two" tvg-logo="https://tv.example.com/iptv/logos/gen?text=Two" group-title="ErsatzTV" tvc-stream-vcodec="h264" tvc-stream-acodec="aac", Two
|
||||
https://tv.example.com/iptv/channel/2.ts
|
||||
#EXTINF:0 tvg-id="C10.193.ersatztv.org" channel-id="AAAAAAAAAAAAAAAAAAAAEA" channel-number="10" CUID="AAAAAAAAAAAAAAAAAAAAEA" tvg-chno="10" tvg-name="Ten" tvg-logo="https://tv.example.com/iptv/logos/gen?text=Ten" group-title="ErsatzTV" tvc-stream-vcodec="h264" tvc-stream-acodec="aac", Ten
|
||||
https://tv.example.com/iptv/channel/10.m3u8?mode=hls-direct
|
||||
@@ -0,0 +1,3 @@
|
||||
#EXTM3U url-tvg="https://tv.example.com/iptv/xmltv.xml" x-tvg-url="https://tv.example.com/iptv/xmltv.xml"
|
||||
#EXTINF:0 tvg-id="C1.145.ersatztv.org" channel-id="AAAAAAAAAAAAAAAAAAAAAQ" channel-number="1" CUID="AAAAAAAAAAAAAAAAAAAAAQ" tvg-chno="1" tvg-name="News" tvg-logo="https://tv.example.com/iptv/logos/gen?text=News" group-title="ErsatzTV" tvc-stream-vcodec="h264" tvc-stream-acodec="aac", News
|
||||
https://tv.example.com/iptv/channel/1.m3u8?mode=hls-direct
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Interfaces.Scheduling;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
@@ -57,7 +57,13 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
|
||||
startState,
|
||||
CollectionEnumerators(scheduleItem, enumerator),
|
||||
scheduleItem,
|
||||
new PlayoutItem(),
|
||||
// real start time, not default MinValue: the mid-roll Pad math subtracts minutes,
|
||||
// which underflows DateTimeOffset.MinValue under a non-UTC local offset (#24)
|
||||
new PlayoutItem
|
||||
{
|
||||
Start = startState.CurrentTime.UtcDateTime,
|
||||
Finish = startState.CurrentTime.UtcDateTime
|
||||
},
|
||||
new List<MediaChapter>(),
|
||||
new PlayoutBuildWarnings(),
|
||||
_cancellationToken);
|
||||
@@ -110,7 +116,13 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
|
||||
startState,
|
||||
enumerators,
|
||||
scheduleItem,
|
||||
new PlayoutItem(),
|
||||
// real start time, not default MinValue: the mid-roll Pad math subtracts minutes,
|
||||
// which underflows DateTimeOffset.MinValue under a non-UTC local offset (#24)
|
||||
new PlayoutItem
|
||||
{
|
||||
Start = startState.CurrentTime.UtcDateTime,
|
||||
Finish = startState.CurrentTime.UtcDateTime
|
||||
},
|
||||
new List<MediaChapter> { new() },
|
||||
new PlayoutBuildWarnings(),
|
||||
_cancellationToken);
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.Core.Api.Channels;
|
||||
|
||||
#nullable enable
|
||||
|
||||
public record ChannelNowPlayingResponseModel(
|
||||
string Title,
|
||||
DateTimeOffset StartUtc,
|
||||
DateTimeOffset FinishUtc);
|
||||
@@ -5,8 +5,13 @@ namespace ErsatzTV.Core.Api.Channels;
|
||||
public record ChannelResponseModel(
|
||||
int Id,
|
||||
string Number,
|
||||
double SortNumber,
|
||||
string Name,
|
||||
string Group,
|
||||
string Categories,
|
||||
[property: JsonProperty("ffmpegProfile")]
|
||||
string FFmpegProfile,
|
||||
string Language,
|
||||
string StreamingMode);
|
||||
string StreamingMode,
|
||||
bool IsEnabled,
|
||||
bool ShowInEpg);
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ErsatzTV.Core.Api.Channels;
|
||||
|
||||
#nullable enable
|
||||
|
||||
public record ChannelStateResponseModel(
|
||||
int ChannelId,
|
||||
string ChannelNumber,
|
||||
bool OnAir,
|
||||
ChannelNowPlayingResponseModel? NowPlaying);
|
||||
@@ -0,0 +1,33 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Core.Api.Playouts;
|
||||
|
||||
public record PlayoutResponseModel(
|
||||
int Id,
|
||||
PlayoutScheduleKind ScheduleKind,
|
||||
string ChannelName,
|
||||
string ChannelNumber,
|
||||
ChannelPlayoutMode PlayoutMode,
|
||||
string ScheduleName,
|
||||
string ScheduleFile,
|
||||
TimeSpan? DailyRebuildTime)
|
||||
{
|
||||
public static PlayoutResponseModel From(
|
||||
int id,
|
||||
PlayoutScheduleKind scheduleKind,
|
||||
string channelName,
|
||||
string channelNumber,
|
||||
ChannelPlayoutMode playoutMode,
|
||||
string scheduleName,
|
||||
string scheduleFile,
|
||||
TimeSpan? dailyRebuildTime) =>
|
||||
new(
|
||||
id,
|
||||
scheduleKind,
|
||||
channelName,
|
||||
channelNumber,
|
||||
playoutMode,
|
||||
scheduleName,
|
||||
scheduleFile,
|
||||
dailyRebuildTime);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace ErsatzTV.Core.Errors;
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="BaseError" /> that indicates the addressed resource does not exist.
|
||||
/// REST endpoints map this to HTTP 404; other <see cref="BaseError" /> values map to 422.
|
||||
/// </summary>
|
||||
public class NotFoundError : BaseError
|
||||
{
|
||||
public NotFoundError(string value) : base(value)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -10,27 +10,27 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Destructurama.Attributed" Version="5.2.0" />
|
||||
<PackageReference Include="Flurl" Version="4.0.0" />
|
||||
<PackageReference Include="Humanizer.Core" Version="3.0.1" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.4.9" />
|
||||
<PackageReference Include="LanguageExt.Transformers" Version="4.4.8" />
|
||||
<PackageReference Include="MediatR" Version="[12.5.0]" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
|
||||
<PackageReference Include="NCalcSync" Version="5.11.0" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<PackageReference Include="Serilog" Version="4.3.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||
<PackageReference Include="SkiaSharp" Version="3.119.1" />
|
||||
<PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="3.119.1" />
|
||||
<PackageReference Include="System.CommandLine" Version="2.0.2" />
|
||||
<PackageReference Include="Testably.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="TimeSpanParserUtil" Version="1.2.0" />
|
||||
<PackageReference Include="YamlDotNet" Version="16.3.0" />
|
||||
<PackageReference Include="Destructurama.Attributed" />
|
||||
<PackageReference Include="Flurl" />
|
||||
<PackageReference Include="Humanizer.Core" />
|
||||
<PackageReference Include="LanguageExt.Core" />
|
||||
<PackageReference Include="LanguageExt.Transformers" />
|
||||
<PackageReference Include="MediatR" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" />
|
||||
<PackageReference Include="NCalcSync" />
|
||||
<PackageReference Include="Newtonsoft.Json" />
|
||||
<PackageReference Include="Serilog" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" />
|
||||
<PackageReference Include="SkiaSharp" />
|
||||
<PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" />
|
||||
<PackageReference Include="System.CommandLine" />
|
||||
<PackageReference Include="Testably.Abstractions" />
|
||||
<PackageReference Include="TimeSpanParserUtil" />
|
||||
<PackageReference Include="YamlDotNet" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -10,11 +10,25 @@ public class ChannelLogoGenerator : IChannelLogoGenerator
|
||||
public const string GetRoute = "/iptv/logos/gen";
|
||||
public const string GetRouteQueryParamName = "text";
|
||||
|
||||
private readonly string _fontPath;
|
||||
private readonly ILogger _logger;
|
||||
private readonly string _overlayImagePath;
|
||||
|
||||
public ChannelLogoGenerator(
|
||||
ILogger<ChannelLogoGenerator> logger) =>
|
||||
ILogger<ChannelLogoGenerator> logger)
|
||||
: this(logger, DefaultOverlayImagePath(), DefaultFontPath())
|
||||
{
|
||||
}
|
||||
|
||||
public ChannelLogoGenerator(
|
||||
ILogger<ChannelLogoGenerator> logger,
|
||||
string overlayImagePath,
|
||||
string fontPath)
|
||||
{
|
||||
_logger = logger;
|
||||
_overlayImagePath = overlayImagePath;
|
||||
_fontPath = fontPath;
|
||||
}
|
||||
|
||||
public Either<BaseError, byte[]> GenerateChannelLogo(
|
||||
string text,
|
||||
@@ -29,13 +43,11 @@ public class ChannelLogoGenerator : IChannelLogoGenerator
|
||||
canvas.Clear(SKColors.Black);
|
||||
|
||||
//etv logo
|
||||
string overlayImagePath = Path.Combine("wwwroot", "images", "ersatztv-500.png");
|
||||
using var overlayImage = SKBitmap.Decode(overlayImagePath);
|
||||
using var overlayImage = SKBitmap.Decode(_overlayImagePath);
|
||||
canvas.DrawBitmap(overlayImage, new SKRect(155, 60, 205, 110));
|
||||
|
||||
//Custom Font
|
||||
string fontPath = Path.Combine(FileSystemLayout.ResourcesCacheFolder, "Sen.ttf");
|
||||
using var fontTypeface = SKTypeface.FromFile(fontPath);
|
||||
using var fontTypeface = SKTypeface.FromFile(_fontPath);
|
||||
var fontSize = 30;
|
||||
var font = new SKFont
|
||||
{
|
||||
@@ -80,4 +92,10 @@ public class ChannelLogoGenerator : IChannelLogoGenerator
|
||||
|
||||
public static string GenerateChannelLogoUrl(Channel channel) =>
|
||||
$"http://localhost:{Settings.StreamingPort}{GetRoute}?{GetRouteQueryParamName}={channel.WebEncodedName}";
|
||||
|
||||
private static string DefaultFontPath() =>
|
||||
Path.Combine(FileSystemLayout.ResourcesCacheFolder, "Sen.ttf");
|
||||
|
||||
private static string DefaultOverlayImagePath() =>
|
||||
Path.Combine("wwwroot", "images", "ersatztv-500.png");
|
||||
}
|
||||
|
||||
@@ -13,6 +13,11 @@ public interface IJellyfinApiClient
|
||||
string authorizationHeader,
|
||||
JellyfinLibrary library);
|
||||
|
||||
IAsyncEnumerable<Tuple<MusicVideo, int>> GetMusicVideoLibraryItems(
|
||||
string address,
|
||||
string authorizationHeader,
|
||||
JellyfinLibrary library);
|
||||
|
||||
IAsyncEnumerable<Tuple<JellyfinShow, int>> GetShowLibraryItemsWithoutPeople(
|
||||
string address,
|
||||
string authorizationHeader,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
|
||||
public interface IJellyfinMusicVideoLibraryScanner
|
||||
{
|
||||
Task<Either<BaseError, Unit>> ScanLibrary(
|
||||
JellyfinConnectionParameters connectionParameters,
|
||||
JellyfinLibrary library,
|
||||
bool deepScan,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -8,14 +8,14 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyModel" Version="[8.0.2]" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||
<PackageReference Include="NSubstitute" Version="5.3.0" />
|
||||
<PackageReference Include="NUnit" Version="4.4.0" />
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="6.1.0" />
|
||||
<PackageReference Include="Shouldly" Version="4.3.0" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4">
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyModel" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="NSubstitute" />
|
||||
<PackageReference Include="NUnit" />
|
||||
<PackageReference Include="NUnit3TestAdapter" />
|
||||
<PackageReference Include="Shouldly" />
|
||||
<PackageReference Include="coverlet.collector">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
using ErsatzTV.FFmpeg.Capabilities;
|
||||
using ErsatzTV.FFmpeg.GlobalOption.HardwareAcceleration;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.FFmpeg.Tests.GlobalOption.HardwareAcceleration;
|
||||
|
||||
[TestFixture]
|
||||
public class QsvHardwareAccelerationOptionTests
|
||||
{
|
||||
[Test]
|
||||
public void GlobalOptions_WithDevice_ShouldDeriveQsvDeviceFromVaapiDevice()
|
||||
{
|
||||
var option = new QsvHardwareAccelerationOption("/dev/dri/renderD128", FFmpegCapability.Software);
|
||||
|
||||
option.GlobalOptions.ShouldBe(
|
||||
[
|
||||
"-init_hw_device", "vaapi=va:/dev/dri/renderD128",
|
||||
"-init_hw_device", "qsv=hw@va",
|
||||
"-filter_hw_device", "hw"
|
||||
]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GlobalOptions_WithHardwareDecode_ShouldEnableQsvHwaccel()
|
||||
{
|
||||
var option = new QsvHardwareAccelerationOption(None, FFmpegCapability.Hardware);
|
||||
|
||||
option.GlobalOptions.ShouldBe(
|
||||
[
|
||||
"-hwaccel", "qsv",
|
||||
"-hwaccel_output_format", "qsv",
|
||||
"-init_hw_device", "qsv=hw",
|
||||
"-filter_hw_device", "hw"
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -11,12 +11,12 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CliWrap" Version="3.10.0" />
|
||||
<PackageReference Include="Hardware.Info" Version="101.1.1.1" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.4.9" />
|
||||
<PackageReference Include="Lennox.NvEncSharp" Version="2.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.2" />
|
||||
<PackageReference Include="CliWrap" />
|
||||
<PackageReference Include="Hardware.Info" />
|
||||
<PackageReference Include="LanguageExt.Core" />
|
||||
<PackageReference Include="Lennox.NvEncSharp" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
|
||||
@@ -16,8 +16,6 @@ public class QsvHardwareAccelerationOption(Option<string> device, FFmpegCapabili
|
||||
{
|
||||
get
|
||||
{
|
||||
string[] initDevices = ["-init_hw_device", "qsv=hw", "-filter_hw_device", "hw"];
|
||||
|
||||
var result = new List<string>
|
||||
{
|
||||
"-hwaccel", "qsv",
|
||||
@@ -29,18 +27,26 @@ public class QsvHardwareAccelerationOption(Option<string> device, FFmpegCapabili
|
||||
result.Clear();
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsLinux())
|
||||
var deviceConfigured = false;
|
||||
foreach (string qsvDevice in device)
|
||||
{
|
||||
foreach (string qsvDevice in device)
|
||||
if (!string.IsNullOrWhiteSpace(qsvDevice))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(qsvDevice))
|
||||
{
|
||||
result.AddRange(new[] { "-qsv_device", qsvDevice });
|
||||
}
|
||||
result.AddRange(
|
||||
[
|
||||
"-init_hw_device", $"vaapi=va:{qsvDevice}",
|
||||
"-init_hw_device", "qsv=hw@va"
|
||||
]);
|
||||
deviceConfigured = true;
|
||||
}
|
||||
}
|
||||
|
||||
result.AddRange(initDevices);
|
||||
if (!deviceConfigured)
|
||||
{
|
||||
result.AddRange(["-init_hw_device", "qsv=hw"]);
|
||||
}
|
||||
|
||||
result.AddRange(["-filter_hw_device", "hw"]);
|
||||
|
||||
return result.ToArray();
|
||||
}
|
||||
|
||||
@@ -16,9 +16,9 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="EFCore.BulkExtensions.MySql" Version="[9.0.2,10)" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="[9.0.12,10)" />
|
||||
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
|
||||
<PackageReference Include="EFCore.BulkExtensions.MySql" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
|
||||
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -12,10 +12,13 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Dapper" Version="2.1.66" />
|
||||
<PackageReference Include="EFCore.BulkExtensions.Sqlite" Version="[9.0.2,10)" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="[9.0.12,10)" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="[9.0.12,10)" />
|
||||
<PackageReference Include="Dapper" />
|
||||
<PackageReference Include="EFCore.BulkExtensions.Sqlite" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
|
||||
<!-- Override EF Core's transitive SQLitePCLRaw 2.1.10 (vulnerable SQLite,
|
||||
GHSA-2m69-gcr7-jv3q) with the patched 3.x bundle. Version is central (#8). -->
|
||||
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
|
||||
@@ -9,20 +9,20 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||
<PackageReference Include="NSubstitute" Version="5.3.0" />
|
||||
<PackageReference Include="NUnit" Version="4.4.0" />
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="6.1.0" />
|
||||
<PackageReference Include="NUnit.Analyzers" Version="4.11.2">
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="NSubstitute" />
|
||||
<PackageReference Include="NUnit" />
|
||||
<PackageReference Include="NUnit3TestAdapter" />
|
||||
<PackageReference Include="NUnit.Analyzers">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4">
|
||||
<PackageReference Include="coverlet.collector">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Shouldly" Version="4.3.0" />
|
||||
<PackageReference Include="Testably.Abstractions.Testing" Version="5.1.0" />
|
||||
<PackageReference Include="Shouldly" />
|
||||
<PackageReference Include="Testably.Abstractions.Testing" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
using System.Net;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Infrastructure.Jellyfin;
|
||||
using LanguageExt;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Tests.Jellyfin;
|
||||
|
||||
public class JellyfinApiClientTests
|
||||
{
|
||||
[TestFixture]
|
||||
public class GetLibraries
|
||||
{
|
||||
[Test]
|
||||
public async Task Should_Project_MusicVideo_Libraries()
|
||||
{
|
||||
const string response = """
|
||||
[
|
||||
{
|
||||
"Name": "Concerts",
|
||||
"CollectionType": "musicvideos",
|
||||
"ItemId": "library-1",
|
||||
"LibraryOptions": {
|
||||
"PathInfos": []
|
||||
}
|
||||
}
|
||||
]
|
||||
""";
|
||||
|
||||
var client = new JellyfinApiClient(
|
||||
new MemoryCache(new MemoryCacheOptions()),
|
||||
Substitute.For<IJellyfinPathReplacementService>(),
|
||||
Substitute.For<IFallbackMetadataProvider>(),
|
||||
new SingleResponseHttpClientFactory(response),
|
||||
Substitute.For<ILogger<JellyfinApiClient>>());
|
||||
|
||||
Either<BaseError, List<JellyfinLibrary>> result =
|
||||
await client.GetLibraries("http://jellyfin.example", "MediaBrowser Token=abc");
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
List<JellyfinLibrary> libraries = result.RightToSeq().Single();
|
||||
libraries.Count.ShouldBe(1);
|
||||
libraries[0].Name.ShouldBe("Concerts");
|
||||
libraries[0].ItemId.ShouldBe("library-1");
|
||||
libraries[0].MediaKind.ShouldBe(LibraryMediaKind.MusicVideos);
|
||||
libraries[0].ShouldSyncItems.ShouldBeFalse();
|
||||
libraries[0].Paths.Single().Path.ShouldBe("jellyfin://library-1");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SingleResponseHttpClientFactory(string response) : IHttpClientFactory
|
||||
{
|
||||
public HttpClient CreateClient(string name) => new(new SingleResponseHttpMessageHandler(response));
|
||||
}
|
||||
|
||||
private sealed class SingleResponseHttpMessageHandler(string response) : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(response)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using ErsatzTV.Infrastructure.Streaming.Graphics;
|
||||
using NCalc;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Tests.Streaming.Graphics;
|
||||
|
||||
[TestFixture]
|
||||
public class OpacityExpressionHelperTests
|
||||
{
|
||||
// Exercises the NCalc custom-function wiring (FunctionEventArgs / FunctionData under NCalc 6.x)
|
||||
// exactly the way the graphics elements do: build an Expression, hook EvaluateFunction, evaluate.
|
||||
private static float Evaluate(string expressionString, TimeSpan timeOfDay)
|
||||
{
|
||||
var expression = new Expression(expressionString);
|
||||
expression.EvaluateFunction += OpacityExpressionHelper.EvaluateFunction;
|
||||
return OpacityExpressionHelper.GetOpacity(
|
||||
expression,
|
||||
timeOfDay,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero);
|
||||
}
|
||||
|
||||
// LinearFadePoints(time, start=0, peakStart=10, peakEnd=20, end=30)
|
||||
[TestCase(5, 0.5)] // fade in: (5-0)/(10-0)
|
||||
[TestCase(15, 1.0)] // solid
|
||||
[TestCase(25, 0.5)] // fade out: (30-25)/(30-20)
|
||||
[TestCase(35, 0.0)] // past end
|
||||
public void LinearFadePoints_ReturnsExpectedOpacity(int timeOfDaySeconds, double expected)
|
||||
{
|
||||
float opacity = Evaluate(
|
||||
"LinearFadePoints(time_of_day_seconds, 0, 10, 20, 30)",
|
||||
TimeSpan.FromSeconds(timeOfDaySeconds));
|
||||
|
||||
opacity.ShouldBe((float)expected, 0.0001);
|
||||
}
|
||||
|
||||
// LinearFadeDuration(time, start=0, fadeSeconds=10, peakSeconds=10)
|
||||
// -> peakStart=10, peakEnd=20, end=30 (same curve as the LinearFadePoints case above)
|
||||
[TestCase(5, 0.5)] // mid fade in
|
||||
[TestCase(15, 1.0)] // solid
|
||||
[TestCase(25, 0.5)] // mid fade out
|
||||
public void LinearFadeDuration_ReturnsExpectedOpacity(int timeOfDaySeconds, double expected)
|
||||
{
|
||||
float opacity = Evaluate(
|
||||
"LinearFadeDuration(time_of_day_seconds, 0, 10, 10)",
|
||||
TimeSpan.FromSeconds(timeOfDaySeconds));
|
||||
|
||||
opacity.ShouldBe((float)expected, 0.0001);
|
||||
}
|
||||
}
|
||||
@@ -12,33 +12,33 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Blurhash.SkiaSharp" Version="2.0.0" />
|
||||
<PackageReference Include="CliWrap" Version="3.10.0" />
|
||||
<PackageReference Include="Dapper" Version="2.1.66" />
|
||||
<PackageReference Include="EFCore.BulkExtensions" Version="[9.0.2,10)" />
|
||||
<PackageReference Include="Elastic.Clients.Elasticsearch" Version="9.3.0" />
|
||||
<PackageReference Include="Humanizer.Core" Version="3.0.1" />
|
||||
<PackageReference Include="Jint" Version="4.5.0" />
|
||||
<PackageReference Include="JsonSchema.Net" Version="9.0.0" />
|
||||
<PackageReference Include="Lucene.Net" Version="4.8.0-beta00017" />
|
||||
<PackageReference Include="Lucene.Net.Analysis.Common" Version="4.8.0-beta00017" />
|
||||
<PackageReference Include="Lucene.Net.QueryParser" Version="4.8.0-beta00017" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="[9.0.12,10)" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="[9.0.12,10)">
|
||||
<PackageReference Include="Blurhash.SkiaSharp" />
|
||||
<PackageReference Include="CliWrap" />
|
||||
<PackageReference Include="Dapper" />
|
||||
<PackageReference Include="EFCore.BulkExtensions" />
|
||||
<PackageReference Include="Elastic.Clients.Elasticsearch" />
|
||||
<PackageReference Include="Humanizer.Core" />
|
||||
<PackageReference Include="Jint" />
|
||||
<PackageReference Include="JsonSchema.Net" />
|
||||
<PackageReference Include="Lucene.Net" />
|
||||
<PackageReference Include="Lucene.Net.Analysis.Common" />
|
||||
<PackageReference Include="Lucene.Net.QueryParser" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="[9.0.12,10)" />
|
||||
<PackageReference Include="Newtonsoft.Json.Schema" Version="4.0.1" />
|
||||
<PackageReference Include="Refit" Version="9.0.2" />
|
||||
<PackageReference Include="Refit.Newtonsoft.Json" Version="9.0.2" />
|
||||
<PackageReference Include="Refit.Xml" Version="9.0.2" />
|
||||
<PackageReference Include="RichTextKit.Stbear" Version="0.4.167.3" />
|
||||
<PackageReference Include="Scriban.Signed" Version="6.5.2" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
|
||||
<PackageReference Include="SkiaSharp" Version="3.119.1" />
|
||||
<PackageReference Include="TagLibSharp" Version="2.3.0" />
|
||||
<PackageReference Include="TimeZoneConverter" Version="7.2.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
|
||||
<PackageReference Include="Newtonsoft.Json.Schema" />
|
||||
<PackageReference Include="Refit" />
|
||||
<PackageReference Include="Refit.Newtonsoft.Json" />
|
||||
<PackageReference Include="Refit.Xml" />
|
||||
<PackageReference Include="RichTextKit.Stbear" />
|
||||
<PackageReference Include="Scriban.Signed" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" />
|
||||
<PackageReference Include="SkiaSharp" />
|
||||
<PackageReference Include="TagLibSharp" />
|
||||
<PackageReference Include="TimeZoneConverter" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -42,6 +42,26 @@ public interface IJellyfinApi
|
||||
[Query]
|
||||
int limit = 0);
|
||||
|
||||
[Get("/Items?sortOrder=Ascending&sortBy=SortName")]
|
||||
Task<JellyfinLibraryItemsResponse> GetMusicVideoLibraryItems(
|
||||
[Header("Authorization")]
|
||||
string authorizationHeader,
|
||||
[Query]
|
||||
string parentId,
|
||||
[Query]
|
||||
string fields =
|
||||
"Path,Genres,Artists,Tags,DateCreated,Etag,Overview,Studios,People,OfficialRating,ProviderIds,Chapters",
|
||||
[Query]
|
||||
string includeItemTypes = "MusicVideo",
|
||||
[Query]
|
||||
bool recursive = true,
|
||||
[Query]
|
||||
string filters = "IsNotFolder",
|
||||
[Query]
|
||||
int startIndex = 0,
|
||||
[Query]
|
||||
int limit = 0);
|
||||
|
||||
[Get("/Items?sortOrder=Ascending&sortBy=SortName")]
|
||||
Task<JellyfinLibraryItemsResponse> GetShowLibraryItemsWithoutPeople(
|
||||
[Header("Authorization")]
|
||||
|
||||
@@ -93,6 +93,23 @@ public class JellyfinApiClient : IJellyfinApiClient
|
||||
limit: pageSize),
|
||||
(maybeLibrary, item) => maybeLibrary.Map(lib => ProjectToMovie(lib, item)).Flatten());
|
||||
|
||||
public IAsyncEnumerable<Tuple<MusicVideo, int>> GetMusicVideoLibraryItems(
|
||||
string address,
|
||||
string authorizationHeader,
|
||||
JellyfinLibrary library) =>
|
||||
GetPagedLibraryItems(
|
||||
"JF Music Videos",
|
||||
address,
|
||||
library,
|
||||
library.MediaSourceId,
|
||||
library.ItemId,
|
||||
(service, itemId, skip, pageSize) => service.GetMusicVideoLibraryItems(
|
||||
authorizationHeader,
|
||||
itemId,
|
||||
startIndex: skip,
|
||||
limit: pageSize),
|
||||
(maybeLibrary, item) => maybeLibrary.Map(lib => ProjectToMusicVideo(lib, item)).Flatten());
|
||||
|
||||
public IAsyncEnumerable<Tuple<JellyfinShow, int>> GetShowLibraryItemsWithoutPeople(
|
||||
string address,
|
||||
string authorizationHeader,
|
||||
@@ -438,6 +455,15 @@ public class JellyfinApiClient : IJellyfinApiClient
|
||||
Paths = new List<LibraryPath> { new() { Path = $"jellyfin://{response.ItemId}" } },
|
||||
PathInfos = GetPathInfos(response)
|
||||
},
|
||||
"musicvideos" => new JellyfinLibrary
|
||||
{
|
||||
ItemId = response.ItemId,
|
||||
Name = response.Name,
|
||||
MediaKind = LibraryMediaKind.MusicVideos,
|
||||
ShouldSyncItems = false,
|
||||
Paths = new List<LibraryPath> { new() { Path = $"jellyfin://{response.ItemId}" } },
|
||||
PathInfos = GetPathInfos(response)
|
||||
},
|
||||
// TODO: ??? for music libraries
|
||||
"boxsets" => CacheCollectionLibraryId(response.ItemId),
|
||||
_ => None
|
||||
@@ -484,7 +510,7 @@ public class JellyfinApiClient : IJellyfinApiClient
|
||||
}
|
||||
|
||||
string path = item.Path ?? string.Empty;
|
||||
foreach (JellyfinPathInfo pathInfo in library.PathInfos.Filter(pi =>
|
||||
foreach (JellyfinPathInfo pathInfo in Optional(library.PathInfos).Flatten().Filter(pi =>
|
||||
!string.IsNullOrWhiteSpace(pi.NetworkPath)))
|
||||
{
|
||||
if (path.StartsWith(pathInfo.NetworkPath, StringComparison.Ordinal))
|
||||
@@ -620,6 +646,139 @@ public class JellyfinApiClient : IJellyfinApiClient
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private Option<MusicVideo> ProjectToMusicVideo(JellyfinLibrary library, JellyfinLibraryItemResponse item)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (item.LocationType != "FileSystem")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
if (Path.GetExtension(item.Path)?.ToLowerInvariant() == ".strm")
|
||||
{
|
||||
_logger.LogInformation("STRM files are not supported; skipping {Path}", item.Path);
|
||||
return None;
|
||||
}
|
||||
|
||||
string path = item.Path ?? string.Empty;
|
||||
foreach (JellyfinPathInfo pathInfo in Optional(library.PathInfos).Flatten().Filter(pi =>
|
||||
!string.IsNullOrWhiteSpace(pi.NetworkPath)))
|
||||
{
|
||||
if (path.StartsWith(pathInfo.NetworkPath, StringComparison.Ordinal))
|
||||
{
|
||||
path = _jellyfinPathReplacementService.ReplaceNetworkPath(
|
||||
(JellyfinMediaSource)library.MediaSource,
|
||||
path,
|
||||
pathInfo.NetworkPath,
|
||||
pathInfo.Path);
|
||||
}
|
||||
}
|
||||
|
||||
var duration = TimeSpan.FromTicks(item.RunTimeTicks);
|
||||
var version = new MediaVersion
|
||||
{
|
||||
Name = "Main",
|
||||
Duration = duration,
|
||||
DateAdded = item.DateCreated.UtcDateTime,
|
||||
MediaFiles =
|
||||
[
|
||||
new MediaFile
|
||||
{
|
||||
Path = path,
|
||||
PathHash = PathUtils.GetPathHash(path)
|
||||
}
|
||||
],
|
||||
Streams = [],
|
||||
Chapters = ProjectToModel(Optional(item.Chapters).Flatten(), duration)
|
||||
};
|
||||
|
||||
MusicVideoMetadata metadata = ProjectToMusicVideoMetadata(item);
|
||||
|
||||
var musicVideo = new MusicVideo
|
||||
{
|
||||
MediaVersions = [version],
|
||||
MusicVideoMetadata = [metadata],
|
||||
TraktListItems = []
|
||||
};
|
||||
|
||||
return musicVideo;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error projecting Jellyfin music video");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
private static MusicVideoMetadata ProjectToMusicVideoMetadata(JellyfinLibraryItemResponse item)
|
||||
{
|
||||
DateTime dateAdded = item.DateCreated.UtcDateTime;
|
||||
var metadata = new MusicVideoMetadata
|
||||
{
|
||||
MetadataKind = MetadataKind.External,
|
||||
Title = item.Name,
|
||||
SortTitle = SortTitle.GetSortTitle(item.Name),
|
||||
Plot = item.Overview,
|
||||
Year = item.ProductionYear,
|
||||
DateAdded = dateAdded,
|
||||
Genres = Optional(item.Genres).Flatten().Map(g => new Genre { Name = g }).ToList(),
|
||||
Tags = Optional(item.Tags).Flatten().Map(t => new Tag { Name = t }).ToList(),
|
||||
Studios = Optional(item.Studios).Flatten().Map(s => new Studio { Name = s.Name }).ToList(),
|
||||
Artists = MusicVideoArtists(item)
|
||||
.ToList(),
|
||||
Directors = Optional(item.People).Flatten().Collect(r => ProjectToDirector(r)).ToList(),
|
||||
Artwork = new List<Artwork>(),
|
||||
Guids = GuidsFromProviderIds(item.ProviderIds),
|
||||
Subtitles = new List<Subtitle>()
|
||||
};
|
||||
|
||||
if (DateTime.TryParse(item.PremiereDate, out DateTime releaseDate))
|
||||
{
|
||||
metadata.ReleaseDate = releaseDate;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(item.ImageTags?.Primary))
|
||||
{
|
||||
var poster = new Artwork
|
||||
{
|
||||
ArtworkKind = ArtworkKind.Poster,
|
||||
Path = $"jellyfin://Items/{item.Id}/Images/Primary?tag={item.ImageTags.Primary}",
|
||||
DateAdded = dateAdded
|
||||
};
|
||||
metadata.Artwork.Add(poster);
|
||||
}
|
||||
|
||||
if (item.BackdropImageTags?.Count > 0)
|
||||
{
|
||||
var fanArt = new Artwork
|
||||
{
|
||||
ArtworkKind = ArtworkKind.FanArt,
|
||||
Path = $"jellyfin://Items/{item.Id}/Images/Backdrop?tag={item.BackdropImageTags.Head()}",
|
||||
DateAdded = dateAdded
|
||||
};
|
||||
metadata.Artwork.Add(fanArt);
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private static IEnumerable<MusicVideoArtist> MusicVideoArtists(JellyfinLibraryItemResponse item)
|
||||
{
|
||||
List<string> artists = Optional(item.Artists).Flatten().ToList();
|
||||
if (artists.Count == 0)
|
||||
{
|
||||
artists = Optional(item.People).Flatten()
|
||||
.Filter(p => p.Type is "Artist")
|
||||
.Map(p => p.Name)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
return artists.Filter(name => !string.IsNullOrWhiteSpace(name))
|
||||
.Distinct()
|
||||
.Map(name => new MusicVideoArtist { Name = name });
|
||||
}
|
||||
|
||||
private static Option<Actor> ProjectToActor(JellyfinPersonResponse person, DateTime dateAdded)
|
||||
{
|
||||
if (person.Type?.ToLowerInvariant() != "actor")
|
||||
|
||||
@@ -10,6 +10,7 @@ public class JellyfinLibraryItemResponse
|
||||
public DateTimeOffset DateCreated { get; set; }
|
||||
public long RunTimeTicks { get; set; }
|
||||
public List<string> Genres { get; set; }
|
||||
public List<string> Artists { get; set; }
|
||||
public List<string> Tags { get; set; }
|
||||
public int ProductionYear { get; set; }
|
||||
public JellyfinProviderIdsResponse ProviderIds { get; set; }
|
||||
|
||||
@@ -6,37 +6,37 @@ namespace ErsatzTV.Infrastructure.Streaming.Graphics;
|
||||
|
||||
public static class OpacityExpressionHelper
|
||||
{
|
||||
public static void EvaluateFunction(string name, FunctionArgs args)
|
||||
public static void EvaluateFunction(string name, FunctionEventArgs args)
|
||||
{
|
||||
switch (name)
|
||||
{
|
||||
case "LinearFadePoints":
|
||||
{
|
||||
if (args.Parameters.Length != 5)
|
||||
if (args.Parameters.Count != 5)
|
||||
{
|
||||
throw new ArgumentException("LinearFadePoints() requires 5 arguments.");
|
||||
}
|
||||
|
||||
var time = Convert.ToDouble(args.Parameters[0].Evaluate(), CultureInfo.CurrentCulture);
|
||||
var start = Convert.ToDouble(args.Parameters[1].Evaluate(), CultureInfo.CurrentCulture);
|
||||
var peakStart = Convert.ToDouble(args.Parameters[2].Evaluate(), CultureInfo.CurrentCulture);
|
||||
var peakEnd = Convert.ToDouble(args.Parameters[3].Evaluate(), CultureInfo.CurrentCulture);
|
||||
var end = Convert.ToDouble(args.Parameters[4].Evaluate(), CultureInfo.CurrentCulture);
|
||||
var time = Convert.ToDouble(args.Parameters.Evaluate(0), CultureInfo.CurrentCulture);
|
||||
var start = Convert.ToDouble(args.Parameters.Evaluate(1), CultureInfo.CurrentCulture);
|
||||
var peakStart = Convert.ToDouble(args.Parameters.Evaluate(2), CultureInfo.CurrentCulture);
|
||||
var peakEnd = Convert.ToDouble(args.Parameters.Evaluate(3), CultureInfo.CurrentCulture);
|
||||
var end = Convert.ToDouble(args.Parameters.Evaluate(4), CultureInfo.CurrentCulture);
|
||||
|
||||
args.Result = LinearFadePoints(time, start, peakStart, peakEnd, end);
|
||||
break;
|
||||
}
|
||||
case "LinearFadeDuration":
|
||||
{
|
||||
if (args.Parameters.Length != 4)
|
||||
if (args.Parameters.Count != 4)
|
||||
{
|
||||
throw new ArgumentException("LinearFadeDuration() requires 4 arguments.");
|
||||
}
|
||||
|
||||
var time = Convert.ToDouble(args.Parameters[0].Evaluate(), CultureInfo.CurrentCulture);
|
||||
var start = Convert.ToDouble(args.Parameters[1].Evaluate(), CultureInfo.CurrentCulture);
|
||||
var fadeSeconds = Convert.ToDouble(args.Parameters[2].Evaluate(), CultureInfo.CurrentCulture);
|
||||
var peakSeconds = Convert.ToDouble(args.Parameters[3].Evaluate(), CultureInfo.CurrentCulture);
|
||||
var time = Convert.ToDouble(args.Parameters.Evaluate(0), CultureInfo.CurrentCulture);
|
||||
var start = Convert.ToDouble(args.Parameters.Evaluate(1), CultureInfo.CurrentCulture);
|
||||
var fadeSeconds = Convert.ToDouble(args.Parameters.Evaluate(2), CultureInfo.CurrentCulture);
|
||||
var peakSeconds = Convert.ToDouble(args.Parameters.Evaluate(3), CultureInfo.CurrentCulture);
|
||||
|
||||
args.Result = LinearFadeDuration(time, start, fadeSeconds, peakSeconds);
|
||||
break;
|
||||
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
using ErsatzTV.Scanner.Application.Jellyfin;
|
||||
using ErsatzTV.Scanner.Core.Interfaces;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Scanner.Tests.Application.Jellyfin;
|
||||
|
||||
public class SynchronizeJellyfinLibraryByIdHandlerTests
|
||||
{
|
||||
[TestFixture]
|
||||
public class Handle
|
||||
{
|
||||
[Test]
|
||||
public async Task Should_Scan_MusicVideo_Libraries()
|
||||
{
|
||||
var scannerProxy = Substitute.For<IScannerProxy>();
|
||||
var mediaSourceRepository = Substitute.For<IMediaSourceRepository>();
|
||||
var jellyfinSecretStore = Substitute.For<IJellyfinSecretStore>();
|
||||
var jellyfinMovieLibraryScanner = Substitute.For<IJellyfinMovieLibraryScanner>();
|
||||
var jellyfinTelevisionLibraryScanner = Substitute.For<IJellyfinTelevisionLibraryScanner>();
|
||||
var jellyfinMusicVideoLibraryScanner = Substitute.For<IJellyfinMusicVideoLibraryScanner>();
|
||||
var libraryRepository = Substitute.For<ILibraryRepository>();
|
||||
var configElementRepository = Substitute.For<IConfigElementRepository>();
|
||||
|
||||
var library = new JellyfinLibrary
|
||||
{
|
||||
Id = 42,
|
||||
Name = "Concerts",
|
||||
MediaKind = LibraryMediaKind.MusicVideos,
|
||||
MediaSourceId = 7
|
||||
};
|
||||
var mediaSource = new JellyfinMediaSource
|
||||
{
|
||||
Id = 7,
|
||||
Connections =
|
||||
[
|
||||
new JellyfinConnection
|
||||
{
|
||||
Address = "http://jellyfin.example",
|
||||
JellyfinMediaSourceId = 7
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
mediaSourceRepository.GetJellyfinByLibraryId(library.Id).Returns(Some(mediaSource).AsTask());
|
||||
mediaSourceRepository.GetJellyfinLibrary(library.Id).Returns(Some(library).AsTask());
|
||||
jellyfinSecretStore.ReadSecrets().Returns(new JellyfinSecrets
|
||||
{
|
||||
Address = "http://jellyfin.example",
|
||||
ApiKey = "abc"
|
||||
});
|
||||
configElementRepository.GetValue<int>(
|
||||
Arg.Is<ConfigElementKey>(key => key.Key == ConfigElementKey.LibraryRefreshInterval.Key),
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<Option<int>>(Some(0)));
|
||||
jellyfinMusicVideoLibraryScanner.ScanLibrary(
|
||||
Arg.Any<JellyfinConnectionParameters>(),
|
||||
library,
|
||||
true,
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default).AsTask());
|
||||
|
||||
var handler = new SynchronizeJellyfinLibraryByIdHandler(
|
||||
scannerProxy,
|
||||
mediaSourceRepository,
|
||||
jellyfinSecretStore,
|
||||
jellyfinMovieLibraryScanner,
|
||||
jellyfinTelevisionLibraryScanner,
|
||||
jellyfinMusicVideoLibraryScanner,
|
||||
libraryRepository,
|
||||
configElementRepository,
|
||||
Substitute.For<ILogger<SynchronizeJellyfinLibraryByIdHandler>>());
|
||||
|
||||
Either<BaseError, string> result = await handler.Handle(
|
||||
new SynchronizeJellyfinLibraryById("http://ersatztv.example", library.Id, true, true),
|
||||
CancellationToken.None);
|
||||
|
||||
result.LeftToSeq().ShouldBeEmpty();
|
||||
result.IsRight.ShouldBeTrue();
|
||||
result.RightToSeq().Single().ShouldBe("Concerts");
|
||||
await jellyfinMusicVideoLibraryScanner.Received(1).ScanLibrary(
|
||||
Arg.Is<JellyfinConnectionParameters>(cp =>
|
||||
cp.Address == "http://jellyfin.example" &&
|
||||
cp.ApiKey == "abc" &&
|
||||
cp.MediaSourceId == 7),
|
||||
library,
|
||||
true,
|
||||
Arg.Any<CancellationToken>());
|
||||
await libraryRepository.Received(1).UpdateLastScan(library);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,21 +9,21 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.4.9" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||
<PackageReference Include="NSubstitute" Version="5.3.0" />
|
||||
<PackageReference Include="NUnit" Version="4.4.0" />
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="6.1.0" />
|
||||
<PackageReference Include="NUnit.Analyzers" Version="4.11.2">
|
||||
<PackageReference Include="LanguageExt.Core" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="NSubstitute" />
|
||||
<PackageReference Include="NUnit" />
|
||||
<PackageReference Include="NUnit3TestAdapter" />
|
||||
<PackageReference Include="NUnit.Analyzers">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4">
|
||||
<PackageReference Include="coverlet.collector">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Shouldly" Version="4.3.0" />
|
||||
<PackageReference Include="Testably.Abstractions.Testing" Version="5.1.0" />
|
||||
<PackageReference Include="Shouldly" />
|
||||
<PackageReference Include="Testably.Abstractions.Testing" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
+9
@@ -14,6 +14,7 @@ public class
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
|
||||
private readonly IJellyfinMovieLibraryScanner _jellyfinMovieLibraryScanner;
|
||||
private readonly IJellyfinMusicVideoLibraryScanner _jellyfinMusicVideoLibraryScanner;
|
||||
|
||||
private readonly IJellyfinSecretStore _jellyfinSecretStore;
|
||||
private readonly IJellyfinTelevisionLibraryScanner _jellyfinTelevisionLibraryScanner;
|
||||
@@ -28,6 +29,7 @@ public class
|
||||
IJellyfinSecretStore jellyfinSecretStore,
|
||||
IJellyfinMovieLibraryScanner jellyfinMovieLibraryScanner,
|
||||
IJellyfinTelevisionLibraryScanner jellyfinTelevisionLibraryScanner,
|
||||
IJellyfinMusicVideoLibraryScanner jellyfinMusicVideoLibraryScanner,
|
||||
ILibraryRepository libraryRepository,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ILogger<SynchronizeJellyfinLibraryByIdHandler> logger)
|
||||
@@ -37,6 +39,7 @@ public class
|
||||
_jellyfinSecretStore = jellyfinSecretStore;
|
||||
_jellyfinMovieLibraryScanner = jellyfinMovieLibraryScanner;
|
||||
_jellyfinTelevisionLibraryScanner = jellyfinTelevisionLibraryScanner;
|
||||
_jellyfinMusicVideoLibraryScanner = jellyfinMusicVideoLibraryScanner;
|
||||
_libraryRepository = libraryRepository;
|
||||
_configElementRepository = configElementRepository;
|
||||
_logger = logger;
|
||||
@@ -75,6 +78,12 @@ public class
|
||||
parameters.Library,
|
||||
parameters.DeepScan,
|
||||
cancellationToken),
|
||||
LibraryMediaKind.MusicVideos =>
|
||||
await _jellyfinMusicVideoLibraryScanner.ScanLibrary(
|
||||
parameters.ConnectionParameters,
|
||||
parameters.Library,
|
||||
parameters.DeepScan,
|
||||
cancellationToken),
|
||||
_ => Unit.Default
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using ErsatzTV.Scanner.Core.Interfaces;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Scanner.Core.Jellyfin;
|
||||
|
||||
public class JellyfinMusicVideoLibraryScanner : IJellyfinMusicVideoLibraryScanner
|
||||
{
|
||||
private const string UnknownArtist = "Unknown Artist";
|
||||
|
||||
private readonly IArtistRepository _artistRepository;
|
||||
private readonly IJellyfinApiClient _jellyfinApiClient;
|
||||
private readonly IJellyfinPathReplacementService _pathReplacementService;
|
||||
private readonly ILibraryRepository _libraryRepository;
|
||||
private readonly ILogger<JellyfinMusicVideoLibraryScanner> _logger;
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly IMetadataRepository _metadataRepository;
|
||||
private readonly IMusicVideoRepository _musicVideoRepository;
|
||||
private readonly IScannerProxy _scannerProxy;
|
||||
|
||||
public JellyfinMusicVideoLibraryScanner(
|
||||
IScannerProxy scannerProxy,
|
||||
IJellyfinApiClient jellyfinApiClient,
|
||||
IJellyfinPathReplacementService pathReplacementService,
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
IArtistRepository artistRepository,
|
||||
IMusicVideoRepository musicVideoRepository,
|
||||
ILibraryRepository libraryRepository,
|
||||
IMetadataRepository metadataRepository,
|
||||
ILogger<JellyfinMusicVideoLibraryScanner> logger)
|
||||
{
|
||||
_scannerProxy = scannerProxy;
|
||||
_jellyfinApiClient = jellyfinApiClient;
|
||||
_pathReplacementService = pathReplacementService;
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
_artistRepository = artistRepository;
|
||||
_musicVideoRepository = musicVideoRepository;
|
||||
_libraryRepository = libraryRepository;
|
||||
_metadataRepository = metadataRepository;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> ScanLibrary(
|
||||
JellyfinConnectionParameters connectionParameters,
|
||||
JellyfinLibrary library,
|
||||
bool deepScan,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
Option<LibraryPath> maybeLibraryPath = Optional(library.Paths).Flatten().HeadOrNone();
|
||||
return await maybeLibraryPath.Match(
|
||||
libraryPath => ScanLibrary(connectionParameters, library, libraryPath, cancellationToken),
|
||||
() => Task.FromResult<Either<BaseError, Unit>>(
|
||||
BaseError.New($"Jellyfin library {library.Id} has no library path")));
|
||||
}
|
||||
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
|
||||
{
|
||||
return new ScanCanceled();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, Unit>> ScanLibrary(
|
||||
JellyfinConnectionParameters connectionParameters,
|
||||
JellyfinLibrary library,
|
||||
LibraryPath libraryPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<JellyfinPathReplacement> pathReplacements =
|
||||
await _mediaSourceRepository.GetJellyfinPathReplacements(library.MediaSourceId);
|
||||
|
||||
var processed = 0;
|
||||
await foreach ((MusicVideo incoming, int totalCount) in _jellyfinApiClient
|
||||
.GetMusicVideoLibraryItems(
|
||||
connectionParameters.Address,
|
||||
connectionParameters.AuthorizationHeader,
|
||||
library)
|
||||
.WithCancellation(cancellationToken))
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return new ScanCanceled();
|
||||
}
|
||||
|
||||
processed++;
|
||||
decimal percentCompletion = totalCount == 0 ? 1 : Math.Clamp((decimal)processed / totalCount, 0, 1);
|
||||
if (!await _scannerProxy.UpdateProgress(percentCompletion, cancellationToken))
|
||||
{
|
||||
return new ScanCanceled();
|
||||
}
|
||||
|
||||
Either<BaseError, MediaItemScanResult<MusicVideo>> maybeMusicVideo =
|
||||
await ProcessMusicVideo(library, libraryPath, pathReplacements, incoming, cancellationToken);
|
||||
|
||||
foreach (BaseError error in maybeMusicVideo.LeftToSeq())
|
||||
{
|
||||
_logger.LogWarning("Error processing Jellyfin music video: {Error}", error.Value);
|
||||
}
|
||||
|
||||
foreach (MediaItemScanResult<MusicVideo> result in maybeMusicVideo.RightToSeq()
|
||||
.Filter(result => result.IsAdded || result.IsUpdated))
|
||||
{
|
||||
if (!await _scannerProxy.ReindexMediaItems([result.Item.Id], cancellationToken))
|
||||
{
|
||||
_logger.LogWarning("Failed to reindex media items from scanner process");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, MediaItemScanResult<MusicVideo>>> ProcessMusicVideo(
|
||||
JellyfinLibrary library,
|
||||
LibraryPath libraryPath,
|
||||
List<JellyfinPathReplacement> pathReplacements,
|
||||
MusicVideo incoming,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string localPath = GetLocalPath(pathReplacements, incoming);
|
||||
string folder = Path.GetDirectoryName(localPath) ?? libraryPath.Path;
|
||||
Option<int> maybeParentFolder = await _libraryRepository.GetParentFolderId(libraryPath, folder, cancellationToken);
|
||||
LibraryFolder libraryFolder = await _libraryRepository.GetOrAddFolder(libraryPath, maybeParentFolder, folder);
|
||||
|
||||
return await GetOrAddArtist(libraryPath, incoming)
|
||||
.BindT(artist => _musicVideoRepository.GetOrAdd(artist, libraryPath, libraryFolder, localPath))
|
||||
.BindT(result => UpdateMusicVideo(result, incoming, localPath, cancellationToken));
|
||||
}
|
||||
|
||||
private string GetLocalPath(List<JellyfinPathReplacement> pathReplacements, MusicVideo musicVideo) =>
|
||||
_pathReplacementService.GetReplacementJellyfinPath(
|
||||
pathReplacements,
|
||||
musicVideo.GetHeadVersion().MediaFiles.Head().Path,
|
||||
false);
|
||||
|
||||
private async Task<Either<BaseError, Artist>> GetOrAddArtist(LibraryPath libraryPath, MusicVideo musicVideo)
|
||||
{
|
||||
string artistName = musicVideo.MusicVideoMetadata
|
||||
.HeadOrNone()
|
||||
.Bind(metadata => Optional(metadata.Artists).Flatten().HeadOrNone())
|
||||
.Map(artist => artist.Name)
|
||||
.IfNone(UnknownArtist);
|
||||
|
||||
var metadata = new ArtistMetadata
|
||||
{
|
||||
MetadataKind = MetadataKind.External,
|
||||
Title = artistName,
|
||||
SortTitle = SortTitle.GetSortTitle(artistName),
|
||||
DateAdded = DateTime.UtcNow,
|
||||
Genres = [],
|
||||
Styles = [],
|
||||
Moods = [],
|
||||
Artwork = [],
|
||||
Guids = [],
|
||||
Subtitles = []
|
||||
};
|
||||
|
||||
Option<Artist> maybeArtist = await _artistRepository.GetArtistByMetadata(libraryPath.Id, metadata);
|
||||
foreach (Artist artist in maybeArtist)
|
||||
{
|
||||
return artist;
|
||||
}
|
||||
|
||||
Either<BaseError, MediaItemScanResult<Artist>> result =
|
||||
await _artistRepository.AddArtist(libraryPath.Id, artistName, metadata);
|
||||
return result.Map(scanResult => scanResult.Item);
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, MediaItemScanResult<MusicVideo>>> UpdateMusicVideo(
|
||||
MediaItemScanResult<MusicVideo> result,
|
||||
MusicVideo incoming,
|
||||
string localPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
result.LocalPath = localPath;
|
||||
MusicVideoMetadata incomingMetadata = incoming.MusicVideoMetadata.Head();
|
||||
|
||||
bool updated = await UpdateMetadata(result.Item, incomingMetadata);
|
||||
updated = await _metadataRepository.UpdateStatistics(result.Item, incoming.GetHeadVersion()) || updated;
|
||||
|
||||
if (updated)
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<bool> UpdateMetadata(MusicVideo musicVideo, MusicVideoMetadata incoming)
|
||||
{
|
||||
Option<MusicVideoMetadata> maybeExisting = Optional(musicVideo.MusicVideoMetadata).Flatten().HeadOrNone();
|
||||
foreach (MusicVideoMetadata existing in maybeExisting)
|
||||
{
|
||||
existing.Title = incoming.Title;
|
||||
existing.SortTitle = incoming.SortTitle;
|
||||
existing.Plot = incoming.Plot;
|
||||
existing.Year = incoming.Year;
|
||||
existing.ReleaseDate = incoming.ReleaseDate;
|
||||
existing.DateUpdated = DateTime.UtcNow;
|
||||
existing.MetadataKind = MetadataKind.External;
|
||||
|
||||
return await _metadataRepository.Update(existing);
|
||||
}
|
||||
|
||||
incoming.MusicVideoId = musicVideo.Id;
|
||||
musicVideo.MusicVideoMetadata = [incoming];
|
||||
return await _metadataRepository.Add(incoming);
|
||||
}
|
||||
}
|
||||
@@ -21,21 +21,21 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CliWrap" Version="3.10.0" />
|
||||
<!-- <PackageReference Include="EntityFrameworkProfiler.Appender" Version="6.0.6049" />-->
|
||||
<PackageReference Include="Humanizer.Core" Version="3.0.1" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.4.9" />
|
||||
<PackageReference Include="MediatR" Version="[12.5.0]" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.2" />
|
||||
<PackageReference Include="Serilog" Version="4.3.0" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||
<PackageReference Include="Serilog.Extensions.Hosting" Version="10.0.0" />
|
||||
<PackageReference Include="Serilog.Formatting.Compact" Version="3.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||
<PackageReference Include="System.CommandLine" Version="2.0.2" />
|
||||
<PackageReference Include="CliWrap" />
|
||||
<!-- <PackageReference Include="EntityFrameworkProfiler.Appender" />-->
|
||||
<PackageReference Include="Humanizer.Core" />
|
||||
<PackageReference Include="LanguageExt.Core" />
|
||||
<PackageReference Include="MediatR" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Serilog" />
|
||||
<PackageReference Include="Serilog.AspNetCore" />
|
||||
<PackageReference Include="Serilog.Extensions.Hosting" />
|
||||
<PackageReference Include="Serilog.Formatting.Compact" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" />
|
||||
<PackageReference Include="Serilog.Sinks.File" />
|
||||
<PackageReference Include="System.CommandLine" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -236,6 +236,7 @@ public class Program
|
||||
|
||||
services.AddScoped<IJellyfinMovieLibraryScanner, JellyfinMovieLibraryScanner>();
|
||||
services.AddScoped<IJellyfinTelevisionLibraryScanner, JellyfinTelevisionLibraryScanner>();
|
||||
services.AddScoped<IJellyfinMusicVideoLibraryScanner, JellyfinMusicVideoLibraryScanner>();
|
||||
services.AddScoped<IJellyfinCollectionScanner, JellyfinCollectionScanner>();
|
||||
services.AddScoped<IJellyfinApiClient, JellyfinApiClient>();
|
||||
services.AddScoped<IJellyfinCollectionRepository, JellyfinCollectionRepository>();
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using LanguageExt;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using Testably.Abstractions.Testing;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Channels;
|
||||
|
||||
[TestFixture]
|
||||
public class BulkDeleteChannelsHandlerTests : ChannelHandlerTestBase
|
||||
{
|
||||
private readonly MockFileSystem _fileSystem = new();
|
||||
|
||||
private BulkDeleteChannelsHandler MakeHandler() => new(Db.Factory, Worker, _fileSystem, SearchTargets);
|
||||
|
||||
[Test]
|
||||
public async Task Should_Delete_All_Channels()
|
||||
{
|
||||
await SeedChannel(1, "5");
|
||||
await SeedChannel(2, "6");
|
||||
|
||||
Either<BaseError, Unit> result =
|
||||
await MakeHandler().Handle(new BulkDeleteChannels([1, 2]), CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
|
||||
await using TvContext context = Db.CreateContext();
|
||||
int count = await context.Channels.CountAsync();
|
||||
count.ShouldBe(0);
|
||||
SearchTargets.Received(1).SearchTargetsChanged();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Return_NotFound_And_Not_Delete_When_Any_Channel_Is_Missing()
|
||||
{
|
||||
await SeedChannel(1, "5");
|
||||
|
||||
Either<BaseError, Unit> result =
|
||||
await MakeHandler().Handle(new BulkDeleteChannels([1, 99]), CancellationToken.None);
|
||||
|
||||
BaseError error = LeftOf(result);
|
||||
error.ShouldBeOfType<NotFoundError>();
|
||||
|
||||
await using TvContext context = Db.CreateContext();
|
||||
bool exists = await context.Channels.AnyAsync(c => c.Id == 1);
|
||||
exists.ShouldBeTrue();
|
||||
}
|
||||
|
||||
private static BaseError LeftOf<TR>(Either<BaseError, TR> either) =>
|
||||
either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result"));
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using LanguageExt;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Channels;
|
||||
|
||||
[TestFixture]
|
||||
public class BulkMoveChannelsToGroupHandlerTests : ChannelHandlerTestBase
|
||||
{
|
||||
private BulkMoveChannelsToGroupHandler MakeHandler() => new(Db.Factory, Worker, SearchTargets);
|
||||
|
||||
[Test]
|
||||
public async Task Should_Move_All_Channels_To_Group()
|
||||
{
|
||||
await SeedChannel(1, "5");
|
||||
await SeedChannel(2, "6");
|
||||
|
||||
Either<BaseError, Unit> result =
|
||||
await MakeHandler().Handle(new BulkMoveChannelsToGroup([1, 2], "Movies"), CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
|
||||
await using TvContext context = Db.CreateContext();
|
||||
List<string> groups = await context.Channels
|
||||
.OrderBy(c => c.Id)
|
||||
.Select(c => c.Group)
|
||||
.ToListAsync();
|
||||
groups.ShouldBe(["Movies", "Movies"]);
|
||||
SearchTargets.Received(1).SearchTargetsChanged();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Return_NotFound_And_Not_Move_When_Any_Channel_Is_Missing()
|
||||
{
|
||||
await SeedChannel(1, "5", group: "Original");
|
||||
|
||||
Either<BaseError, Unit> result =
|
||||
await MakeHandler().Handle(new BulkMoveChannelsToGroup([1, 99], "Movies"), CancellationToken.None);
|
||||
|
||||
BaseError error = LeftOf(result);
|
||||
error.ShouldBeOfType<NotFoundError>();
|
||||
|
||||
await using TvContext context = Db.CreateContext();
|
||||
string group = await context.Channels.Where(c => c.Id == 1).Select(c => c.Group).SingleAsync();
|
||||
group.ShouldBe("Original");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Reject_Empty_Group()
|
||||
{
|
||||
await SeedChannel(1, "5", group: "Original");
|
||||
|
||||
Either<BaseError, Unit> result =
|
||||
await MakeHandler().Handle(new BulkMoveChannelsToGroup([1], ""), CancellationToken.None);
|
||||
|
||||
BaseError error = LeftOf(result);
|
||||
error.ShouldNotBeOfType<NotFoundError>();
|
||||
error.Value.ShouldContain("group");
|
||||
|
||||
await using TvContext context = Db.CreateContext();
|
||||
string group = await context.Channels.Where(c => c.Id == 1).Select(c => c.Group).SingleAsync();
|
||||
group.ShouldBe("Original");
|
||||
}
|
||||
|
||||
private static BaseError LeftOf<TR>(Either<BaseError, TR> either) =>
|
||||
either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result"));
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Channels;
|
||||
|
||||
[TestFixture]
|
||||
public class CreateChannelHandlerTests : ChannelHandlerTestBase
|
||||
{
|
||||
private CreateChannelHandler MakeHandler() => new(Worker, Db.Factory, SearchTargets);
|
||||
|
||||
[Test]
|
||||
public async Task Should_Create_Channel_When_Valid()
|
||||
{
|
||||
await SeedFFmpegProfile();
|
||||
|
||||
Either<BaseError, CreateChannelResult> result =
|
||||
await MakeHandler().Handle(MakeCreate(number: "7", name: "News"), CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
|
||||
await using TvContext context = Db.CreateContext();
|
||||
bool exists = await context.Channels.AnyAsync(c => c.Number == "7" && c.Name == "News");
|
||||
exists.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Reject_Duplicate_Number_With_422_Error()
|
||||
{
|
||||
await SeedFFmpegProfile();
|
||||
await SeedChannel(1, "5");
|
||||
|
||||
Either<BaseError, CreateChannelResult> result =
|
||||
await MakeHandler().Handle(MakeCreate(number: "5"), CancellationToken.None);
|
||||
|
||||
BaseError error = LeftOf(result);
|
||||
error.ShouldNotBeOfType<NotFoundError>();
|
||||
error.Value.ShouldContain("unique");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Reject_ShowInEpg_When_Disabled()
|
||||
{
|
||||
await SeedFFmpegProfile();
|
||||
|
||||
Either<BaseError, CreateChannelResult> result =
|
||||
await MakeHandler().Handle(
|
||||
MakeCreate(number: "8", isEnabled: false, showInEpg: true),
|
||||
CancellationToken.None);
|
||||
|
||||
BaseError error = LeftOf(result);
|
||||
error.ShouldNotBeOfType<NotFoundError>();
|
||||
error.Value.ShouldContain("EPG");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Reject_Invalid_External_Logo_Url()
|
||||
{
|
||||
await SeedFFmpegProfile();
|
||||
|
||||
Either<BaseError, CreateChannelResult> result =
|
||||
await MakeHandler().Handle(
|
||||
MakeCreate(number: "9", logoPath: "ftp://example.com/logo.png"),
|
||||
CancellationToken.None);
|
||||
|
||||
BaseError error = LeftOf(result);
|
||||
error.ShouldNotBeOfType<NotFoundError>();
|
||||
error.Value.ShouldContain("logo");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Reject_Empty_Group()
|
||||
{
|
||||
await SeedFFmpegProfile();
|
||||
|
||||
Either<BaseError, CreateChannelResult> result =
|
||||
await MakeHandler().Handle(MakeCreate(number: "11", group: " "), CancellationToken.None);
|
||||
|
||||
BaseError error = LeftOf(result);
|
||||
error.ShouldNotBeOfType<NotFoundError>();
|
||||
error.Value.ShouldContain("group");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Reject_Nonexistent_FFmpegProfile()
|
||||
{
|
||||
// intentionally do not seed an FFmpegProfile
|
||||
Either<BaseError, CreateChannelResult> result =
|
||||
await MakeHandler().Handle(MakeCreate(number: "12", ffmpegProfileId: 999), CancellationToken.None);
|
||||
|
||||
BaseError error = LeftOf(result);
|
||||
error.ShouldNotBeOfType<NotFoundError>();
|
||||
error.Value.ShouldContain("FFmpegProfile");
|
||||
}
|
||||
|
||||
private static BaseError LeftOf<TR>(Either<BaseError, TR> either) =>
|
||||
either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result"));
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using Testably.Abstractions.Testing;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Channels;
|
||||
|
||||
[TestFixture]
|
||||
public class DeleteChannelHandlerTests : ChannelHandlerTestBase
|
||||
{
|
||||
private DeleteChannelHandler MakeHandler() => new(Worker, Db.Factory, new MockFileSystem(), SearchTargets);
|
||||
|
||||
[Test]
|
||||
public async Task Should_Return_NotFoundError_When_Channel_Missing()
|
||||
{
|
||||
Either<BaseError, Unit> result = await MakeHandler().Handle(new DeleteChannel(999), CancellationToken.None);
|
||||
|
||||
BaseError error = LeftOf(result);
|
||||
error.ShouldBeOfType<NotFoundError>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Delete_Existing_Channel()
|
||||
{
|
||||
await SeedChannel(1, "5");
|
||||
|
||||
Either<BaseError, Unit> result = await MakeHandler().Handle(new DeleteChannel(1), CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
|
||||
await using TvContext context = Db.CreateContext();
|
||||
bool exists = await context.Channels.AnyAsync(c => c.Id == 1);
|
||||
exists.ShouldBeFalse();
|
||||
}
|
||||
|
||||
private static BaseError LeftOf<TR>(Either<BaseError, TR> either) =>
|
||||
either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result"));
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Channels;
|
||||
|
||||
[TestFixture]
|
||||
public class GetAllChannelsForApiHandlerTests
|
||||
{
|
||||
[Test]
|
||||
public async Task Should_Project_Management_Table_Fields()
|
||||
{
|
||||
IChannelRepository repository = Substitute.For<IChannelRepository>();
|
||||
repository.GetAll(Arg.Any<CancellationToken>())
|
||||
.Returns([
|
||||
new Channel(Guid.NewGuid())
|
||||
{
|
||||
Id = 7,
|
||||
Number = "7.1",
|
||||
SortNumber = 7.1,
|
||||
Name = "Retro Cartoons",
|
||||
Group = "Kids",
|
||||
Categories = "animation",
|
||||
FFmpegProfile = new FFmpegProfile { Name = "HLS 720p" },
|
||||
PreferredAudioLanguageCode = "eng",
|
||||
StreamingMode = StreamingMode.HttpLiveStreamingSegmenter,
|
||||
IsEnabled = false,
|
||||
ShowInEpg = false
|
||||
}
|
||||
]);
|
||||
var handler = new GetAllChannelsForApiHandler(repository);
|
||||
|
||||
List<ChannelResponseModel> result = await handler.Handle(new GetAllChannelsForApi(), CancellationToken.None);
|
||||
|
||||
ChannelResponseModel channel = result.ShouldHaveSingleItem();
|
||||
channel.Id.ShouldBe(7);
|
||||
channel.Number.ShouldBe("7.1");
|
||||
channel.SortNumber.ShouldBe(7.1);
|
||||
channel.Name.ShouldBe("Retro Cartoons");
|
||||
channel.Group.ShouldBe("Kids");
|
||||
channel.Categories.ShouldBe("animation");
|
||||
channel.FFmpegProfile.ShouldBe("HLS 720p");
|
||||
channel.Language.ShouldBe("eng");
|
||||
channel.StreamingMode.ShouldBe("HLS Segmenter");
|
||||
channel.IsEnabled.ShouldBeFalse();
|
||||
channel.ShowInEpg.ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Channels;
|
||||
|
||||
[TestFixture]
|
||||
public class GetChannelStatesForApiHandlerTests
|
||||
{
|
||||
private static readonly DateTime Now = new(2026, 7, 2, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private InMemoryTvContext _db = null!;
|
||||
private IFFmpegSegmenterService _segmenter = null!;
|
||||
|
||||
[SetUp]
|
||||
public async Task SetUp()
|
||||
{
|
||||
_db = await InMemoryTvContext.CreateAsync();
|
||||
_segmenter = Substitute.For<IFFmpegSegmenterService>();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public async Task TearDown() => await _db.DisposeAsync();
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Return_OnAir_And_Current_NowPlaying()
|
||||
{
|
||||
DateTime start = Now.AddMinutes(-10);
|
||||
DateTime finish = Now.AddMinutes(20);
|
||||
await SeedChannelWithMovie(start, finish);
|
||||
_segmenter.IsActive("7.1").Returns(true);
|
||||
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
|
||||
|
||||
List<ChannelStateResponseModel> result =
|
||||
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
|
||||
|
||||
ChannelStateResponseModel state = result.ShouldHaveSingleItem();
|
||||
state.ChannelId.ShouldBe(7);
|
||||
state.ChannelNumber.ShouldBe("7.1");
|
||||
state.OnAir.ShouldBeTrue();
|
||||
state.NowPlaying.ShouldNotBeNull();
|
||||
state.NowPlaying.Title.ShouldBe("Retro Cartoons");
|
||||
state.NowPlaying.StartUtc.ShouldBe(new DateTimeOffset(start, TimeSpan.Zero));
|
||||
state.NowPlaying.FinishUtc.ShouldBe(new DateTimeOffset(finish, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Match_Item_When_Now_Equals_Start()
|
||||
{
|
||||
await SeedChannelWithMovie(Now, Now.AddMinutes(30));
|
||||
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
|
||||
|
||||
List<ChannelStateResponseModel> result =
|
||||
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
|
||||
|
||||
result.ShouldHaveSingleItem().NowPlaying.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Not_Match_Item_When_Now_Equals_Finish()
|
||||
{
|
||||
await SeedChannelWithMovie(Now.AddMinutes(-30), Now);
|
||||
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
|
||||
|
||||
List<ChannelStateResponseModel> result =
|
||||
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
|
||||
|
||||
result.ShouldHaveSingleItem().NowPlaying.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Return_Null_NowPlaying_When_No_Current_Item()
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
context.Channels.Add(MakeChannel(8, "8"));
|
||||
await context.SaveChangesAsync();
|
||||
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
|
||||
|
||||
List<ChannelStateResponseModel> result =
|
||||
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
|
||||
|
||||
ChannelStateResponseModel state = result.ShouldHaveSingleItem();
|
||||
state.OnAir.ShouldBeFalse();
|
||||
state.NowPlaying.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Surface_Program_Not_Filler_During_MidRoll_Break()
|
||||
{
|
||||
// program part 1 / mid-roll filler / program part 2, all in one guide group
|
||||
await using TvContext context = _db.CreateContext();
|
||||
Channel channel = MakeChannel(12, "12");
|
||||
var movie = new Movie
|
||||
{
|
||||
Id = 120,
|
||||
MovieMetadata = [new MovieMetadata { Title = "Feature Presentation" }]
|
||||
};
|
||||
var filler = new OtherVideo
|
||||
{
|
||||
Id = 121,
|
||||
OtherVideoMetadata = [new OtherVideoMetadata { Title = "Some Bumper" }]
|
||||
};
|
||||
var playout = new Playout { Id = 122, Channel = channel, ChannelId = channel.Id, Items = [] };
|
||||
PlayoutItem MakeItem(int id, MediaItem mediaItem, DateTime start, DateTime finish, FillerKind fillerKind) =>
|
||||
new()
|
||||
{
|
||||
Id = id,
|
||||
MediaItem = mediaItem,
|
||||
MediaItemId = mediaItem.Id,
|
||||
Playout = playout,
|
||||
PlayoutId = playout.Id,
|
||||
Start = start,
|
||||
Finish = finish,
|
||||
FillerKind = fillerKind,
|
||||
GuideGroup = 1,
|
||||
ChapterTitle = string.Empty
|
||||
};
|
||||
|
||||
context.Channels.Add(channel);
|
||||
context.Movies.Add(movie);
|
||||
context.OtherVideos.Add(filler);
|
||||
context.Playouts.Add(playout);
|
||||
context.PlayoutItems.AddRange(
|
||||
MakeItem(123, movie, Now.AddMinutes(-20), Now.AddMinutes(-2), FillerKind.None),
|
||||
MakeItem(124, filler, Now.AddMinutes(-2), Now.AddMinutes(2), FillerKind.MidRoll),
|
||||
MakeItem(125, movie, Now.AddMinutes(2), Now.AddMinutes(40), FillerKind.None));
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
|
||||
|
||||
List<ChannelStateResponseModel> result =
|
||||
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
|
||||
|
||||
ChannelStateResponseModel state = result.ShouldHaveSingleItem();
|
||||
state.NowPlaying.ShouldNotBeNull();
|
||||
state.NowPlaying.Title.ShouldBe("Feature Presentation");
|
||||
state.NowPlaying.StartUtc.ShouldBe(new DateTimeOffset(Now.AddMinutes(-20), TimeSpan.Zero));
|
||||
state.NowPlaying.FinishUtc.ShouldBe(new DateTimeOffset(Now.AddMinutes(40), TimeSpan.Zero));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Return_Null_NowPlaying_For_Guide_Entry_With_Only_Filler()
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
Channel channel = MakeChannel(13, "13");
|
||||
var filler = new OtherVideo
|
||||
{
|
||||
Id = 130,
|
||||
OtherVideoMetadata = [new OtherVideoMetadata { Title = "Offline Loop" }]
|
||||
};
|
||||
var playout = new Playout { Id = 131, Channel = channel, ChannelId = channel.Id, Items = [] };
|
||||
var item = new PlayoutItem
|
||||
{
|
||||
Id = 132,
|
||||
MediaItem = filler,
|
||||
MediaItemId = filler.Id,
|
||||
Playout = playout,
|
||||
PlayoutId = playout.Id,
|
||||
Start = Now.AddMinutes(-5),
|
||||
Finish = Now.AddMinutes(5),
|
||||
FillerKind = FillerKind.Fallback,
|
||||
GuideGroup = 1,
|
||||
ChapterTitle = string.Empty
|
||||
};
|
||||
|
||||
context.Channels.Add(channel);
|
||||
context.OtherVideos.Add(filler);
|
||||
context.Playouts.Add(playout);
|
||||
context.PlayoutItems.Add(item);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
|
||||
|
||||
List<ChannelStateResponseModel> result =
|
||||
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
|
||||
|
||||
result.ShouldHaveSingleItem().NowPlaying.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Map_Remote_Stream_NowPlaying_Title()
|
||||
{
|
||||
DateTime start = Now.AddMinutes(-10);
|
||||
DateTime finish = Now.AddMinutes(20);
|
||||
|
||||
await using TvContext context = _db.CreateContext();
|
||||
Channel channel = MakeChannel(9, "9");
|
||||
var remoteStream = new RemoteStream
|
||||
{
|
||||
Id = 90,
|
||||
RemoteStreamMetadata = [new RemoteStreamMetadata { Title = "Live Source" }]
|
||||
};
|
||||
var playout = new Playout { Id = 91, Channel = channel, ChannelId = channel.Id, Items = [] };
|
||||
var item = new PlayoutItem
|
||||
{
|
||||
Id = 92,
|
||||
MediaItem = remoteStream,
|
||||
MediaItemId = remoteStream.Id,
|
||||
Playout = playout,
|
||||
PlayoutId = playout.Id,
|
||||
Start = start,
|
||||
Finish = finish,
|
||||
ChapterTitle = string.Empty
|
||||
};
|
||||
|
||||
context.Channels.Add(channel);
|
||||
context.RemoteStreams.Add(remoteStream);
|
||||
context.Playouts.Add(playout);
|
||||
context.PlayoutItems.Add(item);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
|
||||
|
||||
ChannelStateResponseModel state =
|
||||
(await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None))
|
||||
.ShouldHaveSingleItem();
|
||||
|
||||
state.NowPlaying.ShouldNotBeNull();
|
||||
state.NowPlaying.Title.ShouldBe("Live Source");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Resolve_Mirror_Channel_NowPlaying_From_Source_With_Offset()
|
||||
{
|
||||
TimeSpan offset = TimeSpan.FromHours(1);
|
||||
DateTime sourceStart = Now.AddMinutes(-70);
|
||||
DateTime sourceFinish = Now.AddMinutes(-40);
|
||||
|
||||
await using TvContext context = _db.CreateContext();
|
||||
Channel source = MakeChannel(10, "10");
|
||||
Channel mirror = MakeChannel(11, "11");
|
||||
mirror.PlayoutSource = ChannelPlayoutSource.Mirror;
|
||||
mirror.MirrorSourceChannelId = source.Id;
|
||||
mirror.PlayoutOffset = offset;
|
||||
|
||||
var movie = new Movie
|
||||
{
|
||||
Id = 110,
|
||||
MovieMetadata = [new MovieMetadata { Title = "Offset Feature" }]
|
||||
};
|
||||
var playout = new Playout { Id = 111, Channel = source, ChannelId = source.Id, Items = [] };
|
||||
var item = new PlayoutItem
|
||||
{
|
||||
Id = 112,
|
||||
MediaItem = movie,
|
||||
MediaItemId = movie.Id,
|
||||
Playout = playout,
|
||||
PlayoutId = playout.Id,
|
||||
Start = sourceStart,
|
||||
Finish = sourceFinish,
|
||||
ChapterTitle = string.Empty
|
||||
};
|
||||
|
||||
context.Channels.AddRange(source, mirror);
|
||||
context.Movies.Add(movie);
|
||||
context.Playouts.Add(playout);
|
||||
context.PlayoutItems.Add(item);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
|
||||
|
||||
List<ChannelStateResponseModel> result =
|
||||
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
|
||||
|
||||
ChannelStateResponseModel mirrorState = result.Single(s => s.ChannelId == mirror.Id);
|
||||
mirrorState.NowPlaying.ShouldNotBeNull();
|
||||
mirrorState.NowPlaying.Title.ShouldBe("Offset Feature");
|
||||
mirrorState.NowPlaying.StartUtc.ShouldBe(new DateTimeOffset(sourceStart + offset, TimeSpan.Zero));
|
||||
mirrorState.NowPlaying.FinishUtc.ShouldBe(new DateTimeOffset(sourceFinish + offset, TimeSpan.Zero));
|
||||
|
||||
// the source's own item is 40+ minutes in the past with no offset, so its own row is off-air
|
||||
ChannelStateResponseModel sourceState = result.Single(s => s.ChannelId == source.Id);
|
||||
sourceState.NowPlaying.ShouldBeNull();
|
||||
}
|
||||
|
||||
private async Task SeedChannelWithMovie(DateTime start, DateTime finish)
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
Channel channel = MakeChannel(7, "7.1");
|
||||
var movie = new Movie
|
||||
{
|
||||
Id = 10,
|
||||
MovieMetadata = [new MovieMetadata { Title = "Retro Cartoons" }]
|
||||
};
|
||||
var playout = new Playout
|
||||
{
|
||||
Id = 20,
|
||||
Channel = channel,
|
||||
ChannelId = channel.Id,
|
||||
Items = []
|
||||
};
|
||||
var item = new PlayoutItem
|
||||
{
|
||||
Id = 30,
|
||||
MediaItem = movie,
|
||||
MediaItemId = movie.Id,
|
||||
Playout = playout,
|
||||
PlayoutId = playout.Id,
|
||||
Start = start,
|
||||
Finish = finish,
|
||||
ChapterTitle = string.Empty
|
||||
};
|
||||
|
||||
context.Channels.Add(channel);
|
||||
context.Movies.Add(movie);
|
||||
context.Playouts.Add(playout);
|
||||
context.PlayoutItems.Add(item);
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static Channel MakeChannel(int id, string number) =>
|
||||
new(Guid.NewGuid())
|
||||
{
|
||||
Id = id,
|
||||
Number = number,
|
||||
SortNumber = id,
|
||||
Name = $"Channel {number}",
|
||||
Group = "Test",
|
||||
Categories = string.Empty,
|
||||
PreferredAudioLanguageCode = string.Empty
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Channels;
|
||||
|
||||
[TestFixture]
|
||||
public class UpdateChannelHandlerTests : ChannelHandlerTestBase
|
||||
{
|
||||
private UpdateChannelHandler MakeHandler() => new(Worker, Db.Factory, SearchTargets);
|
||||
|
||||
[Test]
|
||||
public async Task Should_Return_NotFoundError_When_Channel_Missing()
|
||||
{
|
||||
Either<BaseError, ChannelViewModel> result =
|
||||
await MakeHandler().Handle(MakeUpdate(999, number: "5"), CancellationToken.None);
|
||||
|
||||
BaseError error = LeftOf(result);
|
||||
error.ShouldBeOfType<NotFoundError>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Update_Existing_Channel()
|
||||
{
|
||||
await SeedFFmpegProfile();
|
||||
await SeedChannel(1, "5", "Old Name");
|
||||
|
||||
Either<BaseError, ChannelViewModel> result =
|
||||
await MakeHandler().Handle(MakeUpdate(1, number: "5", name: "New Name"), CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
|
||||
await using TvContext context = Db.CreateContext();
|
||||
var channel = await context.Channels.SingleAsync(c => c.Id == 1);
|
||||
channel.Name.ShouldBe("New Name");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Allow_Keeping_Own_Number()
|
||||
{
|
||||
await SeedFFmpegProfile();
|
||||
await SeedChannel(1, "5", "Old Name");
|
||||
|
||||
Either<BaseError, ChannelViewModel> result =
|
||||
await MakeHandler().Handle(MakeUpdate(1, number: "5", name: "Renamed"), CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Reject_Number_Used_By_Another_Channel()
|
||||
{
|
||||
await SeedFFmpegProfile();
|
||||
await SeedChannel(1, "5");
|
||||
await SeedChannel(2, "6");
|
||||
|
||||
Either<BaseError, ChannelViewModel> result =
|
||||
await MakeHandler().Handle(MakeUpdate(1, number: "6"), CancellationToken.None);
|
||||
|
||||
BaseError error = LeftOf(result);
|
||||
error.ShouldNotBeOfType<NotFoundError>();
|
||||
error.Value.ShouldContain("unique");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Reject_ShowInEpg_When_Disabled()
|
||||
{
|
||||
await SeedFFmpegProfile();
|
||||
await SeedChannel(1, "5");
|
||||
|
||||
Either<BaseError, ChannelViewModel> result =
|
||||
await MakeHandler().Handle(
|
||||
MakeUpdate(1, number: "5", isEnabled: false, showInEpg: true),
|
||||
CancellationToken.None);
|
||||
|
||||
BaseError error = LeftOf(result);
|
||||
error.ShouldNotBeOfType<NotFoundError>();
|
||||
error.Value.ShouldContain("EPG");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Reject_Empty_Group()
|
||||
{
|
||||
await SeedFFmpegProfile();
|
||||
await SeedChannel(1, "5");
|
||||
|
||||
Either<BaseError, ChannelViewModel> result =
|
||||
await MakeHandler().Handle(MakeUpdate(1, number: "5", group: ""), CancellationToken.None);
|
||||
|
||||
BaseError error = LeftOf(result);
|
||||
error.ShouldNotBeOfType<NotFoundError>();
|
||||
error.Value.ShouldContain("group");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Reject_Nonexistent_FFmpegProfile()
|
||||
{
|
||||
await SeedFFmpegProfile();
|
||||
await SeedChannel(1, "5");
|
||||
|
||||
Either<BaseError, ChannelViewModel> result =
|
||||
await MakeHandler().Handle(
|
||||
MakeUpdate(1, number: "5", ffmpegProfileId: 999),
|
||||
CancellationToken.None);
|
||||
|
||||
BaseError error = LeftOf(result);
|
||||
error.ShouldNotBeOfType<NotFoundError>();
|
||||
error.Value.ShouldContain("FFmpegProfile");
|
||||
}
|
||||
|
||||
private static BaseError LeftOf<TR>(Either<BaseError, TR> either) =>
|
||||
either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result"));
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using LanguageExt;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Channels;
|
||||
|
||||
[TestFixture]
|
||||
public class UpdateChannelNumbersHandlerTests : ChannelHandlerTestBase
|
||||
{
|
||||
private UpdateChannelNumbersHandler MakeHandler() => new(Db.Factory, Worker);
|
||||
|
||||
[Test]
|
||||
public async Task Should_Renumber_All_Channels()
|
||||
{
|
||||
await SeedChannel(1, "5");
|
||||
await SeedChannel(2, "6");
|
||||
|
||||
Option<BaseError> result = await MakeHandler().Handle(
|
||||
new UpdateChannelNumbers(
|
||||
[
|
||||
new ChannelSortViewModel { Id = 1, Number = "10" },
|
||||
new ChannelSortViewModel { Id = 2, Number = "11.1" }
|
||||
]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsNone.ShouldBeTrue();
|
||||
|
||||
await using TvContext context = Db.CreateContext();
|
||||
var channels = await context.Channels
|
||||
.OrderBy(c => c.Id)
|
||||
.Select(c => new { c.Number, c.SortNumber })
|
||||
.ToListAsync();
|
||||
channels[0].Number.ShouldBe("10");
|
||||
channels[0].SortNumber.ShouldBe(10);
|
||||
channels[1].Number.ShouldBe("11.1");
|
||||
channels[1].SortNumber.ShouldBe(11.1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Return_NotFound_And_Not_Renumber_When_Any_Channel_Is_Missing()
|
||||
{
|
||||
await SeedChannel(1, "5");
|
||||
|
||||
Option<BaseError> result = await MakeHandler().Handle(
|
||||
new UpdateChannelNumbers(
|
||||
[
|
||||
new ChannelSortViewModel { Id = 1, Number = "10" },
|
||||
new ChannelSortViewModel { Id = 99, Number = "11" }
|
||||
]),
|
||||
CancellationToken.None);
|
||||
|
||||
BaseError error = ErrorOf(result);
|
||||
error.ShouldBeOfType<NotFoundError>();
|
||||
|
||||
await using TvContext context = Db.CreateContext();
|
||||
string number = await context.Channels.Where(c => c.Id == 1).Select(c => c.Number).SingleAsync();
|
||||
number.ShouldBe("5");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Reject_Duplicate_Numbers_And_Not_Renumber()
|
||||
{
|
||||
await SeedChannel(1, "5");
|
||||
await SeedChannel(2, "6");
|
||||
|
||||
Option<BaseError> result = await MakeHandler().Handle(
|
||||
new UpdateChannelNumbers(
|
||||
[
|
||||
new ChannelSortViewModel { Id = 1, Number = "10" },
|
||||
new ChannelSortViewModel { Id = 2, Number = "10" }
|
||||
]),
|
||||
CancellationToken.None);
|
||||
|
||||
BaseError error = ErrorOf(result);
|
||||
error.ShouldNotBeOfType<NotFoundError>();
|
||||
error.Value.ShouldContain("unique");
|
||||
|
||||
await using TvContext context = Db.CreateContext();
|
||||
List<string> numbers = await context.Channels.OrderBy(c => c.Id).Select(c => c.Number).ToListAsync();
|
||||
numbers.ShouldBe(["5", "6"]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Reject_Invalid_Number_And_Not_Renumber()
|
||||
{
|
||||
await SeedChannel(1, "5");
|
||||
|
||||
Option<BaseError> result = await MakeHandler().Handle(
|
||||
new UpdateChannelNumbers([new ChannelSortViewModel { Id = 1, Number = "10.123" }]),
|
||||
CancellationToken.None);
|
||||
|
||||
BaseError error = ErrorOf(result);
|
||||
error.ShouldNotBeOfType<NotFoundError>();
|
||||
error.Value.ShouldContain("Invalid channel number");
|
||||
|
||||
await using TvContext context = Db.CreateContext();
|
||||
string number = await context.Channels.Where(c => c.Id == 1).Select(c => c.Number).SingleAsync();
|
||||
number.ShouldBe("5");
|
||||
}
|
||||
|
||||
private static BaseError ErrorOf(Option<BaseError> result) =>
|
||||
result.Match(
|
||||
Some: error => error,
|
||||
None: () => throw new AssertionException("Expected an error"));
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
using ErsatzTV.Application.FFmpegProfiles;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using LanguageExt;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using static LanguageExt.Prelude;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.FFmpegProfiles;
|
||||
|
||||
[TestFixture]
|
||||
public class FFmpegProfileHandlerTests
|
||||
{
|
||||
private InMemoryTvContext _db = null!;
|
||||
private IConfigElementRepository _configElementRepository = null!;
|
||||
private ISearchTargets _searchTargets = null!;
|
||||
|
||||
[SetUp]
|
||||
public async Task SetUp()
|
||||
{
|
||||
_db = await InMemoryTvContext.CreateAsync();
|
||||
_configElementRepository = Substitute.For<IConfigElementRepository>();
|
||||
_configElementRepository.GetValue<int>(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<int>.None);
|
||||
_searchTargets = Substitute.For<ISearchTargets>();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public async Task TearDown() => await _db.DisposeAsync();
|
||||
|
||||
[Test]
|
||||
public async Task Create_Should_Return_NotFoundError_When_Resolution_Missing()
|
||||
{
|
||||
var handler = new CreateFFmpegProfileHandler(_db.Factory, _searchTargets);
|
||||
|
||||
Either<BaseError, CreateFFmpegProfileResult> result =
|
||||
await handler.Handle(MakeCreate(resolutionId: 999), CancellationToken.None);
|
||||
|
||||
LeftOf(result).ShouldBeOfType<NotFoundError>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Update_Should_Return_NotFoundError_When_Profile_Missing()
|
||||
{
|
||||
var handler = new UpdateFFmpegProfileHandler(_db.Factory, _searchTargets);
|
||||
|
||||
Either<BaseError, UpdateFFmpegProfileResult> result =
|
||||
await handler.Handle(MakeUpdate(999), CancellationToken.None);
|
||||
|
||||
LeftOf(result).ShouldBeOfType<NotFoundError>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Update_Should_Return_NotFoundError_When_Resolution_Missing()
|
||||
{
|
||||
await SeedProfile(1);
|
||||
var handler = new UpdateFFmpegProfileHandler(_db.Factory, _searchTargets);
|
||||
|
||||
Either<BaseError, UpdateFFmpegProfileResult> result =
|
||||
await handler.Handle(MakeUpdate(1, resolutionId: 999), CancellationToken.None);
|
||||
|
||||
LeftOf(result).ShouldBeOfType<NotFoundError>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Delete_Should_Return_NotFoundError_When_Profile_Missing()
|
||||
{
|
||||
var handler = new DeleteFFmpegProfileHandler(_db.Factory, _configElementRepository, _searchTargets);
|
||||
|
||||
Either<BaseError, Unit> result = await handler.Handle(new DeleteFFmpegProfile(999), CancellationToken.None);
|
||||
|
||||
LeftOf(result).ShouldBeOfType<NotFoundError>();
|
||||
}
|
||||
|
||||
private static BaseError LeftOf<TR>(Either<BaseError, TR> either) =>
|
||||
either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result"));
|
||||
|
||||
private async Task SeedProfile(int id)
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
context.FFmpegProfiles.Add(new FFmpegProfile
|
||||
{
|
||||
Id = id,
|
||||
Name = "Default",
|
||||
ThreadCount = 1,
|
||||
NormalizeAudio = true,
|
||||
NormalizeVideo = true,
|
||||
HardwareAcceleration = HardwareAccelerationKind.None,
|
||||
VaapiDisplay = "drm",
|
||||
VaapiDriver = VaapiDriver.Default,
|
||||
VaapiDevice = "/dev/dri/renderD128",
|
||||
ResolutionId = 1,
|
||||
ScalingBehavior = ScalingBehavior.ScaleAndPad,
|
||||
PadMode = FilterMode.Software,
|
||||
VideoFormat = FFmpegProfileVideoFormat.H264,
|
||||
VideoProfile = string.Empty,
|
||||
VideoPreset = string.Empty,
|
||||
BitDepth = FFmpegProfileBitDepth.EightBit,
|
||||
VideoBitrate = 2_000,
|
||||
VideoBufferSize = 4_000,
|
||||
TonemapAlgorithm = FFmpegProfileTonemapAlgorithm.Linear,
|
||||
AudioFormat = FFmpegProfileAudioFormat.Aac,
|
||||
AudioBitrate = 192,
|
||||
AudioBufferSize = 384,
|
||||
NormalizeLoudnessMode = NormalizeLoudnessMode.Off,
|
||||
AudioChannels = 2,
|
||||
AudioSampleRate = 48_000,
|
||||
NormalizeFramerate = false,
|
||||
NormalizeColors = false,
|
||||
DeinterlaceVideo = false
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static CreateFFmpegProfile MakeCreate(int resolutionId) =>
|
||||
new(
|
||||
"Default",
|
||||
1,
|
||||
true,
|
||||
true,
|
||||
HardwareAccelerationKind.None,
|
||||
"drm",
|
||||
VaapiDriver.Default,
|
||||
"/dev/dri/renderD128",
|
||||
null,
|
||||
resolutionId,
|
||||
ScalingBehavior.ScaleAndPad,
|
||||
FilterMode.Software,
|
||||
FFmpegProfileVideoFormat.H264,
|
||||
string.Empty,
|
||||
string.Empty,
|
||||
false,
|
||||
FFmpegProfileBitDepth.EightBit,
|
||||
2_000,
|
||||
4_000,
|
||||
FFmpegProfileTonemapAlgorithm.Linear,
|
||||
FFmpegProfileAudioFormat.Aac,
|
||||
192,
|
||||
384,
|
||||
NormalizeLoudnessMode.Off,
|
||||
null,
|
||||
2,
|
||||
48_000,
|
||||
false,
|
||||
false,
|
||||
false);
|
||||
|
||||
private static UpdateFFmpegProfile MakeUpdate(int id, int resolutionId = 1) =>
|
||||
new(
|
||||
id,
|
||||
"Default",
|
||||
1,
|
||||
true,
|
||||
true,
|
||||
HardwareAccelerationKind.None,
|
||||
"drm",
|
||||
VaapiDriver.Default,
|
||||
"/dev/dri/renderD128",
|
||||
null,
|
||||
resolutionId,
|
||||
ScalingBehavior.ScaleAndPad,
|
||||
FilterMode.Software,
|
||||
FFmpegProfileVideoFormat.H264,
|
||||
string.Empty,
|
||||
string.Empty,
|
||||
false,
|
||||
FFmpegProfileBitDepth.EightBit,
|
||||
2_000,
|
||||
4_000,
|
||||
FFmpegProfileTonemapAlgorithm.Linear,
|
||||
FFmpegProfileAudioFormat.Aac,
|
||||
192,
|
||||
384,
|
||||
NormalizeLoudnessMode.Off,
|
||||
null,
|
||||
2,
|
||||
48_000,
|
||||
false,
|
||||
false,
|
||||
false);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
using ErsatzTV.Application.Search;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.MediaCollections;
|
||||
|
||||
[TestFixture]
|
||||
public class CollectionHandlerTests : MediaCollectionHandlerTestBase
|
||||
{
|
||||
[Test]
|
||||
public async Task Update_Should_Return_NotFoundError_When_Collection_Missing()
|
||||
{
|
||||
var handler = new UpdateCollectionHandler(
|
||||
Db.Factory,
|
||||
Substitute.For<IMediaCollectionRepository>(),
|
||||
Worker,
|
||||
SearchTargets);
|
||||
|
||||
Either<BaseError, Unit> result =
|
||||
await handler.Handle(new UpdateCollection(999, "Updated"), CancellationToken.None);
|
||||
|
||||
LeftOf(result).ShouldBeOfType<NotFoundError>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Delete_Should_Return_NotFoundError_When_Collection_Missing()
|
||||
{
|
||||
var handler = new DeleteCollectionHandler(Db.Factory, SearchTargets);
|
||||
|
||||
Either<BaseError, Unit> result =
|
||||
await handler.Handle(new DeleteCollection(999), CancellationToken.None);
|
||||
|
||||
LeftOf(result).ShouldBeOfType<NotFoundError>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AddItems_Should_Return_NotFoundError_When_Collection_Missing()
|
||||
{
|
||||
IMovieRepository movieRepository = Substitute.For<IMovieRepository>();
|
||||
movieRepository.AllMoviesExist(Arg.Any<List<int>>()).Returns(true);
|
||||
ITelevisionRepository televisionRepository = Substitute.For<ITelevisionRepository>();
|
||||
televisionRepository.AllShowsExist(Arg.Any<List<int>>()).Returns(true);
|
||||
televisionRepository.AllSeasonsExist(Arg.Any<List<int>>()).Returns(true);
|
||||
televisionRepository.AllEpisodesExist(Arg.Any<List<int>>()).Returns(true);
|
||||
|
||||
var handler = new AddItemsToCollectionHandler(
|
||||
Db.Factory,
|
||||
Substitute.For<IMediaCollectionRepository>(),
|
||||
movieRepository,
|
||||
televisionRepository,
|
||||
Worker,
|
||||
System.Threading.Channels.Channel.CreateUnbounded<ISearchIndexBackgroundServiceRequest>().Writer);
|
||||
|
||||
Either<BaseError, Unit> result =
|
||||
await handler.Handle(MakeAddItems(collectionId: 999, movieIds: [1]), CancellationToken.None);
|
||||
|
||||
LeftOf(result).ShouldBeOfType<NotFoundError>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AddItems_Should_Return_ValidationError_When_Generic_MediaItem_Missing()
|
||||
{
|
||||
await SeedCollection(1);
|
||||
IMovieRepository movieRepository = Substitute.For<IMovieRepository>();
|
||||
movieRepository.AllMoviesExist(Arg.Any<List<int>>()).Returns(true);
|
||||
ITelevisionRepository televisionRepository = Substitute.For<ITelevisionRepository>();
|
||||
televisionRepository.AllShowsExist(Arg.Any<List<int>>()).Returns(true);
|
||||
televisionRepository.AllSeasonsExist(Arg.Any<List<int>>()).Returns(true);
|
||||
televisionRepository.AllEpisodesExist(Arg.Any<List<int>>()).Returns(true);
|
||||
|
||||
var handler = new AddItemsToCollectionHandler(
|
||||
Db.Factory,
|
||||
Substitute.For<IMediaCollectionRepository>(),
|
||||
movieRepository,
|
||||
televisionRepository,
|
||||
Worker,
|
||||
System.Threading.Channels.Channel.CreateUnbounded<ISearchIndexBackgroundServiceRequest>().Writer);
|
||||
|
||||
Either<BaseError, Unit> result =
|
||||
await handler.Handle(
|
||||
new AddItemsToCollection(1, [], [], [], [], [], [], [], [], [], [999]),
|
||||
CancellationToken.None);
|
||||
|
||||
BaseError error = LeftOf(result);
|
||||
error.ShouldNotBeOfType<NotFoundError>();
|
||||
error.Value.ShouldContain("Media item does not exist");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RemoveItems_Should_Return_NotFoundError_When_Collection_Missing()
|
||||
{
|
||||
var handler = new RemoveItemsFromCollectionHandler(
|
||||
Db.Factory,
|
||||
Substitute.For<IMediaCollectionRepository>(),
|
||||
Worker,
|
||||
System.Threading.Channels.Channel.CreateUnbounded<ISearchIndexBackgroundServiceRequest>().Writer);
|
||||
|
||||
Either<BaseError, Unit> result =
|
||||
await handler.Handle(
|
||||
new RemoveItemsFromCollection(999) { MediaItemIds = [1] },
|
||||
CancellationToken.None);
|
||||
|
||||
LeftOf(result).ShouldBeOfType<NotFoundError>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RemoveItems_Should_Return_NotFoundError_When_Association_Missing()
|
||||
{
|
||||
await SeedCollection(1);
|
||||
var handler = new RemoveItemsFromCollectionHandler(
|
||||
Db.Factory,
|
||||
Substitute.For<IMediaCollectionRepository>(),
|
||||
Worker,
|
||||
System.Threading.Channels.Channel.CreateUnbounded<ISearchIndexBackgroundServiceRequest>().Writer);
|
||||
|
||||
Either<BaseError, Unit> result =
|
||||
await handler.Handle(
|
||||
new RemoveItemsFromCollection(1) { MediaItemIds = [999] },
|
||||
CancellationToken.None);
|
||||
|
||||
LeftOf(result).ShouldBeOfType<NotFoundError>();
|
||||
}
|
||||
|
||||
private static AddItemsToCollection MakeAddItems(int collectionId, List<int>? movieIds = null) =>
|
||||
new(
|
||||
collectionId,
|
||||
movieIds ?? [],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[]);
|
||||
|
||||
private static BaseError LeftOf<TR>(Either<BaseError, TR> either) =>
|
||||
either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result"));
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Search;
|
||||
using LanguageExt;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.MediaCollections;
|
||||
|
||||
[TestFixture]
|
||||
public class SmartCollectionHandlerTests : MediaCollectionHandlerTestBase
|
||||
{
|
||||
[Test]
|
||||
public async Task Update_Should_Return_NotFoundError_When_SmartCollection_Missing()
|
||||
{
|
||||
var handler = new UpdateSmartCollectionHandler(
|
||||
Db.Factory,
|
||||
Substitute.For<IMediaCollectionRepository>(),
|
||||
Worker,
|
||||
SearchTargets,
|
||||
Substitute.For<ISmartCollectionCache>());
|
||||
|
||||
Either<BaseError, UpdateSmartCollectionResult> result =
|
||||
await handler.Handle(new UpdateSmartCollection(999, "Updated", "tag:updated"), CancellationToken.None);
|
||||
|
||||
LeftOf(result).ShouldBeOfType<NotFoundError>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Delete_Should_Return_NotFoundError_When_SmartCollection_Missing()
|
||||
{
|
||||
var handler = new DeleteSmartCollectionHandler(
|
||||
Db.Factory,
|
||||
SearchTargets,
|
||||
Substitute.For<ISmartCollectionCache>());
|
||||
|
||||
Either<BaseError, Unit> result =
|
||||
await handler.Handle(new DeleteSmartCollection(999), CancellationToken.None);
|
||||
|
||||
LeftOf(result).ShouldBeOfType<NotFoundError>();
|
||||
}
|
||||
|
||||
private static BaseError LeftOf<TR>(Either<BaseError, TR> either) =>
|
||||
either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result"));
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using Testably.Abstractions.Testing;
|
||||
using Unit = LanguageExt.Unit;
|
||||
using Channel = System.Threading.Channels.Channel;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Playouts;
|
||||
|
||||
[TestFixture]
|
||||
public class PlayoutHandlerTests
|
||||
{
|
||||
private InMemoryTvContext _db = null!;
|
||||
private ChannelWriter<IBackgroundServiceRequest> _worker = null!;
|
||||
|
||||
[SetUp]
|
||||
public async Task SetUp()
|
||||
{
|
||||
_db = await InMemoryTvContext.CreateAsync();
|
||||
_worker = Channel.CreateUnbounded<IBackgroundServiceRequest>().Writer;
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public async Task TearDown() => await _db.DisposeAsync();
|
||||
|
||||
[Test]
|
||||
public async Task CreateClassic_Should_Return_NotFoundError_When_Channel_Missing()
|
||||
{
|
||||
var handler = new CreateClassicPlayoutHandler(_worker, _db.Factory);
|
||||
|
||||
Either<BaseError, CreatePlayoutResponse> result =
|
||||
await handler.Handle(new CreateClassicPlayout(999, 1), CancellationToken.None);
|
||||
|
||||
LeftOf(result).ShouldBeOfType<NotFoundError>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task CreateClassic_Should_Return_NotFoundError_When_Schedule_Missing()
|
||||
{
|
||||
int channelId = await SeedChannel();
|
||||
var handler = new CreateClassicPlayoutHandler(_worker, _db.Factory);
|
||||
|
||||
Either<BaseError, CreatePlayoutResponse> result =
|
||||
await handler.Handle(new CreateClassicPlayout(channelId, 999), CancellationToken.None);
|
||||
|
||||
LeftOf(result).ShouldBeOfType<NotFoundError>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Delete_Should_Return_NotFoundError_When_Playout_Missing()
|
||||
{
|
||||
var handler = new DeletePlayoutHandler(
|
||||
_worker,
|
||||
_db.Factory,
|
||||
new MockFileSystem(),
|
||||
Substitute.For<IMediator>());
|
||||
|
||||
Either<BaseError, Unit> result = await handler.Handle(new DeletePlayout(999), CancellationToken.None);
|
||||
|
||||
LeftOf(result).ShouldBeOfType<NotFoundError>();
|
||||
}
|
||||
|
||||
private static BaseError LeftOf<TR>(Either<BaseError, TR> either) =>
|
||||
either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result"));
|
||||
|
||||
private async Task<int> SeedChannel()
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
var channel = new ErsatzTV.Core.Domain.Channel(Guid.NewGuid())
|
||||
{
|
||||
Number = "101",
|
||||
SortNumber = 101,
|
||||
Name = "Handler",
|
||||
Group = string.Empty,
|
||||
Categories = string.Empty,
|
||||
StreamingMode = StreamingMode.HttpLiveStreamingSegmenter,
|
||||
Playouts = [],
|
||||
Artwork = [],
|
||||
StreamSelector = string.Empty,
|
||||
PreferredAudioLanguageCode = string.Empty,
|
||||
PreferredAudioTitle = string.Empty,
|
||||
PreferredSubtitleLanguageCode = string.Empty,
|
||||
MusicVideoCreditsTemplate = string.Empty,
|
||||
PlayoutSource = ChannelPlayoutSource.Generated,
|
||||
PlayoutMode = ChannelPlayoutMode.Continuous,
|
||||
IsEnabled = true,
|
||||
ShowInEpg = true
|
||||
};
|
||||
context.Channels.Add(channel);
|
||||
await context.SaveChangesAsync();
|
||||
return channel.Id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.ProgramSchedules;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using LanguageExt;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.ProgramSchedules;
|
||||
|
||||
[TestFixture]
|
||||
public class ProgramScheduleHandlerTests
|
||||
{
|
||||
private InMemoryTvContext _db = null!;
|
||||
private ChannelWriter<IBackgroundServiceRequest> _worker = null!;
|
||||
|
||||
[SetUp]
|
||||
public async Task SetUp()
|
||||
{
|
||||
_db = await InMemoryTvContext.CreateAsync();
|
||||
_worker = System.Threading.Channels.Channel.CreateUnbounded<IBackgroundServiceRequest>().Writer;
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public async Task TearDown() => await _db.DisposeAsync();
|
||||
|
||||
[Test]
|
||||
public async Task Update_Should_Return_NotFoundError_When_Schedule_Missing()
|
||||
{
|
||||
var handler = new UpdateProgramScheduleHandler(_db.Factory, _worker);
|
||||
|
||||
Either<BaseError, UpdateProgramScheduleResult> result =
|
||||
await handler.Handle(MakeUpdate(999, "Missing"), CancellationToken.None);
|
||||
|
||||
LeftOf(result).ShouldBeOfType<NotFoundError>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Delete_Should_Return_NotFoundError_When_Schedule_Missing()
|
||||
{
|
||||
var handler = new DeleteProgramScheduleHandler(_db.Factory);
|
||||
|
||||
Either<BaseError, Unit> result =
|
||||
await handler.Handle(new DeleteProgramSchedule(999), CancellationToken.None);
|
||||
|
||||
LeftOf(result).ShouldBeOfType<NotFoundError>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AddItem_Should_Return_NotFoundError_When_Schedule_Missing()
|
||||
{
|
||||
var handler = new AddProgramScheduleItemHandler(_db.Factory, _worker);
|
||||
|
||||
Either<BaseError, ProgramScheduleItemViewModel> result =
|
||||
await handler.Handle(MakeAdd(999, CollectionType.SearchQuery), CancellationToken.None);
|
||||
|
||||
LeftOf(result).ShouldBeOfType<NotFoundError>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AddItem_Should_Return_ValidationError_When_Collection_Type_Is_Invalid()
|
||||
{
|
||||
int scheduleId = await SeedSchedule();
|
||||
var handler = new AddProgramScheduleItemHandler(_db.Factory, _worker);
|
||||
|
||||
Either<BaseError, ProgramScheduleItemViewModel> result =
|
||||
await handler.Handle(MakeAdd(scheduleId, CollectionType.Collection), CancellationToken.None);
|
||||
|
||||
BaseError error = LeftOf(result);
|
||||
error.ShouldNotBeOfType<NotFoundError>();
|
||||
error.Value.ShouldContain("[Collection] is required");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReplaceItems_Should_Return_NotFoundError_When_Schedule_Missing()
|
||||
{
|
||||
var handler = new ReplaceProgramScheduleItemsHandler(_db.Factory, _worker);
|
||||
|
||||
Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>> result =
|
||||
await handler.Handle(
|
||||
new ReplaceProgramScheduleItems(999, [MakeReplace(0)]),
|
||||
CancellationToken.None);
|
||||
|
||||
LeftOf(result).ShouldBeOfType<NotFoundError>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task DeleteItem_Should_Return_NotFoundError_When_Item_Missing()
|
||||
{
|
||||
int scheduleId = await SeedSchedule();
|
||||
var handler = new DeleteProgramScheduleItemHandler(_db.Factory, _worker);
|
||||
|
||||
Either<BaseError, Unit> result =
|
||||
await handler.Handle(new DeleteProgramScheduleItem(scheduleId, 999), CancellationToken.None);
|
||||
|
||||
LeftOf(result).ShouldBeOfType<NotFoundError>();
|
||||
}
|
||||
|
||||
private async Task<int> SeedSchedule()
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
var schedule = new ProgramSchedule
|
||||
{
|
||||
Name = "Handlers",
|
||||
Items = [],
|
||||
Playouts = [],
|
||||
ProgramScheduleAlternates = []
|
||||
};
|
||||
context.ProgramSchedules.Add(schedule);
|
||||
await context.SaveChangesAsync();
|
||||
return schedule.Id;
|
||||
}
|
||||
|
||||
private static UpdateProgramSchedule MakeUpdate(int scheduleId, string name) =>
|
||||
new(
|
||||
scheduleId,
|
||||
name,
|
||||
KeepMultiPartEpisodesTogether: true,
|
||||
TreatCollectionsAsShows: true,
|
||||
ShuffleScheduleItems: false,
|
||||
RandomStartPoint: false,
|
||||
FixedStartTimeBehavior.Flexible);
|
||||
|
||||
private static AddProgramScheduleItem MakeAdd(int scheduleId, CollectionType collectionType) =>
|
||||
new(
|
||||
scheduleId,
|
||||
StartType.Dynamic,
|
||||
StartTime: null,
|
||||
FixedStartTimeBehavior: null,
|
||||
PlayoutMode.One,
|
||||
collectionType,
|
||||
CollectionId: null,
|
||||
MultiCollectionId: null,
|
||||
SmartCollectionId: null,
|
||||
RerunCollectionId: null,
|
||||
MediaItemId: null,
|
||||
PlaylistId: null,
|
||||
SearchTitle: "News",
|
||||
SearchQuery: "news",
|
||||
PlaybackOrder.Shuffle,
|
||||
MarathonGroupBy.None,
|
||||
MarathonShuffleGroups: false,
|
||||
MarathonShuffleItems: false,
|
||||
MarathonBatchSize: null,
|
||||
FillWithGroupMode.None,
|
||||
MultipleMode.Count,
|
||||
MultipleCount: "1",
|
||||
PlayoutDuration: null,
|
||||
TailMode.None,
|
||||
DiscardToFillAttempts: null,
|
||||
CustomTitle: null,
|
||||
GuideMode.Normal,
|
||||
PreRollFillerId: null,
|
||||
MidRollFillerId: null,
|
||||
PostRollFillerId: null,
|
||||
TailFillerId: null,
|
||||
FallbackFillerId: null,
|
||||
WatermarkIds: [],
|
||||
GraphicsElementIds: [],
|
||||
PreferredAudioLanguageCode: null,
|
||||
PreferredAudioTitle: null,
|
||||
PreferredSubtitleLanguageCode: null,
|
||||
SubtitleMode: null);
|
||||
|
||||
private static ReplaceProgramScheduleItem MakeReplace(int index)
|
||||
{
|
||||
AddProgramScheduleItem add = MakeAdd(1, CollectionType.SearchQuery);
|
||||
return new ReplaceProgramScheduleItem(
|
||||
index,
|
||||
add.StartType,
|
||||
add.StartTime,
|
||||
add.FixedStartTimeBehavior,
|
||||
add.PlayoutMode,
|
||||
add.CollectionType,
|
||||
add.CollectionId,
|
||||
add.MultiCollectionId,
|
||||
add.SmartCollectionId,
|
||||
add.RerunCollectionId,
|
||||
add.MediaItemId,
|
||||
add.PlaylistId,
|
||||
add.SearchTitle,
|
||||
add.SearchQuery,
|
||||
add.PlaybackOrder,
|
||||
add.MarathonGroupBy,
|
||||
add.MarathonShuffleGroups,
|
||||
add.MarathonShuffleItems,
|
||||
add.MarathonBatchSize,
|
||||
add.FillWithGroupMode,
|
||||
add.MultipleMode,
|
||||
add.MultipleCount,
|
||||
add.PlayoutDuration,
|
||||
add.TailMode,
|
||||
add.DiscardToFillAttempts,
|
||||
add.CustomTitle,
|
||||
add.GuideMode,
|
||||
add.PreRollFillerId,
|
||||
add.MidRollFillerId,
|
||||
add.PostRollFillerId,
|
||||
add.TailFillerId,
|
||||
add.FallbackFillerId,
|
||||
add.WatermarkIds,
|
||||
add.GraphicsElementIds,
|
||||
add.PreferredAudioLanguageCode,
|
||||
add.PreferredAudioTitle,
|
||||
add.PreferredSubtitleLanguageCode,
|
||||
add.SubtitleMode);
|
||||
}
|
||||
|
||||
private static BaseError LeftOf<TR>(Either<BaseError, TR> either) =>
|
||||
either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result"));
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
using System.Reflection;
|
||||
using ErsatzTV;
|
||||
using ErsatzTV.Controllers.Api;
|
||||
using ErsatzTV.Filters;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Routing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Controllers;
|
||||
|
||||
[TestFixture]
|
||||
public class ApiControllerSecurityTests
|
||||
{
|
||||
private static readonly bool ApiKeyAuthorizationFilterIsGlobal = IsApiKeyAuthorizationFilterRegisteredGlobally();
|
||||
|
||||
[Test]
|
||||
public void Every_Mutating_Api_Action_Should_Be_Globally_Protected_Or_Explicitly_Exempt()
|
||||
{
|
||||
Type[] apiControllers =
|
||||
[
|
||||
typeof(ChannelController),
|
||||
typeof(CollectionController),
|
||||
typeof(FFmpegProfileController),
|
||||
typeof(LibrariesController),
|
||||
typeof(MaintenanceController),
|
||||
typeof(PlayoutController),
|
||||
typeof(ScannerController),
|
||||
typeof(ScheduleController),
|
||||
typeof(ScriptedScheduleController),
|
||||
typeof(SessionController),
|
||||
typeof(SmartCollectionController)
|
||||
];
|
||||
|
||||
foreach (Type controllerType in apiControllers)
|
||||
{
|
||||
bool controllerSkipsApiKey = controllerType
|
||||
.GetCustomAttributes<SkipApiKeyAuthorizationAttribute>(inherit: true)
|
||||
.Any();
|
||||
|
||||
foreach (MethodInfo action in controllerType
|
||||
.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly))
|
||||
{
|
||||
bool isMutating = action
|
||||
.GetCustomAttributes<HttpMethodAttribute>(inherit: true)
|
||||
.SelectMany(a => a.HttpMethods)
|
||||
.Any(m => m is "POST" or "PUT" or "PATCH" or "DELETE");
|
||||
|
||||
if (!isMutating)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
bool actionSkipsApiKey = action
|
||||
.GetCustomAttributes<SkipApiKeyAuthorizationAttribute>(inherit: true)
|
||||
.Any();
|
||||
|
||||
(controllerSkipsApiKey || actionSkipsApiKey || IsGloballyProtected())
|
||||
.ShouldBeTrue($"{controllerType.Name}.{action.Name} must be covered by global API write auth or explicitly exempt");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ScannerController_Should_Be_Only_Api_Key_Exempt_Api_Controller()
|
||||
{
|
||||
Type[] exemptControllers = typeof(ScannerController)
|
||||
.Assembly
|
||||
.GetTypes()
|
||||
.Where(t => t.Namespace == typeof(ScannerController).Namespace)
|
||||
.Where(t => t.GetCustomAttributes<ApiControllerAttribute>(inherit: true).Any())
|
||||
.Where(t => t.GetCustomAttributes<SkipApiKeyAuthorizationAttribute>(inherit: true).Any())
|
||||
.ToArray();
|
||||
|
||||
exemptControllers.ShouldBe([typeof(ScannerController)]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Startup_Should_Register_ApiKeyAuthorizationFilter_Globally()
|
||||
{
|
||||
ApiKeyAuthorizationFilterIsGlobal.ShouldBeTrue();
|
||||
}
|
||||
|
||||
private static bool IsGloballyProtected() => ApiKeyAuthorizationFilterIsGlobal;
|
||||
|
||||
private static bool IsApiKeyAuthorizationFilterRegisteredGlobally()
|
||||
{
|
||||
var settings = new Dictionary<string, string?>
|
||||
{
|
||||
["provider"] = "sqlite",
|
||||
["ConnectionStrings:Data"] = "Data Source=:memory:"
|
||||
};
|
||||
|
||||
IConfiguration configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(settings)
|
||||
.Build();
|
||||
|
||||
var environment = Substitute.For<IWebHostEnvironment>();
|
||||
environment.ApplicationName.Returns("ErsatzTV");
|
||||
environment.EnvironmentName.Returns("Development");
|
||||
environment.ContentRootPath.Returns(TestContext.CurrentContext.TestDirectory);
|
||||
environment.WebRootPath.Returns(TestContext.CurrentContext.TestDirectory);
|
||||
environment.ContentRootFileProvider.Returns(new NullFileProvider());
|
||||
environment.WebRootFileProvider.Returns(new NullFileProvider());
|
||||
|
||||
var services = new ServiceCollection();
|
||||
new Startup(configuration, environment).ConfigureServices(services);
|
||||
|
||||
using ServiceProvider provider = services.BuildServiceProvider();
|
||||
MvcOptions options = provider.GetRequiredService<IOptions<MvcOptions>>().Value;
|
||||
|
||||
return options.Filters
|
||||
.OfType<ServiceFilterAttribute>()
|
||||
.Any(a => a.ServiceType == typeof(ApiKeyAuthorizationFilter));
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user