Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0a0bfed51 |
@@ -1,167 +0,0 @@
|
||||
---
|
||||
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
|
||||
@@ -1,105 +0,0 @@
|
||||
---
|
||||
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,19 +104,3 @@ 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
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
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."
|
||||
@@ -1,282 +0,0 @@
|
||||
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: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22.x'
|
||||
cache: npm
|
||||
cache-dependency-path: web/package-lock.json
|
||||
|
||||
- name: Install SPA dependencies
|
||||
working-directory: web
|
||||
run: npm ci
|
||||
|
||||
- name: Check generated SPA API client
|
||||
working-directory: web
|
||||
run: npm run check:api
|
||||
|
||||
- name: Lint SPA
|
||||
working-directory: web
|
||||
run: npm run lint
|
||||
|
||||
- name: Typecheck SPA
|
||||
working-directory: web
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Test SPA
|
||||
working-directory: web
|
||||
run: npm test -- --run
|
||||
|
||||
- name: Build SPA
|
||||
working-directory: web
|
||||
run: npm run build
|
||||
|
||||
- name: Strip Scanner project ref (matches Docker build)
|
||||
run: sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||
|
||||
- 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
|
||||
@@ -1,70 +0,0 @@
|
||||
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
|
||||
@@ -0,0 +1,2 @@
|
||||
github: jasongdove
|
||||
custom: "https://www.paypal.me/jasongdove"
|
||||
@@ -0,0 +1,26 @@
|
||||
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
|
||||
@@ -0,0 +1,336 @@
|
||||
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 }}
|
||||
@@ -0,0 +1,62 @@
|
||||
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 }}
|
||||
@@ -0,0 +1,141 @@
|
||||
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 }}
|
||||
@@ -0,0 +1,27 @@
|
||||
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.
|
||||
@@ -0,0 +1,118 @@
|
||||
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
|
||||
@@ -0,0 +1,56 @@
|
||||
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 }}
|
||||
-10
@@ -3,13 +3,6 @@
|
||||
project.lock.json
|
||||
.DS_Store
|
||||
*.pyc
|
||||
.worktrees/
|
||||
|
||||
# Claude Code
|
||||
.mcp/
|
||||
.mcp.json
|
||||
.agents/
|
||||
plugins/
|
||||
nupkg/
|
||||
|
||||
# Visual Studio Code
|
||||
@@ -52,6 +45,3 @@ scripts/download-test-content.sh
|
||||
docker-compose.override.yml
|
||||
|
||||
ErsatzTV/wwwroot/v2/
|
||||
ErsatzTV/wwwroot/app/
|
||||
web/dist/
|
||||
web/node_modules/
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
# 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,15 +3,5 @@
|
||||
<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>
|
||||
|
||||
+1
-27
@@ -6,36 +6,10 @@
|
||||
<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>
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
<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>
|
||||
@@ -1,5 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record BulkDeleteChannels(IReadOnlyList<int> ChannelIds) : IRequest<Either<BaseError, Unit>>;
|
||||
@@ -1,62 +0,0 @@
|
||||
using System.IO.Abstractions;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Channel = ErsatzTV.Core.Domain.Channel;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class BulkDeleteChannelsHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
||||
IFileSystem fileSystem,
|
||||
ISearchTargets searchTargets)
|
||||
: IRequestHandler<BulkDeleteChannels, Either<BaseError, Unit>>
|
||||
{
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
BulkDeleteChannels request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.ChannelIds.Count == 0)
|
||||
{
|
||||
return Left<BaseError, Unit>(BaseError.New("At least one channel id is required"));
|
||||
}
|
||||
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
|
||||
|
||||
List<int> channelIds = request.ChannelIds.Distinct().ToList();
|
||||
List<Channel> channels = await dbContext.Channels
|
||||
.Where(c => channelIds.Contains(c.Id))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (channels.Count != channelIds.Count)
|
||||
{
|
||||
var found = channels.Select(c => c.Id).ToHashSet();
|
||||
int missingId = channelIds.First(id => !found.Contains(id));
|
||||
return Left<BaseError, Unit>(new NotFoundError($"Channel {missingId} does not exist."));
|
||||
}
|
||||
|
||||
dbContext.Channels.RemoveRange(channels);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
|
||||
searchTargets.SearchTargetsChanged();
|
||||
|
||||
foreach (Channel channel in channels)
|
||||
{
|
||||
string cacheFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{channel.Number}.xml");
|
||||
if (fileSystem.File.Exists(cacheFile))
|
||||
{
|
||||
fileSystem.File.Delete(cacheFile);
|
||||
}
|
||||
}
|
||||
|
||||
await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
|
||||
|
||||
return Right<BaseError, Unit>(Unit.Default);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record BulkMoveChannelsToGroup(IReadOnlyList<int> ChannelIds, string Group)
|
||||
: IRequest<Either<BaseError, Unit>>;
|
||||
@@ -1,61 +0,0 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.Channels.ChannelValidations;
|
||||
using Channel = ErsatzTV.Core.Domain.Channel;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class BulkMoveChannelsToGroupHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
||||
ISearchTargets searchTargets)
|
||||
: IRequestHandler<BulkMoveChannelsToGroup, Either<BaseError, Unit>>
|
||||
{
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
BulkMoveChannelsToGroup request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.ChannelIds.Count == 0)
|
||||
{
|
||||
return Left<BaseError, Unit>(BaseError.New("At least one channel id is required"));
|
||||
}
|
||||
|
||||
Validation<BaseError, string> groupValidation = ValidateGroup(request.Group);
|
||||
if (groupValidation.IsFail)
|
||||
{
|
||||
return Left<BaseError, Unit>(groupValidation.FailToSeq().Head());
|
||||
}
|
||||
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
|
||||
|
||||
List<int> channelIds = request.ChannelIds.Distinct().ToList();
|
||||
List<Channel> channels = await dbContext.Channels
|
||||
.Where(c => channelIds.Contains(c.Id))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (channels.Count != channelIds.Count)
|
||||
{
|
||||
var found = channels.Select(c => c.Id).ToHashSet();
|
||||
int missingId = channelIds.First(id => !found.Contains(id));
|
||||
return Left<BaseError, Unit>(new NotFoundError($"Channel {missingId} does not exist."));
|
||||
}
|
||||
|
||||
foreach (Channel channel in channels)
|
||||
{
|
||||
channel.Group = request.Group;
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
|
||||
searchTargets.SearchTargetsChanged();
|
||||
await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
|
||||
|
||||
return Right<BaseError, Unit>(Unit.Default);
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
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,7 +8,6 @@ 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;
|
||||
@@ -37,23 +36,18 @@ public class CreateChannelHandler(
|
||||
return new CreateChannelResult(channel.Id);
|
||||
}
|
||||
|
||||
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),
|
||||
private static async Task<Validation<BaseError, Channel>> Validate(TvContext dbContext, CreateChannel request, CancellationToken cancellationToken) =>
|
||||
(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),
|
||||
ValidateShowInEpg(request.IsEnabled, request.ShowInEpg),
|
||||
ValidateLogo(request.Logo?.Path))
|
||||
await MirrorSourceMustBeValid(dbContext, request, cancellationToken))
|
||||
.Apply((
|
||||
name,
|
||||
number,
|
||||
ffmpegProfileId,
|
||||
watermarkId,
|
||||
fillerPresetId,
|
||||
_,
|
||||
_,
|
||||
_) =>
|
||||
{
|
||||
var artwork = new List<Artwork>();
|
||||
@@ -131,11 +125,6 @@ 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));
|
||||
@@ -160,20 +149,16 @@ public class CreateChannelHandler(
|
||||
});
|
||||
}
|
||||
|
||||
private static async Task<Validation<BaseError, int>> FFmpegProfileMustExist(
|
||||
private static Task<Validation<BaseError, int>> FFmpegProfileMustExist(
|
||||
TvContext dbContext,
|
||||
CreateChannel createChannel,
|
||||
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.");
|
||||
}
|
||||
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."));
|
||||
|
||||
private static async Task<Validation<BaseError, Option<int>>> WatermarkMustExist(
|
||||
TvContext dbContext,
|
||||
@@ -185,14 +170,12 @@ public class CreateChannelHandler(
|
||||
return Option<int>.None;
|
||||
}
|
||||
|
||||
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.");
|
||||
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."));
|
||||
}
|
||||
|
||||
private static async Task<Validation<BaseError, Option<int>>> FillerPresetMustExist(
|
||||
@@ -205,15 +188,14 @@ public class CreateChannelHandler(
|
||||
return Option<int>.None;
|
||||
}
|
||||
|
||||
bool exists = await dbContext.FillerPresets
|
||||
return await dbContext.FillerPresets
|
||||
.Filter(fp => fp.FillerKind == FillerKind.Fallback)
|
||||
.AnyAsync(w => w.Id == createChannel.FallbackFillerId, cancellationToken);
|
||||
if (exists)
|
||||
{
|
||||
return Optional(createChannel.FallbackFillerId);
|
||||
}
|
||||
|
||||
return BaseError.New($"Fallback filler {createChannel.FallbackFillerId} does not exist.");
|
||||
.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."));
|
||||
}
|
||||
|
||||
private static async Task<Validation<BaseError, Unit>> MirrorSourceMustBeValid(
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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;
|
||||
@@ -32,17 +31,8 @@ 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);
|
||||
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."))));
|
||||
Validation<BaseError, Channel> validation = await ChannelMustExist(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(c => DoDeletion(dbContext, c, cancellationToken));
|
||||
}
|
||||
|
||||
private async Task<Unit> DoDeletion(TvContext dbContext, Channel channel, CancellationToken cancellationToken)
|
||||
@@ -64,4 +54,14 @@ 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,13 +4,10 @@ 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;
|
||||
|
||||
@@ -27,23 +24,8 @@ public class UpdateChannelHandler(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(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."))));
|
||||
Validation<BaseError, Channel> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request, cancellationToken));
|
||||
}
|
||||
|
||||
private async Task<ChannelViewModel> ApplyUpdateRequest(
|
||||
@@ -180,82 +162,23 @@ public class UpdateChannelHandler(
|
||||
private static async Task<Validation<BaseError, Channel>> Validate(
|
||||
TvContext dbContext,
|
||||
UpdateChannel request,
|
||||
Channel channel,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, Channel> channelValidation = (ValidateName(request),
|
||||
CancellationToken cancellationToken) =>
|
||||
(await ChannelMustExist(dbContext, request, cancellationToken),
|
||||
ValidateName(request),
|
||||
await ValidateNumber(dbContext, request, cancellationToken),
|
||||
await MirrorSourceMustBeValid(dbContext, request, cancellationToken),
|
||||
ValidateShowInEpg(request.IsEnabled, request.ShowInEpg),
|
||||
ValidateLogo(request.Logo?.Path))
|
||||
.Apply((_, _, _, _, _) => channel);
|
||||
await MirrorSourceMustBeValid(dbContext, request, cancellationToken))
|
||||
.Apply((channelToUpdate, _, _, _) => channelToUpdate);
|
||||
|
||||
// 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(
|
||||
private static Task<Validation<BaseError, Channel>> ChannelMustExist(
|
||||
TvContext dbContext,
|
||||
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.");
|
||||
}
|
||||
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."));
|
||||
|
||||
private static async Task<Validation<BaseError, Unit>> MirrorSourceMustBeValid(
|
||||
TvContext dbContext,
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Channel = ErsatzTV.Core.Domain.Channel;
|
||||
@@ -16,41 +14,18 @@ public class UpdateChannelNumbersHandler(
|
||||
{
|
||||
public async Task<Option<BaseError>> Handle(UpdateChannelNumbers request, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<BaseError> validationError = ValidateRequest(request);
|
||||
if (validationError.IsSome)
|
||||
{
|
||||
return validationError;
|
||||
}
|
||||
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
var numberUpdates = request.Channels.ToDictionary(c => c.Id, c => c.Number);
|
||||
List<int> channelIds = numberUpdates.Keys.ToList();
|
||||
var channelIds = numberUpdates.Keys;
|
||||
|
||||
List<Channel> channelsToUpdate = await dbContext.Channels
|
||||
.Where(c => channelIds.Contains(c.Id))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (channelsToUpdate.Count != channelIds.Count)
|
||||
{
|
||||
var found = channelsToUpdate.Select(c => c.Id).ToHashSet();
|
||||
int missingId = channelIds.First(id => !found.Contains(id));
|
||||
return new NotFoundError($"Channel {missingId} does not exist.");
|
||||
}
|
||||
|
||||
List<string> requestedNumbers = numberUpdates.Values.ToList();
|
||||
bool numberConflict = await dbContext.Channels
|
||||
.AnyAsync(
|
||||
c => requestedNumbers.Contains(c.Number) && !channelIds.Contains(c.Id),
|
||||
cancellationToken);
|
||||
if (numberConflict)
|
||||
{
|
||||
return BaseError.New("Channel number must be unique");
|
||||
}
|
||||
|
||||
// give every channel a non-conflicting number
|
||||
foreach (var channel in channelsToUpdate)
|
||||
{
|
||||
@@ -94,32 +69,4 @@ public class UpdateChannelNumbersHandler(
|
||||
return BaseError.New("Failed to update channel numbers: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static Option<BaseError> ValidateRequest(UpdateChannelNumbers request)
|
||||
{
|
||||
if (request.Channels.Count == 0)
|
||||
{
|
||||
return BaseError.New("At least one channel is required");
|
||||
}
|
||||
|
||||
if (request.Channels.Select(c => c.Id).Distinct().Count() != request.Channels.Count)
|
||||
{
|
||||
return BaseError.New("Channel ids must be unique");
|
||||
}
|
||||
|
||||
if (request.Channels.Select(c => c.Number).Distinct(StringComparer.Ordinal).Count() != request.Channels.Count)
|
||||
{
|
||||
return BaseError.New("Channel number must be unique");
|
||||
}
|
||||
|
||||
foreach (ChannelSortViewModel channel in request.Channels)
|
||||
{
|
||||
if (!Regex.IsMatch(channel.Number, Channel.NumberValidator))
|
||||
{
|
||||
return BaseError.New("Invalid channel number; two decimals are allowed for subchannels");
|
||||
}
|
||||
}
|
||||
|
||||
return Option<BaseError>.None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,15 +42,10 @@ internal static class Mapper
|
||||
new(
|
||||
channel.Id,
|
||||
channel.Number,
|
||||
channel.SortNumber,
|
||||
channel.Name,
|
||||
channel.Group,
|
||||
channel.Categories,
|
||||
channel.FFmpegProfile.Name,
|
||||
channel.PreferredAudioLanguageCode,
|
||||
GetStreamingMode(channel),
|
||||
channel.IsEnabled,
|
||||
channel.ShowInEpg);
|
||||
GetStreamingMode(channel));
|
||||
|
||||
internal static ResolutionViewModel ProjectToViewModel(Resolution resolution) =>
|
||||
new(resolution.Height, resolution.Width);
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record GetChannelStatesForApi(DateTime Now) : IRequest<List<ChannelStateResponseModel>>;
|
||||
@@ -1,151 +0,0 @@
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PlayoutMapper = ErsatzTV.Application.Playouts.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class GetChannelStatesForApiHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IFFmpegSegmenterService ffmpegSegmenterService)
|
||||
: IRequestHandler<GetChannelStatesForApi, List<ChannelStateResponseModel>>
|
||||
{
|
||||
// a guide entry (program + surrounding filler) never spans anywhere near a day; the time
|
||||
// bound also protects against GuideGroup values recycling (mod 10000) elsewhere in a playout
|
||||
private static readonly TimeSpan GuideEntryBound = TimeSpan.FromDays(1);
|
||||
|
||||
public async Task<List<ChannelStateResponseModel>> Handle(
|
||||
GetChannelStatesForApi request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
List<Channel> channels = await dbContext.Channels
|
||||
.AsNoTracking()
|
||||
.OrderBy(c => c.SortNumber)
|
||||
.ThenBy(c => c.Number)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (channels.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
Dictionary<int, (int SourceChannelId, TimeSpan Offset, DateTime LookupTime)> channelLookup = channels
|
||||
.ToDictionary(
|
||||
c => c.Id,
|
||||
c =>
|
||||
{
|
||||
TimeSpan offset = c.PlayoutOffset ?? TimeSpan.Zero;
|
||||
return (c.MirrorSourceChannelId ?? c.Id, offset, request.Now - offset);
|
||||
});
|
||||
|
||||
// one covering-item query per distinct lookup time (i.e. per distinct playout offset,
|
||||
// typically just one) with a cheap projection; metadata is hydrated in a second query
|
||||
// for only the covering items' guide groups
|
||||
var coveringBySourceAndTime = new Dictionary<(int SourceChannelId, DateTime LookupTime), CoveringItem>();
|
||||
foreach (IGrouping<DateTime, (int SourceChannelId, TimeSpan Offset, DateTime LookupTime)> timeGroup in
|
||||
channelLookup.Values.GroupBy(v => v.LookupTime))
|
||||
{
|
||||
DateTime lookupTime = timeGroup.Key;
|
||||
int[] sourceChannelIds = timeGroup.Map(v => v.SourceChannelId).Distinct().ToArray();
|
||||
|
||||
List<CoveringItem> covering = await dbContext.PlayoutItems
|
||||
.AsNoTracking()
|
||||
.Where(pi => sourceChannelIds.Contains(pi.Playout.ChannelId))
|
||||
.Where(pi => pi.Start <= lookupTime && pi.Finish > lookupTime)
|
||||
.Select(pi => new CoveringItem(pi.Id, pi.PlayoutId, pi.Playout.ChannelId, pi.GuideGroup, pi.Start))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (CoveringItem item in covering.OrderBy(ci => ci.Start))
|
||||
{
|
||||
coveringBySourceAndTime.TryAdd((item.ChannelId, lookupTime), item);
|
||||
}
|
||||
}
|
||||
|
||||
Dictionary<(int PlayoutId, int GuideGroup), List<PlayoutItem>> itemsByGuideEntry = [];
|
||||
if (coveringBySourceAndTime.Count > 0)
|
||||
{
|
||||
int[] playoutIds = coveringBySourceAndTime.Values.Map(ci => ci.PlayoutId).Distinct().ToArray();
|
||||
int[] guideGroups = coveringBySourceAndTime.Values.Map(ci => ci.GuideGroup).Distinct().ToArray();
|
||||
DateTime windowStart = channelLookup.Values.Min(v => v.LookupTime) - GuideEntryBound;
|
||||
DateTime windowFinish = channelLookup.Values.Max(v => v.LookupTime) + GuideEntryBound;
|
||||
|
||||
List<PlayoutItem> guideEntryItems = await dbContext.PlayoutItems
|
||||
.AsNoTracking()
|
||||
.Include(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as Episode).EpisodeMetadata)
|
||||
.Include(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as Episode).Season)
|
||||
.ThenInclude(s => s.Show)
|
||||
.ThenInclude(s => s.ShowMetadata)
|
||||
.Include(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as Movie).MovieMetadata)
|
||||
.Include(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as MusicVideo).MusicVideoMetadata)
|
||||
.Include(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as MusicVideo).Artist)
|
||||
.ThenInclude(a => a.ArtistMetadata)
|
||||
.Include(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as OtherVideo).OtherVideoMetadata)
|
||||
.Include(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as Song).SongMetadata)
|
||||
.Include(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as Image).ImageMetadata)
|
||||
.Include(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as RemoteStream).RemoteStreamMetadata)
|
||||
.Where(pi => playoutIds.Contains(pi.PlayoutId) && guideGroups.Contains(pi.GuideGroup))
|
||||
.Where(pi => pi.Start <= windowFinish && pi.Finish > windowStart)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
itemsByGuideEntry = guideEntryItems
|
||||
.GroupBy(pi => (pi.PlayoutId, pi.GuideGroup))
|
||||
.ToDictionary(g => g.Key, g => g.OrderBy(pi => pi.Start).ToList());
|
||||
}
|
||||
|
||||
return channels
|
||||
.Map(channel =>
|
||||
{
|
||||
(int sourceChannelId, TimeSpan offset, DateTime lookupTime) = channelLookup[channel.Id];
|
||||
ChannelNowPlayingResponseModel nowPlaying = null;
|
||||
|
||||
if (coveringBySourceAndTime.TryGetValue((sourceChannelId, lookupTime), out CoveringItem covering) &&
|
||||
itemsByGuideEntry.TryGetValue(
|
||||
(covering.PlayoutId, covering.GuideGroup),
|
||||
out List<PlayoutItem> guideEntry))
|
||||
{
|
||||
// like the XMLTV guide, surface the program rather than its filler: use the
|
||||
// covering item itself when it is a program part, otherwise the guide entry's
|
||||
// first program item; an entry with no program item (e.g. offline fallback
|
||||
// filler) stays null
|
||||
PlayoutItem coveringItem = guideEntry.Find(pi => pi.Id == covering.Id);
|
||||
PlayoutItem displayItem = coveringItem?.FillerKind == FillerKind.None
|
||||
? coveringItem
|
||||
: guideEntry.Find(pi => pi.FillerKind == FillerKind.None);
|
||||
|
||||
if (displayItem is not null)
|
||||
{
|
||||
DateTime start = guideEntry[0].Start;
|
||||
DateTime finish = displayItem.GuideFinish ?? guideEntry.Max(pi => pi.Finish);
|
||||
|
||||
nowPlaying = new ChannelNowPlayingResponseModel(
|
||||
PlayoutMapper.GetDisplayTitle(displayItem.MediaItem, Optional(displayItem.ChapterTitle)),
|
||||
new DateTimeOffset(start + offset, TimeSpan.Zero),
|
||||
new DateTimeOffset(finish + offset, TimeSpan.Zero));
|
||||
}
|
||||
}
|
||||
|
||||
return new ChannelStateResponseModel(
|
||||
channel.Id,
|
||||
channel.Number,
|
||||
ffmpegSegmenterService.IsActive(channel.Number),
|
||||
nowPlaying);
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private sealed record CoveringItem(int Id, int PlayoutId, int ChannelId, int GuideGroup, DateTime Start);
|
||||
}
|
||||
@@ -10,15 +10,15 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CliWrap" />
|
||||
<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" />
|
||||
<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" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
@@ -25,15 +24,8 @@ public class CreateFFmpegProfileHandler :
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Option<int> maybeResolutionId = await ResolutionMustExist(dbContext, request, cancellationToken);
|
||||
return await maybeResolutionId.Match(
|
||||
Some: async resolutionId =>
|
||||
{
|
||||
Validation<BaseError, FFmpegProfile> validation = Validate(request, resolutionId);
|
||||
return await validation.Apply(profile => PersistFFmpegProfile(dbContext, profile));
|
||||
},
|
||||
None: () => Task.FromResult<Either<BaseError, CreateFFmpegProfileResult>>(
|
||||
new NotFoundError($"[Resolution] {request.ResolutionId} does not exist")));
|
||||
Validation<BaseError, FFmpegProfile> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(profile => PersistFFmpegProfile(dbContext, profile));
|
||||
}
|
||||
|
||||
private async Task<CreateFFmpegProfileResult> PersistFFmpegProfile(
|
||||
@@ -46,11 +38,13 @@ public class CreateFFmpegProfileHandler :
|
||||
return new CreateFFmpegProfileResult(ffmpegProfile.Id);
|
||||
}
|
||||
|
||||
private static Validation<BaseError, FFmpegProfile> Validate(
|
||||
private static async Task<Validation<BaseError, FFmpegProfile>> Validate(
|
||||
TvContext dbContext,
|
||||
CreateFFmpegProfile request,
|
||||
int resolutionId) =>
|
||||
(ValidateName(request), ValidateThreadCount(request))
|
||||
.Apply((name, threadCount) =>
|
||||
CancellationToken cancellationToken) =>
|
||||
(ValidateName(request), ValidateThreadCount(request),
|
||||
await ResolutionMustExist(dbContext, request, cancellationToken))
|
||||
.Apply((name, threadCount, resolutionId) =>
|
||||
{
|
||||
var hwAccel = request.NormalizeVideo
|
||||
? request.HardwareAcceleration
|
||||
@@ -116,11 +110,12 @@ public class CreateFFmpegProfileHandler :
|
||||
private static Validation<BaseError, int> ValidateThreadCount(CreateFFmpegProfile createFFmpegProfile) =>
|
||||
createFFmpegProfile.AtLeast(0)(p => p.ThreadCount);
|
||||
|
||||
private static Task<Option<int>> ResolutionMustExist(
|
||||
private static Task<Validation<BaseError, int>> ResolutionMustExist(
|
||||
TvContext dbContext,
|
||||
CreateFFmpegProfile createFFmpegProfile,
|
||||
CancellationToken cancellationToken) =>
|
||||
dbContext.Resolutions
|
||||
.SelectOneAsync(r => r.Id, r => r.Id == createFFmpegProfile.ResolutionId, cancellationToken)
|
||||
.MapT(r => r.Id);
|
||||
.MapT(r => r.Id)
|
||||
.Map(o => o.ToValidation<BaseError>($"[Resolution] {createFFmpegProfile.ResolutionId} does not exist"));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
@@ -20,19 +19,8 @@ public class DeleteFFmpegProfileHandler(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Option<FFmpegProfile> maybeProfile = await FFmpegProfileMustExist(dbContext, request, cancellationToken);
|
||||
return await maybeProfile.Match(
|
||||
Some: async profile =>
|
||||
{
|
||||
Validation<BaseError, FFmpegProfile> validation = await Validate(
|
||||
dbContext,
|
||||
request,
|
||||
profile,
|
||||
cancellationToken);
|
||||
return await validation.Apply(p => DoDeletion(dbContext, p));
|
||||
},
|
||||
None: () => Task.FromResult<Either<BaseError, Unit>>(
|
||||
new NotFoundError($"FFmpegProfile {request.FFmpegProfileId} does not exist")));
|
||||
Validation<BaseError, FFmpegProfile> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(p => DoDeletion(dbContext, p));
|
||||
}
|
||||
|
||||
private async Task<Unit> DoDeletion(TvContext dbContext, FFmpegProfile ffmpegProfile)
|
||||
@@ -46,18 +34,19 @@ public class DeleteFFmpegProfileHandler(
|
||||
private async Task<Validation<BaseError, FFmpegProfile>> Validate(
|
||||
TvContext dbContext,
|
||||
DeleteFFmpegProfile request,
|
||||
FFmpegProfile profile,
|
||||
CancellationToken cancellationToken) =>
|
||||
(await FFmpegProfileMustNotBeUsed(dbContext, request, cancellationToken),
|
||||
await FFmpegProfileMustNotBeDefault(request, cancellationToken))
|
||||
.Apply((_, _) => profile);
|
||||
await FFmpegProfileMustNotBeDefault(request, cancellationToken),
|
||||
await FFmpegProfileMustExist(dbContext, request, cancellationToken))
|
||||
.Apply((_, _, ffmpegProfile) => ffmpegProfile);
|
||||
|
||||
private static Task<Option<FFmpegProfile>> FFmpegProfileMustExist(
|
||||
private static Task<Validation<BaseError, FFmpegProfile>> FFmpegProfileMustExist(
|
||||
TvContext dbContext,
|
||||
DeleteFFmpegProfile request,
|
||||
CancellationToken cancellationToken) =>
|
||||
dbContext.FFmpegProfiles
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == request.FFmpegProfileId, cancellationToken);
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == request.FFmpegProfileId, cancellationToken)
|
||||
.Map(o => o.ToValidation<BaseError>($"FFmpegProfile {request.FFmpegProfileId} does not exist"));
|
||||
|
||||
private static async Task<Validation<BaseError, Unit>> FFmpegProfileMustNotBeUsed(
|
||||
TvContext dbContext,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.FFmpeg.Preset;
|
||||
@@ -18,22 +17,8 @@ public class UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFa
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Option<FFmpegProfile> maybeProfile = await FFmpegProfileMustExist(dbContext, request, cancellationToken);
|
||||
return await maybeProfile.Match(
|
||||
Some: async profile =>
|
||||
{
|
||||
Option<int> maybeResolutionId = await ResolutionMustExist(dbContext, request, cancellationToken);
|
||||
return await maybeResolutionId.Match(
|
||||
Some: async _ =>
|
||||
{
|
||||
Validation<BaseError, FFmpegProfile> validation = await Validate(dbContext, request, profile);
|
||||
return await validation.Apply(p => ApplyUpdateRequest(dbContext, p, request, cancellationToken));
|
||||
},
|
||||
None: () => Task.FromResult<Either<BaseError, UpdateFFmpegProfileResult>>(
|
||||
new NotFoundError($"[Resolution] {request.ResolutionId} does not exist")));
|
||||
},
|
||||
None: () => Task.FromResult<Either<BaseError, UpdateFFmpegProfileResult>>(
|
||||
new NotFoundError("FFmpegProfile does not exist.")));
|
||||
Validation<BaseError, FFmpegProfile> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(p => ApplyUpdateRequest(dbContext, p, request, cancellationToken));
|
||||
}
|
||||
|
||||
private async Task<UpdateFFmpegProfileResult> ApplyUpdateRequest(
|
||||
@@ -124,16 +109,20 @@ public class UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFa
|
||||
private static async Task<Validation<BaseError, FFmpegProfile>> Validate(
|
||||
TvContext dbContext,
|
||||
UpdateFFmpegProfile request,
|
||||
FFmpegProfile profile) =>
|
||||
(await ValidateName(dbContext, request), ValidateThreadCount(request))
|
||||
.Apply((_, _) => profile);
|
||||
CancellationToken cancellationToken) =>
|
||||
(await FFmpegProfileMustExist(dbContext, request, cancellationToken),
|
||||
await ValidateName(dbContext, request),
|
||||
ValidateThreadCount(request),
|
||||
await ResolutionMustExist(dbContext, request, cancellationToken))
|
||||
.Apply((ffmpegProfileToUpdate, _, _, _) => ffmpegProfileToUpdate);
|
||||
|
||||
private static Task<Option<FFmpegProfile>> FFmpegProfileMustExist(
|
||||
private static Task<Validation<BaseError, FFmpegProfile>> FFmpegProfileMustExist(
|
||||
TvContext dbContext,
|
||||
UpdateFFmpegProfile updateFFmpegProfile,
|
||||
CancellationToken cancellationToken) =>
|
||||
dbContext.FFmpegProfiles
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == updateFFmpegProfile.FFmpegProfileId, cancellationToken);
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == updateFFmpegProfile.FFmpegProfileId, cancellationToken)
|
||||
.Map(o => o.ToValidation<BaseError>("FFmpegProfile does not exist."));
|
||||
|
||||
private static async Task<Validation<BaseError, string>> ValidateName(
|
||||
TvContext dbContext,
|
||||
@@ -158,11 +147,12 @@ public class UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFa
|
||||
private static Validation<BaseError, int> ValidateThreadCount(UpdateFFmpegProfile updateFFmpegProfile) =>
|
||||
updateFFmpegProfile.AtLeast(0)(p => p.ThreadCount);
|
||||
|
||||
private static Task<Option<int>> ResolutionMustExist(
|
||||
private static Task<Validation<BaseError, int>> ResolutionMustExist(
|
||||
TvContext dbContext,
|
||||
UpdateFFmpegProfile updateFFmpegProfile,
|
||||
CancellationToken cancellationToken) =>
|
||||
dbContext.Resolutions
|
||||
.SelectOneAsync(r => r.Id, r => r.Id == updateFFmpegProfile.ResolutionId, cancellationToken)
|
||||
.MapT(r => r.Id);
|
||||
.MapT(r => r.Id)
|
||||
.Map(o => o.ToValidation<BaseError>($"[Resolution] {updateFFmpegProfile.ResolutionId} does not exist"));
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ 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;
|
||||
@@ -43,15 +42,8 @@ public class AddItemsToCollectionHandler :
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(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.")));
|
||||
Validation<BaseError, Collection> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(c => ApplyAddItemsRequest(dbContext, c, request, cancellationToken));
|
||||
}
|
||||
|
||||
private async Task<Unit> ApplyAddItemsRequest(
|
||||
@@ -97,23 +89,22 @@ public class AddItemsToCollectionHandler :
|
||||
private async Task<Validation<BaseError, Collection>> Validate(
|
||||
TvContext dbContext,
|
||||
AddItemsToCollection request,
|
||||
Collection collection,
|
||||
CancellationToken cancellationToken) =>
|
||||
(await ValidateMovies(request),
|
||||
(await CollectionMustExist(dbContext, request, cancellationToken),
|
||||
await ValidateMovies(request),
|
||||
await ValidateShows(request),
|
||||
await ValidateSeasons(request),
|
||||
await ValidateEpisodes(request),
|
||||
await ValidateMediaItems(dbContext, request, cancellationToken))
|
||||
.Apply((_, _, _, _, _) => collection);
|
||||
await ValidateEpisodes(request))
|
||||
.Apply((collection, _, _, _, _) => collection);
|
||||
|
||||
private static Task<Option<Collection>> CollectionMustExist(
|
||||
private static Task<Validation<BaseError, 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(identity);
|
||||
.Map(o => o.ToValidation<BaseError>("Collection does not exist."));
|
||||
|
||||
private Task<Validation<BaseError, Unit>> ValidateMovies(AddItemsToCollection request) =>
|
||||
_movieRepository.AllMoviesExist(request.MovieIds)
|
||||
@@ -142,30 +133,4 @@ 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,6 +1,5 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
@@ -24,11 +23,8 @@ public class DeleteCollectionHandler : IRequestHandler<DeleteCollection, Either<
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(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.")));
|
||||
Validation<BaseError, Collection> validation = await CollectionMustExist(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(c => DoDeletion(dbContext, c, cancellationToken));
|
||||
}
|
||||
|
||||
private async Task<Unit> DoDeletion(TvContext dbContext, Collection collection, CancellationToken cancellationToken)
|
||||
@@ -39,11 +35,11 @@ public class DeleteCollectionHandler : IRequestHandler<DeleteCollection, Either<
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private static Task<Option<Collection>> CollectionMustExist(
|
||||
private static Task<Validation<BaseError, Collection>> CollectionMustExist(
|
||||
TvContext dbContext,
|
||||
DeleteCollection request,
|
||||
CancellationToken cancellationToken) =>
|
||||
dbContext.Collections
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == request.CollectionId, cancellationToken)
|
||||
.Map(identity);
|
||||
.Map(o => o.ToValidation<BaseError>($"Collection {request.CollectionId} does not exist."));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
@@ -30,14 +29,11 @@ public class DeleteSmartCollectionHandler : IRequestHandler<DeleteSmartCollectio
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Option<SmartCollection> maybeSmartCollection = await SmartCollectionMustExist(
|
||||
Validation<BaseError, SmartCollection> validation = await SmartCollectionMustExist(
|
||||
dbContext,
|
||||
request,
|
||||
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.")));
|
||||
return await validation.Apply(c => DoDeletion(dbContext, c, cancellationToken));
|
||||
}
|
||||
|
||||
private async Task<Unit> DoDeletion(
|
||||
@@ -52,11 +48,11 @@ public class DeleteSmartCollectionHandler : IRequestHandler<DeleteSmartCollectio
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private static Task<Option<SmartCollection>> SmartCollectionMustExist(
|
||||
private static Task<Validation<BaseError, SmartCollection>> SmartCollectionMustExist(
|
||||
TvContext dbContext,
|
||||
DeleteSmartCollection request,
|
||||
CancellationToken cancellationToken) =>
|
||||
dbContext.SmartCollections
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == request.SmartCollectionId, cancellationToken)
|
||||
.Map(identity);
|
||||
.Map(o => o.ToValidation<BaseError>($"SmartCollection {request.SmartCollectionId} does not exist."));
|
||||
}
|
||||
|
||||
+12
-16
@@ -3,7 +3,6 @@ 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;
|
||||
@@ -36,29 +35,20 @@ public class RemoveItemsFromCollectionHandler : IRequestHandler<RemoveItemsFromC
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(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.")));
|
||||
Validation<BaseError, Collection> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(c => ApplyRemoveItemsRequest(dbContext, request, c, cancellationToken));
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, Unit>> ApplyRemoveItemsRequest(
|
||||
private async Task<Unit> ApplyRemoveItemsRequest(
|
||||
TvContext dbContext,
|
||||
RemoveItemsFromCollection request,
|
||||
Collection collection,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<int> requestedIds = request.MediaItemIds.Distinct().ToList();
|
||||
var itemsToRemove = collection.MediaItems
|
||||
.Filter(m => requestedIds.Contains(m.Id))
|
||||
.Filter(m => request.MediaItemIds.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)
|
||||
@@ -77,12 +67,18 @@ public class RemoveItemsFromCollectionHandler : IRequestHandler<RemoveItemsFromC
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private static Task<Option<Collection>> CollectionMustExist(
|
||||
private static Task<Validation<BaseError, Collection>> Validate(
|
||||
TvContext dbContext,
|
||||
RemoveItemsFromCollection request,
|
||||
CancellationToken cancellationToken) =>
|
||||
CollectionMustExist(dbContext, request, cancellationToken);
|
||||
|
||||
private static Task<Validation<BaseError, 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(identity);
|
||||
.Map(o => o.ToValidation<BaseError>("Collection does not exist."));
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
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;
|
||||
@@ -36,15 +35,8 @@ public class UpdateCollectionHandler : IRequestHandler<UpdateCollection, Either<
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(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.")));
|
||||
Validation<BaseError, Collection> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request, cancellationToken));
|
||||
}
|
||||
|
||||
private async Task<Unit> ApplyUpdateRequest(
|
||||
@@ -77,16 +69,17 @@ public class UpdateCollectionHandler : IRequestHandler<UpdateCollection, Either<
|
||||
private static async Task<Validation<BaseError, Collection>> Validate(
|
||||
TvContext dbContext,
|
||||
UpdateCollection request,
|
||||
Collection collection) =>
|
||||
(await ValidateName(dbContext, request)).Map(_ => collection);
|
||||
CancellationToken cancellationToken) =>
|
||||
(await CollectionMustExist(dbContext, request, cancellationToken), await ValidateName(dbContext, request))
|
||||
.Apply((collectionToUpdate, _) => collectionToUpdate);
|
||||
|
||||
private static Task<Option<Collection>> CollectionMustExist(
|
||||
private static Task<Validation<BaseError, Collection>> CollectionMustExist(
|
||||
TvContext dbContext,
|
||||
UpdateCollection updateCollection,
|
||||
CancellationToken cancellationToken) =>
|
||||
dbContext.Collections
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == updateCollection.CollectionId, cancellationToken)
|
||||
.Map(identity);
|
||||
.Map(o => o.ToValidation<BaseError>("Collection does not exist."));
|
||||
|
||||
private static async Task<Validation<BaseError, string>> ValidateName(
|
||||
TvContext dbContext,
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
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;
|
||||
@@ -42,18 +41,8 @@ public class
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(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.")));
|
||||
Validation<BaseError, SmartCollection> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request, cancellationToken));
|
||||
}
|
||||
|
||||
private async Task<UpdateSmartCollectionResult> ApplyUpdateRequest(
|
||||
@@ -84,16 +73,16 @@ public class
|
||||
private static Task<Validation<BaseError, SmartCollection>> Validate(
|
||||
TvContext dbContext,
|
||||
UpdateSmartCollection request,
|
||||
SmartCollection smartCollection) => ValidateName(dbContext, request)
|
||||
.MapT(_ => smartCollection);
|
||||
CancellationToken cancellationToken) => ValidateName(dbContext, request)
|
||||
.BindT(_ => SmartCollectionMustExist(dbContext, request, cancellationToken));
|
||||
|
||||
private static Task<Option<SmartCollection>> SmartCollectionMustExist(
|
||||
private static Task<Validation<BaseError, SmartCollection>> SmartCollectionMustExist(
|
||||
TvContext dbContext,
|
||||
UpdateSmartCollection updateCollection,
|
||||
CancellationToken cancellationToken) =>
|
||||
dbContext.SmartCollections
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == updateCollection.Id, cancellationToken)
|
||||
.Map(identity);
|
||||
.Map(o => o.ToValidation<BaseError>("SmartCollection does not exist."));
|
||||
|
||||
private static async Task<Validation<BaseError, string>> ValidateName(
|
||||
TvContext dbContext,
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
@@ -29,23 +28,8 @@ public class CreateClassicPlayoutHandler : IRequestHandler<CreateClassicPlayout,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Option<Channel> maybeChannel = await GetChannel(dbContext, request, cancellationToken);
|
||||
return await maybeChannel.Match(
|
||||
Some: async channel =>
|
||||
{
|
||||
Option<ProgramSchedule> maybeProgramSchedule =
|
||||
await GetProgramSchedule(dbContext, request, cancellationToken);
|
||||
return await maybeProgramSchedule.Match(
|
||||
Some: async programSchedule =>
|
||||
{
|
||||
Validation<BaseError, Playout> validation = Validate(request, channel, programSchedule);
|
||||
return await validation.Apply(playout => PersistPlayout(dbContext, playout));
|
||||
},
|
||||
None: () => Task.FromResult<Either<BaseError, CreatePlayoutResponse>>(
|
||||
new NotFoundError("Program schedule does not exist")));
|
||||
},
|
||||
None: () => Task.FromResult<Either<BaseError, CreatePlayoutResponse>>(
|
||||
new NotFoundError("Channel does not exist")));
|
||||
Validation<BaseError, Playout> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(playout => PersistPlayout(dbContext, playout));
|
||||
}
|
||||
|
||||
private async Task<CreatePlayoutResponse> PersistPlayout(TvContext dbContext, Playout playout)
|
||||
@@ -62,12 +46,12 @@ public class CreateClassicPlayoutHandler : IRequestHandler<CreateClassicPlayout,
|
||||
return new CreatePlayoutResponse(playout.Id);
|
||||
}
|
||||
|
||||
private static Validation<BaseError, Playout> Validate(
|
||||
private static async Task<Validation<BaseError, Playout>> Validate(
|
||||
TvContext dbContext,
|
||||
CreateClassicPlayout request,
|
||||
Channel channel,
|
||||
ProgramSchedule programSchedule) =>
|
||||
(ChannelMustNotHavePlayouts(channel),
|
||||
ProgramScheduleMustHaveItems(programSchedule),
|
||||
CancellationToken cancellationToken) =>
|
||||
(await ValidateChannel(dbContext, request, cancellationToken),
|
||||
await ValidateProgramSchedule(dbContext, request, cancellationToken),
|
||||
ValidateScheduleKind(request))
|
||||
.Apply((channel, programSchedule, scheduleKind) => new Playout
|
||||
{
|
||||
@@ -76,13 +60,15 @@ public class CreateClassicPlayoutHandler : IRequestHandler<CreateClassicPlayout,
|
||||
ScheduleKind = scheduleKind
|
||||
});
|
||||
|
||||
private static Task<Option<Channel>> GetChannel(
|
||||
private static Task<Validation<BaseError, Channel>> ValidateChannel(
|
||||
TvContext dbContext,
|
||||
CreateClassicPlayout createClassicPlayout,
|
||||
CancellationToken cancellationToken) =>
|
||||
dbContext.Channels
|
||||
.Include(c => c.Playouts)
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == createClassicPlayout.ChannelId, cancellationToken);
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == createClassicPlayout.ChannelId, cancellationToken)
|
||||
.Map(o => o.ToValidation<BaseError>("Channel does not exist"))
|
||||
.BindT(ChannelMustNotHavePlayouts);
|
||||
|
||||
private static Validation<BaseError, Channel> ChannelMustNotHavePlayouts(Channel channel) =>
|
||||
Optional(channel.Playouts.Count)
|
||||
@@ -90,13 +76,15 @@ public class CreateClassicPlayoutHandler : IRequestHandler<CreateClassicPlayout,
|
||||
.Map(_ => channel)
|
||||
.ToValidation<BaseError>("Channel already has one playout");
|
||||
|
||||
private static Task<Option<ProgramSchedule>> GetProgramSchedule(
|
||||
private static Task<Validation<BaseError, ProgramSchedule>> ValidateProgramSchedule(
|
||||
TvContext dbContext,
|
||||
CreateClassicPlayout createClassicPlayout,
|
||||
CancellationToken cancellationToken) =>
|
||||
dbContext.ProgramSchedules
|
||||
.Include(ps => ps.Items)
|
||||
.SelectOneAsync(ps => ps.Id, ps => ps.Id == createClassicPlayout.ProgramScheduleId, cancellationToken);
|
||||
.SelectOneAsync(ps => ps.Id, ps => ps.Id == createClassicPlayout.ProgramScheduleId, cancellationToken)
|
||||
.Map(o => o.ToValidation<BaseError>("Program schedule does not exist"))
|
||||
.BindT(ProgramScheduleMustHaveItems);
|
||||
|
||||
private static Validation<BaseError, ProgramSchedule> ProgramScheduleMustHaveItems(
|
||||
ProgramSchedule programSchedule) =>
|
||||
|
||||
@@ -3,7 +3,6 @@ using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Notifications;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
@@ -46,6 +45,6 @@ public class DeletePlayoutHandler(
|
||||
|
||||
return maybePlayout
|
||||
.Map(_ => Unit.Default)
|
||||
.ToEither<BaseError>(new NotFoundError($"Playout {request.PlayoutId} does not exist."));
|
||||
.ToEither(BaseError.New($"Playout {request.PlayoutId} does not exist."));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -29,16 +28,8 @@ public class AddProgramScheduleItemHandler : ProgramScheduleItemCommandBase,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Option<ProgramSchedule> maybeProgramSchedule =
|
||||
await ProgramScheduleMustExist(dbContext, request.ProgramScheduleId, cancellationToken);
|
||||
return await maybeProgramSchedule.Match(
|
||||
Some: async programSchedule =>
|
||||
{
|
||||
Validation<BaseError, ProgramSchedule> validation = await Validate(dbContext, request, programSchedule);
|
||||
return await validation.Apply(ps => PersistItem(dbContext, request, ps, cancellationToken));
|
||||
},
|
||||
None: () => Task.FromResult<Either<BaseError, ProgramScheduleItemViewModel>>(
|
||||
new NotFoundError("[ProgramScheduleId] does not exist.")));
|
||||
Validation<BaseError, ProgramSchedule> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(ps => PersistItem(dbContext, request, ps, cancellationToken));
|
||||
}
|
||||
|
||||
private async Task<ProgramScheduleItemViewModel> PersistItem(
|
||||
@@ -63,25 +54,10 @@ public class AddProgramScheduleItemHandler : ProgramScheduleItemCommandBase,
|
||||
return ProjectToViewModel(item);
|
||||
}
|
||||
|
||||
private static async Task<Validation<BaseError, ProgramSchedule>> Validate(
|
||||
private static Task<Validation<BaseError, ProgramSchedule>> Validate(
|
||||
TvContext dbContext,
|
||||
AddProgramScheduleItem request,
|
||||
ProgramSchedule programSchedule)
|
||||
{
|
||||
Validation<BaseError, ProgramSchedule> validation =
|
||||
PlayoutModeMustBeValid(request, programSchedule)
|
||||
.Bind(programSchedule => CollectionTypeMustBeValid(request, programSchedule));
|
||||
|
||||
return await validation.ToEither().Match(
|
||||
Left: error => Task.FromResult<Validation<BaseError, ProgramSchedule>>(
|
||||
Fail<BaseError, ProgramSchedule>(error)),
|
||||
Right: async validProgramSchedule =>
|
||||
{
|
||||
Either<BaseError, ProgramSchedule> fillerResult = await FillerConfigurationMustBeValid(
|
||||
dbContext,
|
||||
request,
|
||||
validProgramSchedule);
|
||||
return fillerResult.ToValidation();
|
||||
});
|
||||
}
|
||||
CancellationToken cancellationToken) =>
|
||||
ProgramScheduleMustExist(dbContext, request.ProgramScheduleId, cancellationToken)
|
||||
.BindT(programSchedule => PlayoutModeMustBeValid(request, programSchedule));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -19,14 +18,11 @@ public class DeleteProgramScheduleHandler : IRequestHandler<DeleteProgramSchedul
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Option<ProgramSchedule> maybeProgramSchedule = await ProgramScheduleMustExist(
|
||||
Validation<BaseError, ProgramSchedule> validation = await ProgramScheduleMustExist(
|
||||
dbContext,
|
||||
request,
|
||||
cancellationToken);
|
||||
return await maybeProgramSchedule.Match(
|
||||
Some: programSchedule => DoDeletion(dbContext, programSchedule).Map(Right<BaseError, Unit>),
|
||||
None: () => Task.FromResult<Either<BaseError, Unit>>(
|
||||
new NotFoundError($"ProgramSchedule {request.ProgramScheduleId} does not exist.")));
|
||||
return await validation.Apply(ps => DoDeletion(dbContext, ps));
|
||||
}
|
||||
|
||||
private static Task<Unit> DoDeletion(TvContext dbContext, ProgramSchedule programSchedule)
|
||||
@@ -35,11 +31,11 @@ public class DeleteProgramScheduleHandler : IRequestHandler<DeleteProgramSchedul
|
||||
return dbContext.SaveChangesAsync().ToUnit();
|
||||
}
|
||||
|
||||
private static Task<Option<ProgramSchedule>> ProgramScheduleMustExist(
|
||||
private static Task<Validation<BaseError, ProgramSchedule>> ProgramScheduleMustExist(
|
||||
TvContext dbContext,
|
||||
DeleteProgramSchedule request,
|
||||
CancellationToken cancellationToken) =>
|
||||
dbContext.ProgramSchedules
|
||||
.SelectOneAsync(ps => ps.Id, ps => ps.Id == request.ProgramScheduleId, cancellationToken)
|
||||
.Map(identity);
|
||||
.Map(o => o.ToValidation<BaseError>($"ProgramSchedule {request.ProgramScheduleId} does not exist."));
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.ProgramSchedules;
|
||||
|
||||
public record DeleteProgramScheduleItem(int ProgramScheduleId, int ProgramScheduleItemId)
|
||||
: IRequest<Either<BaseError, Unit>>;
|
||||
@@ -1,46 +0,0 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.ProgramSchedules;
|
||||
|
||||
public class DeleteProgramScheduleItemHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ChannelWriter<IBackgroundServiceRequest> channel) : IRequestHandler<DeleteProgramScheduleItem, Either<BaseError, Unit>>
|
||||
{
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
DeleteProgramScheduleItem request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
ProgramScheduleItem item = await dbContext.ProgramScheduleItems
|
||||
.Include(i => i.ProgramSchedule)
|
||||
.ThenInclude(ps => ps.Playouts)
|
||||
.SingleOrDefaultAsync(
|
||||
i => i.Id == request.ProgramScheduleItemId && i.ProgramScheduleId == request.ProgramScheduleId,
|
||||
cancellationToken);
|
||||
|
||||
if (item is null)
|
||||
{
|
||||
return new NotFoundError(
|
||||
$"ProgramScheduleItem {request.ProgramScheduleItemId} does not exist on schedule {request.ProgramScheduleId}.");
|
||||
}
|
||||
|
||||
List<Playout> playouts = item.ProgramSchedule.Playouts;
|
||||
dbContext.ProgramScheduleItems.Remove(item);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
foreach (Playout playout in playouts)
|
||||
{
|
||||
await channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Refresh), cancellationToken);
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -10,7 +9,7 @@ namespace ErsatzTV.Application.ProgramSchedules;
|
||||
|
||||
public abstract class ProgramScheduleItemCommandBase
|
||||
{
|
||||
protected static Task<Option<ProgramSchedule>> ProgramScheduleMustExist(
|
||||
protected static Task<Validation<BaseError, ProgramSchedule>> ProgramScheduleMustExist(
|
||||
TvContext dbContext,
|
||||
int programScheduleId,
|
||||
CancellationToken cancellationToken) =>
|
||||
@@ -18,7 +17,7 @@ public abstract class ProgramScheduleItemCommandBase
|
||||
.Include(ps => ps.Items)
|
||||
.Include(ps => ps.Playouts)
|
||||
.SelectOneAsync(ps => ps.Id, ps => ps.Id == programScheduleId, cancellationToken)
|
||||
.Map(identity);
|
||||
.Map(o => o.ToValidation<BaseError>("[ProgramScheduleId] does not exist."));
|
||||
|
||||
protected static async Task<Either<BaseError, ProgramSchedule>> FillerConfigurationMustBeValid(
|
||||
TvContext dbContext,
|
||||
@@ -353,11 +352,9 @@ public abstract class ProgramScheduleItemCommandBase
|
||||
_ => throw new NotSupportedException($"Unsupported playout mode {item.PlayoutMode}")
|
||||
};
|
||||
|
||||
result.ProgramScheduleItemWatermarks = [];
|
||||
result.ProgramScheduleItemGraphicsElements = [];
|
||||
|
||||
foreach (int watermarkId in item.WatermarkIds)
|
||||
{
|
||||
result.ProgramScheduleItemWatermarks ??= [];
|
||||
result.ProgramScheduleItemWatermarks.Add(
|
||||
new ProgramScheduleItemWatermark
|
||||
{
|
||||
@@ -368,6 +365,7 @@ public abstract class ProgramScheduleItemCommandBase
|
||||
|
||||
foreach (int graphicsElementId in item.GraphicsElementIds)
|
||||
{
|
||||
result.ProgramScheduleItemGraphicsElements ??= [];
|
||||
result.ProgramScheduleItemGraphicsElements.Add(
|
||||
new ProgramScheduleItemGraphicsElement
|
||||
{
|
||||
|
||||
+9
-23
@@ -2,7 +2,6 @@
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -20,16 +19,8 @@ public class ReplaceProgramScheduleItemsHandler(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Option<ProgramSchedule> maybeProgramSchedule =
|
||||
await ProgramScheduleMustExist(dbContext, request.ProgramScheduleId, cancellationToken);
|
||||
return await maybeProgramSchedule.Match(
|
||||
Some: async programSchedule =>
|
||||
{
|
||||
Validation<BaseError, ProgramSchedule> validation = await Validate(dbContext, request, programSchedule);
|
||||
return await validation.Apply(ps => PersistItems(dbContext, request, ps, cancellationToken));
|
||||
},
|
||||
None: () => Task.FromResult<Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>>>(
|
||||
new NotFoundError("[ProgramScheduleId] does not exist.")));
|
||||
Validation<BaseError, ProgramSchedule> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(ps => PersistItems(dbContext, request, ps, cancellationToken));
|
||||
}
|
||||
|
||||
private async Task<IEnumerable<ProgramScheduleItemViewModel>> PersistItems(
|
||||
@@ -59,20 +50,15 @@ public class ReplaceProgramScheduleItemsHandler(
|
||||
return programSchedule.Items.Map(ProjectToViewModel);
|
||||
}
|
||||
|
||||
private static async Task<Validation<BaseError, ProgramSchedule>> Validate(
|
||||
private static Task<Validation<BaseError, ProgramSchedule>> Validate(
|
||||
TvContext dbContext,
|
||||
ReplaceProgramScheduleItems request,
|
||||
ProgramSchedule programSchedule)
|
||||
{
|
||||
Validation<BaseError, ProgramSchedule> validation = PlayoutModesMustBeValid(request, programSchedule)
|
||||
.Bind(programSchedule => CollectionTypesMustBeValid(request, programSchedule))
|
||||
.Bind(programSchedule => PlaybackOrdersMustBeValid(request, programSchedule));
|
||||
|
||||
return await validation.ToEither().Match(
|
||||
Left: error => Task.FromResult<Validation<BaseError, ProgramSchedule>>(
|
||||
Fail<BaseError, ProgramSchedule>(error)),
|
||||
Right: validProgramSchedule => FillerConfigurationsMustBeValid(dbContext, request, validProgramSchedule));
|
||||
}
|
||||
CancellationToken cancellationToken) =>
|
||||
ProgramScheduleMustExist(dbContext, request.ProgramScheduleId, cancellationToken)
|
||||
.BindT(programSchedule => PlayoutModesMustBeValid(request, programSchedule))
|
||||
.BindT(programSchedule => CollectionTypesMustBeValid(request, programSchedule))
|
||||
.BindT(programSchedule => PlaybackOrdersMustBeValid(request, programSchedule))
|
||||
.BindT(programSchedule => FillerConfigurationsMustBeValid(dbContext, request, programSchedule));
|
||||
|
||||
private static Validation<BaseError, ProgramSchedule> PlayoutModesMustBeValid(
|
||||
ReplaceProgramScheduleItems request,
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
@@ -21,16 +20,8 @@ public class UpdateProgramScheduleHandler(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Option<ProgramSchedule> maybeProgramSchedule =
|
||||
await ProgramScheduleMustExist(dbContext, request, cancellationToken);
|
||||
return await maybeProgramSchedule.Match(
|
||||
Some: async programSchedule =>
|
||||
{
|
||||
Validation<BaseError, ProgramSchedule> validation = await Validate(dbContext, request, programSchedule, cancellationToken);
|
||||
return await validation.Apply(ps => ApplyUpdateRequest(dbContext, ps, request));
|
||||
},
|
||||
None: () => Task.FromResult<Either<BaseError, UpdateProgramScheduleResult>>(
|
||||
new NotFoundError("Schedule does not exist")));
|
||||
Validation<BaseError, ProgramSchedule> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(ps => ApplyUpdateRequest(dbContext, ps, request));
|
||||
}
|
||||
|
||||
private async Task<UpdateProgramScheduleResult> ApplyUpdateRequest(
|
||||
@@ -75,17 +66,17 @@ public class UpdateProgramScheduleHandler(
|
||||
private static async Task<Validation<BaseError, ProgramSchedule>> Validate(
|
||||
TvContext dbContext,
|
||||
UpdateProgramSchedule request,
|
||||
ProgramSchedule programSchedule,
|
||||
CancellationToken cancellationToken) =>
|
||||
(await ValidateName(dbContext, request, cancellationToken)).Map(_ => programSchedule);
|
||||
(await ProgramScheduleMustExist(dbContext, request, cancellationToken), await ValidateName(dbContext, request, cancellationToken))
|
||||
.Apply((programSchedule, _) => programSchedule);
|
||||
|
||||
private static Task<Option<ProgramSchedule>> ProgramScheduleMustExist(
|
||||
private static Task<Validation<BaseError, ProgramSchedule>> ProgramScheduleMustExist(
|
||||
TvContext dbContext,
|
||||
UpdateProgramSchedule request,
|
||||
CancellationToken cancellationToken) =>
|
||||
dbContext.ProgramSchedules
|
||||
.SelectOneAsync(ps => ps.Id, ps => ps.Id == request.ProgramScheduleId, cancellationToken)
|
||||
.Map(identity);
|
||||
.Map(o => o.ToValidation<BaseError>("Schedule does not exist"));
|
||||
|
||||
private static async Task<Validation<BaseError, string>> ValidateName(
|
||||
TvContext dbContext,
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
<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>
|
||||
@@ -1,104 +0,0 @@
|
||||
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,34 +7,28 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<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" />
|
||||
<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" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ErsatzTV.Application\ErsatzTV.Application.csproj" />
|
||||
<ProjectReference Include="..\ErsatzTV.Core\ErsatzTV.Core.csproj" />
|
||||
<ProjectReference Include="..\ErsatzTV.Infrastructure.Sqlite\ErsatzTV.Infrastructure.Sqlite.csproj" />
|
||||
<ProjectReference Include="..\ErsatzTV.Infrastructure\ErsatzTV.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="..\ErsatzTV\Resources\Fonts\Sen.ttf" Link="Resources\Fonts\Sen.ttf" CopyToOutputDirectory="PreserveNewest" />
|
||||
<None Include="..\ErsatzTV\wwwroot\images\ersatztv-500.png" Link="Resources\Images\ersatztv-500.png" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -571,8 +571,7 @@ public class CustomStreamSelectorTests
|
||||
}
|
||||
|
||||
[Test]
|
||||
[SetCulture("en-US")]
|
||||
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Content_Condition_Fail_SundayFirstCulture()
|
||||
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Content_Condition_Fail()
|
||||
{
|
||||
const string YAML =
|
||||
"""
|
||||
@@ -609,8 +608,7 @@ public class CustomStreamSelectorTests
|
||||
}
|
||||
|
||||
[Test]
|
||||
[SetCulture("en-US")]
|
||||
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Content_Condition_Match_SundayFirstCulture()
|
||||
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Content_Condition_Match()
|
||||
{
|
||||
const string YAML =
|
||||
"""
|
||||
@@ -653,8 +651,7 @@ public class CustomStreamSelectorTests
|
||||
}
|
||||
|
||||
[Test]
|
||||
[SetCulture("en-US")]
|
||||
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Fail_Before_SundayFirstCulture()
|
||||
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Fail_Before()
|
||||
{
|
||||
// saturday from 9pm-11pm
|
||||
const string YAML =
|
||||
@@ -692,8 +689,7 @@ public class CustomStreamSelectorTests
|
||||
}
|
||||
|
||||
[Test]
|
||||
[SetCulture("en-US")]
|
||||
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Fail_After_SundayFirstCulture()
|
||||
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Fail_After()
|
||||
{
|
||||
// saturday from 9pm-11pm
|
||||
const string YAML =
|
||||
@@ -731,8 +727,7 @@ public class CustomStreamSelectorTests
|
||||
}
|
||||
|
||||
[Test]
|
||||
[SetCulture("en-US")]
|
||||
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Fail_Wrong_Day_SundayFirstCulture()
|
||||
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Fail_Wrong_Day()
|
||||
{
|
||||
// saturday from 9pm-11pm
|
||||
const string YAML =
|
||||
@@ -770,8 +765,7 @@ public class CustomStreamSelectorTests
|
||||
}
|
||||
|
||||
[Test]
|
||||
[SetCulture("en-US")]
|
||||
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Match_SundayFirstCulture()
|
||||
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Match()
|
||||
{
|
||||
// saturday from 9pm-11pm
|
||||
const string YAML =
|
||||
@@ -816,7 +810,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_MondayFirstCulture()
|
||||
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Match_France()
|
||||
{
|
||||
// saturday from 9pm-11pm
|
||||
const string YAML =
|
||||
@@ -859,45 +853,6 @@ 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()
|
||||
{
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
using ErsatzTV.Core.Images;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using SkiaSharp;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.Images;
|
||||
|
||||
[TestFixture]
|
||||
public class ChannelLogoGeneratorTests
|
||||
{
|
||||
[TestCase("News", 100, 200)]
|
||||
[TestCase("A Very Long Channel Name That Should Force Font Shrinking", 100, 200)]
|
||||
[TestCase("Tall Logo", 180, 120)]
|
||||
public void GenerateChannelLogo_WithInjectedAssets_ReturnsValidPngWithRequestedDimensions(
|
||||
string text,
|
||||
int logoHeight,
|
||||
int logoWidth)
|
||||
{
|
||||
var generator = new ChannelLogoGenerator(
|
||||
NullLogger<ChannelLogoGenerator>.Instance,
|
||||
TestAssetPath("Resources", "Images", "ersatztv-500.png"),
|
||||
TestAssetPath("Resources", "Fonts", "Sen.ttf"));
|
||||
|
||||
byte[] bytes = generator.GenerateChannelLogo(text, logoHeight, logoWidth, CancellationToken.None)
|
||||
.Match(
|
||||
Left: error => throw new AssertionException(error.Value),
|
||||
Right: identity);
|
||||
|
||||
using var stream = new MemoryStream(bytes);
|
||||
using var codec = SKCodec.Create(stream);
|
||||
|
||||
codec.ShouldNotBeNull();
|
||||
codec.EncodedFormat.ShouldBe(SKEncodedImageFormat.Png);
|
||||
codec.Info.Width.ShouldBe(logoWidth);
|
||||
codec.Info.Height.ShouldBe(logoHeight);
|
||||
}
|
||||
|
||||
private static string TestAssetPath(params string[] segments) =>
|
||||
Path.Combine([TestContext.CurrentContext.TestDirectory, .. segments]);
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
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");
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
#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
|
||||
@@ -1,3 +0,0 @@
|
||||
#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
|
||||
@@ -1 +0,0 @@
|
||||
<?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>
|
||||
@@ -1 +0,0 @@
|
||||
<?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>
|
||||
@@ -1 +0,0 @@
|
||||
<?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>
|
||||
@@ -1,3 +0,0 @@
|
||||
#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
|
||||
@@ -1,6 +0,0 @@
|
||||
#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
|
||||
@@ -1,7 +0,0 @@
|
||||
#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
|
||||
@@ -1,3 +0,0 @@
|
||||
#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,13 +57,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
|
||||
startState,
|
||||
CollectionEnumerators(scheduleItem, enumerator),
|
||||
scheduleItem,
|
||||
// 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 PlayoutItem(),
|
||||
new List<MediaChapter>(),
|
||||
new PlayoutBuildWarnings(),
|
||||
_cancellationToken);
|
||||
@@ -116,13 +110,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
|
||||
startState,
|
||||
enumerators,
|
||||
scheduleItem,
|
||||
// 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 PlayoutItem(),
|
||||
new List<MediaChapter> { new() },
|
||||
new PlayoutBuildWarnings(),
|
||||
_cancellationToken);
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
namespace ErsatzTV.Core.Api.Channels;
|
||||
|
||||
#nullable enable
|
||||
|
||||
public record ChannelNowPlayingResponseModel(
|
||||
string Title,
|
||||
DateTimeOffset StartUtc,
|
||||
DateTimeOffset FinishUtc);
|
||||
@@ -5,13 +5,8 @@ namespace ErsatzTV.Core.Api.Channels;
|
||||
public record ChannelResponseModel(
|
||||
int Id,
|
||||
string Number,
|
||||
double SortNumber,
|
||||
string Name,
|
||||
string Group,
|
||||
string Categories,
|
||||
[property: JsonProperty("ffmpegProfile")]
|
||||
string FFmpegProfile,
|
||||
string Language,
|
||||
string StreamingMode,
|
||||
bool IsEnabled,
|
||||
bool ShowInEpg);
|
||||
string StreamingMode);
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
namespace ErsatzTV.Core.Api.Channels;
|
||||
|
||||
#nullable enable
|
||||
|
||||
public record ChannelStateResponseModel(
|
||||
int ChannelId,
|
||||
string ChannelNumber,
|
||||
bool OnAir,
|
||||
ChannelNowPlayingResponseModel? NowPlaying);
|
||||
@@ -1,33 +0,0 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Core.Api.Playouts;
|
||||
|
||||
public record PlayoutResponseModel(
|
||||
int Id,
|
||||
PlayoutScheduleKind ScheduleKind,
|
||||
string ChannelName,
|
||||
string ChannelNumber,
|
||||
ChannelPlayoutMode PlayoutMode,
|
||||
string ScheduleName,
|
||||
string ScheduleFile,
|
||||
TimeSpan? DailyRebuildTime)
|
||||
{
|
||||
public static PlayoutResponseModel From(
|
||||
int id,
|
||||
PlayoutScheduleKind scheduleKind,
|
||||
string channelName,
|
||||
string channelNumber,
|
||||
ChannelPlayoutMode playoutMode,
|
||||
string scheduleName,
|
||||
string scheduleFile,
|
||||
TimeSpan? dailyRebuildTime) =>
|
||||
new(
|
||||
id,
|
||||
scheduleKind,
|
||||
channelName,
|
||||
channelNumber,
|
||||
playoutMode,
|
||||
scheduleName,
|
||||
scheduleFile,
|
||||
dailyRebuildTime);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
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" />
|
||||
<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" />
|
||||
<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" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -10,25 +10,11 @@ public class ChannelLogoGenerator : IChannelLogoGenerator
|
||||
public const string GetRoute = "/iptv/logos/gen";
|
||||
public const string GetRouteQueryParamName = "text";
|
||||
|
||||
private readonly string _fontPath;
|
||||
private readonly ILogger _logger;
|
||||
private readonly string _overlayImagePath;
|
||||
|
||||
public ChannelLogoGenerator(
|
||||
ILogger<ChannelLogoGenerator> logger)
|
||||
: this(logger, DefaultOverlayImagePath(), DefaultFontPath())
|
||||
{
|
||||
}
|
||||
|
||||
public ChannelLogoGenerator(
|
||||
ILogger<ChannelLogoGenerator> logger,
|
||||
string overlayImagePath,
|
||||
string fontPath)
|
||||
{
|
||||
ILogger<ChannelLogoGenerator> logger) =>
|
||||
_logger = logger;
|
||||
_overlayImagePath = overlayImagePath;
|
||||
_fontPath = fontPath;
|
||||
}
|
||||
|
||||
public Either<BaseError, byte[]> GenerateChannelLogo(
|
||||
string text,
|
||||
@@ -43,11 +29,13 @@ public class ChannelLogoGenerator : IChannelLogoGenerator
|
||||
canvas.Clear(SKColors.Black);
|
||||
|
||||
//etv logo
|
||||
using var overlayImage = SKBitmap.Decode(_overlayImagePath);
|
||||
string overlayImagePath = Path.Combine("wwwroot", "images", "ersatztv-500.png");
|
||||
using var overlayImage = SKBitmap.Decode(overlayImagePath);
|
||||
canvas.DrawBitmap(overlayImage, new SKRect(155, 60, 205, 110));
|
||||
|
||||
//Custom Font
|
||||
using var fontTypeface = SKTypeface.FromFile(_fontPath);
|
||||
string fontPath = Path.Combine(FileSystemLayout.ResourcesCacheFolder, "Sen.ttf");
|
||||
using var fontTypeface = SKTypeface.FromFile(fontPath);
|
||||
var fontSize = 30;
|
||||
var font = new SKFont
|
||||
{
|
||||
@@ -92,10 +80,4 @@ public class ChannelLogoGenerator : IChannelLogoGenerator
|
||||
|
||||
public static string GenerateChannelLogoUrl(Channel channel) =>
|
||||
$"http://localhost:{Settings.StreamingPort}{GetRoute}?{GetRouteQueryParamName}={channel.WebEncodedName}";
|
||||
|
||||
private static string DefaultFontPath() =>
|
||||
Path.Combine(FileSystemLayout.ResourcesCacheFolder, "Sen.ttf");
|
||||
|
||||
private static string DefaultOverlayImagePath() =>
|
||||
Path.Combine("wwwroot", "images", "ersatztv-500.png");
|
||||
}
|
||||
|
||||
@@ -13,11 +13,6 @@ public interface IJellyfinApiClient
|
||||
string authorizationHeader,
|
||||
JellyfinLibrary library);
|
||||
|
||||
IAsyncEnumerable<Tuple<MusicVideo, int>> GetMusicVideoLibraryItems(
|
||||
string address,
|
||||
string authorizationHeader,
|
||||
JellyfinLibrary library);
|
||||
|
||||
IAsyncEnumerable<Tuple<JellyfinShow, int>> GetShowLibraryItemsWithoutPeople(
|
||||
string address,
|
||||
string authorizationHeader,
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
|
||||
public interface IJellyfinMusicVideoLibraryScanner
|
||||
{
|
||||
Task<Either<BaseError, Unit>> ScanLibrary(
|
||||
JellyfinConnectionParameters connectionParameters,
|
||||
JellyfinLibrary library,
|
||||
bool deepScan,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -1,28 +1,28 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<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>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ErsatzTV.FFmpeg\ErsatzTV.FFmpeg.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
<IsPackable>false</IsPackable>
|
||||
</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="8.0.0">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ErsatzTV.FFmpeg\ErsatzTV.FFmpeg.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
-38
@@ -1,38 +0,0 @@
|
||||
using ErsatzTV.FFmpeg.Capabilities;
|
||||
using ErsatzTV.FFmpeg.GlobalOption.HardwareAcceleration;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.FFmpeg.Tests.GlobalOption.HardwareAcceleration;
|
||||
|
||||
[TestFixture]
|
||||
public class QsvHardwareAccelerationOptionTests
|
||||
{
|
||||
[Test]
|
||||
public void GlobalOptions_WithDevice_ShouldDeriveQsvDeviceFromVaapiDevice()
|
||||
{
|
||||
var option = new QsvHardwareAccelerationOption("/dev/dri/renderD128", FFmpegCapability.Software);
|
||||
|
||||
option.GlobalOptions.ShouldBe(
|
||||
[
|
||||
"-init_hw_device", "vaapi=va:/dev/dri/renderD128",
|
||||
"-init_hw_device", "qsv=hw@va",
|
||||
"-filter_hw_device", "hw"
|
||||
]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GlobalOptions_WithHardwareDecode_ShouldEnableQsvHwaccel()
|
||||
{
|
||||
var option = new QsvHardwareAccelerationOption(None, FFmpegCapability.Hardware);
|
||||
|
||||
option.GlobalOptions.ShouldBe(
|
||||
[
|
||||
"-hwaccel", "qsv",
|
||||
"-hwaccel_output_format", "qsv",
|
||||
"-init_hw_device", "qsv=hw",
|
||||
"-filter_hw_device", "hw"
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -11,12 +11,12 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CliWrap" />
|
||||
<PackageReference Include="Hardware.Info" />
|
||||
<PackageReference Include="LanguageExt.Core" />
|
||||
<PackageReference Include="Lennox.NvEncSharp" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<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" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ public class QsvHardwareAccelerationOption(Option<string> device, FFmpegCapabili
|
||||
{
|
||||
get
|
||||
{
|
||||
string[] initDevices = ["-init_hw_device", "qsv=hw", "-filter_hw_device", "hw"];
|
||||
|
||||
var result = new List<string>
|
||||
{
|
||||
"-hwaccel", "qsv",
|
||||
@@ -27,26 +29,18 @@ public class QsvHardwareAccelerationOption(Option<string> device, FFmpegCapabili
|
||||
result.Clear();
|
||||
}
|
||||
|
||||
var deviceConfigured = false;
|
||||
foreach (string qsvDevice in device)
|
||||
if (OperatingSystem.IsLinux())
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(qsvDevice))
|
||||
foreach (string qsvDevice in device)
|
||||
{
|
||||
result.AddRange(
|
||||
[
|
||||
"-init_hw_device", $"vaapi=va:{qsvDevice}",
|
||||
"-init_hw_device", "qsv=hw@va"
|
||||
]);
|
||||
deviceConfigured = true;
|
||||
if (!string.IsNullOrWhiteSpace(qsvDevice))
|
||||
{
|
||||
result.AddRange(new[] { "-qsv_device", qsvDevice });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!deviceConfigured)
|
||||
{
|
||||
result.AddRange(["-init_hw_device", "qsv=hw"]);
|
||||
}
|
||||
|
||||
result.AddRange(["-filter_hw_device", "hw"]);
|
||||
result.AddRange(initDevices);
|
||||
|
||||
return result.ToArray();
|
||||
}
|
||||
|
||||
@@ -16,9 +16,9 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="EFCore.BulkExtensions.MySql" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
|
||||
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" />
|
||||
<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" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -12,13 +12,10 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<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" />
|
||||
<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)" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
|
||||
@@ -1,38 +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="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">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Shouldly" />
|
||||
<PackageReference Include="Testably.Abstractions.Testing" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ErsatzTV.Infrastructure\ErsatzTV.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="..\ErsatzTV\Resources\sequential-schedule.schema.json">
|
||||
<Link>Resources/sequential-schedule.schema.json</Link>
|
||||
</EmbeddedResource>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
<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" 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">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" Version="8.0.0">
|
||||
<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" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ErsatzTV.Infrastructure\ErsatzTV.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="..\ErsatzTV\Resources\sequential-schedule.schema.json">
|
||||
<Link>Resources/sequential-schedule.schema.json</Link>
|
||||
</EmbeddedResource>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
using System.Net;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Infrastructure.Jellyfin;
|
||||
using LanguageExt;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Tests.Jellyfin;
|
||||
|
||||
public class JellyfinApiClientTests
|
||||
{
|
||||
[TestFixture]
|
||||
public class GetLibraries
|
||||
{
|
||||
[Test]
|
||||
public async Task Should_Project_MusicVideo_Libraries()
|
||||
{
|
||||
const string response = """
|
||||
[
|
||||
{
|
||||
"Name": "Concerts",
|
||||
"CollectionType": "musicvideos",
|
||||
"ItemId": "library-1",
|
||||
"LibraryOptions": {
|
||||
"PathInfos": []
|
||||
}
|
||||
}
|
||||
]
|
||||
""";
|
||||
|
||||
var client = new JellyfinApiClient(
|
||||
new MemoryCache(new MemoryCacheOptions()),
|
||||
Substitute.For<IJellyfinPathReplacementService>(),
|
||||
Substitute.For<IFallbackMetadataProvider>(),
|
||||
new SingleResponseHttpClientFactory(response),
|
||||
Substitute.For<ILogger<JellyfinApiClient>>());
|
||||
|
||||
Either<BaseError, List<JellyfinLibrary>> result =
|
||||
await client.GetLibraries("http://jellyfin.example", "MediaBrowser Token=abc");
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
List<JellyfinLibrary> libraries = result.RightToSeq().Single();
|
||||
libraries.Count.ShouldBe(1);
|
||||
libraries[0].Name.ShouldBe("Concerts");
|
||||
libraries[0].ItemId.ShouldBe("library-1");
|
||||
libraries[0].MediaKind.ShouldBe(LibraryMediaKind.MusicVideos);
|
||||
libraries[0].ShouldSyncItems.ShouldBeFalse();
|
||||
libraries[0].Paths.Single().Path.ShouldBe("jellyfin://library-1");
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SingleResponseHttpClientFactory(string response) : IHttpClientFactory
|
||||
{
|
||||
public HttpClient CreateClient(string name) => new(new SingleResponseHttpMessageHandler(response));
|
||||
}
|
||||
|
||||
private sealed class SingleResponseHttpMessageHandler(string response) : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(response)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
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" />
|
||||
<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">
|
||||
<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)">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<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" />
|
||||
<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" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -42,26 +42,6 @@ public interface IJellyfinApi
|
||||
[Query]
|
||||
int limit = 0);
|
||||
|
||||
[Get("/Items?sortOrder=Ascending&sortBy=SortName")]
|
||||
Task<JellyfinLibraryItemsResponse> GetMusicVideoLibraryItems(
|
||||
[Header("Authorization")]
|
||||
string authorizationHeader,
|
||||
[Query]
|
||||
string parentId,
|
||||
[Query]
|
||||
string fields =
|
||||
"Path,Genres,Artists,Tags,DateCreated,Etag,Overview,Studios,People,OfficialRating,ProviderIds,Chapters",
|
||||
[Query]
|
||||
string includeItemTypes = "MusicVideo",
|
||||
[Query]
|
||||
bool recursive = true,
|
||||
[Query]
|
||||
string filters = "IsNotFolder",
|
||||
[Query]
|
||||
int startIndex = 0,
|
||||
[Query]
|
||||
int limit = 0);
|
||||
|
||||
[Get("/Items?sortOrder=Ascending&sortBy=SortName")]
|
||||
Task<JellyfinLibraryItemsResponse> GetShowLibraryItemsWithoutPeople(
|
||||
[Header("Authorization")]
|
||||
|
||||
@@ -93,23 +93,6 @@ public class JellyfinApiClient : IJellyfinApiClient
|
||||
limit: pageSize),
|
||||
(maybeLibrary, item) => maybeLibrary.Map(lib => ProjectToMovie(lib, item)).Flatten());
|
||||
|
||||
public IAsyncEnumerable<Tuple<MusicVideo, int>> GetMusicVideoLibraryItems(
|
||||
string address,
|
||||
string authorizationHeader,
|
||||
JellyfinLibrary library) =>
|
||||
GetPagedLibraryItems(
|
||||
"JF Music Videos",
|
||||
address,
|
||||
library,
|
||||
library.MediaSourceId,
|
||||
library.ItemId,
|
||||
(service, itemId, skip, pageSize) => service.GetMusicVideoLibraryItems(
|
||||
authorizationHeader,
|
||||
itemId,
|
||||
startIndex: skip,
|
||||
limit: pageSize),
|
||||
(maybeLibrary, item) => maybeLibrary.Map(lib => ProjectToMusicVideo(lib, item)).Flatten());
|
||||
|
||||
public IAsyncEnumerable<Tuple<JellyfinShow, int>> GetShowLibraryItemsWithoutPeople(
|
||||
string address,
|
||||
string authorizationHeader,
|
||||
@@ -455,15 +438,6 @@ public class JellyfinApiClient : IJellyfinApiClient
|
||||
Paths = new List<LibraryPath> { new() { Path = $"jellyfin://{response.ItemId}" } },
|
||||
PathInfos = GetPathInfos(response)
|
||||
},
|
||||
"musicvideos" => new JellyfinLibrary
|
||||
{
|
||||
ItemId = response.ItemId,
|
||||
Name = response.Name,
|
||||
MediaKind = LibraryMediaKind.MusicVideos,
|
||||
ShouldSyncItems = false,
|
||||
Paths = new List<LibraryPath> { new() { Path = $"jellyfin://{response.ItemId}" } },
|
||||
PathInfos = GetPathInfos(response)
|
||||
},
|
||||
// TODO: ??? for music libraries
|
||||
"boxsets" => CacheCollectionLibraryId(response.ItemId),
|
||||
_ => None
|
||||
@@ -510,7 +484,7 @@ public class JellyfinApiClient : IJellyfinApiClient
|
||||
}
|
||||
|
||||
string path = item.Path ?? string.Empty;
|
||||
foreach (JellyfinPathInfo pathInfo in Optional(library.PathInfos).Flatten().Filter(pi =>
|
||||
foreach (JellyfinPathInfo pathInfo in library.PathInfos.Filter(pi =>
|
||||
!string.IsNullOrWhiteSpace(pi.NetworkPath)))
|
||||
{
|
||||
if (path.StartsWith(pathInfo.NetworkPath, StringComparison.Ordinal))
|
||||
@@ -646,139 +620,6 @@ public class JellyfinApiClient : IJellyfinApiClient
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private Option<MusicVideo> ProjectToMusicVideo(JellyfinLibrary library, JellyfinLibraryItemResponse item)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (item.LocationType != "FileSystem")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
if (Path.GetExtension(item.Path)?.ToLowerInvariant() == ".strm")
|
||||
{
|
||||
_logger.LogInformation("STRM files are not supported; skipping {Path}", item.Path);
|
||||
return None;
|
||||
}
|
||||
|
||||
string path = item.Path ?? string.Empty;
|
||||
foreach (JellyfinPathInfo pathInfo in Optional(library.PathInfos).Flatten().Filter(pi =>
|
||||
!string.IsNullOrWhiteSpace(pi.NetworkPath)))
|
||||
{
|
||||
if (path.StartsWith(pathInfo.NetworkPath, StringComparison.Ordinal))
|
||||
{
|
||||
path = _jellyfinPathReplacementService.ReplaceNetworkPath(
|
||||
(JellyfinMediaSource)library.MediaSource,
|
||||
path,
|
||||
pathInfo.NetworkPath,
|
||||
pathInfo.Path);
|
||||
}
|
||||
}
|
||||
|
||||
var duration = TimeSpan.FromTicks(item.RunTimeTicks);
|
||||
var version = new MediaVersion
|
||||
{
|
||||
Name = "Main",
|
||||
Duration = duration,
|
||||
DateAdded = item.DateCreated.UtcDateTime,
|
||||
MediaFiles =
|
||||
[
|
||||
new MediaFile
|
||||
{
|
||||
Path = path,
|
||||
PathHash = PathUtils.GetPathHash(path)
|
||||
}
|
||||
],
|
||||
Streams = [],
|
||||
Chapters = ProjectToModel(Optional(item.Chapters).Flatten(), duration)
|
||||
};
|
||||
|
||||
MusicVideoMetadata metadata = ProjectToMusicVideoMetadata(item);
|
||||
|
||||
var musicVideo = new MusicVideo
|
||||
{
|
||||
MediaVersions = [version],
|
||||
MusicVideoMetadata = [metadata],
|
||||
TraktListItems = []
|
||||
};
|
||||
|
||||
return musicVideo;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error projecting Jellyfin music video");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
private static MusicVideoMetadata ProjectToMusicVideoMetadata(JellyfinLibraryItemResponse item)
|
||||
{
|
||||
DateTime dateAdded = item.DateCreated.UtcDateTime;
|
||||
var metadata = new MusicVideoMetadata
|
||||
{
|
||||
MetadataKind = MetadataKind.External,
|
||||
Title = item.Name,
|
||||
SortTitle = SortTitle.GetSortTitle(item.Name),
|
||||
Plot = item.Overview,
|
||||
Year = item.ProductionYear,
|
||||
DateAdded = dateAdded,
|
||||
Genres = Optional(item.Genres).Flatten().Map(g => new Genre { Name = g }).ToList(),
|
||||
Tags = Optional(item.Tags).Flatten().Map(t => new Tag { Name = t }).ToList(),
|
||||
Studios = Optional(item.Studios).Flatten().Map(s => new Studio { Name = s.Name }).ToList(),
|
||||
Artists = MusicVideoArtists(item)
|
||||
.ToList(),
|
||||
Directors = Optional(item.People).Flatten().Collect(r => ProjectToDirector(r)).ToList(),
|
||||
Artwork = new List<Artwork>(),
|
||||
Guids = GuidsFromProviderIds(item.ProviderIds),
|
||||
Subtitles = new List<Subtitle>()
|
||||
};
|
||||
|
||||
if (DateTime.TryParse(item.PremiereDate, out DateTime releaseDate))
|
||||
{
|
||||
metadata.ReleaseDate = releaseDate;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(item.ImageTags?.Primary))
|
||||
{
|
||||
var poster = new Artwork
|
||||
{
|
||||
ArtworkKind = ArtworkKind.Poster,
|
||||
Path = $"jellyfin://Items/{item.Id}/Images/Primary?tag={item.ImageTags.Primary}",
|
||||
DateAdded = dateAdded
|
||||
};
|
||||
metadata.Artwork.Add(poster);
|
||||
}
|
||||
|
||||
if (item.BackdropImageTags?.Count > 0)
|
||||
{
|
||||
var fanArt = new Artwork
|
||||
{
|
||||
ArtworkKind = ArtworkKind.FanArt,
|
||||
Path = $"jellyfin://Items/{item.Id}/Images/Backdrop?tag={item.BackdropImageTags.Head()}",
|
||||
DateAdded = dateAdded
|
||||
};
|
||||
metadata.Artwork.Add(fanArt);
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private static IEnumerable<MusicVideoArtist> MusicVideoArtists(JellyfinLibraryItemResponse item)
|
||||
{
|
||||
List<string> artists = Optional(item.Artists).Flatten().ToList();
|
||||
if (artists.Count == 0)
|
||||
{
|
||||
artists = Optional(item.People).Flatten()
|
||||
.Filter(p => p.Type is "Artist")
|
||||
.Map(p => p.Name)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
return artists.Filter(name => !string.IsNullOrWhiteSpace(name))
|
||||
.Distinct()
|
||||
.Map(name => new MusicVideoArtist { Name = name });
|
||||
}
|
||||
|
||||
private static Option<Actor> ProjectToActor(JellyfinPersonResponse person, DateTime dateAdded)
|
||||
{
|
||||
if (person.Type?.ToLowerInvariant() != "actor")
|
||||
|
||||
@@ -10,7 +10,6 @@ public class JellyfinLibraryItemResponse
|
||||
public DateTimeOffset DateCreated { get; set; }
|
||||
public long RunTimeTicks { get; set; }
|
||||
public List<string> Genres { get; set; }
|
||||
public List<string> Artists { get; set; }
|
||||
public List<string> Tags { get; set; }
|
||||
public int ProductionYear { get; set; }
|
||||
public JellyfinProviderIdsResponse ProviderIds { get; set; }
|
||||
|
||||
@@ -6,37 +6,37 @@ namespace ErsatzTV.Infrastructure.Streaming.Graphics;
|
||||
|
||||
public static class OpacityExpressionHelper
|
||||
{
|
||||
public static void EvaluateFunction(string name, FunctionEventArgs args)
|
||||
public static void EvaluateFunction(string name, FunctionArgs args)
|
||||
{
|
||||
switch (name)
|
||||
{
|
||||
case "LinearFadePoints":
|
||||
{
|
||||
if (args.Parameters.Count != 5)
|
||||
if (args.Parameters.Length != 5)
|
||||
{
|
||||
throw new ArgumentException("LinearFadePoints() requires 5 arguments.");
|
||||
}
|
||||
|
||||
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);
|
||||
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);
|
||||
|
||||
args.Result = LinearFadePoints(time, start, peakStart, peakEnd, end);
|
||||
break;
|
||||
}
|
||||
case "LinearFadeDuration":
|
||||
{
|
||||
if (args.Parameters.Count != 4)
|
||||
if (args.Parameters.Length != 4)
|
||||
{
|
||||
throw new ArgumentException("LinearFadeDuration() requires 4 arguments.");
|
||||
}
|
||||
|
||||
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);
|
||||
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);
|
||||
|
||||
args.Result = LinearFadeDuration(time, start, fadeSeconds, peakSeconds);
|
||||
break;
|
||||
|
||||
-99
@@ -1,99 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
using ErsatzTV.Scanner.Application.Jellyfin;
|
||||
using ErsatzTV.Scanner.Core.Interfaces;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Scanner.Tests.Application.Jellyfin;
|
||||
|
||||
public class SynchronizeJellyfinLibraryByIdHandlerTests
|
||||
{
|
||||
[TestFixture]
|
||||
public class Handle
|
||||
{
|
||||
[Test]
|
||||
public async Task Should_Scan_MusicVideo_Libraries()
|
||||
{
|
||||
var scannerProxy = Substitute.For<IScannerProxy>();
|
||||
var mediaSourceRepository = Substitute.For<IMediaSourceRepository>();
|
||||
var jellyfinSecretStore = Substitute.For<IJellyfinSecretStore>();
|
||||
var jellyfinMovieLibraryScanner = Substitute.For<IJellyfinMovieLibraryScanner>();
|
||||
var jellyfinTelevisionLibraryScanner = Substitute.For<IJellyfinTelevisionLibraryScanner>();
|
||||
var jellyfinMusicVideoLibraryScanner = Substitute.For<IJellyfinMusicVideoLibraryScanner>();
|
||||
var libraryRepository = Substitute.For<ILibraryRepository>();
|
||||
var configElementRepository = Substitute.For<IConfigElementRepository>();
|
||||
|
||||
var library = new JellyfinLibrary
|
||||
{
|
||||
Id = 42,
|
||||
Name = "Concerts",
|
||||
MediaKind = LibraryMediaKind.MusicVideos,
|
||||
MediaSourceId = 7
|
||||
};
|
||||
var mediaSource = new JellyfinMediaSource
|
||||
{
|
||||
Id = 7,
|
||||
Connections =
|
||||
[
|
||||
new JellyfinConnection
|
||||
{
|
||||
Address = "http://jellyfin.example",
|
||||
JellyfinMediaSourceId = 7
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
mediaSourceRepository.GetJellyfinByLibraryId(library.Id).Returns(Some(mediaSource).AsTask());
|
||||
mediaSourceRepository.GetJellyfinLibrary(library.Id).Returns(Some(library).AsTask());
|
||||
jellyfinSecretStore.ReadSecrets().Returns(new JellyfinSecrets
|
||||
{
|
||||
Address = "http://jellyfin.example",
|
||||
ApiKey = "abc"
|
||||
});
|
||||
configElementRepository.GetValue<int>(
|
||||
Arg.Is<ConfigElementKey>(key => key.Key == ConfigElementKey.LibraryRefreshInterval.Key),
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<Option<int>>(Some(0)));
|
||||
jellyfinMusicVideoLibraryScanner.ScanLibrary(
|
||||
Arg.Any<JellyfinConnectionParameters>(),
|
||||
library,
|
||||
true,
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default).AsTask());
|
||||
|
||||
var handler = new SynchronizeJellyfinLibraryByIdHandler(
|
||||
scannerProxy,
|
||||
mediaSourceRepository,
|
||||
jellyfinSecretStore,
|
||||
jellyfinMovieLibraryScanner,
|
||||
jellyfinTelevisionLibraryScanner,
|
||||
jellyfinMusicVideoLibraryScanner,
|
||||
libraryRepository,
|
||||
configElementRepository,
|
||||
Substitute.For<ILogger<SynchronizeJellyfinLibraryByIdHandler>>());
|
||||
|
||||
Either<BaseError, string> result = await handler.Handle(
|
||||
new SynchronizeJellyfinLibraryById("http://ersatztv.example", library.Id, true, true),
|
||||
CancellationToken.None);
|
||||
|
||||
result.LeftToSeq().ShouldBeEmpty();
|
||||
result.IsRight.ShouldBeTrue();
|
||||
result.RightToSeq().Single().ShouldBe("Concerts");
|
||||
await jellyfinMusicVideoLibraryScanner.Received(1).ScanLibrary(
|
||||
Arg.Is<JellyfinConnectionParameters>(cp =>
|
||||
cp.Address == "http://jellyfin.example" &&
|
||||
cp.ApiKey == "abc" &&
|
||||
cp.MediaSourceId == 7),
|
||||
library,
|
||||
true,
|
||||
Arg.Any<CancellationToken>());
|
||||
await libraryRepository.Received(1).UpdateLastScan(library);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,63 +1,63 @@
|
||||
<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="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">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Shouldly" />
|
||||
<PackageReference Include="Testably.Abstractions.Testing" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ErsatzTV.Scanner\ErsatzTV.Scanner.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="Resources\Nfo\ArtistInvalidCharacters1.nfo">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="Resources\Nfo\ArtistInvalidCharacters2.nfo">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="Resources\Nfo\EpisodeInvalidCharacters.nfo">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="Resources\ErsatzTV.png">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="Resources\test.srt">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="Resources\test.sup">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="Resources\song.mp3">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="Resources\song_album_cover_512.png">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
<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="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">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" Version="8.0.0">
|
||||
<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" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ErsatzTV.Scanner\ErsatzTV.Scanner.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="Resources\Nfo\ArtistInvalidCharacters1.nfo">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="Resources\Nfo\ArtistInvalidCharacters2.nfo">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="Resources\Nfo\EpisodeInvalidCharacters.nfo">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="Resources\ErsatzTV.png">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="Resources\test.srt">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="Resources\test.sup">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="Resources\song.mp3">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="Resources\song_album_cover_512.png">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
-9
@@ -14,7 +14,6 @@ public class
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
|
||||
private readonly IJellyfinMovieLibraryScanner _jellyfinMovieLibraryScanner;
|
||||
private readonly IJellyfinMusicVideoLibraryScanner _jellyfinMusicVideoLibraryScanner;
|
||||
|
||||
private readonly IJellyfinSecretStore _jellyfinSecretStore;
|
||||
private readonly IJellyfinTelevisionLibraryScanner _jellyfinTelevisionLibraryScanner;
|
||||
@@ -29,7 +28,6 @@ public class
|
||||
IJellyfinSecretStore jellyfinSecretStore,
|
||||
IJellyfinMovieLibraryScanner jellyfinMovieLibraryScanner,
|
||||
IJellyfinTelevisionLibraryScanner jellyfinTelevisionLibraryScanner,
|
||||
IJellyfinMusicVideoLibraryScanner jellyfinMusicVideoLibraryScanner,
|
||||
ILibraryRepository libraryRepository,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ILogger<SynchronizeJellyfinLibraryByIdHandler> logger)
|
||||
@@ -39,7 +37,6 @@ public class
|
||||
_jellyfinSecretStore = jellyfinSecretStore;
|
||||
_jellyfinMovieLibraryScanner = jellyfinMovieLibraryScanner;
|
||||
_jellyfinTelevisionLibraryScanner = jellyfinTelevisionLibraryScanner;
|
||||
_jellyfinMusicVideoLibraryScanner = jellyfinMusicVideoLibraryScanner;
|
||||
_libraryRepository = libraryRepository;
|
||||
_configElementRepository = configElementRepository;
|
||||
_logger = logger;
|
||||
@@ -78,12 +75,6 @@ public class
|
||||
parameters.Library,
|
||||
parameters.DeepScan,
|
||||
cancellationToken),
|
||||
LibraryMediaKind.MusicVideos =>
|
||||
await _jellyfinMusicVideoLibraryScanner.ScanLibrary(
|
||||
parameters.ConnectionParameters,
|
||||
parameters.Library,
|
||||
parameters.DeepScan,
|
||||
cancellationToken),
|
||||
_ => Unit.Default
|
||||
};
|
||||
|
||||
|
||||
@@ -1,216 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using ErsatzTV.Scanner.Core.Interfaces;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Scanner.Core.Jellyfin;
|
||||
|
||||
public class JellyfinMusicVideoLibraryScanner : IJellyfinMusicVideoLibraryScanner
|
||||
{
|
||||
private const string UnknownArtist = "Unknown Artist";
|
||||
|
||||
private readonly IArtistRepository _artistRepository;
|
||||
private readonly IJellyfinApiClient _jellyfinApiClient;
|
||||
private readonly IJellyfinPathReplacementService _pathReplacementService;
|
||||
private readonly ILibraryRepository _libraryRepository;
|
||||
private readonly ILogger<JellyfinMusicVideoLibraryScanner> _logger;
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly IMetadataRepository _metadataRepository;
|
||||
private readonly IMusicVideoRepository _musicVideoRepository;
|
||||
private readonly IScannerProxy _scannerProxy;
|
||||
|
||||
public JellyfinMusicVideoLibraryScanner(
|
||||
IScannerProxy scannerProxy,
|
||||
IJellyfinApiClient jellyfinApiClient,
|
||||
IJellyfinPathReplacementService pathReplacementService,
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
IArtistRepository artistRepository,
|
||||
IMusicVideoRepository musicVideoRepository,
|
||||
ILibraryRepository libraryRepository,
|
||||
IMetadataRepository metadataRepository,
|
||||
ILogger<JellyfinMusicVideoLibraryScanner> logger)
|
||||
{
|
||||
_scannerProxy = scannerProxy;
|
||||
_jellyfinApiClient = jellyfinApiClient;
|
||||
_pathReplacementService = pathReplacementService;
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
_artistRepository = artistRepository;
|
||||
_musicVideoRepository = musicVideoRepository;
|
||||
_libraryRepository = libraryRepository;
|
||||
_metadataRepository = metadataRepository;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> ScanLibrary(
|
||||
JellyfinConnectionParameters connectionParameters,
|
||||
JellyfinLibrary library,
|
||||
bool deepScan,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
Option<LibraryPath> maybeLibraryPath = Optional(library.Paths).Flatten().HeadOrNone();
|
||||
return await maybeLibraryPath.Match(
|
||||
libraryPath => ScanLibrary(connectionParameters, library, libraryPath, cancellationToken),
|
||||
() => Task.FromResult<Either<BaseError, Unit>>(
|
||||
BaseError.New($"Jellyfin library {library.Id} has no library path")));
|
||||
}
|
||||
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
|
||||
{
|
||||
return new ScanCanceled();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, Unit>> ScanLibrary(
|
||||
JellyfinConnectionParameters connectionParameters,
|
||||
JellyfinLibrary library,
|
||||
LibraryPath libraryPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<JellyfinPathReplacement> pathReplacements =
|
||||
await _mediaSourceRepository.GetJellyfinPathReplacements(library.MediaSourceId);
|
||||
|
||||
var processed = 0;
|
||||
await foreach ((MusicVideo incoming, int totalCount) in _jellyfinApiClient
|
||||
.GetMusicVideoLibraryItems(
|
||||
connectionParameters.Address,
|
||||
connectionParameters.AuthorizationHeader,
|
||||
library)
|
||||
.WithCancellation(cancellationToken))
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return new ScanCanceled();
|
||||
}
|
||||
|
||||
processed++;
|
||||
decimal percentCompletion = totalCount == 0 ? 1 : Math.Clamp((decimal)processed / totalCount, 0, 1);
|
||||
if (!await _scannerProxy.UpdateProgress(percentCompletion, cancellationToken))
|
||||
{
|
||||
return new ScanCanceled();
|
||||
}
|
||||
|
||||
Either<BaseError, MediaItemScanResult<MusicVideo>> maybeMusicVideo =
|
||||
await ProcessMusicVideo(library, libraryPath, pathReplacements, incoming, cancellationToken);
|
||||
|
||||
foreach (BaseError error in maybeMusicVideo.LeftToSeq())
|
||||
{
|
||||
_logger.LogWarning("Error processing Jellyfin music video: {Error}", error.Value);
|
||||
}
|
||||
|
||||
foreach (MediaItemScanResult<MusicVideo> result in maybeMusicVideo.RightToSeq()
|
||||
.Filter(result => result.IsAdded || result.IsUpdated))
|
||||
{
|
||||
if (!await _scannerProxy.ReindexMediaItems([result.Item.Id], cancellationToken))
|
||||
{
|
||||
_logger.LogWarning("Failed to reindex media items from scanner process");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, MediaItemScanResult<MusicVideo>>> ProcessMusicVideo(
|
||||
JellyfinLibrary library,
|
||||
LibraryPath libraryPath,
|
||||
List<JellyfinPathReplacement> pathReplacements,
|
||||
MusicVideo incoming,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string localPath = GetLocalPath(pathReplacements, incoming);
|
||||
string folder = Path.GetDirectoryName(localPath) ?? libraryPath.Path;
|
||||
Option<int> maybeParentFolder = await _libraryRepository.GetParentFolderId(libraryPath, folder, cancellationToken);
|
||||
LibraryFolder libraryFolder = await _libraryRepository.GetOrAddFolder(libraryPath, maybeParentFolder, folder);
|
||||
|
||||
return await GetOrAddArtist(libraryPath, incoming)
|
||||
.BindT(artist => _musicVideoRepository.GetOrAdd(artist, libraryPath, libraryFolder, localPath))
|
||||
.BindT(result => UpdateMusicVideo(result, incoming, localPath, cancellationToken));
|
||||
}
|
||||
|
||||
private string GetLocalPath(List<JellyfinPathReplacement> pathReplacements, MusicVideo musicVideo) =>
|
||||
_pathReplacementService.GetReplacementJellyfinPath(
|
||||
pathReplacements,
|
||||
musicVideo.GetHeadVersion().MediaFiles.Head().Path,
|
||||
false);
|
||||
|
||||
private async Task<Either<BaseError, Artist>> GetOrAddArtist(LibraryPath libraryPath, MusicVideo musicVideo)
|
||||
{
|
||||
string artistName = musicVideo.MusicVideoMetadata
|
||||
.HeadOrNone()
|
||||
.Bind(metadata => Optional(metadata.Artists).Flatten().HeadOrNone())
|
||||
.Map(artist => artist.Name)
|
||||
.IfNone(UnknownArtist);
|
||||
|
||||
var metadata = new ArtistMetadata
|
||||
{
|
||||
MetadataKind = MetadataKind.External,
|
||||
Title = artistName,
|
||||
SortTitle = SortTitle.GetSortTitle(artistName),
|
||||
DateAdded = DateTime.UtcNow,
|
||||
Genres = [],
|
||||
Styles = [],
|
||||
Moods = [],
|
||||
Artwork = [],
|
||||
Guids = [],
|
||||
Subtitles = []
|
||||
};
|
||||
|
||||
Option<Artist> maybeArtist = await _artistRepository.GetArtistByMetadata(libraryPath.Id, metadata);
|
||||
foreach (Artist artist in maybeArtist)
|
||||
{
|
||||
return artist;
|
||||
}
|
||||
|
||||
Either<BaseError, MediaItemScanResult<Artist>> result =
|
||||
await _artistRepository.AddArtist(libraryPath.Id, artistName, metadata);
|
||||
return result.Map(scanResult => scanResult.Item);
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, MediaItemScanResult<MusicVideo>>> UpdateMusicVideo(
|
||||
MediaItemScanResult<MusicVideo> result,
|
||||
MusicVideo incoming,
|
||||
string localPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
result.LocalPath = localPath;
|
||||
MusicVideoMetadata incomingMetadata = incoming.MusicVideoMetadata.Head();
|
||||
|
||||
bool updated = await UpdateMetadata(result.Item, incomingMetadata);
|
||||
updated = await _metadataRepository.UpdateStatistics(result.Item, incoming.GetHeadVersion()) || updated;
|
||||
|
||||
if (updated)
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<bool> UpdateMetadata(MusicVideo musicVideo, MusicVideoMetadata incoming)
|
||||
{
|
||||
Option<MusicVideoMetadata> maybeExisting = Optional(musicVideo.MusicVideoMetadata).Flatten().HeadOrNone();
|
||||
foreach (MusicVideoMetadata existing in maybeExisting)
|
||||
{
|
||||
existing.Title = incoming.Title;
|
||||
existing.SortTitle = incoming.SortTitle;
|
||||
existing.Plot = incoming.Plot;
|
||||
existing.Year = incoming.Year;
|
||||
existing.ReleaseDate = incoming.ReleaseDate;
|
||||
existing.DateUpdated = DateTime.UtcNow;
|
||||
existing.MetadataKind = MetadataKind.External;
|
||||
|
||||
return await _metadataRepository.Update(existing);
|
||||
}
|
||||
|
||||
incoming.MusicVideoId = musicVideo.Id;
|
||||
musicVideo.MusicVideoMetadata = [incoming];
|
||||
return await _metadataRepository.Add(incoming);
|
||||
}
|
||||
}
|
||||
@@ -21,21 +21,21 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CliWrap" />
|
||||
<!-- <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" />
|
||||
<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" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -236,7 +236,6 @@ public class Program
|
||||
|
||||
services.AddScoped<IJellyfinMovieLibraryScanner, JellyfinMovieLibraryScanner>();
|
||||
services.AddScoped<IJellyfinTelevisionLibraryScanner, JellyfinTelevisionLibraryScanner>();
|
||||
services.AddScoped<IJellyfinMusicVideoLibraryScanner, JellyfinMusicVideoLibraryScanner>();
|
||||
services.AddScoped<IJellyfinCollectionScanner, JellyfinCollectionScanner>();
|
||||
services.AddScoped<IJellyfinApiClient, JellyfinApiClient>();
|
||||
services.AddScoped<IJellyfinCollectionRepository, JellyfinCollectionRepository>();
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using LanguageExt;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using Testably.Abstractions.Testing;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Channels;
|
||||
|
||||
[TestFixture]
|
||||
public class BulkDeleteChannelsHandlerTests : ChannelHandlerTestBase
|
||||
{
|
||||
private readonly MockFileSystem _fileSystem = new();
|
||||
|
||||
private BulkDeleteChannelsHandler MakeHandler() => new(Db.Factory, Worker, _fileSystem, SearchTargets);
|
||||
|
||||
[Test]
|
||||
public async Task Should_Delete_All_Channels()
|
||||
{
|
||||
await SeedChannel(1, "5");
|
||||
await SeedChannel(2, "6");
|
||||
|
||||
Either<BaseError, Unit> result =
|
||||
await MakeHandler().Handle(new BulkDeleteChannels([1, 2]), CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
|
||||
await using TvContext context = Db.CreateContext();
|
||||
int count = await context.Channels.CountAsync();
|
||||
count.ShouldBe(0);
|
||||
SearchTargets.Received(1).SearchTargetsChanged();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Return_NotFound_And_Not_Delete_When_Any_Channel_Is_Missing()
|
||||
{
|
||||
await SeedChannel(1, "5");
|
||||
|
||||
Either<BaseError, Unit> result =
|
||||
await MakeHandler().Handle(new BulkDeleteChannels([1, 99]), CancellationToken.None);
|
||||
|
||||
BaseError error = LeftOf(result);
|
||||
error.ShouldBeOfType<NotFoundError>();
|
||||
|
||||
await using TvContext context = Db.CreateContext();
|
||||
bool exists = await context.Channels.AnyAsync(c => c.Id == 1);
|
||||
exists.ShouldBeTrue();
|
||||
}
|
||||
|
||||
private static BaseError LeftOf<TR>(Either<BaseError, TR> either) =>
|
||||
either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result"));
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using LanguageExt;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Channels;
|
||||
|
||||
[TestFixture]
|
||||
public class BulkMoveChannelsToGroupHandlerTests : ChannelHandlerTestBase
|
||||
{
|
||||
private BulkMoveChannelsToGroupHandler MakeHandler() => new(Db.Factory, Worker, SearchTargets);
|
||||
|
||||
[Test]
|
||||
public async Task Should_Move_All_Channels_To_Group()
|
||||
{
|
||||
await SeedChannel(1, "5");
|
||||
await SeedChannel(2, "6");
|
||||
|
||||
Either<BaseError, Unit> result =
|
||||
await MakeHandler().Handle(new BulkMoveChannelsToGroup([1, 2], "Movies"), CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
|
||||
await using TvContext context = Db.CreateContext();
|
||||
List<string> groups = await context.Channels
|
||||
.OrderBy(c => c.Id)
|
||||
.Select(c => c.Group)
|
||||
.ToListAsync();
|
||||
groups.ShouldBe(["Movies", "Movies"]);
|
||||
SearchTargets.Received(1).SearchTargetsChanged();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Return_NotFound_And_Not_Move_When_Any_Channel_Is_Missing()
|
||||
{
|
||||
await SeedChannel(1, "5", group: "Original");
|
||||
|
||||
Either<BaseError, Unit> result =
|
||||
await MakeHandler().Handle(new BulkMoveChannelsToGroup([1, 99], "Movies"), CancellationToken.None);
|
||||
|
||||
BaseError error = LeftOf(result);
|
||||
error.ShouldBeOfType<NotFoundError>();
|
||||
|
||||
await using TvContext context = Db.CreateContext();
|
||||
string group = await context.Channels.Where(c => c.Id == 1).Select(c => c.Group).SingleAsync();
|
||||
group.ShouldBe("Original");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Reject_Empty_Group()
|
||||
{
|
||||
await SeedChannel(1, "5", group: "Original");
|
||||
|
||||
Either<BaseError, Unit> result =
|
||||
await MakeHandler().Handle(new BulkMoveChannelsToGroup([1], ""), CancellationToken.None);
|
||||
|
||||
BaseError error = LeftOf(result);
|
||||
error.ShouldNotBeOfType<NotFoundError>();
|
||||
error.Value.ShouldContain("group");
|
||||
|
||||
await using TvContext context = Db.CreateContext();
|
||||
string group = await context.Channels.Where(c => c.Id == 1).Select(c => c.Group).SingleAsync();
|
||||
group.ShouldBe("Original");
|
||||
}
|
||||
|
||||
private static BaseError LeftOf<TR>(Either<BaseError, TR> either) =>
|
||||
either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result"));
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
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"));
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
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"));
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user