Compare commits
42
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 | ||
|
|
8a2ceeddee | ||
|
|
c7aad4689e | ||
|
|
c0c37d8987 | ||
|
|
45627ede82 | ||
|
|
8c9bd680a4 | ||
|
|
46e2fbf0f7 | ||
|
|
9ad0c4a393 | ||
|
|
bbfc9e720a | ||
|
|
e58bb9af21 | ||
|
|
aa6d8eae4c | ||
|
|
f1e97b94a7 | ||
|
|
5034941a79 |
@@ -0,0 +1,167 @@
|
||||
---
|
||||
name: ersatztv
|
||||
description: ErsatzTV custom IPTV channel management — REST API, SQLite DB, Jellyfin integration, FFmpeg profiles. Use when managing custom TV channels.
|
||||
---
|
||||
|
||||
# ErsatzTV Channel Management
|
||||
|
||||
Container: `ersatztv` | Port: `8409` | IP: `172.16.238.11` (may change on restart)
|
||||
Web UI: internal only (`http://localhost:8409` via SSH)
|
||||
SQLite DB: `~/downloadswarm/ersatztv/ersatztv.sqlite3` on jazz (owned by root — use `sudo sqlite3`)
|
||||
Image: `ghcr.io/ersatztv/ersatztv:latest` (v26.3.0, repo archived Feb 2026)
|
||||
|
||||
## Architecture
|
||||
|
||||
ErsatzTV uses **MediatR + Blazor** (not REST for mutations). The REST API is limited:
|
||||
- **GET endpoints**: channels, collections, schedules, playouts, shows, movies, artists, ffmpeg profiles, health, search, watermarks
|
||||
- **POST endpoints**: library scan, playout reset, show scan
|
||||
- **No REST CRUD for channels/collections/schedules** — must use SQLite DB directly
|
||||
|
||||
## REST API
|
||||
|
||||
```bash
|
||||
# Via docker exec
|
||||
docker exec ersatztv curl -s http://localhost:8409/api/ENDPOINT
|
||||
```
|
||||
|
||||
### Read Endpoints (GET)
|
||||
```
|
||||
/api/channels # List channels
|
||||
/api/collections # List collections
|
||||
/api/schedules # List schedules
|
||||
/api/playouts # List playouts
|
||||
/api/shows # List shows
|
||||
/api/movies # List movies
|
||||
/api/artists # List artists
|
||||
/api/search # Search items
|
||||
/api/ffmpeg/profiles # FFmpeg profiles
|
||||
/api/watermarks # Watermarks
|
||||
/iptv/channels.m3u # M3U playlist (for Jellyfin)
|
||||
/iptv/xmltv.xml # XMLTV guide data
|
||||
```
|
||||
|
||||
### Mutation Endpoints (POST)
|
||||
```bash
|
||||
# Library scan
|
||||
POST /api/libraries/{id}/scan
|
||||
|
||||
# Scan single show
|
||||
POST /api/libraries/{id}/scan-show \
|
||||
-H "Content-Type: application/json" -d '{"ShowTitle":"Name","DeepScan":false}'
|
||||
|
||||
# Reset channel playout (rebuilds schedule)
|
||||
POST /api/channels/{channelNumber}/playout/reset
|
||||
```
|
||||
|
||||
## SQLite DB Operations
|
||||
|
||||
```bash
|
||||
# Read queries (safe while running, WAL mode)
|
||||
sudo sqlite3 ~/downloadswarm/ersatztv/ersatztv.sqlite3 "QUERY"
|
||||
|
||||
# Write queries — stop container first
|
||||
docker stop ersatztv
|
||||
sudo sqlite3 ~/downloadswarm/ersatztv/ersatztv.sqlite3 "QUERY"
|
||||
docker start ersatztv
|
||||
```
|
||||
|
||||
### Key Queries
|
||||
```sql
|
||||
-- List channels
|
||||
SELECT Id, Number, Name FROM Channel ORDER BY CAST(Number AS INTEGER);
|
||||
|
||||
-- List collections with item counts
|
||||
SELECT c.Id, c.Name, COUNT(ci.Id) as items FROM Collection c LEFT JOIN CollectionItem ci ON ci.CollectionId = c.Id GROUP BY c.Id;
|
||||
|
||||
-- List schedules
|
||||
SELECT Id, Name FROM ProgramSchedule;
|
||||
|
||||
-- Playout (channel-schedule links)
|
||||
SELECT p.Id, c.Number, c.Name, ps.Name as Schedule FROM Playout p JOIN Channel c ON p.ChannelId = c.Id LEFT JOIN ProgramSchedule ps ON p.ProgramScheduleId = ps.Id;
|
||||
|
||||
-- Media counts
|
||||
SELECT 'Shows' as type, COUNT(*) FROM Show UNION ALL SELECT 'Movies', COUNT(*) FROM Movie UNION ALL SELECT 'Episodes', COUNT(*) FROM Episode UNION ALL SELECT 'MusicVideos', COUNT(*) FROM MusicVideo;
|
||||
|
||||
-- Jellyfin source
|
||||
SELECT jms.Id, jc.Address, jms.ServerName FROM JellyfinMediaSource jms JOIN JellyfinConnection jc ON jc.JellyfinMediaSourceId = jms.Id;
|
||||
|
||||
-- Library sync status
|
||||
SELECT l.Id, l.Name, l.MediaKind, jl.ShouldSyncItems FROM Library l JOIN JellyfinLibrary jl ON jl.Id = l.Id;
|
||||
```
|
||||
|
||||
### Channel Setup Workflow (DB)
|
||||
|
||||
**Show-specific channel** (single TV show, shuffled):
|
||||
```sql
|
||||
-- 1. Schedule
|
||||
INSERT INTO ProgramSchedule (Id, FixedStartTimeBehavior, KeepMultiPartEpisodesTogether, Name, RandomStartPoint, ShuffleScheduleItems, TreatCollectionsAsShows)
|
||||
VALUES (<id>, 0, 0, '<name>', 1, 0, 1);
|
||||
-- 2. Schedule item (CollectionType=1 for Show, PlaybackOrder=3 for Shuffle)
|
||||
INSERT INTO ProgramScheduleItem (Id, CollectionType, FillWithGroupMode, GuideMode, "Index", MarathonGroupBy, MarathonShuffleGroups, MarathonShuffleItems, MediaItemId, PlaybackOrder, ProgramScheduleId)
|
||||
VALUES (<id>, 1, 0, 0, 0, 0, 0, 0, <show_id>, 3, <schedule_id>);
|
||||
INSERT INTO ProgramScheduleOneItem (Id) VALUES (<item_id>);
|
||||
-- 3. Channel
|
||||
INSERT INTO Channel (Id, Categories, FFmpegProfileId, FallbackFillerId, "Group", IdleBehavior, IsEnabled, MirrorSourceChannelId, MusicVideoCreditsMode, MusicVideoCreditsTemplate, Name, Number, PlayoutMode, PlayoutOffset, PlayoutSource, PreferredAudioLanguageCode, PreferredAudioTitle, PreferredSubtitleLanguageCode, ShowInEpg, SongVideoMode, SortNumber, StreamSelector, StreamSelectorMode, StreamingMode, SubtitleMode, TranscodeMode, UniqueId, WatermarkId)
|
||||
VALUES (<id>, '', 1, NULL, '<category>', 0, 1, NULL, 0, NULL, '<name>', '<number>', 0, NULL, 0, NULL, NULL, 'eng', 1, 0, <number>.0, NULL, 0, 4, 2, 0, lower(hex(randomblob(4)))||'-'||lower(hex(randomblob(2)))||'-4'||substr(lower(hex(randomblob(2))),2)||'-'||lower(hex(randomblob(2)))||'-'||lower(hex(randomblob(6))), 1);
|
||||
-- 4. Playout
|
||||
INSERT INTO Playout (Id, ChannelId, ProgramScheduleId, ScheduleKind, Seed)
|
||||
VALUES (<id>, <channel_id>, <schedule_id>, 0, abs(random()) % 1000000);
|
||||
```
|
||||
|
||||
**Collection-based channel** (multiple shows, shuffled):
|
||||
```sql
|
||||
-- 1. Collection + items (MediaItemId = Show.Id)
|
||||
INSERT INTO Collection (Id, Name, UseCustomPlaybackOrder) VALUES (<id>, '<name>', 0);
|
||||
INSERT INTO CollectionItem (CollectionId, MediaItemId) VALUES (<coll_id>, <show_id>);
|
||||
-- 2. Schedule (same as above but CollectionType=0, CollectionId set instead of MediaItemId)
|
||||
INSERT INTO ProgramScheduleItem (Id, CollectionId, CollectionType, ..., PlaybackOrder, ProgramScheduleId)
|
||||
VALUES (<id>, <coll_id>, 0, ..., 3, <schedule_id>);
|
||||
-- 3-4. Channel + Playout same as show-specific
|
||||
```
|
||||
|
||||
After creating: `POST /api/channels/{number}/playout/reset`
|
||||
|
||||
## Volume Mounts (matches Jellyfin)
|
||||
|
||||
| Host Path | Container Path |
|
||||
|-----------|---------------|
|
||||
| `~/downloadswarm/ersatztv` | `/config` |
|
||||
| `/mnt/teramind/episodes` | `/data/tvshows` (ro) |
|
||||
| `/mnt/episodes` | `/data/episodes` (ro) |
|
||||
| `/mnt/media/movies` | `/data/movies` (ro) |
|
||||
| `/mnt/media/standup` | `/data/standup` (ro) |
|
||||
| `/mnt/media/music_videos` | `/data/music` (ro) |
|
||||
|
||||
## FFmpeg & Hardware
|
||||
|
||||
- QSV (Intel Quick Sync) hardware acceleration
|
||||
- Resolution: 1920x1080, H264, AAC stereo
|
||||
- Device: `/dev/dri` passed through
|
||||
- HardwareAccelerationKind: 0=None, 1=Qsv, 2=Nvenc, 3=Vaapi, 4=VideoToolbox, 5=Amf
|
||||
|
||||
## Jellyfin Integration
|
||||
|
||||
- Secrets: `/config/jellyfin-secrets.json` (`{"Address":"http://jellyfin:8096","ApiKey":"978033be716d46678a5d3c54ae0e0ff9"}`)
|
||||
- Libraries: Movies(10), TV Shows(11), Music Videos(8), Standup(9)
|
||||
- `JellyfinLibrary.ShouldSyncItems` must be `1` for scans to work
|
||||
|
||||
## Gotchas
|
||||
|
||||
- DB owned by root — always use `sudo sqlite3`
|
||||
- WAL mode: reads OK while running, stop container for writes
|
||||
- No REST API for channel/collection/schedule CRUD — DB scripting only
|
||||
- Secrets file uses PascalCase JSON (`Address`, `ApiKey`)
|
||||
- Scanner is separate binary (`ErsatzTV.Scanner`) — check with `docker top ersatztv | grep Scanner`
|
||||
- EF TPT inheritance: `ProgramScheduleItem` has subtype tables (`ProgramScheduleOneItem`, etc.) — MUST insert into subtype table
|
||||
- External URL logos work for M3U but NOT for watermark burn-in (code checks `File.Exists()`)
|
||||
- `/api/health` returns Blazor HTML, not JSON — use `/api/channels` to verify API
|
||||
- PlaybackOrder enum: 3=Shuffle, 6=SeasonEpisode (use 3 for all channels)
|
||||
- CollectionType enum: 0=Collection, 1=Show (direct show reference via MediaItemId)
|
||||
- SubtitleMode: 0=None, 2=Burn-in. Set to 2 with PreferredSubtitleLanguageCode='eng' for non-music channels
|
||||
- MediaItem.State: 0=Normal, 1=FileNotFound — clean up state=1 items by deleting cascading deps
|
||||
- ProgramSchedule required NOT NULL columns: FixedStartTimeBehavior, KeepMultiPartEpisodesTogether, RandomStartPoint, ShuffleScheduleItems, TreatCollectionsAsShows
|
||||
- Channel required NOT NULL columns: SongVideoMode (set 0), plus all standard columns (see Channel table schema)
|
||||
- After schedule changes, rebuild playout: `POST /api/channels/{number}/playout/reset`
|
||||
- Playout `ScheduleKind` must be `1` (not `0`/None) — `0` causes "Cannot build playout type None" error
|
||||
- M3U `tvg-logo` URLs hardcode `http://localhost:8409` — Jellyfin can't fetch these from inside its container. Fix by downloading logos from ETV and base64-uploading to Jellyfin (see `docs/Docker/ErsatzTV.md` for script). Tracked in issue #171
|
||||
- Repo archived Feb 2026, v26.3.0 is final stable version. Maintainer welcomes forks
|
||||
@@ -0,0 +1,105 @@
|
||||
---
|
||||
name: jellyfin
|
||||
description: Jellyfin media server management — API for libraries, items, streaming, users. Use when managing media library or checking Jellyfin status.
|
||||
---
|
||||
|
||||
# Jellyfin Management
|
||||
|
||||
Container: `jellyfin` | Port: `8096` | IP: `172.16.238.20` (may change on restart)
|
||||
API Token: `978033be716d46678a5d3c54ae0e0ff9`
|
||||
Web UI: `https://jellyfin.tblindustries.be` (NO Authelia — native login, password: `coup1802`)
|
||||
Config: `/home/timothy/downloadswarm/jellyfin/` on jazz
|
||||
|
||||
## Access Pattern
|
||||
|
||||
```bash
|
||||
docker exec jellyfin curl -s 'http://localhost:8096/ENDPOINT' \
|
||||
-H 'X-Emby-Token: 978033be716d46678a5d3c54ae0e0ff9'
|
||||
```
|
||||
|
||||
## Volume Mounts
|
||||
|
||||
| Host Path | Container Path | Content |
|
||||
|-----------|---------------|---------|
|
||||
| `/mnt/teramind/episodes` | `/data/tvshows` | TV shows |
|
||||
| `/mnt/episodes` | `/data/episodes` | More episodes |
|
||||
| `/mnt/media/movies` | `/data/movies` | Movies |
|
||||
| `/mnt/media/standup` | `/data/standup` | Standup |
|
||||
| `/mnt/media/music_videos` | `/data/music` | Music videos |
|
||||
| `/mnt/media/audio/music` | `/data/audio` | Music audio (ro) |
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### System
|
||||
```
|
||||
GET /System/Info # Server info, version
|
||||
GET /System/Info/Public # Public info (no auth needed)
|
||||
POST /System/Restart # Restart server
|
||||
```
|
||||
|
||||
### Items (Search & Browse)
|
||||
```bash
|
||||
# Search items
|
||||
GET /Items?includeItemTypes=Movie,Episode,Series&recursive=true&searchTerm=QUERY&fields=Path&limit=20
|
||||
|
||||
# Get item details
|
||||
GET /Items?ids=ITEM_ID&fields=Path,MediaStreams,Overview
|
||||
|
||||
# Get all movies
|
||||
GET /Items?includeItemTypes=Movie&recursive=true&fields=Path&limit=1000
|
||||
|
||||
# Get series
|
||||
GET /Items?includeItemTypes=Series&recursive=true&fields=Path
|
||||
|
||||
# Get episodes for a series
|
||||
GET /Shows/{seriesId}/Episodes?fields=Path,MediaStreams
|
||||
|
||||
# Filter by library (parentId)
|
||||
GET /Items?parentId=LIBRARY_ID&recursive=true&fields=Path
|
||||
```
|
||||
|
||||
### Libraries
|
||||
```
|
||||
GET /Library/VirtualFolders # List all libraries
|
||||
POST /Library/Refresh # Trigger full library scan
|
||||
POST /Items/{id}/Refresh # Refresh single item metadata
|
||||
```
|
||||
|
||||
### Streaming
|
||||
```bash
|
||||
# Test stream URL
|
||||
GET /Videos/{itemId}/stream?static=true
|
||||
|
||||
# Get playback info
|
||||
GET /Items/{itemId}/PlaybackInfo
|
||||
```
|
||||
|
||||
### Users
|
||||
```
|
||||
GET /Users # List users
|
||||
GET /Users/{userId} # User details
|
||||
```
|
||||
|
||||
## Library IDs
|
||||
|
||||
Check with: `curl -s -H "X-Emby-Token: TOKEN" http://localhost:8096/Library/VirtualFolders`
|
||||
|
||||
## Live TV
|
||||
|
||||
- **ErsatzTV** (channels <1000): M3U `http://ersatztv:8409/iptv/channels.m3u`, XMLTV `http://ersatztv:8409/iptv/xmltv.xml`
|
||||
- **Dispatcharr** (channels 1000+): IPTV stream manager on port 9191, separate tuner
|
||||
- Configured in Jellyfin Admin > Live TV
|
||||
- Guide refresh task ID: `bea9b218c97bbf98c5dc1303bdb9a0ca` — trigger via `POST /ScheduledTasks/Running/{id}`
|
||||
- **Logo fix after guide refresh**: ErsatzTV logos break (aspect ratio=0) because M3U uses `localhost:8409`. Fix script in `docs/Docker/ErsatzTV.md` downloads from ETV and base64-uploads to `POST /Items/{id}/Images/Primary` (body = base64, Content-Type = image/png)
|
||||
- **Image upload format**: Jellyfin expects base64-encoded body (NOT raw binary) for `POST /Items/{id}/Images/Primary`
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Passwords**: `coup1802` (NOT `ded89Lm4`) — Jellyfin has native auth, no Authelia
|
||||
- Auth header is `X-Emby-Token` (Jellyfin is an Emby fork)
|
||||
- Music videos are typed as "Movie" in Jellyfin
|
||||
- Music library at `/data/music` maps to `/mnt/media/music_videos` on host (not actual music)
|
||||
- Items return 404 on stream if source volume is unmounted
|
||||
- Jellyfin preserves item IDs across restarts unless files are renamed
|
||||
- Full library scan can take a long time — prefer targeted `/Items/{id}/Refresh`
|
||||
- `ffprobe` available in container for checking media streams: `docker exec jellyfin ffprobe -v quiet -print_format json -show_streams FILE`
|
||||
@@ -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."
|
||||
@@ -0,0 +1,251 @@
|
||||
name: Build ErsatzTV Image
|
||||
|
||||
# Builds the fork's own amd64 image and pushes it to the Gitea container registry.
|
||||
# pull_request -> test job only (no image build/push)
|
||||
# push to main -> :latest + :<short-sha> (test image; does NOT touch prod)
|
||||
# push tag v* -> :prod + :<version> + :<short-sha> (prod release)
|
||||
# workflow_dispatch -> manual run; only publishes when the ref is main or a v* tag
|
||||
#
|
||||
# Runner + registry provisioned in server-management#172. The Gitea registry is
|
||||
# HTTP-only, so BuildKit needs the inline `http = true` config below (it does not
|
||||
# inherit the host daemon's insecure-registries setting).
|
||||
#
|
||||
# `:latest` is intentionally the test/dev channel (per ersatztv#3); prod pins
|
||||
# `:prod`, never `:latest` (enforced in the prod compose — server-management#481).
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
# Single runner on jazz: serialize all runs so the push-main-then-tag release
|
||||
# flow can't collide on the shared :buildcache tag or the smoke container.
|
||||
concurrency:
|
||||
group: ersatztv-build
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
REGISTRY: 192.168.1.95:3000
|
||||
IMAGE: 192.168.1.95:3000/timothy/ersatztv
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Build & test (.NET)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore
|
||||
|
||||
- name: Strip Scanner project ref (matches Docker build)
|
||||
run: sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||
|
||||
- name: Build
|
||||
run: dotnet build --configuration Release --no-restore
|
||||
|
||||
- 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, migrations]
|
||||
if: github.event_name != 'pull_request'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Compute version and tags
|
||||
id: meta
|
||||
run: |
|
||||
SHORT=$(git rev-parse --short HEAD)
|
||||
if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
INFO_VERSION="${VERSION}"
|
||||
TAGS=("${IMAGE}:prod" "${IMAGE}:${VERSION}" "${IMAGE}:${SHORT}")
|
||||
else
|
||||
DESC=$(git describe --tags --abbrev=0 2>/dev/null || echo v0.0.0)
|
||||
INFO_VERSION="${DESC#v}-${SHORT}"
|
||||
TAGS=("${IMAGE}:latest" "${IMAGE}:${SHORT}")
|
||||
fi
|
||||
echo "info_version=${INFO_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "short=${SHORT}" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "tags<<__EOT__"
|
||||
printf '%s\n' "${TAGS[@]}"
|
||||
echo "__EOT__"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
echo "INFO_VERSION=${INFO_VERSION}"
|
||||
printf 'tag: %s\n' "${TAGS[@]}"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
buildkitd-config-inline: |
|
||||
[registry."192.168.1.95:3000"]
|
||||
http = true
|
||||
|
||||
- name: Login to Gitea registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/Dockerfile
|
||||
platforms: linux/amd64
|
||||
# only publish from main or a v* tag; other refs (e.g. branch dispatch) build only
|
||||
push: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') }}
|
||||
provenance: false
|
||||
build-args: |
|
||||
INFO_VERSION=${{ steps.meta.outputs.info_version }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
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 + IPTV E2E (assert key endpoints)
|
||||
if: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') }}
|
||||
run: |
|
||||
IMG="${IMAGE}:${{ steps.meta.outputs.short }}"
|
||||
NAME="etv-smoke-${{ github.run_id }}"
|
||||
trap 'docker rm -f "$NAME" >/dev/null 2>&1 || true' EXIT
|
||||
echo "Pulling ${IMG}"
|
||||
docker pull "$IMG"
|
||||
docker run -d --name "$NAME" --memory 2g \
|
||||
-e ETV_CONFIG_FOLDER=/tmp/etv/config \
|
||||
-e ETV_TRANSCODE_FOLDER=/tmp/etv/transcode \
|
||||
"$IMG"
|
||||
# probe ErsatzTV's web server from inside the container (image ships python3)
|
||||
cat > probe.py <<'PY'
|
||||
import urllib.request, urllib.error, sys
|
||||
try:
|
||||
urllib.request.urlopen("http://localhost:8409/", timeout=3)
|
||||
except urllib.error.HTTPError:
|
||||
pass # any HTTP status means the server is serving
|
||||
except Exception:
|
||||
sys.exit(1) # not listening yet
|
||||
PY
|
||||
ok=0
|
||||
for _ in $(seq 1 60); do
|
||||
if [ -z "$(docker ps -q --filter name="$NAME" --filter status=running)" ]; then
|
||||
echo "Container exited early"; break
|
||||
fi
|
||||
if docker exec -i "$NAME" python3 - < probe.py >/dev/null 2>&1; then
|
||||
ok=1; break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
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 "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
|
||||
@@ -1,2 +0,0 @@
|
||||
github: jasongdove
|
||||
custom: "https://www.paypal.me/jasongdove"
|
||||
@@ -1,26 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: nuget
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: daily
|
||||
assignees:
|
||||
- jasongdove
|
||||
- package-ecosystem: docker
|
||||
directory: "/docker"
|
||||
schedule:
|
||||
interval: daily
|
||||
assignees:
|
||||
- jasongdove
|
||||
- package-ecosystem: docker
|
||||
directory: "/docker/nvidia"
|
||||
schedule:
|
||||
interval: daily
|
||||
assignees:
|
||||
- jasongdove
|
||||
- package-ecosystem: docker
|
||||
directory: "/docker/vaapi"
|
||||
schedule:
|
||||
interval: daily
|
||||
assignees:
|
||||
- jasongdove
|
||||
@@ -1,336 +0,0 @@
|
||||
name: Build Artifacts
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: 'Release tag'
|
||||
required: true
|
||||
type: string
|
||||
release_version:
|
||||
description: 'Release version number (e.g. v0.3.7-alpha)'
|
||||
required: true
|
||||
type: string
|
||||
info_version:
|
||||
description: 'Informational version number (e.g. 0.3.7-alpha)'
|
||||
required: true
|
||||
type: string
|
||||
secrets:
|
||||
apple_developer_certificate_p12_base64:
|
||||
required: true
|
||||
apple_developer_certificate_password:
|
||||
required: true
|
||||
ac_username:
|
||||
required: true
|
||||
ac_password:
|
||||
required: true
|
||||
gh_token:
|
||||
required: true
|
||||
azure_client_id:
|
||||
required: true
|
||||
azure_tenant_id:
|
||||
required: true
|
||||
azure_subscription_id:
|
||||
required: true
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
jobs:
|
||||
build_and_upload_mac:
|
||||
name: Mac Build & Upload
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- os: macos-14
|
||||
kind: macOS
|
||||
target: osx-x64
|
||||
- os: macos-14
|
||||
kind: macOS
|
||||
target: osx-arm64
|
||||
steps:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
|
||||
- name: Install dependencies
|
||||
run: dotnet restore -r "${{ matrix.target}}"
|
||||
|
||||
- name: Import Code-Signing Certificates
|
||||
uses: Apple-Actions/import-codesign-certs@v2
|
||||
with:
|
||||
p12-file-base64: ${{ secrets.apple_developer_certificate_p12_base64 }}
|
||||
p12-password: ${{ secrets.apple_developer_certificate_password }}
|
||||
|
||||
- name: Calculate Release Name
|
||||
shell: bash
|
||||
run: |
|
||||
release_name="ErsatzTV-${{ inputs.release_version }}-${{ matrix.target }}"
|
||||
echo "RELEASE_NAME=${release_name}" >> $GITHUB_ENV
|
||||
|
||||
- name: Build
|
||||
shell: bash
|
||||
run: |
|
||||
sed -i '' '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net10.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=false -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net10.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=false -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
|
||||
- name: Bundle
|
||||
shell: bash
|
||||
run: |
|
||||
brew install coreutils
|
||||
plutil -replace CFBundleShortVersionString -string "${{ inputs.info_version }}" ErsatzTV-macOS/ErsatzTV-macOS/Info.plist
|
||||
plutil -replace CFBundleVersion -string "${{ inputs.info_version }}" ErsatzTV-macOS/ErsatzTV-macOS/Info.plist
|
||||
scripts/macOS/bundle.sh
|
||||
|
||||
- name: Sign
|
||||
shell: bash
|
||||
run: scripts/macOS/sign.sh
|
||||
|
||||
- name: Create DMG
|
||||
shell: bash
|
||||
run: |
|
||||
brew install create-dmg
|
||||
create-dmg \
|
||||
--volname "ErsatzTV" \
|
||||
--volicon "artwork/ErsatzTV.icns" \
|
||||
--window-pos 200 120 \
|
||||
--window-size 800 400 \
|
||||
--icon-size 100 \
|
||||
--icon "ErsatzTV.app" 200 190 \
|
||||
--hide-extension "ErsatzTV.app" \
|
||||
--app-drop-link 600 185 \
|
||||
--skip-jenkins \
|
||||
--no-internet-enable \
|
||||
"ErsatzTV.dmg" \
|
||||
"ErsatzTV.app/"
|
||||
|
||||
- name: Notarize
|
||||
shell: bash
|
||||
run: |
|
||||
xcrun notarytool submit ErsatzTV.dmg --apple-id "${{ secrets.ac_username }}" --password "${{ secrets.ac_password }}" --team-id 32MB98Q32R --wait
|
||||
xcrun stapler staple ErsatzTV.dmg
|
||||
|
||||
- name: Cleanup
|
||||
shell: bash
|
||||
run: |
|
||||
mv ErsatzTV.dmg "${{ env.RELEASE_NAME }}.dmg"
|
||||
rm -r publish
|
||||
rm -r ErsatzTV.app
|
||||
|
||||
- name: Delete old release assets
|
||||
uses: mknejp/delete-release-assets@v1
|
||||
if: ${{ inputs.release_tag == 'develop' }}
|
||||
with:
|
||||
token: ${{ secrets.gh_token }}
|
||||
tag: ${{ inputs.release_tag }}
|
||||
fail-if-no-assets: false
|
||||
assets: "*${{ matrix.target }}.dmg"
|
||||
|
||||
- name: Publish
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
prerelease: false
|
||||
tag_name: ${{ inputs.release_tag }}
|
||||
files: "${{ env.RELEASE_NAME }}.dmg"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.gh_token }}
|
||||
|
||||
build_and_upload_linux:
|
||||
name: Build & Upload Linux
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
kind: linux
|
||||
target: linux-x64
|
||||
- os: ubuntu-latest
|
||||
kind: linux
|
||||
target: linux-musl-x64
|
||||
- os: ubuntu-latest
|
||||
kind: linux
|
||||
target: linux-arm
|
||||
- os: ubuntu-24.04-arm
|
||||
kind: linux
|
||||
target: linux-arm64
|
||||
steps:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
|
||||
- name: Install dependencies
|
||||
run: dotnet restore -r "${{ matrix.target }}"
|
||||
|
||||
- name: Build
|
||||
shell: bash
|
||||
run: |
|
||||
# Define some variables for things we need
|
||||
release_name="ErsatzTV-${{ inputs.release_version }}-${{ matrix.target }}"
|
||||
echo "RELEASE_NAME=${release_name}" >> $GITHUB_ENV
|
||||
|
||||
# Build everything
|
||||
sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net10.0 --runtime "${{ matrix.target }}" -c Release -o "scanner" -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net10.0 --runtime "${{ matrix.target }}" -c Release -o "main" -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
mkdir "$release_name"
|
||||
mv scanner/* "$release_name/"
|
||||
mv main/* "$release_name/"
|
||||
tar czvf "${release_name}.tar.gz" "$release_name"
|
||||
|
||||
# Delete output directory
|
||||
rm -r "$release_name"
|
||||
|
||||
- name: Delete old release assets
|
||||
uses: mknejp/delete-release-assets@v1
|
||||
if: ${{ inputs.release_tag == 'develop' }}
|
||||
with:
|
||||
token: ${{ secrets.gh_token }}
|
||||
tag: ${{ inputs.release_tag }}
|
||||
fail-if-no-assets: false
|
||||
assets: "*${{ matrix.target }}.tar.gz"
|
||||
|
||||
- name: Publish
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
prerelease: false
|
||||
tag_name: ${{ inputs.release_tag }}
|
||||
files: "${{ env.RELEASE_NAME }}.tar.gz"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.gh_token }}
|
||||
|
||||
build_dotnet_windows:
|
||||
name: Build dotnet for Windows
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
|
||||
- name: Install dependencies
|
||||
run: dotnet restore -r "win-x64"
|
||||
|
||||
- name: Build dotnet projects
|
||||
shell: bash
|
||||
run: |
|
||||
sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net10.0 --runtime "win-x64" -c Release -o "scanner" -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-win-x64" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net10.0 --runtime "win-x64" -c Release -o "main" -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-win-x64" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
|
||||
- name: Upload .NET Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dotnet-windows-build
|
||||
path: |
|
||||
scanner/
|
||||
main/
|
||||
retention-days: 1
|
||||
|
||||
package_and_upload_windows:
|
||||
name: Package & Upload Windows
|
||||
runs-on: windows-latest
|
||||
needs: build_dotnet_windows
|
||||
steps:
|
||||
- name: Download dotnet artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: dotnet-windows-build
|
||||
path: dotnet-build
|
||||
|
||||
- name: Azure login
|
||||
uses: azure/login@v2
|
||||
with:
|
||||
client-id: ${{ secrets.azure_client_id }}
|
||||
tenant-id: ${{ secrets.azure_tenant_id }}
|
||||
subscription-id: ${{ secrets.azure_subscription_id }}
|
||||
enable-AzPSSession: true
|
||||
|
||||
- name: Sign dotnet artifacts
|
||||
uses: azure/trusted-signing-action@v0
|
||||
with:
|
||||
endpoint: https://eus.codesigning.azure.net/
|
||||
trusted-signing-account-name: ArtifactSigning
|
||||
certificate-profile-name: ErsatzTV
|
||||
files-folder: ${{ github.workspace }}/dotnet-build
|
||||
files-folder-recurse: true
|
||||
files-folder-filter: ErsatzTV.exe,ErsatzTV.Scanner.exe
|
||||
file-digest: SHA256
|
||||
timestamp-rfc3161: http://timestamp.acs.microsoft.com
|
||||
timestamp-digest: SHA256
|
||||
|
||||
- name: Download rust launcher
|
||||
uses: suisei-cn/actions-download-file@v1.3.0
|
||||
with:
|
||||
url: "https://github.com/ErsatzTV/ErsatzTV-Windows/releases/download/v1.0.0/ErsatzTV-Windows.exe"
|
||||
target: rust-build/
|
||||
|
||||
- name: Download ffmpeg
|
||||
uses: suisei-cn/actions-download-file@v1.3.0
|
||||
id: downloadffmpeg
|
||||
with:
|
||||
url: "https://github.com/ErsatzTV/ErsatzTV-ffmpeg/releases/download/7.1.1/ffmpeg-n7.1.1-56-gc2184b65d2-win64-gpl-7.1.zip"
|
||||
target: ffmpeg/
|
||||
|
||||
- name: Package artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
release_name="ErsatzTV-${{ inputs.release_version }}-win-x64"
|
||||
echo "RELEASE_NAME=${release_name}" >> $GITHUB_ENV
|
||||
mkdir "$release_name"
|
||||
|
||||
mv dotnet-build/scanner/* "$release_name/"
|
||||
mv dotnet-build/main/* "$release_name/"
|
||||
|
||||
# dotnet shouldn't copy the resources here, but it does
|
||||
rm -rf "$release_name/Resources"
|
||||
|
||||
mv rust-build/ErsatzTV-Windows.exe "$release_name/ErsatzTV-Windows.exe"
|
||||
7z e "ffmpeg/${{ steps.downloadffmpeg.outputs.filename }}" -o"$release_name" '*.exe' -r
|
||||
rm -f "$release_name/ffplay.exe"
|
||||
|
||||
(cd "${release_name}" && 7z a "../${release_name}.zip" .)
|
||||
|
||||
- name: Delete old release assets
|
||||
uses: mknejp/delete-release-assets@v1
|
||||
if: ${{ inputs.release_tag == 'develop' }}
|
||||
with:
|
||||
token: ${{ secrets.gh_token }}
|
||||
tag: ${{ inputs.release_tag }}
|
||||
fail-if-no-assets: false
|
||||
assets: "*win-x64.zip"
|
||||
|
||||
- name: Publish
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
prerelease: false
|
||||
tag_name: ${{ inputs.release_tag }}
|
||||
files: "${{ env.RELEASE_NAME }}.zip"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.gh_token }}
|
||||
@@ -1,62 +0,0 @@
|
||||
name: Build
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
jobs:
|
||||
calculate_version:
|
||||
name: Calculate version information
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Extract Docker Tag
|
||||
shell: bash
|
||||
run: |
|
||||
tag=$(git describe --tags --abbrev=0)
|
||||
tag2="${tag:1}"
|
||||
short=$(git rev-parse --short HEAD)
|
||||
final="${tag2}-${short}"
|
||||
echo "GIT_TAG=${final}" >> $GITHUB_ENV
|
||||
- name: Extract Artifacts Version
|
||||
shell: bash
|
||||
run: |
|
||||
tag=$(git describe --tags --abbrev=0)
|
||||
short=$(git rev-parse --short HEAD)
|
||||
final="${tag}-${short}"
|
||||
echo "ARTIFACTS_VERSION=${final}" >> $GITHUB_ENV
|
||||
echo "INFO_VERSION=${tag:1}" >> $GITHUB_ENV
|
||||
outputs:
|
||||
git_tag: ${{ env.GIT_TAG }}
|
||||
artifacts_version: ${{ env.ARTIFACTS_VERSION }}
|
||||
info_version: ${{ env.INFO_VERSION }}
|
||||
build_and_upload:
|
||||
uses: ersatztv/ersatztv/.github/workflows/artifacts.yml@main
|
||||
needs: calculate_version
|
||||
with:
|
||||
release_tag: develop
|
||||
release_version: ${{ needs.calculate_version.outputs.artifacts_version }}
|
||||
info_version: ${{ needs.calculate_version.outputs.info_version }}
|
||||
secrets:
|
||||
apple_developer_certificate_p12_base64: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_P12_BASE64 }}
|
||||
apple_developer_certificate_password: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_PASSWORD }}
|
||||
ac_username: ${{ secrets.AC_USERNAME }}
|
||||
ac_password: ${{ secrets.AC_PASSWORD }}
|
||||
gh_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
azure_client_id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
azure_tenant_id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
azure_subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
build_images:
|
||||
uses: ersatztv/ersatztv/.github/workflows/docker.yml@main
|
||||
needs: calculate_version
|
||||
with:
|
||||
base_version: develop
|
||||
info_version: ${{ needs.calculate_version.outputs.git_tag }}
|
||||
tag_version: ${{ github.sha }}
|
||||
secrets:
|
||||
docker_hub_username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
docker_hub_access_token: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
|
||||
@@ -1,141 +0,0 @@
|
||||
name: Build & Publish to Docker Hub
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
base_version:
|
||||
description: 'Base version (latest or develop)'
|
||||
required: true
|
||||
type: string
|
||||
info_version:
|
||||
description: 'Informational version number (e.g. 0.3.7-alpha)'
|
||||
required: true
|
||||
type: string
|
||||
tag_version:
|
||||
description: 'Docker tag version (e.g. v0.3.7)'
|
||||
required: true
|
||||
type: string
|
||||
secrets:
|
||||
docker_hub_username:
|
||||
required: true
|
||||
docker_hub_access_token:
|
||||
required: true
|
||||
jobs:
|
||||
build_images:
|
||||
name: Build ${{ matrix.name }} image
|
||||
runs-on: ${{ matrix.os }}
|
||||
if: contains(github.event.head_commit.message, '[no build]') == false
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- name: amd64
|
||||
os: ubuntu-latest
|
||||
path: ''
|
||||
suffix: '-amd64'
|
||||
platform: 'linux/amd64'
|
||||
- name: arm32v7
|
||||
os: ubuntu-latest
|
||||
path: 'arm32v7/'
|
||||
suffix: '-arm'
|
||||
platform: 'linux/arm/v7'
|
||||
- name: arm64
|
||||
os: ubuntu-24.04-arm
|
||||
path: 'arm64/'
|
||||
suffix: '-arm64'
|
||||
platform: 'linux/arm64'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up QEMU
|
||||
if: ${{ matrix.name == 'arm32v7' }}
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.docker_hub_username }}
|
||||
password: ${{ secrets.docker_hub_access_token }}
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/${{ matrix.path }}Dockerfile
|
||||
push: true
|
||||
provenance: false
|
||||
platforms: ${{ matrix.platform }}
|
||||
build-args: |
|
||||
INFO_VERSION=${{ inputs.info_version }}-docker${{ matrix.suffix }}
|
||||
outputs: |
|
||||
type=image,name=jasongdove/ersatztv,name-canonical=true,push-by-digest=true
|
||||
type=image,name=ghcr.io/ersatztv/ersatztv,name-canonical=true,push-by-digest=true
|
||||
|
||||
- name: Save digest to artifact
|
||||
run: echo ${{ steps.build.outputs.digest }} > digest.txt
|
||||
|
||||
- name: Upload digest artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digest-${{ matrix.name }}
|
||||
path: digest.txt
|
||||
|
||||
merge_manifests:
|
||||
name: Merge Manifests
|
||||
runs-on: ubuntu-latest
|
||||
needs: build_images
|
||||
steps:
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.docker_hub_username }}
|
||||
password: ${{ secrets.docker_hub_access_token }}
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Download all digest artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: digests/
|
||||
|
||||
- name: Read digests from artifacts
|
||||
id: digests
|
||||
run: |
|
||||
AMD64_HASH=$(cat digests/digest-amd64/digest.txt)
|
||||
ARM32V7_HASH=$(cat digests/digest-arm32v7/digest.txt)
|
||||
ARM64_HASH=$(cat digests/digest-arm64/digest.txt)
|
||||
|
||||
DOCKER_HUB_DIGESTS="jasongdove/ersatztv@${AMD64_HASH} jasongdove/ersatztv@${ARM64_HASH} jasongdove/ersatztv@${ARM32V7_HASH}"
|
||||
GHCR_DIGESTS="ghcr.io/ersatztv/ersatztv@${AMD64_HASH} ghcr.io/ersatztv/ersatztv@${ARM64_HASH} ghcr.io/ersatztv/ersatztv@${ARM32V7_HASH}"
|
||||
|
||||
echo "docker_hub_digests=${DOCKER_HUB_DIGESTS}" >> $GITHUB_OUTPUT
|
||||
echo "ghcr_digests=${GHCR_DIGESTS}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create and push manifests
|
||||
run: |
|
||||
docker manifest create jasongdove/ersatztv:${{ inputs.base_version }} ${{ steps.digests.outputs.docker_hub_digests }}
|
||||
docker manifest push jasongdove/ersatztv:${{ inputs.base_version }}
|
||||
docker manifest create jasongdove/ersatztv:${{ inputs.tag_version }} ${{ steps.digests.outputs.docker_hub_digests }}
|
||||
docker manifest push jasongdove/ersatztv:${{ inputs.tag_version }}
|
||||
|
||||
docker manifest create ghcr.io/ersatztv/ersatztv:${{ inputs.base_version }} ${{ steps.digests.outputs.ghcr_digests }}
|
||||
docker manifest push ghcr.io/ersatztv/ersatztv:${{ inputs.base_version }}
|
||||
docker manifest create ghcr.io/ersatztv/ersatztv:${{ inputs.tag_version }} ${{ steps.digests.outputs.ghcr_digests }}
|
||||
docker manifest push ghcr.io/ersatztv/ersatztv:${{ inputs.tag_version }}
|
||||
@@ -1,27 +0,0 @@
|
||||
name: 'Close stale issues'
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '30 1 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
ascending: true
|
||||
days-before-stale: 120
|
||||
days-before-pr-stale: -1
|
||||
days-before-close: 21
|
||||
days-before-pr-close: -1
|
||||
operations-per-run: 500
|
||||
exempt-issue-labels: 'regression,security,roadmap,future,feature,enhancement,confirmed'
|
||||
stale-issue-label: 'stale'
|
||||
stale-issue-message: |-
|
||||
This issue has gone 120 days without an update and will be closed within 21 days if there is no new activity. To prevent this issue from being closed, please confirm the issue has not already been fixed by providing updated examples or logs.
|
||||
|
||||
If you have any questions you can use one of several ways to [contact us](https://ersatztv.org).
|
||||
close-issue-message: |-
|
||||
This issue was closed due to inactivity.
|
||||
@@ -1,118 +0,0 @@
|
||||
name: Pull Request
|
||||
on:
|
||||
pull_request:
|
||||
jobs:
|
||||
build_and_analyze:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
|
||||
- name: Install dependencies
|
||||
run: dotnet restore
|
||||
|
||||
- name: Prep project file
|
||||
run: sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||
|
||||
- name: Build
|
||||
run: dotnet build --configuration Release --no-restore /p:EnableThreadingAnalyzers=true
|
||||
build_and_test_windows:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
|
||||
- name: Install dependencies
|
||||
run: dotnet restore
|
||||
|
||||
- name: Prep project file
|
||||
run: sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||
|
||||
- name: Build
|
||||
run: dotnet build --configuration Release --no-restore
|
||||
|
||||
- name: Test
|
||||
run: dotnet test --blame-hang-timeout "2m" --no-restore --verbosity normal
|
||||
build_and_test_linux:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
target: linux-x64
|
||||
- os: ubuntu-latest
|
||||
target: linux-musl-x64
|
||||
- os: ubuntu-latest
|
||||
target: linux-arm
|
||||
- os: ubuntu-24.04-arm
|
||||
target: linux-arm64
|
||||
steps:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
|
||||
- name: Install dependencies
|
||||
run: dotnet restore -p:RestoreEnablePackagePruning=true -r "${{ matrix.target }}"
|
||||
|
||||
- name: Prep project file
|
||||
run: sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||
|
||||
- name: Build
|
||||
run: dotnet build ErsatzTV/ErsatzTV.csproj --runtime "${{ matrix.target }}" --configuration Release --no-restore && dotnet build --configuration Release --no-restore
|
||||
|
||||
- name: Test
|
||||
run: dotnet test --blame-hang-timeout "2m" --no-restore --verbosity normal
|
||||
build_and_test_mac:
|
||||
runs-on: macos-14
|
||||
steps:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
|
||||
- name: Setup dotnet
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
|
||||
- name: Install dependencies
|
||||
run: dotnet restore
|
||||
|
||||
- name: Prep project file
|
||||
run: sed -i '' '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||
|
||||
- name: Build
|
||||
run: dotnet build --configuration Release --no-restore
|
||||
|
||||
- name: Test
|
||||
run: dotnet test --blame-hang-timeout "2m" --no-restore --verbosity normal
|
||||
@@ -1,56 +0,0 @@
|
||||
name: Release
|
||||
on:
|
||||
release:
|
||||
types: [ published ]
|
||||
jobs:
|
||||
calculate_version:
|
||||
name: Calculate version information
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Extract Docker Tag
|
||||
shell: bash
|
||||
run: |
|
||||
tag=$(git describe --tags --abbrev=0)
|
||||
echo "GIT_TAG=${tag:1}" >> $GITHUB_ENV
|
||||
echo "DOCKER_TAG=${tag}" >> $GITHUB_ENV
|
||||
- name: Extract Artifacts Version
|
||||
shell: bash
|
||||
run: |
|
||||
tag=$(git describe --tags --abbrev=0)
|
||||
echo "ARTIFACTS_VERSION=${tag}" >> $GITHUB_ENV
|
||||
echo "INFO_VERSION=${tag:1}" >> $GITHUB_ENV
|
||||
outputs:
|
||||
git_tag: ${{ env.GIT_TAG }}
|
||||
docker_tag: ${{ env.DOCKER_TAG }}
|
||||
artifacts_version: ${{ env.ARTIFACTS_VERSION }}
|
||||
info_version: ${{ env.INFO_VERSION }}
|
||||
build_and_upload:
|
||||
uses: ersatztv/ersatztv/.github/workflows/artifacts.yml@main
|
||||
needs: calculate_version
|
||||
with:
|
||||
release_tag: ${{ needs.calculate_version.outputs.artifacts_version }}
|
||||
release_version: ${{ needs.calculate_version.outputs.artifacts_version }}
|
||||
info_version: ${{ needs.calculate_version.outputs.info_version }}
|
||||
secrets:
|
||||
apple_developer_certificate_p12_base64: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_P12_BASE64 }}
|
||||
apple_developer_certificate_password: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_PASSWORD }}
|
||||
ac_username: ${{ secrets.AC_USERNAME }}
|
||||
ac_password: ${{ secrets.AC_PASSWORD }}
|
||||
gh_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
azure_client_id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
azure_tenant_id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
azure_subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
build_images:
|
||||
uses: ersatztv/ersatztv/.github/workflows/docker.yml@main
|
||||
needs: calculate_version
|
||||
with:
|
||||
base_version: latest
|
||||
info_version: ${{ needs.calculate_version.outputs.git_tag }}
|
||||
tag_version: ${{ needs.calculate_version.outputs.docker_tag }}
|
||||
secrets:
|
||||
docker_hub_username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
docker_hub_access_token: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
|
||||
@@ -3,6 +3,9 @@
|
||||
project.lock.json
|
||||
.DS_Store
|
||||
*.pyc
|
||||
|
||||
# Claude Code
|
||||
.mcp/
|
||||
nupkg/
|
||||
|
||||
# Visual Studio Code
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"docker-mcp": {
|
||||
"command": "uvx",
|
||||
"args": [
|
||||
"mcp-server-docker"
|
||||
],
|
||||
"env": {
|
||||
"DOCKER_HOST": "ssh://timothy@192.168.1.99"
|
||||
}
|
||||
},
|
||||
"ssh-mcp": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"ssh-mcp",
|
||||
"--",
|
||||
"--host=192.168.1.99",
|
||||
"--user=timothy"
|
||||
],
|
||||
"env": {}
|
||||
},
|
||||
"gitea": {
|
||||
"command": "gitea-mcp-server",
|
||||
"args": [
|
||||
"-t", "stdio",
|
||||
"-host", "http://192.168.1.95:3000",
|
||||
"-token", "8341af0733ab9ce084ea7adf38b76aa9ebc3bd67"
|
||||
],
|
||||
"env": {}
|
||||
},
|
||||
"csharp-lsp": {
|
||||
"command": "/usr/local/share/dotnet/dotnet",
|
||||
"args": [
|
||||
"run",
|
||||
"--project", "/Users/timothy/ersatztv/.mcp/csharp-lsp-mcp/csharp-lsp-mcp/src/CSharpLspMcp",
|
||||
"-c", "Release"
|
||||
],
|
||||
"env": {
|
||||
"PATH": "/usr/local/share/dotnet:/Users/timothy/.dotnet/tools:/usr/bin:/bin:/usr/sbin:/sbin"
|
||||
}
|
||||
},
|
||||
"nuget": {
|
||||
"command": "/usr/local/share/dotnet/dotnet",
|
||||
"args": [
|
||||
"dnx",
|
||||
"NuGet.Mcp.Server",
|
||||
"--source", "https://api.nuget.org/v3/index.json",
|
||||
"--yes"
|
||||
],
|
||||
"env": {
|
||||
"DOTNET_ROOT": "/usr/local/share/dotnet"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
# ErsatzTV Fork
|
||||
|
||||
Custom IPTV channel server for Jellyfin. Forked from [ErsatzTV/ErsatzTV](https://github.com/ErsatzTV/ErsatzTV) after upstream archival (Feb 2026, v26.3.0). Our fork lives on [Gitea](http://192.168.1.95:3000/timothy/ersatztv).
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Language**: C# / .NET 10, Blazor Server UI (MudBlazor)
|
||||
- **Pattern**: CQRS via MediatR — queries/commands in `ErsatzTV.Application/`
|
||||
- **Database**: EF Core (SQLite default, MySQL optional) — context in `ErsatzTV.Infrastructure/Data/TvContext.cs`
|
||||
- **Media**: FFmpeg via CliWrap, SkiaSharp for logo generation
|
||||
- **Functional C#**: Language Ext (Option, Either monads throughout)
|
||||
|
||||
### Project Layout
|
||||
|
||||
| Project | Role |
|
||||
|---------|------|
|
||||
| `ErsatzTV/` | ASP.NET Core host, Blazor pages, API controllers, DI setup |
|
||||
| `ErsatzTV.Application/` | MediatR handlers (business logic) |
|
||||
| `ErsatzTV.Core/` | Domain entities, interfaces, no infrastructure deps |
|
||||
| `ErsatzTV.Infrastructure/` | EF Core repos, data access |
|
||||
| `ErsatzTV.Infrastructure.Sqlite/` | SQLite-specific implementations |
|
||||
| `ErsatzTV.FFmpeg/` | FFmpeg process wrapper |
|
||||
| `ErsatzTV.Scanner/` | Media library scanning |
|
||||
|
||||
### Key Files
|
||||
|
||||
- **M3U generation**: `ErsatzTV.Core/Iptv/ChannelPlaylist.cs` → `ToM3U()`
|
||||
- **XMLTV generation**: `ErsatzTV.Application/Channels/Queries/GetChannelGuideHandler.cs`
|
||||
- **IPTV controller**: `ErsatzTV/Controllers/IptvController.cs` — `/iptv/*` routes
|
||||
- **Logo generation**: `ErsatzTV.Core/Images/ChannelLogoGenerator.cs`
|
||||
- **Channel entities**: `ErsatzTV.Core/Domain/Channel.cs`
|
||||
- **DB context**: `ErsatzTV.Infrastructure/Data/TvContext.cs`
|
||||
|
||||
## Deployment
|
||||
|
||||
- **Docker host**: jazz (192.168.1.99), container `ersatztv`, port 8409
|
||||
- **Config volume**: `~/downloadswarm/ersatztv/` on jazz → `/config` in container
|
||||
- **SQLite DB**: `/config/ersatztv.sqlite3` (WAL mode, root-owned)
|
||||
- **Images** (our fork, built by `.gitea/workflows/docker-build.yml` → `192.168.1.95:3000/timothy/ersatztv`): push to `main` → `:latest` + `:<sha>` (test image); push `v*` tag → `:prod` + `:<version>` + `:<sha>`. Prod container still runs upstream `ghcr.io/ersatztv/ersatztv:latest` pending cutover (server-management#481). Pipeline details: `docs/ci-cd.md`.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Build
|
||||
dotnet build ErsatzTV.sln
|
||||
|
||||
# Run locally (needs FFmpeg in PATH)
|
||||
dotnet run --project ErsatzTV
|
||||
|
||||
# Docker build
|
||||
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 **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)
|
||||
|
||||
## Task Completion Protocol
|
||||
|
||||
Every task that closes a Gitea issue MUST complete ALL of these before it is considered done. Use `/done <issue>` to run through this automatically.
|
||||
|
||||
1. **Root cause** (bug fixes / incidents only): Document WHY the problem existed, not just what was changed. If root cause is unknown, say so explicitly and open a follow-up investigation issue. Fixing symptoms without understanding causes creates recurring problems.
|
||||
2. **Comment on issues** as you work — what you found, what approach you're taking, any deviations from the suggested fix.
|
||||
3. **Push changes**: `git push` all commits before closing. Use `fixes #N` in commit messages to auto-close where appropriate.
|
||||
4. **Close comment**: Add a structured closing comment on the issue covering: what was done, root cause (if applicable), files changed, anything deferred, follow-up issues created, and which docs were updated.
|
||||
5. **Close the issue** via API or `fixes #N` commit. Leave open with a comment only if partially addressed.
|
||||
6. **Update docs**: If the change affects operational behavior, update the relevant Obsidian docs (`~/homelab-docs/`), MEMORY.md, or CLAUDE.md inline — not as a follow-up.
|
||||
7. **Reply to reviewer** (if from adversarial review): Summary of done/deferred/questions. This triggers the next review cycle.
|
||||
|
||||
## Project Boundaries
|
||||
|
||||
**ersatztv OWNS**: ErsatzTV fork code (C#/.NET), channel/collection/schedule management, M3U/XMLTV generation, the ErsatzTV skill in server-management.
|
||||
|
||||
**ersatztv does NOT own**:
|
||||
- Docker compose configs → server-management (`~/downloadswarm/stacks/ersatztv/`)
|
||||
- NFS mounts, Ansible, DNS, networking → server-management
|
||||
- Content sourcing (yt-dlp downloads, Sonarr/Radarr libraries) → media-management (planned)
|
||||
- Jellyfin skill → server-management (symlinked)
|
||||
|
||||
**For infrastructure changes** (Docker, NFS, ports, Authelia): open an issue in `timothy/server-management`.
|
||||
|
||||
**For content/media sourcing questions** (what goes into channels, yt-dlp pipelines): open an issue in `timothy/media-management` once it exists; for now, `timothy/server-management`.
|
||||
|
||||
**For plan/audit reviews**: open `~/adversarial-reviewer` before significant architecture changes.
|
||||
|
||||
**Full cross-project rules**: `~/homelab-docs/Operations/Project Boundaries.md` (https://docs.tblindustries.be).
|
||||
**ErsatzTV docs**: `~/homelab-docs/Docker/ErsatzTV.md` + project-local `docs/` (fork strategy, channels, M3U/XMLTV).
|
||||
@@ -3,5 +3,15 @@
|
||||
<InformationalVersion>develop</InformationalVersion>
|
||||
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
|
||||
<AllowMissingPrunePackageData>true</AllowMissingPrunePackageData>
|
||||
<!-- NuGet audit (on by default in .NET 10) reports vulnerable transitive
|
||||
packages as NU1901-1904 warnings. Several projects set
|
||||
TreatWarningsAsErrors=true, which would otherwise fail `dotnet restore`
|
||||
on advisories we can't immediately fix. Demote low/moderate/high audit
|
||||
advisories to warnings (still printed in build logs); NU1904 (critical)
|
||||
stays an error so criticals still block. Track fixes separately.
|
||||
WarningsAsErrors promotes NU1904 in EVERY project (even those without
|
||||
TreatWarningsAsErrors), so "criticals block" actually holds repo-wide. -->
|
||||
<WarningsNotAsErrors>$(WarningsNotAsErrors);NU1901;NU1902;NU1903</WarningsNotAsErrors>
|
||||
<WarningsAsErrors>$(WarningsAsErrors);NU1904</WarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
||||
+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
+7
-1
@@ -1,6 +1,6 @@
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-amd64 AS dotnet-runtime
|
||||
|
||||
FROM --platform=linux/amd64 ghcr.io/ersatztv/ersatztv-ffmpeg:7.1.1 AS runtime-base
|
||||
FROM --platform=linux/amd64 192.168.1.95:3000/timothy/ersatztv-ffmpeg:7.1.1 AS runtime-base
|
||||
COPY --from=dotnet-runtime /usr/share/dotnet /usr/share/dotnet
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends python3 python3-pip && \
|
||||
@@ -24,6 +24,12 @@ 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, 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/
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
# Channel Architecture
|
||||
|
||||
## Channel Entity
|
||||
|
||||
Defined in `ErsatzTV.Core/Domain/Channel.cs`. Key fields:
|
||||
|
||||
- **Identity**: `Number` (e.g., "1", "2.1"), `Name`, `UniqueId` (GUID for M3U/XMLTV)
|
||||
- **Encoding**: `FFmpegProfileId` — video/audio codec, bitrate, resolution, hardware acceleration
|
||||
- **Streaming**: `StreamingMode` (TransportStream, HLS Direct, HLS Segmenter, TS Hybrid)
|
||||
- **Behavior**: `PlayoutMode` (Continuous vs OnDemand), `IdleBehavior` (StopOnDisconnect vs KeepRunning)
|
||||
- **Visual**: `WatermarkId`, `FallbackFillerId`, artwork (logos)
|
||||
- **Mirroring**: `PlayoutSource` (Generated vs Mirror) — a mirror channel copies another with optional time offset
|
||||
- **Display**: `Group`, `Categories`, `ShowInEpg`, `IsEnabled`
|
||||
- **Audio/Subtitle defaults**: preferred language codes, subtitle mode
|
||||
|
||||
## Content Sources
|
||||
|
||||
Channels get content through a **Playout** → **ProgramSchedule** → **ProgramScheduleItem** chain.
|
||||
|
||||
### Collection Types
|
||||
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
| `Collection` | Manual grouping of media items with custom playback order |
|
||||
| `MultiCollection` | Aggregate of collections + smart collections |
|
||||
| `SmartCollection` | Query-based (Lucene.Net) dynamic filtering |
|
||||
| `Playlist` | Ordered items with per-item config (count, fillers, playback order) |
|
||||
| `TelevisionShow` / `TelevisionSeason` | Structured TV hierarchy |
|
||||
| `Movie`, `Episode`, `MusicVideo`, `OtherVideo`, `Song`, `Image` | Individual media items |
|
||||
| `RerunCollection` | Wraps any collection with separate first-run/rerun playback orders |
|
||||
| `SearchQuery` | Dynamic results from a search |
|
||||
| `RemoteStream` | External stream URLs |
|
||||
|
||||
### Media Sources
|
||||
|
||||
Media items are imported from configured libraries (Jellyfin, Plex, Emby, or local filesystem). Each source type has its own entity variants (e.g., `JellyfinMovie`, `PlexEpisode`).
|
||||
|
||||
## Scheduling
|
||||
|
||||
### Schedule Kinds
|
||||
|
||||
- **Classic**: Traditional ProgramSchedule with items — the most common
|
||||
- **Block**: Template-based block scheduling
|
||||
- **Sequential**: Strict sequential ordering
|
||||
- **Scripted**: External script-driven playout
|
||||
- **ExternalJson**: Playout defined by external JSON file
|
||||
|
||||
### Schedule Item Types
|
||||
|
||||
Each `ProgramScheduleItem` is one of four concrete types:
|
||||
|
||||
1. **One** — Play exactly 1 item per cycle
|
||||
2. **Multiple** — Play N items (fixed count, collection size, or playlist item size)
|
||||
3. **Duration** — Fill a time window (with tail mode: none, offline, slate, or filler)
|
||||
4. **Flood** — Play items continuously until the next fixed-start item
|
||||
|
||||
Items can have `StartType` of Fixed (anchored to clock time) or Dynamic (follows previous item).
|
||||
|
||||
### Playback Orders
|
||||
|
||||
`Chronological`, `Random`, `Shuffle`, `ShuffleInOrder`, `MultiEpisodeShuffle`, `SeasonEpisode`, `RandomRotation`, `Marathon` (group by show/season/artist/album/director).
|
||||
|
||||
### Filler System
|
||||
|
||||
`FillerPreset` defines content to fill gaps. Each schedule item can have:
|
||||
- **PreRoll** — before main content
|
||||
- **MidRoll** — during (chapter breaks)
|
||||
- **PostRoll** — after main content
|
||||
- **Tail** — pad remaining time in a duration block
|
||||
- **Fallback** — channel-level default when nothing else available
|
||||
|
||||
Filler modes: Duration, Count, Pad (to nearest minute), RandomCount.
|
||||
|
||||
### Alternate Schedules
|
||||
|
||||
`ProgramScheduleAlternate` overrides the main schedule for specific days of week, days of month, months of year, or date ranges. Useful for seasonal programming or weekend variations.
|
||||
|
||||
## Playout Pipeline
|
||||
|
||||
```
|
||||
Channel
|
||||
└── Playout
|
||||
├── ProgramSchedule
|
||||
│ └── ProgramScheduleItems (One|Multiple|Duration|Flood)
|
||||
│ └── Content source (Collection, Playlist, SmartCollection, etc.)
|
||||
├── PlayoutItems (generated — the actual timeline)
|
||||
│ └── MediaItem + start/finish times + filler kind + watermarks
|
||||
├── PlayoutGaps (time periods with no content)
|
||||
└── ProgramScheduleAlternates (day/date overrides)
|
||||
```
|
||||
|
||||
The scheduling engine (`ErsatzTV.Core/Scheduling/`) resolves schedule items into concrete `PlayoutItem` entries with precise start/finish times. Each `PlayoutItem` references a specific `MediaItem` and includes trim points (`InPoint`/`OutPoint`), filler classification, and per-item audio/subtitle overrides.
|
||||
|
||||
## Watermarks
|
||||
|
||||
`ChannelWatermark` supports modes: Permanent, Intermittent, OpacityExpression. Image sources: custom upload, channel logo, or built-in resource. Positioned with percentage-based margins and z-index.
|
||||
|
||||
Note: `ChannelLogoGenerator.GenerateChannelLogoUrl()` hardcodes `localhost` for watermark logo fetching — see issue #1 for details.
|
||||
+230
@@ -0,0 +1,230 @@
|
||||
# CI/CD for the ErsatzTV Fork
|
||||
|
||||
The fork builds its own Docker image via **Gitea Actions** on the homelab and pushes
|
||||
to the **Gitea container registry**. Runner + registry were provisioned in
|
||||
server-management#172; the build pipeline is ersatztv#4; test/prod containers are
|
||||
server-management#481.
|
||||
|
||||
## Versioning & releases
|
||||
|
||||
The fork inherits upstream ErsatzTV's scheme: **`vYY.<release-seq>.<patch>`** (lightweight, `v`-prefixed git tags).
|
||||
|
||||
- **`YY`** — two-digit year.
|
||||
- **`<release-seq>`** — a sequential release counter **within the year**, reset at each year boundary. It is **not** the calendar month. (Evidence: `v25.2.0` shipped in June 2025, `v25.5.0` in Sep, `v26.3.0` in Feb 2026 — minors don't track months; and `v25.9.0` → `v26.1.0` shows the year-reset.)
|
||||
- **`<patch>`** — a small follow-up/hotfix on the *same* release line (e.g. `v26.1.0` → `v26.1.1`, days later).
|
||||
|
||||
Upstream's final release was **`v26.3.0`** (archived). Our line continues from there:
|
||||
|
||||
| Tag | Meaning |
|
||||
|-----|---------|
|
||||
| `v26.3.1` | Upstream 26.3.0 **rebuilt on our infra** (Gitea CI/registry, fork ffmpeg base) — **no application changes**. A patch bump, because nothing functional changed. |
|
||||
| `v26.4.0` | Reserved for our **first release that carries actual app changes** (e.g. the #1 M3U fix). Later 2026 releases: `26.5.0`, `26.6.0`, …; a new year resets to `27.1.0`. |
|
||||
|
||||
**Cutting a release:** push a `vYY.N.P` tag on `main` → CI builds `:prod` + `:<version>` + `:<sha>`; server-management does the prod switch (server-management#481).
|
||||
|
||||
**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`).
|
||||
|
||||
### Triggers & tags
|
||||
|
||||
| Trigger | `test` job | `build` job | Image tags pushed |
|
||||
|---------|:----------:|:-----------:|-------------------|
|
||||
| `pull_request` | ✅ | — (skipped) | none |
|
||||
| push to `main` | ✅ | ✅ | `:latest` + `:<short-sha>` |
|
||||
| push tag `v*` | ✅ | ✅ | `:prod` + `:<version>` + `:<short-sha>` |
|
||||
| `workflow_dispatch` | ✅ | ✅ | only if ref is `main`/`v*`, else build-only (no push) |
|
||||
|
||||
`:latest` is the **test/dev** channel (every `main` commit). Prod pins **`:prod`**,
|
||||
never `:latest` — enforced in the prod compose (server-management#481). `:prod` is
|
||||
only produced by pushing a `v*` tag.
|
||||
|
||||
`concurrency: { group: ersatztv-build, cancel-in-progress: false }` serializes all
|
||||
runs — the single runner on jazz can't safely run the push-`main`-then-push-`v*`
|
||||
release flow in parallel (shared `:buildcache` tag, shared smoke container).
|
||||
|
||||
### `test` job
|
||||
|
||||
`dotnet restore` → strip the Scanner project ref (`sed -i '/Scanner/d'`, matching the
|
||||
Docker build) → `dotnet build -c Release` → `dotnet test -c Release --no-build`. Gates
|
||||
the image build.
|
||||
|
||||
### `build` job
|
||||
|
||||
1. Compute `INFO_VERSION` (`git describe` + short sha on `main`; tag version on `v*`).
|
||||
2. `docker/setup-buildx-action` with `buildkitd-config-inline` setting `http = true`
|
||||
for `192.168.1.95:3000` — **BuildKit does not inherit the host daemon's
|
||||
`insecure-registries`**, so without this, cache/base-image/push over the HTTP
|
||||
registry fails (`http: server gave HTTP response to HTTPS client`).
|
||||
3. `docker/login-action` with repo secrets `REGISTRY_USER` / `REGISTRY_PASSWORD`.
|
||||
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 + 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`, `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.
|
||||
|
||||
## Dependency management (Central Package Management + scans)
|
||||
|
||||
**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
|
||||
|
||||
Gitea Packages, HTTP-only at `192.168.1.95:3000`. jazz's Docker daemon has it as an
|
||||
insecure-registry (server-management#172). Images: `192.168.1.95:3000/timothy/ersatztv:<tag>`.
|
||||
|
||||
## Test / prod environments
|
||||
|
||||
Container/compose wiring lives in **server-management** (project boundary): test
|
||||
`ersatztv-test` on 8410 (`:latest`), prod `ersatztv` on 8409 (`:prod`). See
|
||||
server-management#481 for the full spec (registry pull on jazz, volumes, Jellyfin
|
||||
isolation for test, Watchtower/manual promotion).
|
||||
|
||||
## Retired upstream workflows
|
||||
|
||||
The upstream `.github/workflows/` (`ci.yml`, `docker.yml`, `artifacts.yml`,
|
||||
`release.yml`, `pr.yml`, `issue-stale.yml`) were removed — they targeted
|
||||
GHCR/DockerHub + Azure/Apple signing and called reusable workflows at dead
|
||||
`ersatztv/ersatztv@main` paths, and ran as noise (incl. a daily stale-issue cron) on
|
||||
the Gitea runner. Upstream is archived, so there are no future merges to preserve them
|
||||
for. The dead `.github/dependabot.yml` and `FUNDING.yml` (upstream-pointed) were also
|
||||
removed.
|
||||
|
||||
## Known follow-ups
|
||||
|
||||
- Pin third-party actions to commit SHAs (currently floating major tags cloned from
|
||||
github.com at runtime) — low priority for a homelab; tracked informally.
|
||||
@@ -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,68 @@
|
||||
# Fork Maintenance Strategy
|
||||
|
||||
Upstream ErsatzTV was archived February 2026 at v26.3.0. This fork is maintained independently on [Gitea](http://192.168.1.95:3000/timothy/ersatztv).
|
||||
|
||||
## Divergence Policy
|
||||
|
||||
We diverge freely from upstream's final state. There is no upstream to merge from, so maintaining merge compatibility serves no purpose. All changes are our own.
|
||||
|
||||
## Security & Dependency Updates
|
||||
|
||||
### .NET Runtime
|
||||
|
||||
- **Current**: .NET 10.0 (LTS candidate, supported through Nov 2028)
|
||||
- **Upgrade path**: When .NET 11 ships (Nov 2026), upgrade by updating `TargetFramework` across all projects and `global.json`. The `rollForward: latestMinor` setting in `global.json` handles patch versions automatically.
|
||||
- **Key constraint**: EF Core is pinned to `[9.0.12,10)` — a .NET 11 upgrade will likely require bumping to EF Core 10.x simultaneously.
|
||||
|
||||
### NuGet Packages
|
||||
|
||||
- No central package management (`Directory.Packages.props`) — versions are declared per-project. This means bulk updates require editing multiple .csproj files.
|
||||
- Upstream had a GitHub Dependabot config (`.github/dependabot.yml`) that is not active on Gitea.
|
||||
- **Current approach**: Manual periodic audits. Run `dotnet list package --outdated` to check for updates.
|
||||
- **Future consideration**: Add a Gitea Actions workflow for dependency scanning, or adopt `Directory.Packages.props` to centralize version management.
|
||||
|
||||
### Docker Base Images
|
||||
|
||||
- .NET SDK/runtime images (`mcr.microsoft.com/dotnet/sdk:10.0-noble-amd64`) — update when .NET patches ship.
|
||||
- FFmpeg image: forked separately at [timothy/ersatztv-ffmpeg](http://192.168.1.95:3000/timothy/ersatztv-ffmpeg). Currently `192.168.1.95:3000/timothy/ersatztv-ffmpeg:7.1.1`. The main Dockerfile still references the upstream `ghcr.io` image and needs updating.
|
||||
|
||||
### CVE Response
|
||||
|
||||
1. Check if the CVE affects a dependency we use (most NuGet advisories are noise)
|
||||
2. Update the package version in the relevant .csproj file(s)
|
||||
3. Build, run tests, deploy to test environment (port 8410)
|
||||
4. Promote to prod after verification
|
||||
|
||||
## EF Core Migrations
|
||||
|
||||
- **SQLite**: 196 migrations (primary, Feb 2021 – Feb 2026)
|
||||
- **MySQL**: 153 migrations (secondary, Aug 2023 – Feb 2026, parity maintained)
|
||||
|
||||
### Adding New Migrations
|
||||
|
||||
```bash
|
||||
# SQLite (primary)
|
||||
dotnet ef migrations add MigrationName \
|
||||
--project ErsatzTV.Infrastructure.Sqlite \
|
||||
--startup-project ErsatzTV
|
||||
|
||||
# MySQL (if maintaining parity)
|
||||
dotnet ef migrations add MigrationName \
|
||||
--project ErsatzTV.Infrastructure.MySql \
|
||||
--startup-project ErsatzTV
|
||||
```
|
||||
|
||||
We only use SQLite in the homelab. MySQL migrations can be maintained for completeness but are not tested in deployment.
|
||||
|
||||
## Feature Development
|
||||
|
||||
New features follow the existing CQRS/MediatR pattern. No compatibility constraints — we own the entire codebase now. Track work via [Gitea Issues](http://192.168.1.95:3000/timothy/ersatztv/issues).
|
||||
|
||||
## Key Risks
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| EF Core major version gap | Pin to `[9.x,10)` range; bump when .NET upgrade forces it |
|
||||
| Lucene.Net stuck on beta (`4.8.0-beta00017`) | Monitor for stable release; functional as-is |
|
||||
| SkiaSharp native deps | Pinned with `NativeAssets.Linux.NoDependencies`; test on Linux after updates |
|
||||
| OpenAPI generator JAR (`7.15.0`) | Hardcoded in Dockerfile; update manually when needed |
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user