Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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."
|
||||
@@ -59,10 +59,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 +191,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 +223,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
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,25 +7,26 @@
|
||||
</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" />
|
||||
|
||||
@@ -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,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,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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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,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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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,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,53 @@
|
||||
using System.Reflection;
|
||||
using ErsatzTV.Controllers.Api;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Controllers;
|
||||
|
||||
[TestFixture]
|
||||
public class ApiErrorResponseMetadataTests
|
||||
{
|
||||
[TestCase(typeof(ChannelController), nameof(ChannelController.GetById), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(ChannelController), nameof(ChannelController.Create), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(ChannelController), nameof(ChannelController.Create), StatusCodes.Status422UnprocessableEntity)]
|
||||
[TestCase(typeof(ChannelController), nameof(ChannelController.Update), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(ChannelController), nameof(ChannelController.Update), StatusCodes.Status422UnprocessableEntity)]
|
||||
[TestCase(typeof(ChannelController), nameof(ChannelController.Delete), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(ChannelController), nameof(ChannelController.Delete), StatusCodes.Status422UnprocessableEntity)]
|
||||
[TestCase(typeof(ChannelController), nameof(ChannelController.ResetPlayout), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(CollectionController), nameof(CollectionController.GetById), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(CollectionController), nameof(CollectionController.Create), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(CollectionController), nameof(CollectionController.Create), StatusCodes.Status422UnprocessableEntity)]
|
||||
[TestCase(typeof(CollectionController), nameof(CollectionController.Update), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(CollectionController), nameof(CollectionController.Update), StatusCodes.Status422UnprocessableEntity)]
|
||||
[TestCase(typeof(CollectionController), nameof(CollectionController.Delete), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(CollectionController), nameof(CollectionController.Delete), StatusCodes.Status422UnprocessableEntity)]
|
||||
[TestCase(typeof(CollectionController), nameof(CollectionController.AddItems), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(CollectionController), nameof(CollectionController.AddItems), StatusCodes.Status422UnprocessableEntity)]
|
||||
[TestCase(typeof(CollectionController), nameof(CollectionController.RemoveItem), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(CollectionController), nameof(CollectionController.RemoveItem), StatusCodes.Status422UnprocessableEntity)]
|
||||
[TestCase(typeof(SmartCollectionController), nameof(SmartCollectionController.GetById), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(SmartCollectionController), nameof(SmartCollectionController.Create), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(SmartCollectionController), nameof(SmartCollectionController.Create), StatusCodes.Status422UnprocessableEntity)]
|
||||
[TestCase(typeof(SmartCollectionController), nameof(SmartCollectionController.Update), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(SmartCollectionController), nameof(SmartCollectionController.Update), StatusCodes.Status422UnprocessableEntity)]
|
||||
[TestCase(typeof(SmartCollectionController), nameof(SmartCollectionController.Delete), StatusCodes.Status404NotFound)]
|
||||
[TestCase(typeof(SmartCollectionController), nameof(SmartCollectionController.Delete), StatusCodes.Status422UnprocessableEntity)]
|
||||
public void Api_Error_Response_Metadata_Should_Document_ProblemDetails(
|
||||
Type controllerType,
|
||||
string actionName,
|
||||
int statusCode)
|
||||
{
|
||||
MethodInfo action = controllerType.GetMethods().Single(m => m.Name == actionName);
|
||||
|
||||
ProducesResponseTypeAttribute? metadata = action
|
||||
.GetCustomAttributes<ProducesResponseTypeAttribute>(inherit: true)
|
||||
.SingleOrDefault(a => a.StatusCode == statusCode);
|
||||
|
||||
metadata.ShouldNotBeNull($"{controllerType.Name}.{actionName} should document HTTP {statusCode}");
|
||||
metadata.Type.ShouldBe(typeof(ProblemDetails));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using ErsatzTV.Controllers.Api;
|
||||
using ErsatzTV.Filters;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Routing;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Guards the API-key write-path contract for <see cref="ChannelController" />: every mutating
|
||||
/// action (POST/PUT/PATCH/DELETE) must be covered by <see cref="ApiKeyAuthorizationFilter" />.
|
||||
/// The filter is applied at the controller level, so this also protects any future write endpoint
|
||||
/// added to the controller (regression net for the ResetPlayout bypass).
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class ChannelControllerSecurityTests
|
||||
{
|
||||
[Test]
|
||||
public void Controller_Should_Apply_ApiKeyAuthorizationFilter()
|
||||
{
|
||||
ServiceFilterAttribute? filter = typeof(ChannelController)
|
||||
.GetCustomAttributes<ServiceFilterAttribute>(inherit: true)
|
||||
.SingleOrDefault(a => a.ServiceType == typeof(ApiKeyAuthorizationFilter));
|
||||
|
||||
filter.ShouldNotBeNull("ChannelController must carry the ApiKeyAuthorizationFilter at the class level");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Every_Mutating_Action_Should_Be_Protected()
|
||||
{
|
||||
MethodInfo[] actions = typeof(ChannelController)
|
||||
.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly);
|
||||
|
||||
bool controllerHasFilter = typeof(ChannelController)
|
||||
.GetCustomAttributes<ServiceFilterAttribute>(inherit: true)
|
||||
.Any(a => a.ServiceType == typeof(ApiKeyAuthorizationFilter));
|
||||
|
||||
foreach (MethodInfo action in actions)
|
||||
{
|
||||
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 actionHasFilter = action
|
||||
.GetCustomAttributes<ServiceFilterAttribute>(inherit: true)
|
||||
.Any(a => a.ServiceType == typeof(ApiKeyAuthorizationFilter));
|
||||
|
||||
(controllerHasFilter || actionHasFilter)
|
||||
.ShouldBeTrue($"Mutating action {action.Name} is not protected by ApiKeyAuthorizationFilter");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.Artworks;
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Controllers.Api;
|
||||
using ErsatzTV.Controllers.Api.Requests;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using LanguageExt;
|
||||
using static LanguageExt.Prelude;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Tests.Controllers;
|
||||
|
||||
[TestFixture]
|
||||
public class ChannelControllerTests
|
||||
{
|
||||
private IMediator _mediator = null!;
|
||||
private ChannelController _controller = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_mediator = Substitute.For<IMediator>();
|
||||
ChannelWriter<IBackgroundServiceRequest> writer =
|
||||
System.Threading.Channels.Channel.CreateUnbounded<IBackgroundServiceRequest>().Writer;
|
||||
_controller = new ChannelController(writer, _mediator);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_Should_Return_201_With_Location_And_Body()
|
||||
{
|
||||
_mediator.Send(Arg.Any<CreateChannel>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, CreateChannelResult>(new CreateChannelResult(5)));
|
||||
ChannelViewModel vm = MakeVm(5);
|
||||
_mediator.Send(Arg.Any<GetChannelById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<ChannelViewModel>.Some(vm));
|
||||
|
||||
IActionResult result = await _controller.Create(MakeCreateRequest(number: "5"), CancellationToken.None);
|
||||
|
||||
var created = result.ShouldBeOfType<CreatedResult>();
|
||||
created.StatusCode.ShouldBe(201);
|
||||
created.Location.ShouldBe("/api/channels/5");
|
||||
created.Value.ShouldBe(vm);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_Should_Map_Request_To_Command()
|
||||
{
|
||||
_mediator.Send(Arg.Any<CreateChannel>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, CreateChannelResult>(new CreateChannelResult(5)));
|
||||
_mediator.Send(Arg.Any<GetChannelById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<ChannelViewModel>.Some(MakeVm(5)));
|
||||
|
||||
await _controller.Create(MakeCreateRequest(number: "12", name: "Movies"), CancellationToken.None);
|
||||
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<CreateChannel>(c => c.Number == "12" && c.Name == "Movies"),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_Should_Return_422_On_Validation_Error()
|
||||
{
|
||||
_mediator.Send(Arg.Any<CreateChannel>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, CreateChannelResult>(BaseError.New("bad")));
|
||||
|
||||
IActionResult result = await _controller.Create(MakeCreateRequest(), CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Update_Should_Return_200_And_Map_Route_Id()
|
||||
{
|
||||
ChannelViewModel vm = MakeVm(7);
|
||||
_mediator.Send(Arg.Any<UpdateChannel>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, ChannelViewModel>(vm));
|
||||
|
||||
IActionResult result = await _controller.Update(7, MakeUpdateRequest(number: "5"), CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(vm);
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<UpdateChannel>(c => c.ChannelId == 7),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Update_Should_Return_404_For_NotFoundError()
|
||||
{
|
||||
_mediator.Send(Arg.Any<UpdateChannel>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, ChannelViewModel>(new NotFoundError("missing")));
|
||||
|
||||
IActionResult result = await _controller.Update(99, MakeUpdateRequest(), CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Delete_Should_Return_204_On_Success()
|
||||
{
|
||||
_mediator.Send(Arg.Any<DeleteChannel>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default));
|
||||
|
||||
IActionResult result = await _controller.Delete(3, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NoContentResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Delete_Should_Return_404_For_NotFoundError()
|
||||
{
|
||||
_mediator.Send(Arg.Any<DeleteChannel>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, Unit>(new NotFoundError("missing")));
|
||||
|
||||
IActionResult result = await _controller.Delete(99, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetById_Should_Return_200_For_Some()
|
||||
{
|
||||
ChannelViewModel vm = MakeVm(4);
|
||||
_mediator.Send(Arg.Any<GetChannelById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<ChannelViewModel>.Some(vm));
|
||||
|
||||
IActionResult result = await _controller.GetById(4, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(vm);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetById_Should_Return_404_For_None()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetChannelById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<ChannelViewModel>.None);
|
||||
|
||||
IActionResult result = await _controller.GetById(4, CancellationToken.None);
|
||||
|
||||
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
var problemDetails = notFound.Value.ShouldBeOfType<ProblemDetails>();
|
||||
problemDetails.Status.ShouldBe(404);
|
||||
problemDetails.Title.ShouldBe("Resource not found");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ResetPlayout_Should_Return_ProblemDetails_404_For_Missing_Channel()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlayoutIdByChannelNumber>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<int>.None);
|
||||
|
||||
IActionResult result = await _controller.ResetPlayout("404");
|
||||
|
||||
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
var problemDetails = notFound.Value.ShouldBeOfType<ProblemDetails>();
|
||||
problemDetails.Status.ShouldBe(404);
|
||||
problemDetails.Title.ShouldBe("Resource not found");
|
||||
}
|
||||
|
||||
private static ChannelViewModel MakeVm(int id) =>
|
||||
new(
|
||||
id,
|
||||
"5",
|
||||
"Test",
|
||||
"ErsatzTV",
|
||||
string.Empty,
|
||||
1,
|
||||
null,
|
||||
ArtworkContentTypeModel.None,
|
||||
ChannelStreamSelectorMode.Default,
|
||||
string.Empty,
|
||||
string.Empty,
|
||||
string.Empty,
|
||||
ChannelPlayoutSource.Generated,
|
||||
ChannelPlayoutMode.Continuous,
|
||||
null,
|
||||
null,
|
||||
StreamingMode.TransportStreamHybrid,
|
||||
null,
|
||||
null,
|
||||
0,
|
||||
string.Empty,
|
||||
ChannelSubtitleMode.None,
|
||||
ChannelMusicVideoCreditsMode.None,
|
||||
string.Empty,
|
||||
ChannelSongVideoMode.Default,
|
||||
ChannelTranscodeMode.OnDemand,
|
||||
ChannelIdleBehavior.StopOnDisconnect,
|
||||
true,
|
||||
false);
|
||||
|
||||
private static CreateChannelRequest MakeCreateRequest(string number = "5", string name = "Test") =>
|
||||
new(
|
||||
name,
|
||||
number,
|
||||
"ErsatzTV",
|
||||
string.Empty,
|
||||
1,
|
||||
null,
|
||||
ArtworkContentTypeModel.None,
|
||||
ChannelStreamSelectorMode.Default,
|
||||
string.Empty,
|
||||
string.Empty,
|
||||
string.Empty,
|
||||
ChannelPlayoutSource.Generated,
|
||||
ChannelPlayoutMode.Continuous,
|
||||
null,
|
||||
null,
|
||||
StreamingMode.TransportStreamHybrid,
|
||||
null,
|
||||
null,
|
||||
string.Empty,
|
||||
ChannelSubtitleMode.None,
|
||||
ChannelMusicVideoCreditsMode.None,
|
||||
string.Empty,
|
||||
ChannelSongVideoMode.Default,
|
||||
ChannelTranscodeMode.OnDemand,
|
||||
ChannelIdleBehavior.StopOnDisconnect,
|
||||
true,
|
||||
false);
|
||||
|
||||
private static UpdateChannelRequest MakeUpdateRequest(string number = "5", string name = "Test") =>
|
||||
new(
|
||||
name,
|
||||
number,
|
||||
"ErsatzTV",
|
||||
string.Empty,
|
||||
1,
|
||||
null,
|
||||
ArtworkContentTypeModel.None,
|
||||
ChannelStreamSelectorMode.Default,
|
||||
string.Empty,
|
||||
string.Empty,
|
||||
string.Empty,
|
||||
ChannelPlayoutSource.Generated,
|
||||
ChannelPlayoutMode.Continuous,
|
||||
null,
|
||||
null,
|
||||
StreamingMode.TransportStreamHybrid,
|
||||
null,
|
||||
null,
|
||||
string.Empty,
|
||||
ChannelSubtitleMode.None,
|
||||
ChannelMusicVideoCreditsMode.None,
|
||||
string.Empty,
|
||||
ChannelSongVideoMode.Default,
|
||||
ChannelTranscodeMode.OnDemand,
|
||||
ChannelIdleBehavior.StopOnDisconnect,
|
||||
true,
|
||||
false);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System.Reflection;
|
||||
using ErsatzTV.Controllers.Api;
|
||||
using ErsatzTV.Filters;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Routing;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Controllers;
|
||||
|
||||
[TestFixture]
|
||||
public class CollectionControllerSecurityTests
|
||||
{
|
||||
[TestCase(typeof(CollectionController))]
|
||||
[TestCase(typeof(SmartCollectionController))]
|
||||
public void Controller_Should_Apply_ApiKeyAuthorizationFilter(Type controllerType)
|
||||
{
|
||||
ServiceFilterAttribute? filter = controllerType
|
||||
.GetCustomAttributes<ServiceFilterAttribute>(inherit: true)
|
||||
.SingleOrDefault(a => a.ServiceType == typeof(ApiKeyAuthorizationFilter));
|
||||
|
||||
filter.ShouldNotBeNull($"{controllerType.Name} must carry ApiKeyAuthorizationFilter at the class level");
|
||||
}
|
||||
|
||||
[TestCase(typeof(CollectionController))]
|
||||
[TestCase(typeof(SmartCollectionController))]
|
||||
public void Every_Mutating_Action_Should_Be_Protected(Type controllerType)
|
||||
{
|
||||
MethodInfo[] actions = controllerType
|
||||
.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly);
|
||||
|
||||
bool controllerHasFilter = controllerType
|
||||
.GetCustomAttributes<ServiceFilterAttribute>(inherit: true)
|
||||
.Any(a => a.ServiceType == typeof(ApiKeyAuthorizationFilter));
|
||||
|
||||
foreach (MethodInfo action in actions)
|
||||
{
|
||||
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 actionHasFilter = action
|
||||
.GetCustomAttributes<ServiceFilterAttribute>(inherit: true)
|
||||
.Any(a => a.ServiceType == typeof(ApiKeyAuthorizationFilter));
|
||||
|
||||
(controllerHasFilter || actionHasFilter)
|
||||
.ShouldBeTrue($"Mutating action {controllerType.Name}.{action.Name} is not protected");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
using System.Reflection;
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
using ErsatzTV.Controllers.Api;
|
||||
using ErsatzTV.Controllers.Api.Requests;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Routing;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using static LanguageExt.Prelude;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Tests.Controllers;
|
||||
|
||||
[TestFixture]
|
||||
public class CollectionControllerTests
|
||||
{
|
||||
private CollectionController _controller = null!;
|
||||
private IMediator _mediator = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_mediator = Substitute.For<IMediator>();
|
||||
_controller = new CollectionController(_mediator);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Controller_Should_Expose_Idiomatic_Rest_Routes()
|
||||
{
|
||||
ShouldHaveActionRoute(nameof(CollectionController.GetAll), "GET", "/api/collections");
|
||||
ShouldHaveActionRoute(nameof(CollectionController.GetById), "GET", "/api/collections/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(CollectionController.Create), "POST", "/api/collections");
|
||||
ShouldHaveActionRoute(nameof(CollectionController.Update), "PUT", "/api/collections/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(CollectionController.Delete), "DELETE", "/api/collections/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(CollectionController.AddItems), "POST", "/api/collections/{id:int}/items");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(CollectionController.RemoveItem),
|
||||
"DELETE",
|
||||
"/api/collections/{id:int}/items/{mediaItemId:int}");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_Should_Return_201_With_Location_And_Body()
|
||||
{
|
||||
MediaCollectionViewModel vm = MakeVm(5, "Movies");
|
||||
_mediator.Send(Arg.Any<CreateCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, MediaCollectionViewModel>(vm));
|
||||
|
||||
IActionResult result = await _controller.Create(new CreateCollectionRequest("Movies"), CancellationToken.None);
|
||||
|
||||
var created = result.ShouldBeOfType<CreatedResult>();
|
||||
created.StatusCode.ShouldBe(201);
|
||||
created.Location.ShouldBe("/api/collections/5");
|
||||
created.Value.ShouldBe(vm);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_Should_Return_422_On_Validation_Error()
|
||||
{
|
||||
_mediator.Send(Arg.Any<CreateCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, MediaCollectionViewModel>(BaseError.New("bad")));
|
||||
|
||||
IActionResult result = await _controller.Create(new CreateCollectionRequest(string.Empty), CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_Should_Map_Request_To_Command()
|
||||
{
|
||||
_mediator.Send(Arg.Any<CreateCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, MediaCollectionViewModel>(MakeVm(5, "Movies")));
|
||||
|
||||
await _controller.Create(new CreateCollectionRequest("Movies"), CancellationToken.None);
|
||||
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<CreateCollection>(c => c.Name == "Movies"),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Update_Should_Return_200_And_Map_Route_Id()
|
||||
{
|
||||
_mediator.Send(Arg.Any<UpdateCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default));
|
||||
MediaCollectionViewModel vm = MakeVm(7, "Updated");
|
||||
_mediator.Send(Arg.Any<GetCollectionById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<MediaCollectionViewModel>.Some(vm));
|
||||
|
||||
IActionResult result = await _controller.Update(
|
||||
7,
|
||||
new UpdateCollectionRequest("Updated", true),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(vm);
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<UpdateCollection>(c =>
|
||||
c.CollectionId == 7 &&
|
||||
c.Name == "Updated" &&
|
||||
c.UseCustomPlaybackOrder.Match(Some: v => v, None: () => false)),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Update_Should_Return_404_For_NotFoundError()
|
||||
{
|
||||
_mediator.Send(Arg.Any<UpdateCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, Unit>(new NotFoundError("missing")));
|
||||
|
||||
IActionResult result = await _controller.Update(
|
||||
99,
|
||||
new UpdateCollectionRequest("Missing", false),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Delete_Should_Return_204_On_Success()
|
||||
{
|
||||
_mediator.Send(Arg.Any<DeleteCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default));
|
||||
|
||||
IActionResult result = await _controller.Delete(3, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NoContentResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Delete_Should_Return_404_For_NotFoundError()
|
||||
{
|
||||
_mediator.Send(Arg.Any<DeleteCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, Unit>(new NotFoundError("missing")));
|
||||
|
||||
IActionResult result = await _controller.Delete(99, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AddItems_Should_Return_204_And_Map_Route_Id()
|
||||
{
|
||||
_mediator.Send(Arg.Any<AddItemsToCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default));
|
||||
|
||||
IActionResult result = await _controller.AddItems(
|
||||
3,
|
||||
new AddItemsToCollectionRequest([10], null, null, null, null, null, null, null, null, null),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NoContentResult>();
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<AddItemsToCollection>(c => c.CollectionId == 3 && c.MovieIds.SequenceEqual(new[] { 10 })),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task AddItems_Should_Return_422_On_Validation_Error()
|
||||
{
|
||||
_mediator.Send(Arg.Any<AddItemsToCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, Unit>(BaseError.New("bad")));
|
||||
|
||||
IActionResult result = await _controller.AddItems(
|
||||
3,
|
||||
new AddItemsToCollectionRequest(null, null, null, null, null, null, null, null, null, [999]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RemoveItem_Should_Return_204_And_Map_Route_Ids()
|
||||
{
|
||||
_mediator.Send(Arg.Any<RemoveItemsFromCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default));
|
||||
|
||||
IActionResult result = await _controller.RemoveItem(3, 10, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NoContentResult>();
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<RemoveItemsFromCollection>(c =>
|
||||
c.MediaCollectionId == 3 && c.MediaItemIds.SequenceEqual(new[] { 10 })),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RemoveItem_Should_Return_404_For_NotFoundError()
|
||||
{
|
||||
_mediator.Send(Arg.Any<RemoveItemsFromCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, Unit>(new NotFoundError("missing")));
|
||||
|
||||
IActionResult result = await _controller.RemoveItem(3, 10, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetAll_Should_Return_Collections()
|
||||
{
|
||||
List<MediaCollectionViewModel> vms = [MakeVm(1, "Movies"), MakeVm(2, "Shows")];
|
||||
_mediator.Send(Arg.Any<GetAllCollections>(), Arg.Any<CancellationToken>())
|
||||
.Returns(vms);
|
||||
|
||||
List<MediaCollectionViewModel> result = await _controller.GetAll(CancellationToken.None);
|
||||
|
||||
result.ShouldBe(vms);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetById_Should_Return_200_For_Some()
|
||||
{
|
||||
MediaCollectionViewModel vm = MakeVm(4, "Movies");
|
||||
_mediator.Send(Arg.Any<GetCollectionById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<MediaCollectionViewModel>.Some(vm));
|
||||
|
||||
IActionResult result = await _controller.GetById(4, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(vm);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetById_Should_Return_404_For_None()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetCollectionById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<MediaCollectionViewModel>.None);
|
||||
|
||||
IActionResult result = await _controller.GetById(4, CancellationToken.None);
|
||||
|
||||
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
var problemDetails = notFound.Value.ShouldBeOfType<ProblemDetails>();
|
||||
problemDetails.Status.ShouldBe(404);
|
||||
problemDetails.Title.ShouldBe("Resource not found");
|
||||
}
|
||||
|
||||
private static MediaCollectionViewModel MakeVm(int id, string name) =>
|
||||
new(CollectionType.Collection, id, name, false, MediaItemState.Normal);
|
||||
|
||||
private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route)
|
||||
{
|
||||
MethodInfo action = typeof(CollectionController).GetMethod(actionName)
|
||||
?? throw new AssertionException($"Missing action {actionName}");
|
||||
|
||||
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
|
||||
attribute.HttpMethods.ShouldContain(httpMethod);
|
||||
attribute.Template.ShouldBe(route);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using System.Text.Json;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Controllers;
|
||||
|
||||
[TestFixture]
|
||||
public class OpenApiErrorResponseContractTests
|
||||
{
|
||||
[TestCase("/api/channels/{id}", "get", "404")]
|
||||
[TestCase("/api/channels", "post", "404")]
|
||||
[TestCase("/api/channels", "post", "422")]
|
||||
[TestCase("/api/channels/{id}", "put", "404")]
|
||||
[TestCase("/api/channels/{id}", "put", "422")]
|
||||
[TestCase("/api/channels/{id}", "delete", "404")]
|
||||
[TestCase("/api/channels/{id}", "delete", "422")]
|
||||
[TestCase("/api/channels/{channelNumber}/playout/reset", "post", "404")]
|
||||
[TestCase("/api/collections/{id}", "get", "404")]
|
||||
[TestCase("/api/collections", "post", "404")]
|
||||
[TestCase("/api/collections", "post", "422")]
|
||||
[TestCase("/api/collections/{id}", "put", "404")]
|
||||
[TestCase("/api/collections/{id}", "put", "422")]
|
||||
[TestCase("/api/collections/{id}", "delete", "404")]
|
||||
[TestCase("/api/collections/{id}", "delete", "422")]
|
||||
[TestCase("/api/collections/{id}/items", "post", "404")]
|
||||
[TestCase("/api/collections/{id}/items", "post", "422")]
|
||||
[TestCase("/api/collections/{id}/items/{mediaItemId}", "delete", "404")]
|
||||
[TestCase("/api/collections/{id}/items/{mediaItemId}", "delete", "422")]
|
||||
[TestCase("/api/smart-collections/{id}", "get", "404")]
|
||||
[TestCase("/api/smart-collections", "post", "404")]
|
||||
[TestCase("/api/smart-collections", "post", "422")]
|
||||
[TestCase("/api/smart-collections/{id}", "put", "404")]
|
||||
[TestCase("/api/smart-collections/{id}", "put", "422")]
|
||||
[TestCase("/api/smart-collections/{id}", "delete", "404")]
|
||||
[TestCase("/api/smart-collections/{id}", "delete", "422")]
|
||||
public void Static_OpenApi_Should_Document_ProblemDetails_For_Api_Error_Responses(
|
||||
string path,
|
||||
string method,
|
||||
string statusCode)
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(File.ReadAllText(FindOpenApiDocument()));
|
||||
|
||||
JsonElement response = document.RootElement
|
||||
.GetProperty("paths")
|
||||
.GetProperty(path)
|
||||
.GetProperty(method)
|
||||
.GetProperty("responses")
|
||||
.GetProperty(statusCode);
|
||||
|
||||
JsonElement content = response.GetProperty("content");
|
||||
content.EnumerateObject().ShouldNotBeEmpty();
|
||||
|
||||
foreach (JsonProperty mediaType in content.EnumerateObject())
|
||||
{
|
||||
string? schemaRef = mediaType.Value
|
||||
.GetProperty("schema")
|
||||
.GetProperty("$ref")
|
||||
.GetString();
|
||||
|
||||
schemaRef.ShouldBe("#/components/schemas/ProblemDetails", mediaType.Name);
|
||||
}
|
||||
}
|
||||
|
||||
private static string FindOpenApiDocument()
|
||||
{
|
||||
DirectoryInfo? directory = new(TestContext.CurrentContext.TestDirectory);
|
||||
while (directory is not null)
|
||||
{
|
||||
string candidate = Path.Combine(directory.FullName, "ErsatzTV", "wwwroot", "openapi", "v1.json");
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
throw new FileNotFoundException("Could not find ErsatzTV/wwwroot/openapi/v1.json");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
using System.Reflection;
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
using ErsatzTV.Controllers.Api;
|
||||
using ErsatzTV.Controllers.Api.Requests;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.SmartCollections;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Filters;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Routing;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using static LanguageExt.Prelude;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Tests.Controllers;
|
||||
|
||||
[TestFixture]
|
||||
public class SmartCollectionControllerTests
|
||||
{
|
||||
private SmartCollectionController _controller = null!;
|
||||
private IMediator _mediator = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_mediator = Substitute.For<IMediator>();
|
||||
_controller = new SmartCollectionController(_mediator);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Controller_Should_Expose_Idiomatic_Rest_Routes()
|
||||
{
|
||||
ShouldHaveActionRoute(nameof(SmartCollectionController.GetAll), "GET", "/api/smart-collections");
|
||||
ShouldHaveActionRoute(nameof(SmartCollectionController.GetById), "GET", "/api/smart-collections/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(SmartCollectionController.Create), "POST", "/api/smart-collections");
|
||||
ShouldHaveActionRoute(nameof(SmartCollectionController.Update), "PUT", "/api/smart-collections/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(SmartCollectionController.Delete), "DELETE", "/api/smart-collections/{id:int}");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Controller_Should_Apply_ApiKeyAuthorizationFilter()
|
||||
{
|
||||
ServiceFilterAttribute? filter = typeof(SmartCollectionController)
|
||||
.GetCustomAttributes<ServiceFilterAttribute>(inherit: true)
|
||||
.SingleOrDefault(a => a.ServiceType == typeof(ApiKeyAuthorizationFilter));
|
||||
|
||||
filter.ShouldNotBeNull("SmartCollectionController must carry ApiKeyAuthorizationFilter at the class level");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_Should_Return_201_With_Location_And_Body()
|
||||
{
|
||||
var vm = new SmartCollectionViewModel(7, "Kids", "tag:family");
|
||||
_mediator.Send(Arg.Any<CreateSmartCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, SmartCollectionViewModel>(vm));
|
||||
|
||||
IActionResult result = await _controller.Create(
|
||||
new CreateSmartCollectionRequest("Kids", "tag:family"),
|
||||
CancellationToken.None);
|
||||
|
||||
var created = result.ShouldBeOfType<CreatedResult>();
|
||||
created.StatusCode.ShouldBe(201);
|
||||
created.Location.ShouldBe("/api/smart-collections/7");
|
||||
created.Value.ShouldBe(vm);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_Should_Return_422_On_Validation_Error()
|
||||
{
|
||||
_mediator.Send(Arg.Any<CreateSmartCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, SmartCollectionViewModel>(BaseError.New("bad")));
|
||||
|
||||
IActionResult result = await _controller.Create(
|
||||
new CreateSmartCollectionRequest(string.Empty, string.Empty),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_Should_Map_Request_To_Command()
|
||||
{
|
||||
_mediator.Send(Arg.Any<CreateSmartCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, SmartCollectionViewModel>(
|
||||
new SmartCollectionViewModel(7, "Kids", "tag:family")));
|
||||
|
||||
await _controller.Create(new CreateSmartCollectionRequest("Kids", "tag:family"), CancellationToken.None);
|
||||
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<CreateSmartCollection>(c => c.Name == "Kids" && c.Query == "tag:family"),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Update_Should_Return_200_And_Map_Route_Id()
|
||||
{
|
||||
_mediator.Send(Arg.Any<UpdateSmartCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, UpdateSmartCollectionResult>(new UpdateSmartCollectionResult(8)));
|
||||
var vm = new SmartCollectionViewModel(8, "Updated", "tag:updated");
|
||||
_mediator.Send(Arg.Any<GetSmartCollectionById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<SmartCollectionViewModel>.Some(vm));
|
||||
|
||||
IActionResult result = await _controller.Update(
|
||||
8,
|
||||
new UpdateSmartCollectionRequest("Updated", "tag:updated"),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(vm);
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<UpdateSmartCollection>(c =>
|
||||
c.Id == 8 && c.Name == "Updated" && c.Query == "tag:updated"),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Update_Should_Return_404_For_NotFoundError()
|
||||
{
|
||||
_mediator.Send(Arg.Any<UpdateSmartCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, UpdateSmartCollectionResult>(new NotFoundError("missing")));
|
||||
|
||||
IActionResult result = await _controller.Update(
|
||||
99,
|
||||
new UpdateSmartCollectionRequest("Missing", "tag:missing"),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Delete_Should_Return_204_On_Success()
|
||||
{
|
||||
_mediator.Send(Arg.Any<DeleteSmartCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default));
|
||||
|
||||
IActionResult result = await _controller.Delete(9, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NoContentResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Delete_Should_Return_404_For_NotFoundError()
|
||||
{
|
||||
_mediator.Send(Arg.Any<DeleteSmartCollection>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, Unit>(new NotFoundError("missing")));
|
||||
|
||||
IActionResult result = await _controller.Delete(9, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetAll_Should_Return_SmartCollections()
|
||||
{
|
||||
List<SmartCollectionResponseModel> vms =
|
||||
[
|
||||
new SmartCollectionResponseModel(1, "Kids", "tag:kids"),
|
||||
new SmartCollectionResponseModel(2, "News", "tag:news")
|
||||
];
|
||||
_mediator.Send(Arg.Any<GetAllSmartCollectionsForApi>(), Arg.Any<CancellationToken>())
|
||||
.Returns(vms);
|
||||
|
||||
List<SmartCollectionResponseModel> result = await _controller.GetAll(CancellationToken.None);
|
||||
|
||||
result.ShouldBe(vms);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetById_Should_Return_200_For_Some()
|
||||
{
|
||||
var vm = new SmartCollectionViewModel(4, "Kids", "tag:kids");
|
||||
_mediator.Send(Arg.Any<GetSmartCollectionById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<SmartCollectionViewModel>.Some(vm));
|
||||
|
||||
IActionResult result = await _controller.GetById(4, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(vm);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetById_Should_Return_404_For_None()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetSmartCollectionById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<SmartCollectionViewModel>.None);
|
||||
|
||||
IActionResult result = await _controller.GetById(4, CancellationToken.None);
|
||||
|
||||
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
var problemDetails = notFound.Value.ShouldBeOfType<ProblemDetails>();
|
||||
problemDetails.Status.ShouldBe(404);
|
||||
problemDetails.Title.ShouldBe("Resource not found");
|
||||
}
|
||||
|
||||
private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route)
|
||||
{
|
||||
MethodInfo action = typeof(SmartCollectionController).GetMethod(actionName)
|
||||
?? throw new AssertionException($"Missing action {actionName}");
|
||||
|
||||
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
|
||||
attribute.HttpMethods.ShouldContain(httpMethod);
|
||||
attribute.Template.ShouldBe(route);
|
||||
}
|
||||
}
|
||||
@@ -11,13 +11,16 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="NUnit" Version="4.4.0" />
|
||||
<PackageReference Include="NUnit.Analyzers" Version="4.11.2">
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="NUnit" />
|
||||
<PackageReference Include="NUnit.Analyzers">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="6.1.0" />
|
||||
<PackageReference Include="Shouldly" Version="4.3.0" />
|
||||
<PackageReference Include="NUnit3TestAdapter" />
|
||||
<PackageReference Include="NSubstitute" />
|
||||
<PackageReference Include="Shouldly" />
|
||||
<PackageReference Include="Testably.Abstractions.Testing" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Extensions;
|
||||
using LanguageExt;
|
||||
using static LanguageExt.Prelude;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Extensions;
|
||||
|
||||
[TestFixture]
|
||||
public class ApiResultsTests
|
||||
{
|
||||
[Test]
|
||||
public void ToErrorResult_Should_Map_NotFoundError_To_404()
|
||||
{
|
||||
IActionResult result = new NotFoundError("missing").ToErrorResult();
|
||||
result.ShouldBeOfType<NotFoundObjectResult>().StatusCode.ShouldBe(404);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ToErrorResult_Should_Return_ProblemDetails_For_NotFoundError()
|
||||
{
|
||||
IActionResult result = new NotFoundError("missing").ToErrorResult();
|
||||
|
||||
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
var problemDetails = notFound.Value.ShouldBeOfType<ProblemDetails>();
|
||||
problemDetails.Status.ShouldBe(404);
|
||||
problemDetails.Title.ShouldBe("Resource not found");
|
||||
problemDetails.Detail.ShouldBe("missing");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ToErrorResult_Should_Map_Other_Error_To_422()
|
||||
{
|
||||
IActionResult result = BaseError.New("bad").ToErrorResult();
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>().StatusCode.ShouldBe(422);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ToErrorResult_Should_Return_ProblemDetails_For_Validation_Error()
|
||||
{
|
||||
IActionResult result = BaseError.New("bad").ToErrorResult();
|
||||
|
||||
var unprocessable = result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
var problemDetails = unprocessable.Value.ShouldBeOfType<ProblemDetails>();
|
||||
problemDetails.Status.ShouldBe(422);
|
||||
problemDetails.Title.ShouldBe("Validation failed");
|
||||
problemDetails.Detail.ShouldBe("bad");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ToCreatedResult_Should_Return_201_With_Location_And_Body()
|
||||
{
|
||||
Either<BaseError, int> either = Right<BaseError, int>(5);
|
||||
|
||||
IActionResult result = either.ToCreatedResult(id => $"/api/channels/{id}", id => $"body-{id}");
|
||||
|
||||
var created = result.ShouldBeOfType<CreatedResult>();
|
||||
created.StatusCode.ShouldBe(201);
|
||||
created.Location.ShouldBe("/api/channels/5");
|
||||
created.Value.ShouldBe("body-5");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ToCreatedResult_Should_Map_NotFoundError_To_404()
|
||||
{
|
||||
Either<BaseError, int> either = Left<BaseError, int>(new NotFoundError("nope"));
|
||||
|
||||
IActionResult result = either.ToCreatedResult(id => $"/api/channels/{id}", id => id);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ToUpdatedResult_Should_Return_200_On_Right()
|
||||
{
|
||||
Either<BaseError, string> either = Right<BaseError, string>("vm");
|
||||
IActionResult result = either.ToUpdatedResult();
|
||||
|
||||
var ok = result.ShouldBeOfType<OkObjectResult>();
|
||||
ok.Value.ShouldBe("vm");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ToUpdatedResult_Should_Return_404_For_NotFoundError()
|
||||
{
|
||||
Either<BaseError, string> either = Left<BaseError, string>(new NotFoundError("missing"));
|
||||
IActionResult result = either.ToUpdatedResult();
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ToUpdatedResult_Should_Return_422_For_Other_Error()
|
||||
{
|
||||
Either<BaseError, string> either = Left<BaseError, string>(BaseError.New("bad"));
|
||||
IActionResult result = either.ToUpdatedResult();
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ToDeletedResult_Should_Return_204_On_Right()
|
||||
{
|
||||
Either<BaseError, Unit> either = Right<BaseError, Unit>(Unit.Default);
|
||||
IActionResult result = either.ToDeletedResult();
|
||||
|
||||
result.ShouldBeOfType<NoContentResult>().StatusCode.ShouldBe(204);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ToDeletedResult_Should_Return_404_For_NotFoundError()
|
||||
{
|
||||
Either<BaseError, Unit> either = Left<BaseError, Unit>(new NotFoundError("missing"));
|
||||
IActionResult result = either.ToDeletedResult();
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ToGetResult_Should_Return_200_For_Some()
|
||||
{
|
||||
Option<string> option = "value";
|
||||
IActionResult result = option.ToGetResult();
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe("value");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ToGetResult_Should_Return_404_For_None()
|
||||
{
|
||||
Option<string> option = Option<string>.None;
|
||||
IActionResult result = option.ToGetResult();
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ToGetResult_Should_Return_ProblemDetails_For_None()
|
||||
{
|
||||
Option<string> option = Option<string>.None;
|
||||
IActionResult result = option.ToGetResult();
|
||||
|
||||
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
var problemDetails = notFound.Value.ShouldBeOfType<ProblemDetails>();
|
||||
problemDetails.Status.ShouldBe(404);
|
||||
problemDetails.Title.ShouldBe("Resource not found");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using System.Collections.Generic;
|
||||
using ErsatzTV.Filters;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Abstractions;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Filters;
|
||||
|
||||
[TestFixture]
|
||||
public class ApiKeyAuthorizationFilterTests
|
||||
{
|
||||
private static AuthorizationFilterContext MakeContext(string method, string? apiKeyHeader)
|
||||
{
|
||||
var httpContext = new DefaultHttpContext();
|
||||
httpContext.Request.Method = method;
|
||||
if (apiKeyHeader is not null)
|
||||
{
|
||||
httpContext.Request.Headers[ApiKeyAuthorizationFilter.HeaderName] = apiKeyHeader;
|
||||
}
|
||||
|
||||
var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor());
|
||||
return new AuthorizationFilterContext(actionContext, new List<IFilterMetadata>());
|
||||
}
|
||||
|
||||
private static ApiKeyAuthorizationFilter MakeFilter(string? configuredKey)
|
||||
{
|
||||
var settings = new Dictionary<string, string?>();
|
||||
if (configuredKey is not null)
|
||||
{
|
||||
settings[ApiKeyAuthorizationFilter.ConfigurationKey] = configuredKey;
|
||||
}
|
||||
|
||||
IConfiguration configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build();
|
||||
return new ApiKeyAuthorizationFilter(configuration);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Allow_When_Key_Not_Configured()
|
||||
{
|
||||
AuthorizationFilterContext context = MakeContext("POST", apiKeyHeader: null);
|
||||
MakeFilter(configuredKey: null).OnAuthorization(context);
|
||||
context.Result.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Allow_When_Key_Configured_Empty()
|
||||
{
|
||||
AuthorizationFilterContext context = MakeContext("POST", apiKeyHeader: null);
|
||||
MakeFilter(configuredKey: string.Empty).OnAuthorization(context);
|
||||
context.Result.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Reject_Mutating_Request_When_Key_Configured_And_Header_Missing()
|
||||
{
|
||||
AuthorizationFilterContext context = MakeContext("POST", apiKeyHeader: null);
|
||||
MakeFilter(configuredKey: "secret").OnAuthorization(context);
|
||||
context.Result.ShouldBeOfType<UnauthorizedResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Reject_Mutating_Request_When_Key_Wrong()
|
||||
{
|
||||
AuthorizationFilterContext context = MakeContext("DELETE", apiKeyHeader: "wrong");
|
||||
MakeFilter(configuredKey: "secret").OnAuthorization(context);
|
||||
context.Result.ShouldBeOfType<UnauthorizedResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Allow_Mutating_Request_When_Key_Correct()
|
||||
{
|
||||
AuthorizationFilterContext context = MakeContext("PUT", apiKeyHeader: "secret");
|
||||
MakeFilter(configuredKey: "secret").OnAuthorization(context);
|
||||
context.Result.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Allow_Get_Request_Even_When_Key_Configured()
|
||||
{
|
||||
AuthorizationFilterContext context = MakeContext("GET", apiKeyHeader: null);
|
||||
MakeFilter(configuredKey: "secret").OnAuthorization(context);
|
||||
context.Result.ShouldBeNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
using ErsatzTV.Infrastructure.Data.Repositories;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using Testably.Abstractions.Testing;
|
||||
|
||||
namespace ErsatzTV.Tests.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end create -> read (get-by-id) -> delete against the in-memory SQLite harness,
|
||||
/// exercising the real EF Core handlers and repository.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class ChannelLifecycleIntegrationTests : ChannelHandlerTestBase
|
||||
{
|
||||
[Test]
|
||||
public async Task Create_Then_Read_Then_Delete()
|
||||
{
|
||||
await SeedFFmpegProfile();
|
||||
|
||||
var createHandler = new CreateChannelHandler(Worker, Db.Factory, SearchTargets);
|
||||
Either<BaseError, CreateChannelResult> created =
|
||||
await createHandler.Handle(MakeCreate(number: "42", name: "Integration"), CancellationToken.None);
|
||||
|
||||
int channelId = created.Match(Left: _ => throw new AssertionException("create failed"), Right: r => r.ChannelId);
|
||||
channelId.ShouldBeGreaterThan(0);
|
||||
|
||||
var getHandler = new GetChannelByIdHandler(new ChannelRepository(Db.Factory));
|
||||
Option<ChannelViewModel> afterCreate =
|
||||
await getHandler.Handle(new GetChannelById(channelId), CancellationToken.None);
|
||||
afterCreate.IsSome.ShouldBeTrue();
|
||||
afterCreate.Match(
|
||||
Some: vm =>
|
||||
{
|
||||
vm.Number.ShouldBe("42");
|
||||
vm.Name.ShouldBe("Integration");
|
||||
},
|
||||
None: () => throw new AssertionException("expected channel to exist"));
|
||||
|
||||
var deleteHandler = new DeleteChannelHandler(Worker, Db.Factory, new MockFileSystem(), SearchTargets);
|
||||
Either<BaseError, Unit> deleted =
|
||||
await deleteHandler.Handle(new DeleteChannel(channelId), CancellationToken.None);
|
||||
deleted.IsRight.ShouldBeTrue();
|
||||
|
||||
Option<ChannelViewModel> afterDelete =
|
||||
await getHandler.Handle(new GetChannelById(channelId), CancellationToken.None);
|
||||
afterDelete.IsNone.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
using ErsatzTV.Application.Search;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using LanguageExt;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Tests.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end create -> read -> add item -> remove item -> delete against the in-memory SQLite harness,
|
||||
/// exercising the real EF Core handlers and collection item persistence.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class CollectionLifecycleIntegrationTests : MediaCollectionHandlerTestBase
|
||||
{
|
||||
[Test]
|
||||
public async Task Create_Read_AddItem_RemoveItem_Delete()
|
||||
{
|
||||
int movieId = await SeedMovie();
|
||||
IMediaCollectionRepository mediaCollectionRepository = Substitute.For<IMediaCollectionRepository>();
|
||||
mediaCollectionRepository.PlayoutIdsUsingCollection(Arg.Any<int>()).Returns([]);
|
||||
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 createHandler = new CreateCollectionHandler(Db.Factory, SearchTargets);
|
||||
Either<BaseError, MediaCollectionViewModel> created =
|
||||
await createHandler.Handle(new CreateCollection("Integration"), CancellationToken.None);
|
||||
|
||||
int collectionId = created.Match(Left: _ => throw new AssertionException("create failed"), Right: r => r.Id);
|
||||
collectionId.ShouldBeGreaterThan(0);
|
||||
|
||||
var getHandler = new GetCollectionByIdHandler(Db.Factory);
|
||||
Option<MediaCollectionViewModel> afterCreate =
|
||||
await getHandler.Handle(new GetCollectionById(collectionId), CancellationToken.None);
|
||||
afterCreate.IsSome.ShouldBeTrue();
|
||||
afterCreate.Match(
|
||||
Some: vm => vm.Name.ShouldBe("Integration"),
|
||||
None: () => throw new AssertionException("expected collection to exist"));
|
||||
|
||||
var addHandler = new AddItemsToCollectionHandler(
|
||||
Db.Factory,
|
||||
mediaCollectionRepository,
|
||||
movieRepository,
|
||||
televisionRepository,
|
||||
Worker,
|
||||
System.Threading.Channels.Channel.CreateUnbounded<ISearchIndexBackgroundServiceRequest>().Writer);
|
||||
Either<BaseError, Unit> added =
|
||||
await addHandler.Handle(MakeAddItems(collectionId, movieId), CancellationToken.None);
|
||||
added.IsRight.ShouldBeTrue();
|
||||
|
||||
await using (TvContext context = Db.CreateContext())
|
||||
{
|
||||
bool itemExists = await context.CollectionItems
|
||||
.AnyAsync(ci => ci.CollectionId == collectionId && ci.MediaItemId == movieId);
|
||||
itemExists.ShouldBeTrue();
|
||||
}
|
||||
|
||||
var removeHandler = new RemoveItemsFromCollectionHandler(
|
||||
Db.Factory,
|
||||
mediaCollectionRepository,
|
||||
Worker,
|
||||
System.Threading.Channels.Channel.CreateUnbounded<ISearchIndexBackgroundServiceRequest>().Writer);
|
||||
Either<BaseError, Unit> removed =
|
||||
await removeHandler.Handle(
|
||||
new RemoveItemsFromCollection(collectionId) { MediaItemIds = [movieId] },
|
||||
CancellationToken.None);
|
||||
removed.IsRight.ShouldBeTrue();
|
||||
|
||||
await using (TvContext context = Db.CreateContext())
|
||||
{
|
||||
bool itemExists = await context.CollectionItems
|
||||
.AnyAsync(ci => ci.CollectionId == collectionId && ci.MediaItemId == movieId);
|
||||
itemExists.ShouldBeFalse();
|
||||
}
|
||||
|
||||
var deleteHandler = new DeleteCollectionHandler(Db.Factory, SearchTargets);
|
||||
Either<BaseError, Unit> deleted =
|
||||
await deleteHandler.Handle(new DeleteCollection(collectionId), CancellationToken.None);
|
||||
deleted.IsRight.ShouldBeTrue();
|
||||
|
||||
Option<MediaCollectionViewModel> afterDelete =
|
||||
await getHandler.Handle(new GetCollectionById(collectionId), CancellationToken.None);
|
||||
afterDelete.IsNone.ShouldBeTrue();
|
||||
}
|
||||
|
||||
private async Task<int> SeedMovie()
|
||||
{
|
||||
await using TvContext context = Db.CreateContext();
|
||||
var mediaSource = new LocalMediaSource();
|
||||
var library = new LocalLibrary
|
||||
{
|
||||
Name = "Movies",
|
||||
MediaKind = LibraryMediaKind.Movies,
|
||||
MediaSource = mediaSource,
|
||||
Paths = []
|
||||
};
|
||||
var libraryPath = new LibraryPath
|
||||
{
|
||||
Path = "/media/movies",
|
||||
Library = library,
|
||||
LibraryFolders = [],
|
||||
MediaItems = []
|
||||
};
|
||||
var movie = new Movie
|
||||
{
|
||||
LibraryPath = libraryPath,
|
||||
MovieMetadata = [],
|
||||
MediaVersions = [],
|
||||
Collections = []
|
||||
};
|
||||
context.Movies.Add(movie);
|
||||
await context.SaveChangesAsync();
|
||||
return movie.Id;
|
||||
}
|
||||
|
||||
private static AddItemsToCollection MakeAddItems(int collectionId, int movieId) =>
|
||||
new(collectionId, [movieId], [], [], [], [], [], [], [], [], []);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.Artworks;
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using DomainChannel = ErsatzTV.Core.Domain.Channel;
|
||||
|
||||
namespace ErsatzTV.Tests.Support;
|
||||
|
||||
public abstract class ChannelHandlerTestBase
|
||||
{
|
||||
protected InMemoryTvContext Db = null!;
|
||||
protected ChannelWriter<IBackgroundServiceRequest> Worker = null!;
|
||||
protected ISearchTargets SearchTargets = null!;
|
||||
|
||||
[SetUp]
|
||||
public async Task BaseSetUp()
|
||||
{
|
||||
Db = await InMemoryTvContext.CreateAsync();
|
||||
Worker = System.Threading.Channels.Channel.CreateUnbounded<IBackgroundServiceRequest>().Writer;
|
||||
SearchTargets = Substitute.For<ISearchTargets>();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public async Task BaseTearDown() => await Db.DisposeAsync();
|
||||
|
||||
protected async Task SeedFFmpegProfile(int id = 1)
|
||||
{
|
||||
await using TvContext context = Db.CreateContext();
|
||||
context.FFmpegProfiles.Add(new FFmpegProfile { Id = id, Name = $"profile-{id}" });
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
protected async Task SeedChannel(int id, string number, string name = "Test", int ffmpegProfileId = 1)
|
||||
{
|
||||
await using TvContext context = Db.CreateContext();
|
||||
context.Channels.Add(
|
||||
new DomainChannel(Guid.NewGuid())
|
||||
{
|
||||
Id = id,
|
||||
Number = number,
|
||||
Name = name,
|
||||
Group = "ErsatzTV",
|
||||
Categories = string.Empty,
|
||||
FFmpegProfileId = ffmpegProfileId,
|
||||
StreamSelector = string.Empty,
|
||||
PreferredAudioLanguageCode = string.Empty,
|
||||
PreferredAudioTitle = string.Empty,
|
||||
PreferredSubtitleLanguageCode = string.Empty,
|
||||
MusicVideoCreditsTemplate = string.Empty,
|
||||
StreamingMode = StreamingMode.TransportStreamHybrid,
|
||||
PlayoutSource = ChannelPlayoutSource.Generated,
|
||||
PlayoutMode = ChannelPlayoutMode.Continuous
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
protected static CreateChannel MakeCreate(
|
||||
string number = "5",
|
||||
int ffmpegProfileId = 1,
|
||||
bool isEnabled = true,
|
||||
bool showInEpg = false,
|
||||
string logoPath = "",
|
||||
string name = "Test",
|
||||
string group = "ErsatzTV") =>
|
||||
new(
|
||||
name,
|
||||
number,
|
||||
group,
|
||||
string.Empty,
|
||||
ffmpegProfileId,
|
||||
null,
|
||||
new ArtworkContentTypeModel(logoPath, string.Empty),
|
||||
ChannelStreamSelectorMode.Default,
|
||||
string.Empty,
|
||||
string.Empty,
|
||||
string.Empty,
|
||||
ChannelPlayoutSource.Generated,
|
||||
ChannelPlayoutMode.Continuous,
|
||||
null,
|
||||
null,
|
||||
StreamingMode.TransportStreamHybrid,
|
||||
null,
|
||||
null,
|
||||
string.Empty,
|
||||
ChannelSubtitleMode.None,
|
||||
ChannelMusicVideoCreditsMode.None,
|
||||
string.Empty,
|
||||
ChannelSongVideoMode.Default,
|
||||
ChannelTranscodeMode.OnDemand,
|
||||
ChannelIdleBehavior.StopOnDisconnect,
|
||||
isEnabled,
|
||||
showInEpg);
|
||||
|
||||
protected static UpdateChannel MakeUpdate(
|
||||
int channelId,
|
||||
string number = "5",
|
||||
int ffmpegProfileId = 1,
|
||||
bool isEnabled = true,
|
||||
bool showInEpg = false,
|
||||
string logoPath = "",
|
||||
string name = "Test",
|
||||
string group = "ErsatzTV") =>
|
||||
new(
|
||||
channelId,
|
||||
name,
|
||||
number,
|
||||
group,
|
||||
string.Empty,
|
||||
ffmpegProfileId,
|
||||
null,
|
||||
new ArtworkContentTypeModel(logoPath, string.Empty),
|
||||
ChannelStreamSelectorMode.Default,
|
||||
string.Empty,
|
||||
string.Empty,
|
||||
string.Empty,
|
||||
ChannelPlayoutSource.Generated,
|
||||
ChannelPlayoutMode.Continuous,
|
||||
null,
|
||||
null,
|
||||
StreamingMode.TransportStreamHybrid,
|
||||
null,
|
||||
null,
|
||||
string.Empty,
|
||||
ChannelSubtitleMode.None,
|
||||
ChannelMusicVideoCreditsMode.None,
|
||||
string.Empty,
|
||||
ChannelSongVideoMode.Default,
|
||||
ChannelTranscodeMode.OnDemand,
|
||||
ChannelIdleBehavior.StopOnDisconnect,
|
||||
isEnabled,
|
||||
showInEpg);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using ErsatzTV.Infrastructure;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace ErsatzTV.Tests.Support;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory SQLite harness for handler/integration tests. A single <see cref="SqliteConnection" />
|
||||
/// is kept open for the lifetime of the harness so the schema (built via
|
||||
/// <see cref="DatabaseFacade.EnsureCreatedAsync" />) and data persist across the multiple
|
||||
/// <see cref="TvContext" /> instances created by an <see cref="IDbContextFactory{TvContext}" />.
|
||||
/// Foreign keys are disabled so partial graphs can be seeded without satisfying every FK.
|
||||
/// </summary>
|
||||
public sealed class InMemoryTvContext : IAsyncDisposable
|
||||
{
|
||||
private readonly SqliteConnection _connection;
|
||||
private readonly DbContextOptions<TvContext> _options;
|
||||
|
||||
private InMemoryTvContext(SqliteConnection connection, DbContextOptions<TvContext> options)
|
||||
{
|
||||
_connection = connection;
|
||||
_options = options;
|
||||
}
|
||||
|
||||
public IDbContextFactory<TvContext> Factory => new TestDbContextFactory(_options);
|
||||
|
||||
public static async Task<InMemoryTvContext> CreateAsync()
|
||||
{
|
||||
TvContext.IsSqlite = true;
|
||||
|
||||
var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=False");
|
||||
await connection.OpenAsync();
|
||||
|
||||
DbContextOptions<TvContext> options = new DbContextOptionsBuilder<TvContext>()
|
||||
.UseSqlite(connection)
|
||||
.Options;
|
||||
|
||||
await using (TvContext context = Create(options))
|
||||
{
|
||||
await context.Database.EnsureCreatedAsync();
|
||||
await context.Database.ExecuteSqlRawAsync("PRAGMA foreign_keys=OFF");
|
||||
}
|
||||
|
||||
return new InMemoryTvContext(connection, options);
|
||||
}
|
||||
|
||||
public TvContext CreateContext() => Create(_options);
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _connection.DisposeAsync();
|
||||
}
|
||||
|
||||
private static TvContext Create(DbContextOptions<TvContext> options) =>
|
||||
new(
|
||||
options,
|
||||
NullLoggerFactory.Instance,
|
||||
new SlowQueryInterceptor(NullLogger<SlowQueryInterceptor>.Instance));
|
||||
|
||||
private sealed class TestDbContextFactory(DbContextOptions<TvContext> options) : IDbContextFactory<TvContext>
|
||||
{
|
||||
public TvContext CreateDbContext() => Create(options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace ErsatzTV.Tests.Support;
|
||||
|
||||
public abstract class MediaCollectionHandlerTestBase
|
||||
{
|
||||
protected InMemoryTvContext Db = null!;
|
||||
protected ChannelWriter<IBackgroundServiceRequest> Worker = null!;
|
||||
protected ISearchTargets SearchTargets = null!;
|
||||
|
||||
[SetUp]
|
||||
public async Task BaseSetUp()
|
||||
{
|
||||
Db = await InMemoryTvContext.CreateAsync();
|
||||
Worker = System.Threading.Channels.Channel.CreateUnbounded<IBackgroundServiceRequest>().Writer;
|
||||
SearchTargets = Substitute.For<ISearchTargets>();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public async Task BaseTearDown() => await Db.DisposeAsync();
|
||||
|
||||
protected async Task SeedCollection(int id, string name = "Collection")
|
||||
{
|
||||
await using TvContext context = Db.CreateContext();
|
||||
context.Collections.Add(new Collection
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
MediaItems = []
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
protected async Task SeedSmartCollection(int id, string name = "Smart", string query = "tag:family")
|
||||
{
|
||||
await using TvContext context = Db.CreateContext();
|
||||
context.SmartCollections.Add(new SmartCollection
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
Query = query
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
+201
-4
@@ -30,97 +30,294 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ErsatzTV.Core.Nullable", "E
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ErsatzTV.Tests", "ErsatzTV.Tests\ErsatzTV.Tests.csproj", "{56F56E76-CEF4-4639-B7BB-03FD201BB019}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ErsatzTV.Architecture.Tests", "ErsatzTV.Architecture.Tests\ErsatzTV.Architecture.Tests.csproj", "{49123AF1-38FE-4546-9DBD-8B7F1B4CFC7F}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Debug|x64 = Debug|x64
|
||||
Debug|x86 = Debug|x86
|
||||
Release|Any CPU = Release|Any CPU
|
||||
Release|x64 = Release|x64
|
||||
Release|x86 = Release|x86
|
||||
Debug No Sync|Any CPU = Debug No Sync|Any CPU
|
||||
Debug No Sync|x64 = Debug No Sync|x64
|
||||
Debug No Sync|x86 = Debug No Sync|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{E83551AD-27E4-46E5-AD06-5B0DF797B8FF}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{E83551AD-27E4-46E5-AD06-5B0DF797B8FF}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{E83551AD-27E4-46E5-AD06-5B0DF797B8FF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{E83551AD-27E4-46E5-AD06-5B0DF797B8FF}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{E83551AD-27E4-46E5-AD06-5B0DF797B8FF}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{E83551AD-27E4-46E5-AD06-5B0DF797B8FF}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{E83551AD-27E4-46E5-AD06-5B0DF797B8FF}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{E83551AD-27E4-46E5-AD06-5B0DF797B8FF}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{E83551AD-27E4-46E5-AD06-5B0DF797B8FF}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{E83551AD-27E4-46E5-AD06-5B0DF797B8FF}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{E83551AD-27E4-46E5-AD06-5B0DF797B8FF}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{E83551AD-27E4-46E5-AD06-5B0DF797B8FF}.Release|x64.Build.0 = Release|Any CPU
|
||||
{E83551AD-27E4-46E5-AD06-5B0DF797B8FF}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{E83551AD-27E4-46E5-AD06-5B0DF797B8FF}.Release|x86.Build.0 = Release|Any CPU
|
||||
{E83551AD-27E4-46E5-AD06-5B0DF797B8FF}.Debug No Sync|Any CPU.ActiveCfg = Debug No Sync|Any CPU
|
||||
{E83551AD-27E4-46E5-AD06-5B0DF797B8FF}.Debug No Sync|Any CPU.Build.0 = Debug No Sync|Any CPU
|
||||
{E83551AD-27E4-46E5-AD06-5B0DF797B8FF}.Debug No Sync|x64.ActiveCfg = Debug No Sync|Any CPU
|
||||
{E83551AD-27E4-46E5-AD06-5B0DF797B8FF}.Debug No Sync|x64.Build.0 = Debug No Sync|Any CPU
|
||||
{E83551AD-27E4-46E5-AD06-5B0DF797B8FF}.Debug No Sync|x86.ActiveCfg = Debug No Sync|Any CPU
|
||||
{E83551AD-27E4-46E5-AD06-5B0DF797B8FF}.Debug No Sync|x86.Build.0 = Debug No Sync|Any CPU
|
||||
{C56FC23D-B863-401E-8E7C-E92BC307AFC1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{C56FC23D-B863-401E-8E7C-E92BC307AFC1}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{C56FC23D-B863-401E-8E7C-E92BC307AFC1}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{C56FC23D-B863-401E-8E7C-E92BC307AFC1}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{C56FC23D-B863-401E-8E7C-E92BC307AFC1}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{C56FC23D-B863-401E-8E7C-E92BC307AFC1}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{C56FC23D-B863-401E-8E7C-E92BC307AFC1}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{C56FC23D-B863-401E-8E7C-E92BC307AFC1}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{C56FC23D-B863-401E-8E7C-E92BC307AFC1}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{C56FC23D-B863-401E-8E7C-E92BC307AFC1}.Release|x64.Build.0 = Release|Any CPU
|
||||
{C56FC23D-B863-401E-8E7C-E92BC307AFC1}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{C56FC23D-B863-401E-8E7C-E92BC307AFC1}.Release|x86.Build.0 = Release|Any CPU
|
||||
{C56FC23D-B863-401E-8E7C-E92BC307AFC1}.Debug No Sync|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{C56FC23D-B863-401E-8E7C-E92BC307AFC1}.Debug No Sync|Any CPU.Build.0 = Debug|Any CPU
|
||||
{C56FC23D-B863-401E-8E7C-E92BC307AFC1}.Debug No Sync|x64.ActiveCfg = Debug No Sync|Any CPU
|
||||
{C56FC23D-B863-401E-8E7C-E92BC307AFC1}.Debug No Sync|x64.Build.0 = Debug No Sync|Any CPU
|
||||
{C56FC23D-B863-401E-8E7C-E92BC307AFC1}.Debug No Sync|x86.ActiveCfg = Debug No Sync|Any CPU
|
||||
{C56FC23D-B863-401E-8E7C-E92BC307AFC1}.Debug No Sync|x86.Build.0 = Debug No Sync|Any CPU
|
||||
{BAC52351-F5CC-47A7-9C60-3E9551A3E26A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{BAC52351-F5CC-47A7-9C60-3E9551A3E26A}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{BAC52351-F5CC-47A7-9C60-3E9551A3E26A}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{BAC52351-F5CC-47A7-9C60-3E9551A3E26A}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{BAC52351-F5CC-47A7-9C60-3E9551A3E26A}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{BAC52351-F5CC-47A7-9C60-3E9551A3E26A}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{BAC52351-F5CC-47A7-9C60-3E9551A3E26A}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{BAC52351-F5CC-47A7-9C60-3E9551A3E26A}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{BAC52351-F5CC-47A7-9C60-3E9551A3E26A}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{BAC52351-F5CC-47A7-9C60-3E9551A3E26A}.Release|x64.Build.0 = Release|Any CPU
|
||||
{BAC52351-F5CC-47A7-9C60-3E9551A3E26A}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{BAC52351-F5CC-47A7-9C60-3E9551A3E26A}.Release|x86.Build.0 = Release|Any CPU
|
||||
{BAC52351-F5CC-47A7-9C60-3E9551A3E26A}.Debug No Sync|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{BAC52351-F5CC-47A7-9C60-3E9551A3E26A}.Debug No Sync|Any CPU.Build.0 = Debug|Any CPU
|
||||
{BAC52351-F5CC-47A7-9C60-3E9551A3E26A}.Debug No Sync|x64.ActiveCfg = Debug No Sync|Any CPU
|
||||
{BAC52351-F5CC-47A7-9C60-3E9551A3E26A}.Debug No Sync|x64.Build.0 = Debug No Sync|Any CPU
|
||||
{BAC52351-F5CC-47A7-9C60-3E9551A3E26A}.Debug No Sync|x86.ActiveCfg = Debug No Sync|Any CPU
|
||||
{BAC52351-F5CC-47A7-9C60-3E9551A3E26A}.Debug No Sync|x86.Build.0 = Debug No Sync|Any CPU
|
||||
{CBA93B70-0241-4A73-ABC6-A6E3976A6D7C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{CBA93B70-0241-4A73-ABC6-A6E3976A6D7C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{CBA93B70-0241-4A73-ABC6-A6E3976A6D7C}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{CBA93B70-0241-4A73-ABC6-A6E3976A6D7C}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{CBA93B70-0241-4A73-ABC6-A6E3976A6D7C}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{CBA93B70-0241-4A73-ABC6-A6E3976A6D7C}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{CBA93B70-0241-4A73-ABC6-A6E3976A6D7C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{CBA93B70-0241-4A73-ABC6-A6E3976A6D7C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{CBA93B70-0241-4A73-ABC6-A6E3976A6D7C}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{CBA93B70-0241-4A73-ABC6-A6E3976A6D7C}.Release|x64.Build.0 = Release|Any CPU
|
||||
{CBA93B70-0241-4A73-ABC6-A6E3976A6D7C}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{CBA93B70-0241-4A73-ABC6-A6E3976A6D7C}.Release|x86.Build.0 = Release|Any CPU
|
||||
{CBA93B70-0241-4A73-ABC6-A6E3976A6D7C}.Debug No Sync|Any CPU.ActiveCfg = Debug No Sync|Any CPU
|
||||
{CBA93B70-0241-4A73-ABC6-A6E3976A6D7C}.Debug No Sync|Any CPU.Build.0 = Debug No Sync|Any CPU
|
||||
{CBA93B70-0241-4A73-ABC6-A6E3976A6D7C}.Debug No Sync|x64.ActiveCfg = Debug No Sync|Any CPU
|
||||
{CBA93B70-0241-4A73-ABC6-A6E3976A6D7C}.Debug No Sync|x64.Build.0 = Debug No Sync|Any CPU
|
||||
{CBA93B70-0241-4A73-ABC6-A6E3976A6D7C}.Debug No Sync|x86.ActiveCfg = Debug No Sync|Any CPU
|
||||
{CBA93B70-0241-4A73-ABC6-A6E3976A6D7C}.Debug No Sync|x86.Build.0 = Debug No Sync|Any CPU
|
||||
{CE7F1ACD-F286-4761-A7BC-A541A1E25C86}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{CE7F1ACD-F286-4761-A7BC-A541A1E25C86}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{CE7F1ACD-F286-4761-A7BC-A541A1E25C86}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{CE7F1ACD-F286-4761-A7BC-A541A1E25C86}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{CE7F1ACD-F286-4761-A7BC-A541A1E25C86}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{CE7F1ACD-F286-4761-A7BC-A541A1E25C86}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{CE7F1ACD-F286-4761-A7BC-A541A1E25C86}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{CE7F1ACD-F286-4761-A7BC-A541A1E25C86}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{CE7F1ACD-F286-4761-A7BC-A541A1E25C86}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{CE7F1ACD-F286-4761-A7BC-A541A1E25C86}.Release|x64.Build.0 = Release|Any CPU
|
||||
{CE7F1ACD-F286-4761-A7BC-A541A1E25C86}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{CE7F1ACD-F286-4761-A7BC-A541A1E25C86}.Release|x86.Build.0 = Release|Any CPU
|
||||
{CE7F1ACD-F286-4761-A7BC-A541A1E25C86}.Debug No Sync|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{CE7F1ACD-F286-4761-A7BC-A541A1E25C86}.Debug No Sync|Any CPU.Build.0 = Debug|Any CPU
|
||||
{CE7F1ACD-F286-4761-A7BC-A541A1E25C86}.Debug No Sync|x64.ActiveCfg = Debug No Sync|Any CPU
|
||||
{CE7F1ACD-F286-4761-A7BC-A541A1E25C86}.Debug No Sync|x64.Build.0 = Debug No Sync|Any CPU
|
||||
{CE7F1ACD-F286-4761-A7BC-A541A1E25C86}.Debug No Sync|x86.ActiveCfg = Debug No Sync|Any CPU
|
||||
{CE7F1ACD-F286-4761-A7BC-A541A1E25C86}.Debug No Sync|x86.Build.0 = Debug No Sync|Any CPU
|
||||
{1B6A8CD8-7E5A-448E-BED2-2774BC87A20F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{1B6A8CD8-7E5A-448E-BED2-2774BC87A20F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{1B6A8CD8-7E5A-448E-BED2-2774BC87A20F}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{1B6A8CD8-7E5A-448E-BED2-2774BC87A20F}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{1B6A8CD8-7E5A-448E-BED2-2774BC87A20F}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{1B6A8CD8-7E5A-448E-BED2-2774BC87A20F}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{1B6A8CD8-7E5A-448E-BED2-2774BC87A20F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{1B6A8CD8-7E5A-448E-BED2-2774BC87A20F}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{1B6A8CD8-7E5A-448E-BED2-2774BC87A20F}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{1B6A8CD8-7E5A-448E-BED2-2774BC87A20F}.Release|x64.Build.0 = Release|Any CPU
|
||||
{1B6A8CD8-7E5A-448E-BED2-2774BC87A20F}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{1B6A8CD8-7E5A-448E-BED2-2774BC87A20F}.Release|x86.Build.0 = Release|Any CPU
|
||||
{1B6A8CD8-7E5A-448E-BED2-2774BC87A20F}.Debug No Sync|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{1B6A8CD8-7E5A-448E-BED2-2774BC87A20F}.Debug No Sync|Any CPU.Build.0 = Debug|Any CPU
|
||||
{1B6A8CD8-7E5A-448E-BED2-2774BC87A20F}.Debug No Sync|x64.ActiveCfg = Debug No Sync|Any CPU
|
||||
{1B6A8CD8-7E5A-448E-BED2-2774BC87A20F}.Debug No Sync|x64.Build.0 = Debug No Sync|Any CPU
|
||||
{1B6A8CD8-7E5A-448E-BED2-2774BC87A20F}.Debug No Sync|x86.ActiveCfg = Debug No Sync|Any CPU
|
||||
{1B6A8CD8-7E5A-448E-BED2-2774BC87A20F}.Debug No Sync|x86.Build.0 = Debug No Sync|Any CPU
|
||||
{1C892530-CF92-4F43-8C64-BCEEF958D726}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{1C892530-CF92-4F43-8C64-BCEEF958D726}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{1C892530-CF92-4F43-8C64-BCEEF958D726}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{1C892530-CF92-4F43-8C64-BCEEF958D726}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{1C892530-CF92-4F43-8C64-BCEEF958D726}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{1C892530-CF92-4F43-8C64-BCEEF958D726}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{1C892530-CF92-4F43-8C64-BCEEF958D726}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{1C892530-CF92-4F43-8C64-BCEEF958D726}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{1C892530-CF92-4F43-8C64-BCEEF958D726}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{1C892530-CF92-4F43-8C64-BCEEF958D726}.Release|x64.Build.0 = Release|Any CPU
|
||||
{1C892530-CF92-4F43-8C64-BCEEF958D726}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{1C892530-CF92-4F43-8C64-BCEEF958D726}.Release|x86.Build.0 = Release|Any CPU
|
||||
{1C892530-CF92-4F43-8C64-BCEEF958D726}.Debug No Sync|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{1C892530-CF92-4F43-8C64-BCEEF958D726}.Debug No Sync|Any CPU.Build.0 = Debug|Any CPU
|
||||
{1C892530-CF92-4F43-8C64-BCEEF958D726}.Debug No Sync|x64.ActiveCfg = Debug No Sync|Any CPU
|
||||
{1C892530-CF92-4F43-8C64-BCEEF958D726}.Debug No Sync|x64.Build.0 = Debug No Sync|Any CPU
|
||||
{1C892530-CF92-4F43-8C64-BCEEF958D726}.Debug No Sync|x86.ActiveCfg = Debug No Sync|Any CPU
|
||||
{1C892530-CF92-4F43-8C64-BCEEF958D726}.Debug No Sync|x86.Build.0 = Debug No Sync|Any CPU
|
||||
{591FB3F4-4DD8-441B-B7C8-F2A42BF69992}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{591FB3F4-4DD8-441B-B7C8-F2A42BF69992}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{591FB3F4-4DD8-441B-B7C8-F2A42BF69992}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{591FB3F4-4DD8-441B-B7C8-F2A42BF69992}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{591FB3F4-4DD8-441B-B7C8-F2A42BF69992}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{591FB3F4-4DD8-441B-B7C8-F2A42BF69992}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{591FB3F4-4DD8-441B-B7C8-F2A42BF69992}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{591FB3F4-4DD8-441B-B7C8-F2A42BF69992}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{591FB3F4-4DD8-441B-B7C8-F2A42BF69992}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{591FB3F4-4DD8-441B-B7C8-F2A42BF69992}.Release|x64.Build.0 = Release|Any CPU
|
||||
{591FB3F4-4DD8-441B-B7C8-F2A42BF69992}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{591FB3F4-4DD8-441B-B7C8-F2A42BF69992}.Release|x86.Build.0 = Release|Any CPU
|
||||
{591FB3F4-4DD8-441B-B7C8-F2A42BF69992}.Debug No Sync|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{591FB3F4-4DD8-441B-B7C8-F2A42BF69992}.Debug No Sync|Any CPU.Build.0 = Debug|Any CPU
|
||||
{5664D574-2B8B-41C1-B091-8D3E887AE24E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{5664D574-2B8B-41C1-B091-8D3E887AE24E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{591FB3F4-4DD8-441B-B7C8-F2A42BF69992}.Debug No Sync|x64.ActiveCfg = Debug No Sync|Any CPU
|
||||
{591FB3F4-4DD8-441B-B7C8-F2A42BF69992}.Debug No Sync|x64.Build.0 = Debug No Sync|Any CPU
|
||||
{591FB3F4-4DD8-441B-B7C8-F2A42BF69992}.Debug No Sync|x86.ActiveCfg = Debug No Sync|Any CPU
|
||||
{591FB3F4-4DD8-441B-B7C8-F2A42BF69992}.Debug No Sync|x86.Build.0 = Debug No Sync|Any CPU
|
||||
{5664D574-2B8B-41C1-B091-8D3E887AE24E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{5664D574-2B8B-41C1-B091-8D3E887AE24E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{5664D574-2B8B-41C1-B091-8D3E887AE24E}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{5664D574-2B8B-41C1-B091-8D3E887AE24E}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{5664D574-2B8B-41C1-B091-8D3E887AE24E}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{5664D574-2B8B-41C1-B091-8D3E887AE24E}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{5664D574-2B8B-41C1-B091-8D3E887AE24E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{5664D574-2B8B-41C1-B091-8D3E887AE24E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{5664D574-2B8B-41C1-B091-8D3E887AE24E}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{5664D574-2B8B-41C1-B091-8D3E887AE24E}.Release|x64.Build.0 = Release|Any CPU
|
||||
{5664D574-2B8B-41C1-B091-8D3E887AE24E}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{5664D574-2B8B-41C1-B091-8D3E887AE24E}.Release|x86.Build.0 = Release|Any CPU
|
||||
{5664D574-2B8B-41C1-B091-8D3E887AE24E}.Debug No Sync|Any CPU.ActiveCfg = Debug No Sync|Any CPU
|
||||
{5664D574-2B8B-41C1-B091-8D3E887AE24E}.Debug No Sync|Any CPU.Build.0 = Debug No Sync|Any CPU
|
||||
{5664D574-2B8B-41C1-B091-8D3E887AE24E}.Debug No Sync|x64.ActiveCfg = Debug No Sync|Any CPU
|
||||
{5664D574-2B8B-41C1-B091-8D3E887AE24E}.Debug No Sync|x64.Build.0 = Debug No Sync|Any CPU
|
||||
{5664D574-2B8B-41C1-B091-8D3E887AE24E}.Debug No Sync|x86.ActiveCfg = Debug No Sync|Any CPU
|
||||
{5664D574-2B8B-41C1-B091-8D3E887AE24E}.Debug No Sync|x86.Build.0 = Debug No Sync|Any CPU
|
||||
{2EF80455-953D-4696-831D-E8CBCA82B0EF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{2EF80455-953D-4696-831D-E8CBCA82B0EF}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{2EF80455-953D-4696-831D-E8CBCA82B0EF}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{2EF80455-953D-4696-831D-E8CBCA82B0EF}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{2EF80455-953D-4696-831D-E8CBCA82B0EF}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{2EF80455-953D-4696-831D-E8CBCA82B0EF}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{2EF80455-953D-4696-831D-E8CBCA82B0EF}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{2EF80455-953D-4696-831D-E8CBCA82B0EF}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{2EF80455-953D-4696-831D-E8CBCA82B0EF}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{2EF80455-953D-4696-831D-E8CBCA82B0EF}.Release|x64.Build.0 = Release|Any CPU
|
||||
{2EF80455-953D-4696-831D-E8CBCA82B0EF}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{2EF80455-953D-4696-831D-E8CBCA82B0EF}.Release|x86.Build.0 = Release|Any CPU
|
||||
{2EF80455-953D-4696-831D-E8CBCA82B0EF}.Debug No Sync|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{2EF80455-953D-4696-831D-E8CBCA82B0EF}.Debug No Sync|Any CPU.Build.0 = Debug|Any CPU
|
||||
{2EF80455-953D-4696-831D-E8CBCA82B0EF}.Debug No Sync|x64.ActiveCfg = Debug No Sync|Any CPU
|
||||
{2EF80455-953D-4696-831D-E8CBCA82B0EF}.Debug No Sync|x64.Build.0 = Debug No Sync|Any CPU
|
||||
{2EF80455-953D-4696-831D-E8CBCA82B0EF}.Debug No Sync|x86.ActiveCfg = Debug No Sync|Any CPU
|
||||
{2EF80455-953D-4696-831D-E8CBCA82B0EF}.Debug No Sync|x86.Build.0 = Debug No Sync|Any CPU
|
||||
{37A0C761-F59E-44EB-9660-5E158980D1D8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{37A0C761-F59E-44EB-9660-5E158980D1D8}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{37A0C761-F59E-44EB-9660-5E158980D1D8}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{37A0C761-F59E-44EB-9660-5E158980D1D8}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{37A0C761-F59E-44EB-9660-5E158980D1D8}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{37A0C761-F59E-44EB-9660-5E158980D1D8}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{37A0C761-F59E-44EB-9660-5E158980D1D8}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{37A0C761-F59E-44EB-9660-5E158980D1D8}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{37A0C761-F59E-44EB-9660-5E158980D1D8}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{37A0C761-F59E-44EB-9660-5E158980D1D8}.Release|x64.Build.0 = Release|Any CPU
|
||||
{37A0C761-F59E-44EB-9660-5E158980D1D8}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{37A0C761-F59E-44EB-9660-5E158980D1D8}.Release|x86.Build.0 = Release|Any CPU
|
||||
{37A0C761-F59E-44EB-9660-5E158980D1D8}.Debug No Sync|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{37A0C761-F59E-44EB-9660-5E158980D1D8}.Debug No Sync|Any CPU.Build.0 = Debug|Any CPU
|
||||
{37A0C761-F59E-44EB-9660-5E158980D1D8}.Debug No Sync|x64.ActiveCfg = Debug No Sync|Any CPU
|
||||
{37A0C761-F59E-44EB-9660-5E158980D1D8}.Debug No Sync|x64.Build.0 = Debug No Sync|Any CPU
|
||||
{37A0C761-F59E-44EB-9660-5E158980D1D8}.Debug No Sync|x86.ActiveCfg = Debug No Sync|Any CPU
|
||||
{37A0C761-F59E-44EB-9660-5E158980D1D8}.Debug No Sync|x86.Build.0 = Debug No Sync|Any CPU
|
||||
{C105FD01-9D6F-4574-A987-BD39E7B349E8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{C105FD01-9D6F-4574-A987-BD39E7B349E8}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{C105FD01-9D6F-4574-A987-BD39E7B349E8}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{C105FD01-9D6F-4574-A987-BD39E7B349E8}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{C105FD01-9D6F-4574-A987-BD39E7B349E8}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{C105FD01-9D6F-4574-A987-BD39E7B349E8}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{C105FD01-9D6F-4574-A987-BD39E7B349E8}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{C105FD01-9D6F-4574-A987-BD39E7B349E8}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{C105FD01-9D6F-4574-A987-BD39E7B349E8}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{C105FD01-9D6F-4574-A987-BD39E7B349E8}.Release|x64.Build.0 = Release|Any CPU
|
||||
{C105FD01-9D6F-4574-A987-BD39E7B349E8}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{C105FD01-9D6F-4574-A987-BD39E7B349E8}.Release|x86.Build.0 = Release|Any CPU
|
||||
{C105FD01-9D6F-4574-A987-BD39E7B349E8}.Debug No Sync|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{C105FD01-9D6F-4574-A987-BD39E7B349E8}.Debug No Sync|Any CPU.Build.0 = Debug|Any CPU
|
||||
{C105FD01-9D6F-4574-A987-BD39E7B349E8}.Debug No Sync|x64.ActiveCfg = Debug No Sync|Any CPU
|
||||
{C105FD01-9D6F-4574-A987-BD39E7B349E8}.Debug No Sync|x64.Build.0 = Debug No Sync|Any CPU
|
||||
{C105FD01-9D6F-4574-A987-BD39E7B349E8}.Debug No Sync|x86.ActiveCfg = Debug No Sync|Any CPU
|
||||
{C105FD01-9D6F-4574-A987-BD39E7B349E8}.Debug No Sync|x86.Build.0 = Debug No Sync|Any CPU
|
||||
{557D88A6-C982-4FFD-8FD2-6446CB07D093}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{557D88A6-C982-4FFD-8FD2-6446CB07D093}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{557D88A6-C982-4FFD-8FD2-6446CB07D093}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{557D88A6-C982-4FFD-8FD2-6446CB07D093}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{557D88A6-C982-4FFD-8FD2-6446CB07D093}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{557D88A6-C982-4FFD-8FD2-6446CB07D093}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{557D88A6-C982-4FFD-8FD2-6446CB07D093}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{557D88A6-C982-4FFD-8FD2-6446CB07D093}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{557D88A6-C982-4FFD-8FD2-6446CB07D093}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{557D88A6-C982-4FFD-8FD2-6446CB07D093}.Release|x64.Build.0 = Release|Any CPU
|
||||
{557D88A6-C982-4FFD-8FD2-6446CB07D093}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{557D88A6-C982-4FFD-8FD2-6446CB07D093}.Release|x86.Build.0 = Release|Any CPU
|
||||
{557D88A6-C982-4FFD-8FD2-6446CB07D093}.Debug No Sync|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{557D88A6-C982-4FFD-8FD2-6446CB07D093}.Debug No Sync|Any CPU.Build.0 = Debug|Any CPU
|
||||
{557D88A6-C982-4FFD-8FD2-6446CB07D093}.Debug No Sync|x64.ActiveCfg = Debug No Sync|Any CPU
|
||||
{557D88A6-C982-4FFD-8FD2-6446CB07D093}.Debug No Sync|x64.Build.0 = Debug No Sync|Any CPU
|
||||
{557D88A6-C982-4FFD-8FD2-6446CB07D093}.Debug No Sync|x86.ActiveCfg = Debug No Sync|Any CPU
|
||||
{557D88A6-C982-4FFD-8FD2-6446CB07D093}.Debug No Sync|x86.Build.0 = Debug No Sync|Any CPU
|
||||
{56F56E76-CEF4-4639-B7BB-03FD201BB019}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{56F56E76-CEF4-4639-B7BB-03FD201BB019}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{56F56E76-CEF4-4639-B7BB-03FD201BB019}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{56F56E76-CEF4-4639-B7BB-03FD201BB019}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{56F56E76-CEF4-4639-B7BB-03FD201BB019}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{56F56E76-CEF4-4639-B7BB-03FD201BB019}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{56F56E76-CEF4-4639-B7BB-03FD201BB019}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{56F56E76-CEF4-4639-B7BB-03FD201BB019}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{56F56E76-CEF4-4639-B7BB-03FD201BB019}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{56F56E76-CEF4-4639-B7BB-03FD201BB019}.Release|x64.Build.0 = Release|Any CPU
|
||||
{56F56E76-CEF4-4639-B7BB-03FD201BB019}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{56F56E76-CEF4-4639-B7BB-03FD201BB019}.Release|x86.Build.0 = Release|Any CPU
|
||||
{56F56E76-CEF4-4639-B7BB-03FD201BB019}.Debug No Sync|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{56F56E76-CEF4-4639-B7BB-03FD201BB019}.Debug No Sync|Any CPU.Build.0 = Debug|Any CPU
|
||||
{56F56E76-CEF4-4639-B7BB-03FD201BB019}.Debug No Sync|x64.ActiveCfg = Debug No Sync|Any CPU
|
||||
{56F56E76-CEF4-4639-B7BB-03FD201BB019}.Debug No Sync|x64.Build.0 = Debug No Sync|Any CPU
|
||||
{56F56E76-CEF4-4639-B7BB-03FD201BB019}.Debug No Sync|x86.ActiveCfg = Debug No Sync|Any CPU
|
||||
{56F56E76-CEF4-4639-B7BB-03FD201BB019}.Debug No Sync|x86.Build.0 = Debug No Sync|Any CPU
|
||||
{49123AF1-38FE-4546-9DBD-8B7F1B4CFC7F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{49123AF1-38FE-4546-9DBD-8B7F1B4CFC7F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{49123AF1-38FE-4546-9DBD-8B7F1B4CFC7F}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{49123AF1-38FE-4546-9DBD-8B7F1B4CFC7F}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{49123AF1-38FE-4546-9DBD-8B7F1B4CFC7F}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{49123AF1-38FE-4546-9DBD-8B7F1B4CFC7F}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{49123AF1-38FE-4546-9DBD-8B7F1B4CFC7F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{49123AF1-38FE-4546-9DBD-8B7F1B4CFC7F}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{49123AF1-38FE-4546-9DBD-8B7F1B4CFC7F}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{49123AF1-38FE-4546-9DBD-8B7F1B4CFC7F}.Release|x64.Build.0 = Release|Any CPU
|
||||
{49123AF1-38FE-4546-9DBD-8B7F1B4CFC7F}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{49123AF1-38FE-4546-9DBD-8B7F1B4CFC7F}.Release|x86.Build.0 = Release|Any CPU
|
||||
{49123AF1-38FE-4546-9DBD-8B7F1B4CFC7F}.Debug No Sync|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{49123AF1-38FE-4546-9DBD-8B7F1B4CFC7F}.Debug No Sync|Any CPU.Build.0 = Debug|Any CPU
|
||||
{49123AF1-38FE-4546-9DBD-8B7F1B4CFC7F}.Debug No Sync|x64.ActiveCfg = Debug|Any CPU
|
||||
{49123AF1-38FE-4546-9DBD-8B7F1B4CFC7F}.Debug No Sync|x64.Build.0 = Debug|Any CPU
|
||||
{49123AF1-38FE-4546-9DBD-8B7F1B4CFC7F}.Debug No Sync|x86.ActiveCfg = Debug|Any CPU
|
||||
{49123AF1-38FE-4546-9DBD-8B7F1B4CFC7F}.Debug No Sync|x86.Build.0 = Debug|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{CE7F1ACD-F286-4761-A7BC-A541A1E25C86} = {325E6DA0-52B3-4431-98A2-72C36F403704}
|
||||
|
||||
@@ -1,26 +1,104 @@
|
||||
using System.Threading.Channels;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Controllers.Api.Requests;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Extensions;
|
||||
using ErsatzTV.Filters;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api;
|
||||
|
||||
[ApiController]
|
||||
// Apply the optional API-key control at the controller level so that EVERY mutating action
|
||||
// (including future ones) is covered by default; the filter no-ops on read methods (GET) and
|
||||
// when Api:WriteKey is unset, preserving the open LAN behavior. This is fail-safe: a developer
|
||||
// adding a new write endpoint here cannot accidentally leave it unauthenticated.
|
||||
[ServiceFilter(typeof(ApiKeyAuthorizationFilter))]
|
||||
public class ChannelController(ChannelWriter<IBackgroundServiceRequest> workerChannel, IMediator mediator)
|
||||
{
|
||||
[HttpGet("/api/channels")]
|
||||
[EndpointGroupName("general")]
|
||||
public async Task<List<ChannelResponseModel>> GetAll() => await mediator.Send(new GetAllChannelsForApi());
|
||||
|
||||
[HttpGet("/api/channels/{id:int}", Name = "GetChannelById")]
|
||||
[Tags("Channels")]
|
||||
[EndpointSummary("Get a channel by id")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(ChannelViewModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<ChannelViewModel> result = await mediator.Send(new GetChannelById(id), cancellationToken);
|
||||
return result.ToGetResult();
|
||||
}
|
||||
|
||||
[HttpPost("/api/channels")]
|
||||
[Tags("Channels")]
|
||||
[EndpointSummary("Create a channel")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(ChannelViewModel), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Create(
|
||||
[Required] [FromBody] CreateChannelRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, CreateChannelResult> result = await mediator.Send(request.ToCommand(), cancellationToken);
|
||||
return await result.Match(
|
||||
Left: error => Task.FromResult(error.ToErrorResult()),
|
||||
Right: async created =>
|
||||
{
|
||||
Option<ChannelViewModel> channel =
|
||||
await mediator.Send(new GetChannelById(created.ChannelId), cancellationToken);
|
||||
return channel.Match(
|
||||
Some: vm => (IActionResult)new CreatedResult($"/api/channels/{vm.Id}", vm),
|
||||
None: () => ApiResults.NotFoundProblem());
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPut("/api/channels/{id:int}")]
|
||||
[Tags("Channels")]
|
||||
[EndpointSummary("Update a channel")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(ChannelViewModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Update(
|
||||
int id,
|
||||
[Required] [FromBody] UpdateChannelRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, ChannelViewModel> result =
|
||||
await mediator.Send(request.ToCommand(id), cancellationToken);
|
||||
return result.ToUpdatedResult();
|
||||
}
|
||||
|
||||
[HttpDelete("/api/channels/{id:int}")]
|
||||
[Tags("Channels")]
|
||||
[EndpointSummary("Delete a channel")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Delete(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, Unit> result = await mediator.Send(new DeleteChannel(id), cancellationToken);
|
||||
return result.ToDeletedResult();
|
||||
}
|
||||
|
||||
[HttpPost("/api/channels/{channelNumber}/playout/reset")]
|
||||
[Tags("Channels")]
|
||||
[EndpointSummary("Reset channel playout")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> ResetPlayout(string channelNumber)
|
||||
{
|
||||
Option<int> maybePlayoutId = await mediator.Send(new GetPlayoutIdByChannelNumber(channelNumber));
|
||||
@@ -30,26 +108,6 @@ public class ChannelController(ChannelWriter<IBackgroundServiceRequest> workerCh
|
||||
return new OkResult();
|
||||
}
|
||||
|
||||
return new NotFoundResult();
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
// for debugging by fast-forwarding a playout
|
||||
// [HttpPost("/api/channels/{channelNumber}/playout/continue")]
|
||||
// public async Task<IActionResult> ContinuePlayout(string channelNumber, [FromQuery] int days = 1)
|
||||
// {
|
||||
// Option<int> maybePlayoutId = await mediator.Send(new GetPlayoutIdByChannelNumber(channelNumber));
|
||||
// foreach (int playoutId in maybePlayoutId)
|
||||
// {
|
||||
// DateTimeOffset start = DateTimeOffset.Now;
|
||||
// for (int i = 0; i < 24 * days; i++)
|
||||
// {
|
||||
// await workerChannel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Continue, start));
|
||||
// start += TimeSpan.FromHours(1);
|
||||
// }
|
||||
//
|
||||
// return new OkResult();
|
||||
// }
|
||||
//
|
||||
// return new NotFoundResult();
|
||||
// }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
using ErsatzTV.Controllers.Api.Requests;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Extensions;
|
||||
using ErsatzTV.Filters;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api;
|
||||
|
||||
[ApiController]
|
||||
[ServiceFilter(typeof(ApiKeyAuthorizationFilter))]
|
||||
public class CollectionController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet("/api/collections")]
|
||||
[Tags("Collections")]
|
||||
[EndpointSummary("Get all collections")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<MediaCollectionViewModel>), StatusCodes.Status200OK)]
|
||||
public async Task<List<MediaCollectionViewModel>> GetAll(CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetAllCollections(), cancellationToken);
|
||||
|
||||
[HttpGet("/api/collections/{id:int}", Name = "GetCollectionById")]
|
||||
[Tags("Collections")]
|
||||
[EndpointSummary("Get a collection by id")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(MediaCollectionViewModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<MediaCollectionViewModel> result = await mediator.Send(new GetCollectionById(id), cancellationToken);
|
||||
return result.ToGetResult();
|
||||
}
|
||||
|
||||
[HttpPost("/api/collections")]
|
||||
[Tags("Collections")]
|
||||
[EndpointSummary("Create a collection")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(MediaCollectionViewModel), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Create(
|
||||
[Required] [FromBody] CreateCollectionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, MediaCollectionViewModel> result =
|
||||
await mediator.Send(request.ToCommand(), cancellationToken);
|
||||
return result.ToCreatedResult(vm => $"/api/collections/{vm.Id}", vm => vm);
|
||||
}
|
||||
|
||||
[HttpPut("/api/collections/{id:int}")]
|
||||
[Tags("Collections")]
|
||||
[EndpointSummary("Update a collection")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(MediaCollectionViewModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Update(
|
||||
int id,
|
||||
[Required] [FromBody] UpdateCollectionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(id), cancellationToken);
|
||||
return await result.Match(
|
||||
Left: error => Task.FromResult(error.ToErrorResult()),
|
||||
Right: async _ =>
|
||||
{
|
||||
Option<MediaCollectionViewModel> collection =
|
||||
await mediator.Send(new GetCollectionById(id), cancellationToken);
|
||||
return collection.Match(
|
||||
Some: vm => (IActionResult)new OkObjectResult(vm),
|
||||
None: () => ApiResults.NotFoundProblem());
|
||||
});
|
||||
}
|
||||
|
||||
[HttpDelete("/api/collections/{id:int}")]
|
||||
[Tags("Collections")]
|
||||
[EndpointSummary("Delete a collection")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Delete(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, Unit> result = await mediator.Send(new DeleteCollection(id), cancellationToken);
|
||||
return result.ToDeletedResult();
|
||||
}
|
||||
|
||||
[HttpPost("/api/collections/{id:int}/items")]
|
||||
[Tags("Collections")]
|
||||
[EndpointSummary("Add items to a collection")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> AddItems(
|
||||
int id,
|
||||
[Required] [FromBody] AddItemsToCollectionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(id), cancellationToken);
|
||||
return result.ToDeletedResult();
|
||||
}
|
||||
|
||||
[HttpDelete("/api/collections/{id:int}/items/{mediaItemId:int}")]
|
||||
[Tags("Collections")]
|
||||
[EndpointSummary("Remove an item from a collection")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> RemoveItem(int id, int mediaItemId, CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, Unit> result = await mediator.Send(
|
||||
new RemoveItemsFromCollection(id)
|
||||
{
|
||||
MediaItemIds = [mediaItemId]
|
||||
},
|
||||
cancellationToken);
|
||||
return result.ToDeletedResult();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
public record AddItemsToCollectionRequest(
|
||||
List<int> MovieIds,
|
||||
List<int> ShowIds,
|
||||
List<int> SeasonIds,
|
||||
List<int> EpisodeIds,
|
||||
List<int> ArtistIds,
|
||||
List<int> MusicVideoIds,
|
||||
List<int> OtherVideoIds,
|
||||
List<int> SongIds,
|
||||
List<int> ImageIds,
|
||||
List<int> RemoteStreamIds)
|
||||
{
|
||||
public AddItemsToCollection ToCommand(int collectionId) =>
|
||||
new(
|
||||
collectionId,
|
||||
MovieIds ?? [],
|
||||
ShowIds ?? [],
|
||||
SeasonIds ?? [],
|
||||
EpisodeIds ?? [],
|
||||
ArtistIds ?? [],
|
||||
MusicVideoIds ?? [],
|
||||
OtherVideoIds ?? [],
|
||||
SongIds ?? [],
|
||||
ImageIds ?? [],
|
||||
RemoteStreamIds ?? []);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using ErsatzTV.Application.Artworks;
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
/// <summary>
|
||||
/// JSON request body for creating a channel. Mirrors <see cref="CreateChannel" />; decoupling the
|
||||
/// API contract from the MediatR command keeps the wire format stable as the command evolves.
|
||||
/// </summary>
|
||||
public record CreateChannelRequest(
|
||||
string Name,
|
||||
string Number,
|
||||
string Group,
|
||||
string Categories,
|
||||
int FFmpegProfileId,
|
||||
double? SlugSeconds,
|
||||
ArtworkContentTypeModel Logo,
|
||||
ChannelStreamSelectorMode StreamSelectorMode,
|
||||
string StreamSelector,
|
||||
string PreferredAudioLanguageCode,
|
||||
string PreferredAudioTitle,
|
||||
ChannelPlayoutSource PlayoutSource,
|
||||
ChannelPlayoutMode PlayoutMode,
|
||||
int? MirrorSourceChannelId,
|
||||
TimeSpan? PlayoutOffset,
|
||||
StreamingMode StreamingMode,
|
||||
int? WatermarkId,
|
||||
int? FallbackFillerId,
|
||||
string PreferredSubtitleLanguageCode,
|
||||
ChannelSubtitleMode SubtitleMode,
|
||||
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
|
||||
string MusicVideoCreditsTemplate,
|
||||
ChannelSongVideoMode SongVideoMode,
|
||||
ChannelTranscodeMode TranscodeMode,
|
||||
ChannelIdleBehavior IdleBehavior,
|
||||
bool IsEnabled,
|
||||
bool ShowInEpg)
|
||||
{
|
||||
public CreateChannel ToCommand() =>
|
||||
new(
|
||||
Name,
|
||||
Number,
|
||||
Group,
|
||||
Categories,
|
||||
FFmpegProfileId,
|
||||
SlugSeconds,
|
||||
Logo,
|
||||
StreamSelectorMode,
|
||||
StreamSelector,
|
||||
PreferredAudioLanguageCode,
|
||||
PreferredAudioTitle,
|
||||
PlayoutSource,
|
||||
PlayoutMode,
|
||||
MirrorSourceChannelId,
|
||||
PlayoutOffset,
|
||||
StreamingMode,
|
||||
WatermarkId,
|
||||
FallbackFillerId,
|
||||
PreferredSubtitleLanguageCode,
|
||||
SubtitleMode,
|
||||
MusicVideoCreditsMode,
|
||||
MusicVideoCreditsTemplate,
|
||||
SongVideoMode,
|
||||
TranscodeMode,
|
||||
IdleBehavior,
|
||||
IsEnabled,
|
||||
ShowInEpg);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
public record CreateCollectionRequest(string Name)
|
||||
{
|
||||
public CreateCollection ToCommand() => new(Name);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
public record CreateSmartCollectionRequest(string Name, string Query)
|
||||
{
|
||||
public CreateSmartCollection ToCommand() => new(Query, Name);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using ErsatzTV.Application.Artworks;
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
/// <summary>
|
||||
/// JSON request body for updating a channel. The channel id comes from the route, not the body;
|
||||
/// all other fields mirror <see cref="UpdateChannel" />.
|
||||
/// </summary>
|
||||
public record UpdateChannelRequest(
|
||||
string Name,
|
||||
string Number,
|
||||
string Group,
|
||||
string Categories,
|
||||
int FFmpegProfileId,
|
||||
double? SlugSeconds,
|
||||
ArtworkContentTypeModel Logo,
|
||||
ChannelStreamSelectorMode StreamSelectorMode,
|
||||
string StreamSelector,
|
||||
string PreferredAudioLanguageCode,
|
||||
string PreferredAudioTitle,
|
||||
ChannelPlayoutSource PlayoutSource,
|
||||
ChannelPlayoutMode PlayoutMode,
|
||||
int? MirrorSourceChannelId,
|
||||
TimeSpan? PlayoutOffset,
|
||||
StreamingMode StreamingMode,
|
||||
int? WatermarkId,
|
||||
int? FallbackFillerId,
|
||||
string PreferredSubtitleLanguageCode,
|
||||
ChannelSubtitleMode SubtitleMode,
|
||||
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
|
||||
string MusicVideoCreditsTemplate,
|
||||
ChannelSongVideoMode SongVideoMode,
|
||||
ChannelTranscodeMode TranscodeMode,
|
||||
ChannelIdleBehavior IdleBehavior,
|
||||
bool IsEnabled,
|
||||
bool ShowInEpg)
|
||||
{
|
||||
public UpdateChannel ToCommand(int channelId) =>
|
||||
new(
|
||||
channelId,
|
||||
Name,
|
||||
Number,
|
||||
Group,
|
||||
Categories,
|
||||
FFmpegProfileId,
|
||||
SlugSeconds,
|
||||
Logo,
|
||||
StreamSelectorMode,
|
||||
StreamSelector,
|
||||
PreferredAudioLanguageCode,
|
||||
PreferredAudioTitle,
|
||||
PlayoutSource,
|
||||
PlayoutMode,
|
||||
MirrorSourceChannelId,
|
||||
PlayoutOffset,
|
||||
StreamingMode,
|
||||
WatermarkId,
|
||||
FallbackFillerId,
|
||||
PreferredSubtitleLanguageCode,
|
||||
SubtitleMode,
|
||||
MusicVideoCreditsMode,
|
||||
MusicVideoCreditsTemplate,
|
||||
SongVideoMode,
|
||||
TranscodeMode,
|
||||
IdleBehavior,
|
||||
IsEnabled,
|
||||
ShowInEpg);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
public record UpdateCollectionRequest(string Name, bool? UseCustomPlaybackOrder)
|
||||
{
|
||||
public UpdateCollection ToCommand(int id) =>
|
||||
new(id, Name)
|
||||
{
|
||||
UseCustomPlaybackOrder = Optional(UseCustomPlaybackOrder)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
public record UpdateSmartCollectionRequest(string Name, string Query)
|
||||
{
|
||||
public UpdateSmartCollection ToCommand(int id) => new(id, Name, Query);
|
||||
}
|
||||
@@ -1,43 +1,93 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
using ErsatzTV.Controllers.Api.Requests;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.SmartCollections;
|
||||
using ErsatzTV.Extensions;
|
||||
using ErsatzTV.Filters;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api;
|
||||
|
||||
[ApiController]
|
||||
[EndpointGroupName("general")]
|
||||
[ServiceFilter(typeof(ApiKeyAuthorizationFilter))]
|
||||
public class SmartCollectionController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
[HttpGet("/api/collections/smart", Name="GetSmartCollections")]
|
||||
public async Task<List<SmartCollectionResponseModel>> GetAll() =>
|
||||
await mediator.Send(new GetAllSmartCollectionsForApi());
|
||||
[HttpGet("/api/smart-collections")]
|
||||
[Tags("Smart Collections")]
|
||||
[EndpointSummary("Get all smart collections")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<SmartCollectionResponseModel>), StatusCodes.Status200OK)]
|
||||
public async Task<List<SmartCollectionResponseModel>> GetAll(CancellationToken cancellationToken) =>
|
||||
await mediator.Send(new GetAllSmartCollectionsForApi(), cancellationToken);
|
||||
|
||||
[HttpPost("/api/collections/smart/new", Name = "CreateSmartCollection")]
|
||||
public async Task<IActionResult> AddOne(
|
||||
[Required] [FromBody]
|
||||
CreateSmartCollection request)
|
||||
[HttpGet("/api/smart-collections/{id:int}", Name = "GetSmartCollectionById")]
|
||||
[Tags("Smart Collections")]
|
||||
[EndpointSummary("Get a smart collection by id")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(SmartCollectionViewModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, CreateSmartCollectionResult> result =
|
||||
await mediator.Send(request).MapT(r => new CreateSmartCollectionResult(r.Id));
|
||||
return result.Match<IActionResult>(Ok, error => Problem(error.ToString()));
|
||||
Option<SmartCollectionViewModel> result =
|
||||
await mediator.Send(new GetSmartCollectionById(id), cancellationToken);
|
||||
return result.ToGetResult();
|
||||
}
|
||||
|
||||
[HttpPut("/api/collections/smart/update", Name="UpdateSmartCollection")]
|
||||
public async Task<IActionResult> UpdateOne(
|
||||
[Required] [FromBody]
|
||||
UpdateSmartCollection request)
|
||||
[HttpPost("/api/smart-collections")]
|
||||
[Tags("Smart Collections")]
|
||||
[EndpointSummary("Create a smart collection")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(SmartCollectionViewModel), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Create(
|
||||
[Required] [FromBody] CreateSmartCollectionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, UpdateSmartCollectionResult> result = await mediator.Send(request);
|
||||
return result.Match<IActionResult>(Ok, error => Problem(error.ToString()));
|
||||
Either<BaseError, SmartCollectionViewModel> result =
|
||||
await mediator.Send(request.ToCommand(), cancellationToken);
|
||||
return result.ToCreatedResult(vm => $"/api/smart-collections/{vm.Id}", vm => vm);
|
||||
}
|
||||
|
||||
[HttpDelete("/api/collections/smart/delete/{id:int}", Name="DeleteSmartCollection")]
|
||||
public async Task<IActionResult> DeleteSmartCollection(int id)
|
||||
[HttpPut("/api/smart-collections/{id:int}")]
|
||||
[Tags("Smart Collections")]
|
||||
[EndpointSummary("Update a smart collection")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(SmartCollectionViewModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Update(
|
||||
int id,
|
||||
[Required] [FromBody] UpdateSmartCollectionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, Unit> result = await mediator.Send(new DeleteSmartCollection(id));
|
||||
return result.Match<IActionResult>(_ => Ok(), error => Problem(error.ToString()));
|
||||
Either<BaseError, UpdateSmartCollectionResult> result =
|
||||
await mediator.Send(request.ToCommand(id), cancellationToken);
|
||||
return await result.Match(
|
||||
Left: error => Task.FromResult(error.ToErrorResult()),
|
||||
Right: async _ =>
|
||||
{
|
||||
Option<SmartCollectionViewModel> smartCollection =
|
||||
await mediator.Send(new GetSmartCollectionById(id), cancellationToken);
|
||||
return smartCollection.Match(
|
||||
Some: vm => (IActionResult)new OkObjectResult(vm),
|
||||
None: () => ApiResults.NotFoundProblem());
|
||||
});
|
||||
}
|
||||
|
||||
[HttpDelete("/api/smart-collections/{id:int}")]
|
||||
[Tags("Smart Collections")]
|
||||
[EndpointSummary("Delete a smart collection")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Delete(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, Unit> result = await mediator.Send(new DeleteSmartCollection(id), cancellationToken);
|
||||
return result.ToDeletedResult();
|
||||
}
|
||||
}
|
||||
|
||||
+32
-26
@@ -7,6 +7,12 @@
|
||||
<IsPackable>false</IsPackable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<NoWarn>VSTHRD200,CA1873</NoWarn>
|
||||
<!-- ersatztv#25: SonarAnalyzer findings in Blazor .razor @code can't be downgraded via
|
||||
.editorconfig (Razor source-generator limitation), so under TreatWarningsAsErrors they
|
||||
would break the build. Temporarily suppressed here; the #25 burn-down removes each ID
|
||||
as its .razor findings are fixed. (These same rules run at `suggestion` on .cs via
|
||||
.editorconfig — visible, non-blocking.) -->
|
||||
<NoWarn>$(NoWarn);S6966;S3267;S2325;S3260;S2094;S3458;S3358;S6667;S125;S1135;S1751;S1125;S1066;S127;S927;S6610;S6580;S5693;S3440;S2933;S1871</NoWarn>
|
||||
<IncludeAllContentForSelfExtract>true</IncludeAllContentForSelfExtract>
|
||||
<Configurations>Debug;Release;Debug No Sync</Configurations>
|
||||
<Platforms>AnyCPU</Platforms>
|
||||
@@ -29,38 +35,38 @@
|
||||
</Target>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- <PackageReference Include="EntityFrameworkProfiler.Appender" Version="6.0.6049" /> -->
|
||||
<PackageReference Include="Blazored.FluentValidation" Version="2.2.0" />
|
||||
<PackageReference Include="BlazorSortable" Version="5.2.1" />
|
||||
<PackageReference Include="Chronic.Core" Version="0.4.0" />
|
||||
<PackageReference Include="FluentValidation" Version="12.1.1" />
|
||||
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.1" />
|
||||
<PackageReference Include="Heron.MudCalendar" Version="3.4.0" />
|
||||
<PackageReference Include="HtmlSanitizer" Version="9.0.892" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.4.9" />
|
||||
<PackageReference Include="Markdig" Version="0.44.0" />
|
||||
<PackageReference Include="MediatR.Courier.DependencyInjection" Version="5.0.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="[9.0.12,10)">
|
||||
<!-- <PackageReference Include="EntityFrameworkProfiler.Appender" /> -->
|
||||
<PackageReference Include="Blazored.FluentValidation" />
|
||||
<PackageReference Include="BlazorSortable" />
|
||||
<PackageReference Include="Chronic.Core" />
|
||||
<PackageReference Include="FluentValidation" />
|
||||
<PackageReference Include="FluentValidation.AspNetCore" />
|
||||
<PackageReference Include="Heron.MudCalendar" />
|
||||
<PackageReference Include="HtmlSanitizer" />
|
||||
<PackageReference Include="LanguageExt.Core" />
|
||||
<PackageReference Include="Markdig" />
|
||||
<PackageReference Include="MediatR.Courier.DependencyInjection" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SpaServices.Extensions" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.Extensions.ApiDescription.Server" Version="10.0.2">
|
||||
<PackageReference Include="Microsoft.Extensions.ApiDescription.Server">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="MudBlazor" Version="8.15.0" />
|
||||
<PackageReference Include="NaturalSort.Extension" Version="4.4.1" />
|
||||
<PackageReference Include="Refit.HttpClientFactory" Version="9.0.2" />
|
||||
<PackageReference Include="Scalar.AspNetCore" Version="2.12.32" />
|
||||
<PackageReference Include="Serilog" Version="4.3.0" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||
<PackageReference Include="Serilog.Settings.Configuration" Version="10.0.0" />
|
||||
<PackageReference Include="VueCliMiddleware" Version="6.0.0" />
|
||||
<PackageReference Include="MudBlazor" />
|
||||
<PackageReference Include="NaturalSort.Extension" />
|
||||
<PackageReference Include="Refit.HttpClientFactory" />
|
||||
<PackageReference Include="Scalar.AspNetCore" />
|
||||
<PackageReference Include="Serilog" />
|
||||
<PackageReference Include="Serilog.AspNetCore" />
|
||||
<PackageReference Include="Serilog.Settings.Configuration" />
|
||||
<PackageReference Include="VueCliMiddleware" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ErsatzTV.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// REST status-code mapping helpers for the JSON write/read API (slice #2a).
|
||||
/// These are additive and must not change the behavior of the existing
|
||||
/// <see cref="EitherToActionResult" /> / <see cref="OptionToActionResult" /> helpers,
|
||||
/// which IptvController and other read endpoints depend on.
|
||||
/// </summary>
|
||||
[SuppressMessage("ReSharper", "VSTHRD003")]
|
||||
public static class ApiResults
|
||||
{
|
||||
/// <summary>Maps a failure to 404 when it is a <see cref="NotFoundError" />, otherwise 422.</summary>
|
||||
public static IActionResult ToErrorResult(this BaseError error) =>
|
||||
error is NotFoundError
|
||||
? new NotFoundObjectResult(CreateProblemDetails(404, "Resource not found", error.Value))
|
||||
: new UnprocessableEntityObjectResult(CreateProblemDetails(422, "Validation failed", error.Value));
|
||||
|
||||
/// <summary>Right: 201 Created with a Location header and body; Left: 404 (NotFound) or 422.</summary>
|
||||
public static IActionResult ToCreatedResult<TR>(
|
||||
this Either<BaseError, TR> either,
|
||||
Func<TR, string> location,
|
||||
Func<TR, object> body) =>
|
||||
either.Match(
|
||||
Left: error => error.ToErrorResult(),
|
||||
Right: value => new CreatedResult(location(value), body(value)));
|
||||
|
||||
/// <summary>Right: 200 with body; Left: 404 (NotFound) or 422.</summary>
|
||||
public static IActionResult ToUpdatedResult<TR>(this Either<BaseError, TR> either) =>
|
||||
either.Match(
|
||||
Left: error => error.ToErrorResult(),
|
||||
Right: value => (IActionResult)new OkObjectResult(value));
|
||||
|
||||
/// <summary>Right(Unit): 204 No Content; Left: 404 (NotFound) or 422.</summary>
|
||||
public static IActionResult ToDeletedResult(this Either<BaseError, Unit> either) =>
|
||||
either.Match(
|
||||
Left: error => error.ToErrorResult(),
|
||||
Right: _ => (IActionResult)new NoContentResult());
|
||||
|
||||
/// <summary>Some: 200 with body; None: 404.</summary>
|
||||
public static IActionResult ToGetResult<T>(this Option<T> option) =>
|
||||
option.Match(
|
||||
Some: value => (IActionResult)new OkObjectResult(value),
|
||||
None: () => NotFoundProblem());
|
||||
|
||||
public static IActionResult NotFoundProblem(string detail = "Resource not found") =>
|
||||
new NotFoundObjectResult(CreateProblemDetails(404, "Resource not found", detail));
|
||||
|
||||
private static ProblemDetails CreateProblemDetails(int status, string title, string detail) =>
|
||||
new()
|
||||
{
|
||||
Status = status,
|
||||
Title = title,
|
||||
Detail = detail
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
|
||||
namespace ErsatzTV.Filters;
|
||||
|
||||
/// <summary>
|
||||
/// Optional API-key authorization for mutating JSON API endpoints (slice #2a).
|
||||
/// Reads the configured key from <c>Api:WriteKey</c>. When that key is empty the filter is
|
||||
/// a no-op (preserving the current open LAN behavior); when it is set, mutating requests
|
||||
/// (POST/PUT/PATCH/DELETE) must present a matching <c>X-Api-Key</c> header or receive 401.
|
||||
/// This is fully independent of <see cref="JwtHelper" /> and only applies to the actions it
|
||||
/// decorates — it never affects /iptv/* or any read endpoint.
|
||||
/// </summary>
|
||||
public class ApiKeyAuthorizationFilter(IConfiguration configuration) : IAuthorizationFilter
|
||||
{
|
||||
public const string HeaderName = "X-Api-Key";
|
||||
public const string ConfigurationKey = "Api:WriteKey";
|
||||
|
||||
public void OnAuthorization(AuthorizationFilterContext context)
|
||||
{
|
||||
string configuredKey = configuration[ConfigurationKey];
|
||||
|
||||
// empty key => API-key auth disabled, endpoint is open
|
||||
if (string.IsNullOrEmpty(configuredKey))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string method = context.HttpContext.Request.Method;
|
||||
bool isMutating = HttpMethods.IsPost(method)
|
||||
|| HttpMethods.IsPut(method)
|
||||
|| HttpMethods.IsPatch(method)
|
||||
|| HttpMethods.IsDelete(method);
|
||||
|
||||
if (!isMutating)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!context.HttpContext.Request.Headers.TryGetValue(HeaderName, out StringValues provided)
|
||||
|| !string.Equals(provided.ToString(), configuredKey, StringComparison.Ordinal))
|
||||
{
|
||||
context.Result = new UnauthorizedResult();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -309,6 +309,9 @@ public class Startup
|
||||
|
||||
services.AddScoped(_ => new ConditionalIptvAuthorizeFilter("JwtOnlyScheme"));
|
||||
|
||||
// optional API-key authorization for mutating JSON API endpoints (independent of JWT/OIDC)
|
||||
services.AddScoped<ApiKeyAuthorizationFilter>();
|
||||
|
||||
services.AddFluentValidationAutoValidation();
|
||||
services.AddValidatorsFromAssemblyContaining<Startup>();
|
||||
|
||||
|
||||
+2059
-66
File diff suppressed because it is too large
Load Diff
+5
-3
@@ -25,9 +25,11 @@ COPY scripts/scripted-schedules/. /app/scripted-schedules/
|
||||
# copy csproj and restore as distinct layers
|
||||
COPY *.sln .
|
||||
# repo-wide build config (MSBuild props/targets incl. NuGet-audit warning
|
||||
# exemptions, SDK pin, analyzer severities) must be present before restore so the
|
||||
# image build matches local/CI builds
|
||||
COPY Directory.Build.props Directory.Build.targets global.json .editorconfig ./
|
||||
# exemptions, SDK pin, analyzer severities, and the Central Package Management
|
||||
# version manifest) must be present before restore so the image build matches
|
||||
# local/CI builds. Directory.Packages.props is REQUIRED here: with CPM the csproj
|
||||
# carry no versions, so restore fails without the central manifest.
|
||||
COPY Directory.Build.props Directory.Build.targets Directory.Packages.props global.json .editorconfig ./
|
||||
COPY artwork/* ./artwork/
|
||||
COPY ErsatzTV/*.csproj ./ErsatzTV/
|
||||
COPY ErsatzTV.Application/*.csproj ./ErsatzTV.Application/
|
||||
|
||||
+130
-13
@@ -24,6 +24,8 @@ Upstream's final release was **`v26.3.0`** (archived). Our line continues from t
|
||||
|
||||
**Gotcha:** never put a `[skip ci]` token in a commit you intend to tag — Gitea reads skip-ci from the *tagged* commit and will **suppress the release build**. (Also, `workflow_dispatch` on a tag ref isn't supported on this Gitea version, so the tag *push* must do the triggering.) Release commits, and anything you'll tag, must not contain skip-ci.
|
||||
|
||||
**Also avoid firing several pushes back-to-back** (e.g. a `[skip ci]` commit, then `main`, then a tag, all within ~1s). Observed once on this Gitea instance: the later events were silently dropped — no `ActionRun` records created at all, even though the runner was online and the workflow `active`. Pushing again, spaced out, created the runs normally. If a push/tag doesn't produce a run, re-push (or push an empty commit) rather than assuming the runner is broken.
|
||||
|
||||
## The workflow: `.gitea/workflows/docker-build.yml`
|
||||
|
||||
Single workflow, two jobs (`test` → `build`).
|
||||
@@ -62,28 +64,143 @@ the image build.
|
||||
4. `docker/build-push-action@v6`: amd64-only, `docker/Dockerfile`, `INFO_VERSION`
|
||||
build-arg, registry layer cache (`type=registry,ref=…:buildcache`,
|
||||
`cache-to … ignore-error=true`).
|
||||
5. **Smoke test**: pull the just-pushed `:<sha>`, run it, and poll ErsatzTV's web
|
||||
server from inside the container (`docker exec … python3` → `http://localhost:8409/`).
|
||||
Unique container name + `trap … EXIT` cleanup. Fails if it never serves HTTP.
|
||||
5. **Smoke + IPTV E2E test**: pull the just-pushed `:<sha>`, run it, poll for HTTP
|
||||
readiness (`docker exec … python3` → `http://localhost:8409/`), then assert the real
|
||||
Jellyfin-facing surfaces on the freshly built image (ersatztv#16): `/iptv/channels.m3u`
|
||||
returns 2xx containing `#EXTM3U`, and `/iptv/xmltv.xml` returns 2xx containing a `<tv`
|
||||
root. `xmltv.xml` needs `channels.xml` (written by the scheduler a few seconds after
|
||||
boot), so each endpoint is polled with a deadline. Unique container name + `trap … EXIT`
|
||||
cleanup; dumps container logs on failure. Catches routing / base-URL (#1) / migration
|
||||
regressions that leave the app "up" but serving broken output.
|
||||
|
||||
## Dockerfile notes (`docker/Dockerfile`)
|
||||
|
||||
- Base image: **`192.168.1.95:3000/timothy/ersatztv-ffmpeg:7.1.1`** (our Gitea fork of
|
||||
the archived `ghcr.io/ersatztv/ersatztv-ffmpeg`). FFmpeg 8 upgrade is backlogged:
|
||||
base image → ersatztv-ffmpeg#4, app-side compat → ersatztv#9.
|
||||
- Copies `Directory.Build.props`, `Directory.Build.targets`, `global.json`,
|
||||
`.editorconfig` before `dotnet restore` so the image build uses the same MSBuild
|
||||
config, SDK pin, and analyzer severities as local/CI builds (it previously copied
|
||||
only `*.sln`).
|
||||
- Copies `Directory.Build.props`, `Directory.Build.targets`, `Directory.Packages.props`,
|
||||
`global.json`, `.editorconfig` before `dotnet restore` so the image build uses the same
|
||||
MSBuild config, central package versions, SDK pin, and analyzer severities as local/CI
|
||||
builds (it previously copied only `*.sln`). `Directory.Packages.props` is **required**
|
||||
here: under Central Package Management the csproj carry no inline versions, so the
|
||||
image's restore fails (`NU1015`) without the central manifest.
|
||||
- amd64-only (jazz is x86_64). No arm32/arm64, no DMG/exe artifacts, no GHCR/DockerHub.
|
||||
|
||||
## NuGet audit
|
||||
## Dependency management (Central Package Management + scans)
|
||||
|
||||
.NET 10 runs NuGet audit on restore. Several projects set `TreatWarningsAsErrors=true`,
|
||||
so vulnerable transitive packages failed the build. `Directory.Build.props` demotes
|
||||
low/moderate/high advisories (NU1901-1903) to warnings and promotes NU1904 (critical)
|
||||
to an error in **every** project via `WarningsAsErrors`. Underlying vulnerable deps are
|
||||
tracked in ersatztv#8.
|
||||
**Central Package Management (CPM)** — package versions live in a single repo-root
|
||||
`Directory.Packages.props` (`ManagePackageVersionsCentrally=true`); the per-project
|
||||
csproj reference packages by name only (no `Version=`). One source of truth, atomic
|
||||
one-line bumps, and cross-project version drift is structurally impossible. To add or
|
||||
change a dependency, edit the `<PackageVersion>` entry centrally — never put a `Version=`
|
||||
back on a `<PackageReference>` (that trips `NU1008`). The Docker build must copy this file
|
||||
before restore (see Dockerfile notes). The `.mcp/` vendored tool (gitignored, not in the
|
||||
solution) keeps inline versions via a **local-only** `.mcp/Directory.Packages.props`
|
||||
opt-out (`ManagePackageVersionsCentrally=false`). (ersatztv#14)
|
||||
|
||||
**NuGet audit** — .NET 10 runs NuGet audit on restore. Several projects set
|
||||
`TreatWarningsAsErrors=true`, so vulnerable transitive packages failed the build.
|
||||
`Directory.Build.props` demotes low/moderate/high advisories (NU1901-1903) to warnings
|
||||
and promotes NU1904 (critical) to an error in **every** project via `WarningsAsErrors`.
|
||||
The advisories that prompted this were resolved in ersatztv#8 (NCalcSync→6.x; SQLitePCLRaw
|
||||
bundle 3.x). The NU1901-1903 demotion is **kept by design**: criticals (NU1904) still hard-
|
||||
block, while low/moderate/high advisories surface as warnings + via the weekly scan and
|
||||
Renovate security PRs, rather than breaking unrelated PRs the moment a new transitive
|
||||
advisory drops.
|
||||
|
||||
**Scheduled vulnerability scan** — `.gitea/workflows/dependency-scan.yml` runs weekly
|
||||
(cron `0 6 * * 1`) + on `workflow_dispatch`: `dotnet list package --vulnerable
|
||||
--include-transitive` over the **full** solution (incl. Scanner, which the image build
|
||||
strips). `dotnet list` exits 0 even with findings, so the step (`bash -euo pipefail`)
|
||||
greps for the "has the following vulnerable packages" marker and fails the run if present.
|
||||
Detection only — it surfaces advisories on a schedule, a Gitea-native stand-in for
|
||||
Dependabot; it does **not** open update PRs (that's Renovate — server-management#484).
|
||||
Gitea registers `schedule` triggers only from the default branch, so the cron starts
|
||||
after merge to `main`; use `workflow_dispatch` to run on demand. It went **green** once
|
||||
ersatztv#8 cleared the NCalcSync/SQLitePCLRaw advisories — a red run now means a **new**
|
||||
advisory has appeared. (ersatztv#14, ersatztv#8)
|
||||
|
||||
**Renovate (automated update PRs)** — `.gitea/workflows/renovate.yml` runs self-hosted
|
||||
[Renovate](https://docs.renovatebot.com) weekly (cron `0 3 * * 1`) + on `workflow_dispatch`,
|
||||
as a `renovate/renovate:43` container job on the shared act_runner. This is the *proposing*
|
||||
layer the scan above deliberately omits: it opens grouped dependency-update PRs and
|
||||
OSV-driven vulnerability-fix PRs against `main`, and maintains a **Dependency Dashboard**
|
||||
issue listing the full backlog. Config is the repo-root `renovate.json` — managers `nuget`
|
||||
(via CPM), `github-actions`, and `dockerfile` (scoped to the built `docker/Dockerfile`; it reads the
|
||||
HTTP-only Gitea registry for the `ersatztv-ffmpeg` base via a `RENOVATE_HOST_RULES` host rule —
|
||||
`insecureRegistry` + registry read creds, set in the workflow env, not the committed config). The
|
||||
docker-compose manager is unused (repo compose files are `build:`-only). Auth: a dedicated
|
||||
**`renovate` Gitea bot** (Write
|
||||
collaborator) via repo Actions secrets `RENOVATE_TOKEN` (bot PAT) + `GH_COM_TOKEN` (no-scope
|
||||
github.com PAT for changelogs — named `GH_`, **not** `GITHUB_`, a prefix Gitea reserves).
|
||||
**Patch** bumps to test/dev-only packages (NUnit\*, NSubstitute, Shouldly, coverlet,
|
||||
`Microsoft.NET.Test.Sdk`, `Testably.Abstractions*`, threading analyzer) **auto-merge** once
|
||||
the `Build & test (.NET)` check passes — branch protection on `main` requires that context;
|
||||
everything else is manual review (ersatztv is prod-bearing). Range-pinned packages (e.g. EF
|
||||
Core `[9.0.x,10)`) are respected — no v10 jump. PR volume is throttled (`prConcurrentLimit`
|
||||
5 + `config:recommended`'s `prHourlyLimit` 2); tick a dashboard checkbox or raise the limits
|
||||
to drain faster. `workflow_dispatch` defaults to a safe **dry run**. Cross-repo rollout
|
||||
tracked in server-management#484. (server-management#484)
|
||||
|
||||
## Static analysis & formatting
|
||||
|
||||
**Analyzer packs** — `Directory.Build.targets` references **Roslynator**, **SonarAnalyzer.CSharp**,
|
||||
**Meziantou.Analyzer**, and **AsyncFixer** for every project (versions central via CPM; guarded on
|
||||
`ManagePackageVersionsCentrally` so the gitignored `.mcp` tool isn't pulled in). They are introduced
|
||||
**incrementally** (ersatztv#15): `.editorconfig` sets `dotnet_analyzer_diagnostic.severity = suggestion`
|
||||
so the packs surface findings without failing the `TreatWarningsAsErrors` (TWAE) build. **Promotion is
|
||||
the enforcement** — raising a rule to `warning` makes it a CI-blocking error via the existing TWAE
|
||||
build, so no separate lint step is needed.
|
||||
|
||||
- **StyleCop.Analyzers is intentionally excluded**: its latest stable (1.1.118) crashes (`AD0001`) on
|
||||
C# `record` declarations, and its rules overlap the existing `.editorconfig`/Roslynator. Revisit via
|
||||
the record-compatible `1.2.0-beta` only if specifically wanted.
|
||||
- **Blazor `.razor` caveat**: editorconfig severity overrides don't reach analyzer diagnostics in Razor
|
||||
`@code` (source-generator limitation — `dotnet format` can't fix them either), so the currently-firing
|
||||
SonarAnalyzer rules are temporarily `NoWarn`-ed in `ErsatzTV.csproj` and burned down rule-by-rule in
|
||||
**ersatztv#25**. The same rules run at `suggestion` on `.cs`.
|
||||
|
||||
**Formatting** — the tree isn't yet `dotnet format`-clean (mixed UTF-8 BOM + whitespace inherited from
|
||||
upstream: ~1,500 BOM files + ~480 whitespace). A one-time normalization lands as its **own dedicated
|
||||
PR** (kept out of the analyzer work to stay reviewable); afterwards `dotnet format whitespace
|
||||
--verify-no-changes` (+ `style`) joins the `test` job so drift can't return. `.gitattributes` already
|
||||
pins line endings.
|
||||
|
||||
## Migration integrity (EF Core, both providers)
|
||||
|
||||
`TvContext` (`ErsatzTV.Infrastructure/Data/TvContext.cs`) has **two** migration sets — one per
|
||||
provider project: `ErsatzTV.Infrastructure.Sqlite/Migrations` and
|
||||
`ErsatzTV.Infrastructure.MySql/Migrations`, each with its own `TvContextModelSnapshot`. **A model
|
||||
change needs a migration in BOTH.** Add them with `scripts/add-migration.sh <Name>` (runs the EF CLI
|
||||
for each provider). The EF CLI pattern (provider selected by the post-`--` arg, which `Startup`
|
||||
reads as the `provider` config key):
|
||||
|
||||
```
|
||||
dotnet ef <cmd> --context TvContext --startup-project ErsatzTV \
|
||||
--project ErsatzTV.Infrastructure.{Sqlite|MySql} -- --provider {Sqlite|MySql}
|
||||
```
|
||||
|
||||
The **`migrations` job** in `docker-build.yml` runs on every push/PR and, for **each** provider:
|
||||
|
||||
1. `dotnet ef migrations has-pending-model-changes` — fails if an entity changed without a matching
|
||||
migration (**model drift**), so a forgotten migration can't merge.
|
||||
2. `dotnet ef database update` against a **fresh empty DB** — applies *all* migrations in order and
|
||||
fails on any broken/un-orderable one.
|
||||
|
||||
- **SQLite** (the prod provider) uses a throwaway file (`ETV_CONFIG_FOLDER=$(mktemp -d)`); no service
|
||||
needed. Validated: 787 migrations → 139 tables.
|
||||
- **MySql** uses `ServerVersion.AutoDetect`, which **connects at config time**, so the job needs a
|
||||
reachable server — provided by a `services: mysql:8.4` container (the act_runner uses Docker
|
||||
execution on network `downloadswarm`, so the service is reachable as `mysql:3306`). Connection
|
||||
string via `MySql__ConnectionString` (→ config key `MySql:ConnectionString`). Validated: 305
|
||||
migrations → 137 tables. It's an **independent gate** (not yet a `needs:` of the image build) so
|
||||
the new MySql-service dependency can't block image builds until it's proven; promote it to a
|
||||
required check once stable.
|
||||
|
||||
**Caveat — non-transactional operations**: some migrations (e.g. SQLite `PRAGMA foreign_keys`) run
|
||||
outside a transaction and warn at startup; they can't be rolled back mid-migration, so review such
|
||||
migrations carefully (this is part of what motivated the apply-to-fresh check before the prod
|
||||
cutover, server-management#481).
|
||||
|
||||
## Registry
|
||||
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
# Contributing to the ErsatzTV fork
|
||||
|
||||
A **descriptive** guide to the conventions already in this codebase — written so future changes
|
||||
(human or AI) extend the established architecture instead of reinventing it. It's a living
|
||||
document: update it when a pattern legitimately evolves.
|
||||
|
||||
## The guiding principle
|
||||
|
||||
**Match the established style and patterns. Diverge only when there's a genuinely strong reason —
|
||||
when it's the real best or only option — even if that means rearchitecting. Never deviate
|
||||
gratuitously or just because a different approach is locally convenient. When a deviation is
|
||||
justified, state why** (in the PR/commit, and in code comments where it'll surprise a reader).
|
||||
|
||||
Several of the rules below are now **enforced in CI** (layering tests, analyzers, formatting,
|
||||
migration checks) — see the pointers per section.
|
||||
|
||||
## 1. Architecture & layering
|
||||
|
||||
CQRS via MediatR. Dependency direction is enforced by `ErsatzTV.Architecture.Tests` (NetArchTest):
|
||||
|
||||
| Project | Responsibility | May depend on |
|
||||
|---|---|---|
|
||||
| `ErsatzTV.FFmpeg` | ffmpeg process/pipeline wrapper | (nothing — lowest layer) |
|
||||
| `ErsatzTV.Core` | domain entities, interfaces, pure logic | `FFmpeg` only — **no** EF Core, **no** Infrastructure/Application |
|
||||
| `ErsatzTV.Infrastructure` | EF Core data access, external clients | `Core` |
|
||||
| `ErsatzTV.Infrastructure.Sqlite` / `.MySql` | provider-specific EF (migrations) | `Core`, `Infrastructure` |
|
||||
| `ErsatzTV.Application` | MediatR handlers (business logic) | `Core`, `Infrastructure` (the **abstraction**, not the concrete providers) |
|
||||
| `ErsatzTV.Scanner` | library scanning host | `Core`, `Infrastructure*` |
|
||||
| `ErsatzTV` | ASP.NET Core host, Blazor, controllers, DI composition root | everything |
|
||||
|
||||
If a test fails here, the fix is almost always to move the type, not to relax the rule. (ersatztv#12)
|
||||
|
||||
## 2. CQRS handler conventions
|
||||
|
||||
- Requests are **`record` types** named for the action — `GetX` / `CreateX` / `UpdateX` / `DeleteX` —
|
||||
implementing `IRequest<TResult>`, living under `<Domain>/Queries/` or `<Domain>/Commands/`
|
||||
**next to** their handler.
|
||||
- **Result types** (LanguageExt, see §3): queries return `Option<T>` or `List<T>`; commands return
|
||||
`Option<BaseError>` (void success) or `Either<BaseError, T>` (value success).
|
||||
- Handlers implement `IRequestHandler<TRequest, TResult>` with **primary-constructor DI** and an
|
||||
`async Task<TResult> Handle(TRequest request, CancellationToken cancellationToken)`.
|
||||
- **Validation lives in the handler** as private static methods returning `Validation<BaseError, T>`,
|
||||
composed with tuple `.Apply(...)` — **not** FluentValidation or MediatR pipeline behaviors.
|
||||
- Examples: `ErsatzTV.Application/Channels/Queries/GetChannelById.cs` (+ `…/GetChannelByIdHandler.cs`,
|
||||
`Option<ChannelViewModel>`), `ErsatzTV.Application/FFmpegProfiles/Commands/DeleteFFmpegProfileHandler.cs`
|
||||
(tuple-`Apply` validation → `Either<BaseError, Unit>`).
|
||||
|
||||
## 3. Functional style (LanguageExt)
|
||||
|
||||
Every project globally imports `using static LanguageExt.Prelude;`. Use the monads instead of
|
||||
exceptions for control flow.
|
||||
|
||||
- **`Option<T>`** — `Optional(x)`, `Some`/`None`, `HeadOrNone()` to pluck from a collection;
|
||||
branch with `Match`/`MatchAsync(onSome, onNone)`; default with `IfNone(...)`; transform with
|
||||
`Map`/`Bind`; `foreach (var x in option)` to unpack conditionally.
|
||||
- **`Either<BaseError, T>` / `Validation<BaseError, T>`** — the error backbone. `BaseError.New("…")`
|
||||
for failures, `Unit.Default` for void success. Accumulate multiple checks with `Validation` (tuple
|
||||
`.Apply((a, b, c) => …)`) then `ToEither()`. `MapLeft` to transform errors.
|
||||
- Examples: `ErsatzTV.Core/FFmpeg/…`, `ErsatzTV.Application/FFmpegProfiles/Commands/DeleteFFmpegProfileHandler.cs`.
|
||||
|
||||
## 4. Blazor / MudBlazor UI
|
||||
|
||||
- **Keep pages thin** — inject `IMediator` and `await Mediator.Send(new SomeQuery(...), token)`;
|
||||
no business logic or data access in the component.
|
||||
- Pages `@implements IDisposable` and own a `CancellationTokenSource` per lifecycle phase, cancelled
|
||||
on parameter change / disposal.
|
||||
- Forms: `MudForm` + **Blazored FluentValidation** (`Validation="@(validator.ValidateValue)"`,
|
||||
`For="@(() => _model.Prop)"`); surface errors via `Snackbar.Add(msg, Severity.Error)`.
|
||||
- Dialogs: pass `DialogParameters`, accept `[CascadingParameter] IMudDialogInstance`, close with
|
||||
`MudDialog.Close(DialogResult.Ok(...))`. Global providers + event subscriptions live in
|
||||
`Shared/MainLayout.razor` (unsubscribed in `Dispose()`).
|
||||
- Examples: `ErsatzTV/Pages/Channels.razor`, `ErsatzTV/Pages/ChannelEditor.razor`,
|
||||
`ErsatzTV/Shared/ChannelPreviewDialog.razor`.
|
||||
|
||||
## 5. Data access & EF Core
|
||||
|
||||
- `TvContext` (`ErsatzTV.Infrastructure/Data/TvContext.cs`) is the single DbContext; resolve it via
|
||||
`IDbContextFactory<TvContext>` in handlers.
|
||||
- **Two providers**: `ErsatzTV.Infrastructure.Sqlite` (the prod default, `/config/ersatztv.sqlite3`)
|
||||
and `…MySql`, each with its own `Migrations/` set + `TvContextModelSnapshot`. The provider is
|
||||
chosen by the `provider` config key in `Startup`.
|
||||
- **A model change needs a migration in BOTH providers** — run `scripts/add-migration.sh <Name>`.
|
||||
CI's `migrations` job enforces model-drift + apply-to-fresh-DB per provider (see `docs/ci-cd.md`
|
||||
→ Migration integrity). Review non-transactional ops (e.g. SQLite `PRAGMA foreign_keys`) carefully.
|
||||
(ersatztv#13)
|
||||
|
||||
## 6. FFmpeg pipeline
|
||||
|
||||
- **Step-based composition**: each task is an `IPipelineStep` (`ErsatzTV.FFmpeg/IPipelineStep.cs`) that
|
||||
contributes `GlobalOptions`/`InputOptions`/`FilterOptions`/`OutputOptions` and a `NextState()` that
|
||||
threads frame metadata (pixel format, data location, dimensions). `CommandGenerator.GenerateArguments`
|
||||
assembles the steps into the final argument list.
|
||||
- **Detect capabilities at runtime; don't hardcode** — `IHardwareCapabilities` implementations
|
||||
(`Vaapi`/`Nvidia`/… `HardwareCapabilities`) report available codecs/profiles, and builders pick the
|
||||
encoder/decoder accordingly.
|
||||
- **Version-gate features explicitly** — e.g. `Capabilities/NvidiaHardwareCapabilities.cs` parses
|
||||
`FFmpegCapabilities.Version` and disables 10-bit H.264 decode hwaccel below ffmpeg 8. Follow this
|
||||
pattern for the ffmpeg 8 upgrade (ersatztv#9).
|
||||
- Examples: `ErsatzTV.FFmpeg/Encoder/EncoderBase.cs`, `ErsatzTV.FFmpeg/Filter/ScaleFilter.cs`.
|
||||
|
||||
## 7. Naming, formatting, analyzers
|
||||
|
||||
- **`.editorconfig` is the source of truth** for formatting + rule severities (plus
|
||||
`ErsatzTV.sln.DotSettings` for ReSharper). Run `dotnet format` before committing.
|
||||
- **`TreatWarningsAsErrors=true`** in the app projects — a warning fails the build. `NoWarn` carries a
|
||||
small, documented exemption list (e.g. `VSTHRD200`, `CA1873`); NuGet-audit `NU1901-1903` are demoted
|
||||
to warnings in `Directory.Build.props` while `NU1904` (critical) blocks.
|
||||
- **Static-analysis packs** (Roslynator, SonarAnalyzer, Meziantou, AsyncFixer) run at `suggestion` and
|
||||
are promoted to `warning`/`error` rule-by-rule; promotion is the enforcement (the TWAE build). New
|
||||
rules start at suggestion — never flip a wall of rules to error at once. (ersatztv#15)
|
||||
|
||||
## 8. Testing
|
||||
|
||||
- **NUnit** (`[TestFixture]`/`[Test]`/`[TestCase]`) + **Shouldly** (`.ShouldBe(...)`) + **NSubstitute**
|
||||
+ Testably.Abstractions for a fake filesystem. **xUnit is not used.** Tests live in `*.Tests`
|
||||
projects mirroring the source.
|
||||
- Established kinds: **FFmpeg command-string assertions** (build a pipeline → assert the exact arg
|
||||
string — `ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs`); **golden-file tests** for
|
||||
Jellyfin-facing output (`ErsatzTV.Core.Tests/Iptv/ChannelPlaylistGoldenTests.cs`, regen via
|
||||
`ETV_UPDATE_GOLDENS=1`, ersatztv#11); **architecture tests** (§1, ersatztv#12).
|
||||
- The suite is **timezone-independent** — the previously timezone-sensitive `DateTimeOffset`
|
||||
filler-scheduling tests were fixed (ersatztv#24) by giving fixtures realistic times instead of the
|
||||
default `DateTime.MinValue`, which underflowed `DateTimeOffset.MinValue` under a non-UTC offset.
|
||||
When constructing test `PlayoutItem`s, set a real `Start` (e.g. `startState.CurrentTime.UtcDateTime`),
|
||||
not the default. CI runs UTC and uses `dotnet test … --blame-hang-timeout 2m`.
|
||||
|
||||
## 9. Build / CI
|
||||
|
||||
- **Central Package Management**: versions live in the repo-root `Directory.Packages.props`; csproj
|
||||
reference packages by name only — never put `Version=` back on a `<PackageReference>` (NU1008).
|
||||
Repo-wide MSBuild config is in `Directory.Build.props` / `Directory.Build.targets`. (ersatztv#14)
|
||||
- The Gitea Actions pipeline (`.gitea/workflows/docker-build.yml`): `test` → `migrations` →
|
||||
`build` (image + smoke/IPTV-E2E). **Renovate** opens dependency PRs; a weekly `dependency-scan`
|
||||
surfaces advisories. Full details: **`docs/ci-cd.md`**.
|
||||
- Versioning is CalVer `vYY.<release-seq>.<patch>` (not year.month); never `[skip ci]` a commit you'll
|
||||
tag. See `docs/ci-cd.md` → Versioning.
|
||||
|
||||
## 10. Deviation policy
|
||||
|
||||
Re-stating §0 because it's the whole point: **follow the established pattern; diverge only with a
|
||||
concrete, stated reason.** Record an intentional departure in the PR description and a code comment at
|
||||
the point of surprise (e.g. "using X instead of the usual Y because …"). If a pattern itself should
|
||||
change, change it deliberately and update this guide in the same PR.
|
||||
@@ -0,0 +1,87 @@
|
||||
# Handoff — ersatztv#2: REST API for channel / collection / schedule / playout CRUD
|
||||
|
||||
**How to use:** start a **fresh, parallel** session and say something like
|
||||
*"Read `docs/handoffs/rest-api.md` and execute ersatztv#2 — do Phase 1 (investigate + determine the API shape) first and stop for sign-off before writing code."*
|
||||
Read **ersatztv#2** in full (Gitea, `timothy/ersatztv`) before acting. This runs **in parallel** with smaller work happening on `main` in another session — coordinate via branches/PRs, don't assume exclusive ownership of the tree.
|
||||
|
||||
This is a **large greenfield feature**, deliberately split into two phases. Do **not** start coding in Phase 1.
|
||||
|
||||
---
|
||||
|
||||
## Why (from the issue)
|
||||
|
||||
ErsatzTV has no REST API for create/update/delete of channels, collections, schedules, or playouts — management is via the Blazor UI or fragile direct SQLite writes. Direct DB writes are dangerous because:
|
||||
- EF Core uses **TPT inheritance** (e.g. `ProgramScheduleItem` → `ProgramScheduleOneItem`/…): must insert into the right subtype.
|
||||
- Many NOT NULL / enum constraints (ScheduleKind, PlaybackOrder, CollectionType, …); wrong values produce broken playouts ("Cannot build playout type None").
|
||||
- WAL mode → container must be stopped for safe external writes.
|
||||
- No validation → easy to create unbuildable state.
|
||||
|
||||
The API closes this by routing all mutations through EF + existing domain validation, returning proper HTTP codes.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Investigate & determine the shape (DESIGN ONLY, get sign-off)
|
||||
|
||||
Goal: produce a short **design doc** (`docs/rest-api.md`) + an **increment split into sub-issues**, then stop for the user to approve before any implementation. Mirror the rigor we used on #1: ground every claim in the actual code, don't assume.
|
||||
|
||||
### 1a. Map what already exists (the issue says `GET /api/channels` already exists — find it)
|
||||
- Locate the **existing API controller(s)** under `ErsatzTV/Controllers/` (there is already at least one read endpoint — find the route prefix, base class, content negotiation, and any auth/`access_token` handling). The new CRUD endpoints must match this style, not invent a new one.
|
||||
- The **MediatR CQRS pattern**: queries/commands live in `ErsatzTV.Application/<Area>/{Queries,Commands}/`. Many handlers already return `Either<BaseError, T>`; there is a `.ToActionResult()` extension (used in `IptvController`) that maps `Either` → HTTP. Confirm the exact error/success mapping and reuse it (don't hand-roll status codes).
|
||||
- The **Blazor pages** that already create/update/delete each resource (under `ErsatzTV/Pages/`): they call MediatR commands and carry the real validation. The API should call the **same** commands/handlers where they exist, and only add new ones where a Blazor flow doesn't map cleanly. Enumerate, per resource, which commands already exist vs. which are net-new.
|
||||
- The **domain model + EF mapping**: `ErsatzTV.Core/Domain/` entities + `ErsatzTV.Infrastructure/Data/TvContext.cs`. Note the TPT hierarchies (ProgramScheduleItem subtypes, Collection kinds, StreamingMode/ScheduleKind enums). Determine whether any CRUD operation needs a **schema change** (most should not — pure CRUD over existing tables). **If a model change IS needed → dual-provider migration via `scripts/add-migration.sh` (Sqlite + MySql), enforced by the `migrations` CI job.** Flag this explicitly; CRUD-only likely needs none.
|
||||
- **Auth**: how are existing endpoints secured (the IPTV ones use an `access_token` query param)? Decide the API's auth story and state it. Don't ship unauthenticated mutation endpoints without calling that out.
|
||||
|
||||
### 1b. Decide conventions (write them down)
|
||||
- Route layout (`/api/channels`, `/api/collections/{id}/items`, …) — keep consistent with the existing controller.
|
||||
- Status codes: 201 Created (+ Location), 200/204, 404, 422 validation. Map from `Either<BaseError,_>`.
|
||||
- Request/response **DTOs** vs. reusing existing `*ViewModel`s — pick one and be consistent.
|
||||
- Validation: reuse domain/handler validation; where Blazor validated in the page code-behind, that logic must move into (or already live in) the handler so the API gets it too.
|
||||
- OpenAPI/Swagger? Pagination for list endpoints? Idempotency of deletes? — decide or explicitly defer.
|
||||
- **URL emission caveat (from #1):** if any API response includes absolute URLs (logos, stream URLs), they inherit the request-`Host` fragility documented in `docs/m3u-xmltv.md`. Note how the API will handle it (probably: same request-derived host; don't bake).
|
||||
|
||||
### 1c. Increment plan (the sub-issues)
|
||||
Split #2 into vertical slices, **Channels first** as the pattern-setter (it's the simplest full CRUD and the others copy its shape):
|
||||
- **#2a Channels** — POST/PUT/DELETE (+ confirm existing GETs).
|
||||
- **#2b Collections** — CRUD + item add/remove.
|
||||
- **#2c Schedules** — CRUD + schedule-item add/remove (this is the TPT-heavy one; budget for it).
|
||||
- **#2d Playouts** — create (link channel↔schedule) / delete (+ existing reset).
|
||||
|
||||
Each slice = one branch = one PR. Open the sub-issues, link them to #2 (which becomes the tracker), and **stop for sign-off**.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Implement (workflows / ultracode, one slice at a time)
|
||||
|
||||
Only after Phase 1 sign-off. Per slice (start with #2a Channels):
|
||||
|
||||
**Shape of the work per resource** (good fit for a `Workflow` pipeline or `ultracode`):
|
||||
1. For each endpoint: Command + Handler in `ErsatzTV.Application/<Resource>/Commands/` (reuse existing where found), returning `Either<BaseError, T>`.
|
||||
2. Controller action in `ErsatzTV/Controllers/` mapping via `.ToActionResult()`.
|
||||
3. Validation reused from the corresponding Blazor flow / domain.
|
||||
4. **NUnit** tests (+ Shouldly + NSubstitute) — handler unit tests for success + each failure (404/validation), and a controller/integration test for status-code mapping. (No xUnit.)
|
||||
5. Run the suite locally with `TZ=UTC` (matches CI; see the known TZ-sensitive filler tests, #24).
|
||||
|
||||
**Suggested workflow pattern:** pipeline over the endpoints of the slice — stage 1 author handler+test, stage 2 adversarially review each (does it actually go through EF? does it handle TPT/enum constraints? does delete cascade correctly?). Adversarial verify is worth it here because broken writes corrupt playouts. Use `ultracode` if the user opts in, otherwise a single `Workflow` per slice.
|
||||
|
||||
**Regression nets already in place — lean on them:**
|
||||
- **Architecture tests (#12)** enforce layering: controllers in `ErsatzTV`, business logic in `ErsatzTV.Application`, no concrete-provider leakage. New code must pass `ErsatzTV.Architecture.Tests` (will fail the PR if you put logic in the wrong layer).
|
||||
- **M3U goldens (#11)** guard `ToM3U` output if a channel change touches it.
|
||||
- Add focused tests for new behavior; consider an integration test that creates→reads→deletes through the real EF stack (SQLite) to prove TPT/cascade correctness.
|
||||
|
||||
---
|
||||
|
||||
## Process (non-negotiable — CLAUDE.md + `docs/contributing.md`)
|
||||
- Follow established patterns; diverge only with a stated reason (contributors guide #10).
|
||||
- Dependencies via **Central Package Management** (`Directory.Packages.props`); never re-add `Version=` to a `<PackageReference>`.
|
||||
- **One branch = one PR.** PR runs `test` + `migrations` (both **required** to merge). Merge to `main` runs `test`+`migrations`+`build`+smoke/E2E. Verify green before closing each sub-issue.
|
||||
- Migrations only if the model changes — `scripts/add-migration.sh <Name>` does **both** providers.
|
||||
- Adversarial self-review of the diff before closing (see memory: adversarial-self-review-at-milestones). Then Task Completion Protocol / `/done <sub-issue>`.
|
||||
- CI poll: `curl -u timothy:ded89Lm4 …/api/v1/repos/timothy/ersatztv/actions/tasks` (jobs by name), or the runs API.
|
||||
|
||||
## Repo state at handoff
|
||||
- `main` is green; #1 closed (config/topology, not code — see `docs/m3u-xmltv.md`). #5 triaged as Jellyfin/infra (→ server-management).
|
||||
- `main` requires `Build & test (.NET)` **and** `EF migration integrity (SQLite + MySql)` to merge a PR.
|
||||
- Test framework is **NUnit** (+ Shouldly + NSubstitute), not xUnit.
|
||||
- Versioning is CalVer `vYY.N.P`; only tag when the user asks. The API is the first real app-feature work → first such release would be `v26.4.0`.
|
||||
- Infra: Docker host **bumblebee** `192.168.1.99`; container `ersatztv` (port 8409); registry `192.168.1.95:3000/timothy/ersatztv`. Plain `ssh timothy@192.168.1.99` works; ssh-mcp/docker-mcp need `--key=~/.ssh/id_rsa` + client restart if they 401.
|
||||
- This session runs **in parallel** with smaller tasks (#28 XMLTV/logo goldens, #24 TZ tests) landing on `main` — rebase before opening PRs.
|
||||
+36
-9
@@ -49,9 +49,11 @@ XMLTV data is pre-generated from Scriban templates (`ErsatzTV/Resources/Template
|
||||
|
||||
## Jellyfin Integration
|
||||
|
||||
### Tuner Setup
|
||||
> **Current homelab topology (2026-06):** Jellyfin does **not** tuner ErsatzTV directly — it funnels ErsatzTV through **Dispatcharr** (`dispatcharr:9191`), which ingests ErsatzTV and re-serves the channels/EPG. The direct-tuner setup below is the generic reference for pointing *any* tuner at ErsatzTV; see "Resolution (2026-06-27)" under the Logo URL Issue for the live pipeline.
|
||||
|
||||
In Jellyfin, add an IPTV tuner pointing to:
|
||||
### Tuner Setup (direct — reference)
|
||||
|
||||
To point an IPTV tuner directly at ErsatzTV:
|
||||
```
|
||||
http://ersatztv:8409/iptv/channels.m3u
|
||||
```
|
||||
@@ -96,10 +98,9 @@ curl -H "Host: ersatztv:8409" .../iptv/channels.m3u -> tvg-logo="http://ersat
|
||||
|
||||
So the `http://localhost:8409` URLs that broke in Jellyfin were baked in because a client fetched ErsatzTV using `Host: localhost:8409` (a misconfigured tuner/XMLTV URL in Jellyfin's Live TV settings, or a proxy passing that Host). The original issue framing ("hardcoded localhost in code") is a red herring — see Investigation Findings above.
|
||||
|
||||
This is therefore **not a code bug in the M3U/XMLTV generators**. There are two complementary fixes:
|
||||
This is therefore **not a code bug in the M3U/XMLTV generators**. The fix is operational, not code:
|
||||
|
||||
1. **Operational (server-management):** Jellyfin's M3U tuner URL and XMLTV listing URL must use a host that is resolvable *and* correct from Jellyfin's network context (e.g. `http://ersatztv:8409`), never `localhost`. Owned by `server-management` (related: its issue #171).
|
||||
2. **Durable hardening (ersatztv, deferred — Gitea #1):** add an optional advertised **Base URL** override so generated URLs stop depending on the fragile `Host` header. See below.
|
||||
- **Operational (server-management):** Jellyfin's IPTV source must reach ErsatzTV via a host that is resolvable *and* correct from Jellyfin's network context (e.g. `http://ersatztv:8409`), never `localhost`. Owned by `server-management` (related: its issue #171).
|
||||
|
||||
### Architecture: absolute-URL generation (relevant for future work)
|
||||
|
||||
@@ -115,10 +116,36 @@ Implications for future work:
|
||||
- Any client that connects via a different host (localhost, a reverse-proxy alias, split-DNS name) gets that host echoed back into stream + logo + artwork URLs. This affects the planned REST API (#2) and any reverse-proxy fronting (server-management) too.
|
||||
- **Settings are stored in the `ConfigElement` table as simple key/value** (`ConfigElementKey` in `ErsatzTV.Core/Domain/ConfigElementKey.cs`; read/write via `IConfigElementRepository.GetValue<T>` / upsert). Adding a new setting needs **no EF migration**.
|
||||
|
||||
### Planned fix for #1 (deferred until CI/CD #3/#4 land)
|
||||
### Resolution (2026-06-27) — Gitea #1 closed as config/topology, no code change
|
||||
|
||||
Add an optional `iptv.base_url` ConfigElement + a "Base URL" field on the settings page. When set, `GetChannelPlaylistHandler` and `GetChannelGuideHandler` parse it into scheme/host/base and use it instead of the request values; when blank, behaviour is unchanged (request-derived). Cover with unit tests on `ChannelPlaylist.ToM3U()` (pure class — easy to assert logo/stream hosts) plus the override-resolution logic. Run the existing suite before and after to guard against regressions.
|
||||
A second live investigation (against the running stack on **bumblebee**, 192.168.1.99) confirmed the original symptom is **not reproducible in the current architecture**, because the topology changed since #1 was filed (Feb 2026):
|
||||
|
||||
### Current Workaround
|
||||
**Jellyfin no longer tuners ErsatzTV directly.** Per Jellyfin's `livetv.xml`, its only Live TV source is **Dispatcharr** (`http://dispatcharr:9191/output/m3u` + `/output/epg`). ErsatzTV is funneled *through* Dispatcharr:
|
||||
|
||||
A script downloads logos from ErsatzTV and base64-uploads them directly to Jellyfin's `/Items/{id}/Images/Primary` API, bypassing the M3U logo URLs entirely. Documented in `server-management` repo (issue #171).
|
||||
```
|
||||
ErsatzTV ──(Dispatcharr fetches via ersatztv:8409)──▶ Dispatcharr ──▶ Jellyfin
|
||||
• channel logos → Dispatcharr re-hosts as http://dispatcharr:9191/... (HTTP 200 image/png ✓)
|
||||
• programme icons → passed through as http://ersatztv:8409/... (3095 of them, HTTP 200 image/png ✓)
|
||||
• localhost references anywhere Jellyfin consumes: 0
|
||||
```
|
||||
|
||||
Evidence gathered (all read-only):
|
||||
- ErsatzTV fetched via `ersatztv:8409` returns correct `ersatztv:8409` URLs (no `localhost`).
|
||||
- All 44 cached guide fragments under `/config/cache/channel-guide/*.xml` contain the `{RequestBase}` placeholder — **zero** baked `localhost`. Nothing is persisted absolute.
|
||||
- Dispatcharr's M3U + EPG output contain **0** `localhost`; sample logo and icon both fetch HTTP 200 `image/png` from inside the Jellyfin container.
|
||||
|
||||
**Root cause = configuration/topology, not code.** The historical `localhost:8409` came from a client fetching ErsatzTV with `Host: localhost:8409`; that path no longer exists. Closed without a code change.
|
||||
|
||||
> Note — the forwarded-headers theory (`KnownProxies.Clear()` in `Startup.cs` making `X-Forwarded-*` be *ignored*) is **incorrect**: clearing *both* `KnownProxies` and `KnownNetworks` sets `checkKnownIps = false`, which *trusts all* proxies, not none. And there is no proxy between Jellyfin and ErsatzTV. Red herring.
|
||||
|
||||
### Latent fragility (not currently broken)
|
||||
|
||||
The EPG Jellyfin consumes still contains 3095 absolute `http://ersatztv:8409/...` programme-icon URLs. They work **only** because Dispatcharr fetches ErsatzTV with a Jellyfin-resolvable host, and Jellyfin shares the Docker network. ErsatzTV still has no advertised/base-URL setting — every absolute URL is hostage to the request `Host`. If a client ever fetches ErsatzTV with a host that downstream consumers can't resolve, the same class of breakage returns.
|
||||
|
||||
### Optional future hardening (NOT committed)
|
||||
|
||||
If that fragility is ever worth removing: add an optional `iptv.base_url` ConfigElement (key/value, **no EF migration**) + a "Base URL" settings field; when set, `GetChannelPlaylistHandler` / `GetChannelGuideHandler` parse it into scheme/host/base instead of the request values; when blank, behaviour is unchanged. Cover with unit tests on `ChannelPlaylist.ToM3U()`. Deliberately **not** built for #1 — the problem no longer manifests, so this stays a documented option rather than committed work.
|
||||
|
||||
### Historical Workaround (pre-Dispatcharr)
|
||||
|
||||
Before the Dispatcharr funnel, a script downloaded logos from ErsatzTV and base64-uploaded them directly to Jellyfin's `/Items/{id}/Images/Primary` API, bypassing the M3U logo URLs. Documented in `server-management` (issue #171). No longer needed for the current pipeline.
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
# REST API Design — ersatztv#2
|
||||
|
||||
**Status:** Phase 1 signed off → Phase 2 in progress.
|
||||
**Tracker:** ersatztv#2. **Sub-issues:** #34 (#2a), #35 (#2b), #36 (#2c), #37 (#2d), #38 (#2e). **Handoff:** `docs/handoffs/rest-api.md`.
|
||||
|
||||
## 1. Goals
|
||||
|
||||
1. **Programmatic CRUD** for channels / collections / schedules / playouts via REST — enabling **MCP** and direct API automation.
|
||||
2. **Foundation for a new UI** built on top of the API (it replaces read paths too, not just writes).
|
||||
3. **Retire fragile direct-SQLite writes.** All mutations route through EF Core + existing domain validation, returning proper HTTP codes — no more TPT/enum/NOT-NULL footguns, no container-stop-for-writes.
|
||||
|
||||
These goals push three things to the front: **OpenAPI is in-scope** (MCP tool-gen + UI codegen), **stable DTO contracts**, and a **programmatic auth** story.
|
||||
|
||||
## 2. What the investigation found (code-grounded)
|
||||
|
||||
### 2.1 No migration needed
|
||||
Pure CRUD over existing tables. The full schema — including the four `ProgramScheduleItem` **TPT subtype tables** (`ProgramScheduleOneItem`, `ProgramScheduleMultipleItem`, `ProgramScheduleFloodItem`, `ProgramScheduleDurationItem`, via `.UseTptMappingStrategy()`) — already exists. The `migrations` CI job will stay green; no `scripts/add-migration.sh` run is required for any slice.
|
||||
|
||||
### 2.2 Every command already exists
|
||||
All four resources already have MediatR Create/Update/Delete (+ item ops) handlers, **almost all returning `Either<BaseError, T>`** — which maps cleanly to HTTP. The API is mostly **thin controllers over existing handlers**, not new business logic.
|
||||
|
||||
| Resource | Create | Update | Delete | Item ops |
|
||||
|---|---|---|---|---|
|
||||
| **Channels** | `CreateChannel` → `Either<_, CreateChannelResult>` | `UpdateChannel` → `Either<_, ChannelViewModel>` | `DeleteChannel` → `Either<_, Unit>` | — |
|
||||
| **Collections** | `CreateCollection` → `Either<_, MediaCollectionViewModel>` | `UpdateCollection` → `Either<_, Unit>` | `DeleteCollection` → `Either<_, Unit>` | `AddItemsToCollection`, `RemoveItemsFromCollection` |
|
||||
| **Schedules** | `CreateProgramSchedule` → `Either<_, CreateProgramScheduleResult>` | `UpdateProgramSchedule` → `Either<_, UpdateProgramScheduleResult>` | `DeleteProgramSchedule` → `Either<_, Unit>` | `AddProgramScheduleItem`, `ReplaceProgramScheduleItems` (bulk) |
|
||||
| **Playouts** | `CreateClassic/Block/Sequential/Scripted/ExternalJsonPlayout` → `Either<_, CreatePlayoutResponse>` | `UpdatePlayout` → `Either<_, PlayoutNameViewModel>` | `DeletePlayout` → `Either<_, Unit>` | reset (exists, via worker channel) |
|
||||
|
||||
Net-new command work is essentially **zero** for the happy paths. Effort is in controllers, DTOs, status-code mapping, auth, and tests.
|
||||
|
||||
### 2.3 Validation already lives in the handlers (mostly)
|
||||
The substantive rules are in-handler, so the API inherits them:
|
||||
- Channel **number uniqueness** (`CreateChannelHandler.cs:137`, `UpdateChannelHandler.cs:234` excludes self), name/FK-exists, mirror-source validity.
|
||||
- Collection / schedule **name uniqueness** + length.
|
||||
- Playout business rules: **"Channel already has one playout"** (`CreateClassicPlayoutHandler.cs:77`), **"Program schedule must have items"** (`:93`).
|
||||
|
||||
A few **format/UX checks are page-only** (FluentValidation in `.razor` / `Validators/`) and would NOT reach the API — port these into the handlers (with test-before/after):
|
||||
- Channel: `ShowInEpg` must be false when `!IsEnabled`; external-logo-URL format. (`ChannelEditViewModelValidator.cs`)
|
||||
- Playout: required-field shape per kind. (`PlayoutEditViewModelValidator.cs`) — mostly redundant with handler checks; confirm parity.
|
||||
|
||||
### 2.4 Existing controller conventions (match these)
|
||||
- Controllers in `ErsatzTV/Controllers/Api/`, base `ControllerBase`, **routes declared per-action** (full path, no controller `[Route]` prefix).
|
||||
- JSON: **Newtonsoft**, **camelCase** (`CustomNamingStrategy`, with `FFmpegProfileId` special-cased), **enums as strings** (`StringEnumConverter`), `NullValueHandling.Ignore` (`Startup.cs:294`).
|
||||
- **OpenAPI already wired**: `AddOpenApi("v1")` + **Scalar UI at `/docs`** (`Startup.cs:136`, `:664`); endpoints opt in via `[EndpointGroupName("general")]`.
|
||||
- `.ToActionResult()` extensions map: `Either` Left→**400**, Right→**200**; `Option` None→**404**, Some→**200**; `Validation` Failure→**400**. **No 201/422 today.**
|
||||
|
||||
### 2.5 Two existing CRUD controllers are non-idiomatic → standardize
|
||||
`FFmpegProfileController` and `SmartCollectionController` use verb-in-path (`/new`, `/update`, `/delete/{id}`). Per the modernization decision, retrofit them to idiomatic REST as we work the related slices, gated by characterization tests.
|
||||
|
||||
### 2.6 Auth today
|
||||
A **JWT bearer scheme** exists (`JwtHelper`, `JwtOnlyScheme` policy, `access_token` query-param support, 1-day tokens) but is wired **only to `IptvController`** via `ConditionalIptvAuthorizeFilter` (enforced only when `JWT:IssuerSigningKey` is set). `/api/*` currently has **no auth**; CORS is **AllowAll**.
|
||||
|
||||
## 3. Conventions (decided)
|
||||
|
||||
### 3.1 Routing — idiomatic REST
|
||||
```
|
||||
POST /api/channels GET /api/channels GET /api/channels/{id}
|
||||
PUT /api/channels/{id} DELETE /api/channels/{id}
|
||||
|
||||
POST /api/collections GET /api/collections GET /api/collections/{id}
|
||||
PUT /api/collections/{id} DELETE /api/collections/{id}
|
||||
POST /api/collections/{id}/items
|
||||
DELETE /api/collections/{id}/items/{mediaItemId}
|
||||
|
||||
POST /api/schedules GET /api/schedules GET /api/schedules/{id}
|
||||
PUT /api/schedules/{id} DELETE /api/schedules/{id}
|
||||
GET /api/schedules/{id}/items
|
||||
POST /api/schedules/{id}/items
|
||||
PUT /api/schedules/{id}/items # bulk replace (maps to ReplaceProgramScheduleItems)
|
||||
DELETE /api/schedules/{id}/items/{itemId}
|
||||
|
||||
POST /api/playouts GET /api/playouts GET /api/playouts/{id}
|
||||
DELETE /api/playouts/{id}
|
||||
POST /api/channels/{number}/playout/reset # exists; keep
|
||||
```
|
||||
`{id}` = numeric entity id. Channel reset keeps its existing `{number}` route (already public).
|
||||
|
||||
### 3.2 Status codes
|
||||
| Outcome | Code |
|
||||
|---|---|
|
||||
| Create success | **201 Created** + `Location` header + body |
|
||||
| Update success | **200 OK** + body |
|
||||
| Delete success | **204 No Content** |
|
||||
| Read success | **200 OK** |
|
||||
| Resource not found | **404** + `ProblemDetails` body |
|
||||
| Validation failure (bad field, uniqueness, business rule) | **422 Unprocessable Entity** + `ProblemDetails` body |
|
||||
| Malformed request (unparseable JSON, missing required) | **400** |
|
||||
|
||||
The current `.ToActionResult()` only yields 200/400/404, so we add **richer mapping helpers** (a deliberate, stated deviation — proper HTTP semantics matter for MCP/UI consumers). **Foundation decision (#2a):** to distinguish 404 from 422, handlers fold "does not exist" into `BaseError` today (flat). Introduce a lightweight typed error (e.g. `NotFoundError : BaseError`) in `ErsatzTV.Core`; mapping helper returns 404 for it, 422 for other `BaseError`s. This is a Core/error-layer change (not an EF model change) — test-before/after applies where it touches existing handlers. If it proves to sprawl, fall back to per-controller existence checks and backlog the typed-error refactor.
|
||||
|
||||
**Error body contract (#46):** `/docs` and `wwwroot/openapi/v1.json` are generated-client contracts for MCP/UI consumers, so runtime 404/422 bodies MUST match the OpenAPI `ProblemDetails` schema. API helpers return `ProblemDetails` with `status`, `title`, and `detail`; 404 uses title `Resource not found`, and 422 uses title `Validation failed`. Do not return plain string error bodies from REST API #2 endpoints.
|
||||
|
||||
### 3.3 DTOs
|
||||
- **Requests:** dedicated request DTOs (bound from JSON) → mapped to existing Commands. Stable external contract; decouples the wire shape from internal command records.
|
||||
- **Responses:** **reuse existing `*ViewModel`s** (already the camelCase read contract used by current GETs). One contract, already proven.
|
||||
|
||||
### 3.4 Validation
|
||||
Reuse handler validation. Port the page-only checks in §2.3 into handlers so the API reaches parity with the Blazor UI. No validation logic in controllers.
|
||||
|
||||
### 3.5 Auth — dedicated API key for writes (decoupled from IPTV)
|
||||
**Mechanism:** a dedicated **API key** for mutations — a new config key (e.g. `Api__WriteKey`) checked by a filter applied **only to POST/PUT/DELETE on `/api/*`**. Enforced only when the key is configured (LAN-open default preserved); reads and all `/iptv/*` stay open.
|
||||
|
||||
**Why not reuse the JWT scheme (important):** JWT is gated by a single global toggle, `JwtHelper.IsEnabled` ← `JWT:IssuerSigningKey` (`Startup.cs:166`). That **same toggle also gates the IPTV endpoints** (`/iptv/channels.m3u`, `/iptv/xmltv.xml`, streams — `ConditionalIptvAuthorizeFilter:18`) which **Jellyfin and Dispatcharr consume**. Enabling JWT to protect writes would force token auth onto those media feeds — and JWT tokens **expire in 1 day** (`JwtHelper.cs:27`), unsuitable for a standing tuner URL. A dedicated API key **decouples write-auth from media-consumer auth**: turning it on changes **nothing** for Jellyfin/Dispatcharr.
|
||||
|
||||
**Properties:** long-lived credential (no 1-day churn), fit for MCP / new-UI write clients; net-new but small (one filter + one config key); a standard machine-API pattern.
|
||||
|
||||
**Backlog:** tighten CORS for mutation routes if writes are exposed beyond LAN.
|
||||
|
||||
### 3.6 OpenAPI / discoverability
|
||||
New endpoints carry `[EndpointGroupName("general")]` → appear in the existing `v1` doc + Scalar `/docs`. This *is* the MCP/UI enabler (tool-gen + client codegen). Keep summaries/`[ProducesResponseType]` accurate per action.
|
||||
|
||||
### 3.7 Other conventions
|
||||
- **Absolute URLs** in responses (logos/streams) use the **request-derived host**, same as M3U/XMLTV (`docs/m3u-xmltv.md`) — never bake a host. (Carries the #1 lesson forward.)
|
||||
- **List endpoints:** add simple pagination where the underlying query supports it; defer rich filtering/sorting (backlog — the new UI will want it).
|
||||
- **DELETE idempotency:** return 404 if the entity is already absent (clear over silently-204). EF cascade handles dependents (e.g. Channel→Playout cascade, ProgramSchedule→Items cascade) — integration tests prove this per slice.
|
||||
|
||||
## 4. Standardization scope
|
||||
|
||||
- Retrofit `FFmpegProfileController` + `SmartCollectionController` to idiomatic REST, each gated by **characterization tests** (capture current behavior → change → prove green).
|
||||
- Opportunistic-fix policy: backlog/document unrelated issues found en route; fix-in-place only when limited-scope + useful-now, or when deferring would force rework of the new code.
|
||||
|
||||
## 5. Increment plan (sub-issues under #2 as tracker)
|
||||
|
||||
One slice = one branch = one PR. PR runs `test` + `migrations` (both required); merge to `main` adds `build` + smoke/E2E. Tests with **NUnit + Shouldly + NSubstitute**, run locally `TZ=UTC`.
|
||||
|
||||
- **#2a (#34) — API foundation + Channels (pattern-setter).** Shared conventions: idiomatic routing, status-code mapping helpers (incl. `NotFoundError` typed error + 201/422), request-DTO pattern, response = ViewModels, `[EndpointGroupName("general")]`, API-key mutation auth filter (decoupled from the IPTV JWT toggle). Then Channels CRUD (POST/PUT/DELETE, confirm/standardize GET list + add GET `/{id}`). Port page-only channel validations into handlers. Tests: handler unit (success + 404/422), controller status-mapping, create→read→delete EF integration.
|
||||
- **#2b (#35) — Collections** CRUD + add/remove items; **retrofit `SmartCollectionController`** to idiomatic (characterization tests first).
|
||||
- **#2c (#36) — Schedules** CRUD + schedule items (**TPT-heavy** — the one to budget for). Design the item DTO around the `PlayoutMode` discriminator (One/Multiple/Flood/Duration); reuse `AddProgramScheduleItem` / `ReplaceProgramScheduleItems`. Integration test proving the correct TPT subtype rows are written.
|
||||
- **#2d (#37) — Playouts** create (Classic + 4 kinds via discriminated DTO) / delete; keep existing reset. Validation already strong.
|
||||
- **#2e (#38) — Standardization cleanup.** Retrofit `FFmpegProfileController`; OpenAPI/doc polish; CORS review for mutations; sweep for any other non-idiomatic `/api` endpoints; fold in backlog items gathered during #2a–#2d.
|
||||
|
||||
**Sequencing:** #2a first (sets every convention the others copy), then #2b–#2d in parallel-able order, #2e last. Each slice ships its own read endpoints so the new UI gains coverage incrementally.
|
||||
|
||||
## 6. Testing strategy (per slice)
|
||||
- Handler unit tests: success + each failure (not-found→404, validation→422).
|
||||
- Controller tests: status-code/`Location`/DTO mapping.
|
||||
- EF integration test (real SQLite): create→read→delete proving TPT subtype creation + cascade correctness.
|
||||
- Characterization tests before any existing-controller retrofit.
|
||||
- Lean on existing nets: architecture tests (#12 — controllers in `ErsatzTV`, logic in `ErsatzTV.Application`), M3U goldens (#11) if a change touches `ToM3U`.
|
||||
|
||||
## 7. Open items / backlog seeds
|
||||
- CORS tightening for mutation routes (if exposed beyond LAN).
|
||||
- List-endpoint filtering/sorting/pagination depth (new-UI driven).
|
||||
- API-key provisioning UX for MCP/UI write clients (how a caller obtains/sets `Api__WriteKey`).
|
||||
- Decide whether `NotFoundError` typed-error becomes a repo-wide convention or stays API-local.
|
||||
```
|
||||
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"config:recommended"
|
||||
],
|
||||
"timezone": "Europe/Brussels",
|
||||
"semanticCommits": "enabled",
|
||||
"dependencyDashboard": true,
|
||||
"labels": ["dependencies"],
|
||||
"enabledManagers": ["nuget", "github-actions", "dockerfile"],
|
||||
"osvVulnerabilityAlerts": true,
|
||||
"vulnerabilityAlerts": {
|
||||
"labels": ["dependencies", "security"]
|
||||
},
|
||||
"prConcurrentLimit": 5,
|
||||
"packageRules": [
|
||||
{
|
||||
"description": "Only manage the built amd64 docker/Dockerfile; ignore the vestigial upstream arm/ffmpeg-tests Dockerfiles (this fork ships amd64-only)",
|
||||
"matchManagers": ["dockerfile"],
|
||||
"matchFileNames": [
|
||||
"docker/arm32v7/Dockerfile",
|
||||
"docker/arm64/Dockerfile",
|
||||
"docker/ffmpeg-tests/Dockerfile"
|
||||
],
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"description": "Group the .NET base images (mcr.microsoft.com/dotnet/*) into one PR",
|
||||
"matchManagers": ["dockerfile"],
|
||||
"matchPackageNames": ["/^mcr\\.microsoft\\.com\\/dotnet\\//"],
|
||||
"groupName": ".NET base images"
|
||||
},
|
||||
{
|
||||
"description": "Group the Microsoft.Extensions.* family into one PR",
|
||||
"matchPackageNames": ["/^Microsoft\\.Extensions\\./"],
|
||||
"groupName": "Microsoft.Extensions"
|
||||
},
|
||||
{
|
||||
"description": "Group the Microsoft.AspNetCore.* family into one PR",
|
||||
"matchPackageNames": ["/^Microsoft\\.AspNetCore\\./"],
|
||||
"groupName": "Microsoft.AspNetCore"
|
||||
},
|
||||
{
|
||||
"description": "Group Entity Framework Core + providers (held to v9 by the range pins in Directory.Packages.props)",
|
||||
"matchPackageNames": [
|
||||
"/^Microsoft\\.EntityFrameworkCore/",
|
||||
"/^EFCore\\.BulkExtensions/",
|
||||
"/^Pomelo\\.EntityFrameworkCore/"
|
||||
],
|
||||
"groupName": "Entity Framework Core"
|
||||
},
|
||||
{
|
||||
"description": "Group the Serilog family into one PR",
|
||||
"matchPackageNames": ["/^Serilog/"],
|
||||
"groupName": "Serilog"
|
||||
},
|
||||
{
|
||||
"description": "Group the Refit family into one PR",
|
||||
"matchPackageNames": ["/^Refit/"],
|
||||
"groupName": "Refit"
|
||||
},
|
||||
{
|
||||
"description": "Group the Lucene.Net family into one PR",
|
||||
"matchPackageNames": ["/^Lucene\\.Net/"],
|
||||
"groupName": "Lucene.Net"
|
||||
},
|
||||
{
|
||||
"description": "Auto-merge patch bumps to test/dev-only packages once the build+test check passes; everything else stays manual (ersatztv is prod-bearing). server-management#484",
|
||||
"matchPackageNames": [
|
||||
"NUnit",
|
||||
"NUnit.Analyzers",
|
||||
"NUnit3TestAdapter",
|
||||
"NSubstitute",
|
||||
"Shouldly",
|
||||
"coverlet.collector",
|
||||
"Microsoft.NET.Test.Sdk",
|
||||
"Testably.Abstractions",
|
||||
"Testably.Abstractions.Testing",
|
||||
"Microsoft.VisualStudio.Threading.Analyzers"
|
||||
],
|
||||
"matchUpdateTypes": ["patch"],
|
||||
"automerge": true,
|
||||
"platformAutomerge": true
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user