Compare commits

..
Author SHA1 Message Date
Jason Dove 97725ac92d add small hdr test file 2025-06-14 10:04:03 -05:00
2881 changed files with 34583 additions and 1458803 deletions
-167
View File
@@ -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
-105
View File
@@ -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`
+3 -4
View File
@@ -3,11 +3,10 @@
"isRoot": true,
"tools": {
"jetbrains.resharper.globaltools": {
"version": "2025.3.0.2",
"version": "2024.1.1",
"commands": [
"jb"
],
"rollForward": false
]
}
}
}
}
+5 -37
View File
@@ -1,8 +1,9 @@
[*]
charset=utf-8
end_of_line=lf
trim_trailing_whitespace=true
insert_final_newline=true
insert_final_newline=false
indent_style=space
indent_size=4
@@ -14,7 +15,7 @@ csharp_style_expression_bodied_constructors=true:none
csharp_style_expression_bodied_methods=true:none
csharp_style_expression_bodied_properties=true:suggestion
csharp_style_var_elsewhere=false:suggestion
csharp_style_var_for_built_in_types=false:none
csharp_style_var_for_built_in_types=false:suggestion
csharp_style_var_when_type_is_apparent=true:suggestion
dotnet_naming_rule.local_constants_rule.severity=warning
dotnet_naming_rule.local_constants_rule.style=all_upper_style
@@ -41,8 +42,6 @@ resharper_braces_for_for=required
resharper_braces_for_foreach=required
resharper_braces_for_ifelse=required
resharper_braces_for_while=required
resharper_csharp_arguments_literal=positional
resharper_csharp_arguments_named=positional
resharper_csharp_insert_final_newline=true
resharper_csharp_max_attribute_length_for_same_line=0
resharper_csharp_place_accessorholder_attribute_on_same_line=never
@@ -67,7 +66,7 @@ resharper_built_in_type_reference_style_highlighting=hint
resharper_redundant_base_qualifier_highlighting=warning
resharper_suggest_var_or_type_built_in_types_highlighting=hint
resharper_suggest_var_or_type_elsewhere_highlighting=hint
resharper_suggest_var_or_type_simple_types_highlighting=none
resharper_suggest_var_or_type_simple_types_highlighting=hint
resharper_web_config_module_not_resolved_highlighting=warning
resharper_web_config_type_not_resolved_highlighting=warning
resharper_web_config_wrong_module_highlighting=warning
@@ -85,38 +84,7 @@ tab_width=4
indent_style = space
indent_size = 2
[*.json]
ij_json_array_wrapping = normal
ij_json_keep_blank_lines_in_code = 0
ij_json_keep_indents_on_empty_lines = false
ij_json_keep_line_breaks = true
ij_json_keep_trailing_comma = false
ij_json_object_wrapping = normal
ij_json_property_alignment = do_not_align
ij_json_space_after_colon = true
ij_json_space_after_comma = true
ij_json_space_before_colon = false
ij_json_space_before_comma = false
ij_json_spaces_within_braces = true
ij_json_spaces_within_brackets = true
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
dotnet_diagnostic.ca1848.severity = none
-58
View File
@@ -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."
-343
View File
@@ -1,343 +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*'
# Concurrency is scoped per ref (originally one global group for the single
# jazz runner; with 3 runners that serialized the whole queue). PR runs
# parallelize across PRs and a new sync auto-cancels its superseded run.
# Real image builds (main / v* tags) still serialize within their own ref;
# don't push main and a v* tag simultaneously — they share :buildcache and
# the smoke container name.
concurrency:
group: ersatztv-build-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
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:
# only the test job's steps below need the working tree; git history/tags
# are only needed by the `build` job's `git describe` (ersatztv#190)
fetch-depth: 1
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Cache NuGet packages
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('Directory.Packages.props', 'global.json') }}
restore-keys: nuget-${{ runner.os }}-
- 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
# No host-port binding: the job reaches this service as mysql:3306 on the shared
# runner network. Publishing 3306 made concurrent runs collide ("port is already
# allocated") whenever two migrations jobs overlapped.
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
# default fetch-depth: 1 -- this job never runs git describe/log, only
# actions/checkout@v4's default (shallow) history is needed (ersatztv#190)
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Cache NuGet packages
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('Directory.Packages.props', 'global.json') }}
restore-keys: nuget-${{ runner.os }}-
- 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)
# `small` = the dedicated small-jobs runner lane (server-management#574).
# On PR runs this job only resolves its skip, but Gitea still dispatches it
# as a task — on the ubuntu-latest runners that skip queued behind long
# builds (observed 31 min). Real builds (main/tags) run on bumblebee,
# capped at 4 CPUs / 10g.
runs-on: small
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" && check "/app/" "ChicoryTV"; then
echo "Smoke + IPTV E2E passed: channels.m3u + xmltv.xml serve a valid playlist + guide; /app/ serves the ChicoryTV SPA"
else
echo "===== container logs (tail) ====="; docker logs "$NAME" 2>&1 | tail -n 40 || true
exit 1
fi
# Non-blocking nudge: if a PR migrates/adds a route but forgets the parity tracker, warn.
# The rule lives in CLAUDE.md → Conventions; this only surfaces an easy-to-miss omission.
# Deliberately no setup-dotnet/setup-node (and thus no actions/cache) so it can't hit the
# cache-save issues seen on the relocated runner (server-management#570).
docs-reminder:
name: Docs update reminder
runs-on: small # seconds-long git diff; keep it off the build runners
if: github.event_name == 'pull_request'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Warn when a screen/route change skips the parity doc
run: |
base_ref="${{ github.base_ref }}"
git fetch --no-tags --depth=100 origin "$base_ref" || true
changed="$(git diff --name-only "origin/${base_ref}...HEAD" 2>/dev/null || true)"
echo "Changed files in this PR:"; printf '%s\n' "$changed"
screen_or_route=no
if printf '%s\n' "$changed" | grep -Eq '^web/src/screens/.+\.tsx$|^ErsatzTV/LegacyUiRedirects\.cs$'; then
screen_or_route=yes
fi
parity=no
if printf '%s\n' "$changed" | grep -qx 'docs/blazor-route-parity.md'; then
parity=yes
fi
if [ "$screen_or_route" = yes ] && [ "$parity" = no ]; then
echo "::warning::This PR touches a SPA screen or LegacyUiRedirects.cs but does not update docs/blazor-route-parity.md. If you added/migrated/redirected a route, update the parity tracker (and docs/domain-model.md) in THIS PR — see CLAUDE.md → Conventions."
else
echo "Parity-doc reminder: nothing to flag."
fi
-70
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
github: jasongdove
custom: "https://www.paypal.me/jasongdove"
-14
View File
@@ -1,14 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: Feature Requests
url: https://features.ersatztv.org
about: Features
- name: Contact
url: https://ersatztv.org/contact
about: Chat Options
- name: Community
url: https://discuss.ersatztv.org
about: Forum
- name: Discussions
url: https://github.com/ErsatzTV/ErsatzTV/discussions
about: Discuss
-77
View File
@@ -1,77 +0,0 @@
name: Issue Report
description: Report an issue
type: Bug
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this form! Please make sure to fill all fields, including the Title above.
- type: checkboxes
id: before-posting
attributes:
label: "This issue respects the following points:"
description: All conditions are **required**. Failure to comply with any of these conditions may cause your issue to be closed without comment.
options:
- label: This is a **bug**, not a question or a configuration issue; Please visit our [forum](https://discuss.ersatztv.org) or [chat](https://ersatztv.org/contact) first to troubleshoot with volunteers before creating a report.
required: true
- label: This issue is **not** already reported on [GitHub](https://github.com/ErsatzTV/ErsatzTV/issues?q=is%3Aopen+is%3Aissue) _(I've searched it)_.
required: true
- label: I'm using an up to date version of ErsatzTV (full release or develop release); We generally do not support previous older versions. If possible, please update to the latest version before opening an issue.
required: true
- label: This report addresses only a single issue; If you encounter multiple issues, please create separate reports for each one.
required: true
- type: textarea
id: description
attributes:
label: Description
description: |
Description of the problem or issue here.
validations:
required: true
- type: textarea
id: repro-steps
attributes:
label: Steps to reproduce the problem.
description: |
1. Step 1
2. Step 2
3. Step 3
If this is a playback issue, follow these steps and post the resulting zip:
1. Search for the required content using the search bar.
2. Use the overflow/three dots menu on the content and select Troubleshoot Playback.
3. Select the appropriate Playback Settings that trigger the undesired behavior.
4. Click Play to start playback.
5. Repeat steps 3 and 4 until the undesired behavior is reproduced.
6. Click Download Results to have ErsatzTV collect relevant troubleshooting logs (ffmpeg log, ffmpeg profile, hardware capabilities, media info, etc) and compress them in a zip file.
7. Attach the zip to this field.
validations:
required: true
- type: textarea
id: actual-behavior
attributes:
label: What is the current _bug_ behavior?
description: Write down the incorrect behavior that currently happens after following the reproduction steps.
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: What is the expected _correct_ behavior?
description: Write down the correct expected behavior that is supposed to happen after following the reproduction steps.
validations:
required: true
- type: input
id: version
attributes:
label: Specify full version
description: Provide the full version of ErsatzTV, which can be found below the left menu.
placeholder: |
25.5.0-bd695412-docker-amd64
validations:
required: true
- type: textarea
id: additional-information
attributes:
label: Additional information
description: Any additional information that might be useful to this issue.
+26
View File
@@ -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
+242
View File
@@ -0,0 +1,242 @@
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
jobs:
build_and_upload_mac:
name: Mac Build & Upload
runs-on: ${{ matrix.os }}
if: contains(github.event.head_commit.message, '[no build]') == false
strategy:
matrix:
include:
- os: macos-13
kind: macOS
target: osx-x64
- os: macos-13
kind: macOS
target: osx-arm64
steps:
- name: Get the sources
uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: true
- name: Setup .NET Core
uses: actions/setup-dotnet@v4
- 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 net8.0 --runtime "${{ matrix.target }}" -c Release -o publish -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 net8.0 --runtime "${{ matrix.target }}" -c Release -o publish -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:
name: Build & Upload
runs-on: ${{ matrix.os }}
if: contains(github.event.head_commit.message, '[no build]') == false
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-latest
kind: linux
target: linux-arm64
- os: windows-latest
kind: windows
target: win-x64
steps:
- name: Get the sources
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup .NET Core
uses: actions/setup-dotnet@v4
- name: Clean
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
- name: Install dependencies
run: dotnet restore -r "${{ matrix.target }}"
- uses: suisei-cn/actions-download-file@v1.3.0
if: ${{ matrix.kind == 'windows' }}
id: downloadffmpeg
name: Download ffmpeg
with:
url: "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2025-06-12-14-05/ffmpeg-n7.1.1-22-g0f1fe3d153-win64-gpl-7.1.zip"
target: ffmpeg/
- 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 net8.0 --runtime "${{ matrix.target }}" -c Release -o "scanner" -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 net8.0 --runtime "${{ matrix.target }}" -c Release -o "main" -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/"
# Build Windows launcher
if [ "${{ matrix.kind }}" == "windows" ]; then
cargo build --manifest-path=ErsatzTV-Windows/Cargo.toml --release --all-features
ls -l ErsatzTV-Windows/target/release
mv ErsatzTV-Windows/target/release/ersatztv_windows.exe "$release_name/ErsatzTV-Windows.exe"
fi
# Download ffmpeg
if [ "${{ matrix.kind }}" == "windows" ]; then
7z e "ffmpeg/${{ steps.downloadffmpeg.outputs.filename }}" -o"$release_name" '*.exe' -r
rm -f "$release_name/ffplay.exe"
fi
# Pack files
if [ "${{ matrix.kind }}" == "windows" ]; then
7z a -tzip "${release_name}.zip" "./${release_name}/*"
else
tar czvf "${release_name}.tar.gz" "$release_name"
fi
# 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 }}.zip
*${{ matrix.target }}.tar.gz
- name: Publish
uses: softprops/action-gh-release@v1
with:
prerelease: false
tag_name: ${{ inputs.release_tag }}
files: |
${{ env.RELEASE_NAME }}.zip
${{ env.RELEASE_NAME }}.tar.gz
env:
GITHUB_TOKEN: ${{ secrets.gh_token }}
+58
View File
@@ -0,0 +1,58 @@
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 }}
build_and_push:
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 }}
+125
View File
@@ -0,0 +1,125 @@
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_and_push:
name: Build & Publish
runs-on: ubuntu-latest
if: contains(github.event.head_commit.message, '[no build]') == false
strategy:
matrix:
include:
- name: base
path: ''
suffix: ''
qemu: false
- name: nvidia
path: 'nvidia/'
suffix: '-nvidia'
qemu: false
- name: vaapi
path: 'vaapi/'
suffix: '-vaapi'
qemu: false
- name: arm32v7
path: 'arm32v7/'
suffix: '-arm'
qemu: true
- name: arm64
path: 'arm64/'
suffix: '-arm64'
qemu: true
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
if: ${{ matrix.qemu == true }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
id: docker-buildx
- 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
uses: docker/build-push-action@v5
with:
builder: ${{ steps.docker-buildx.outputs.name }}
context: .
file: ./docker/${{ matrix.path }}Dockerfile
push: true
build-args: |
INFO_VERSION=${{ inputs.info_version }}-docker${{ matrix.suffix }}
tags: |
jasongdove/ersatztv:${{ inputs.base_version }}${{ matrix.suffix }}
jasongdove/ersatztv:${{ inputs.tag_version }}${{ matrix.suffix }}
ghcr.io/ersatztv/ersatztv:${{ inputs.base_version }}${{ matrix.suffix }}
ghcr.io/ersatztv/ersatztv:${{ inputs.tag_version }}${{ matrix.suffix }}
if: ${{ matrix.name != 'arm64' && matrix.name != 'arm32v7' }}
- name: Build and push
uses: docker/build-push-action@v5
with:
builder: ${{ steps.docker-buildx.outputs.name }}
context: .
file: ./docker/${{ matrix.path }}Dockerfile
push: true
platforms: 'linux/arm64'
build-args: |
INFO_VERSION=${{ inputs.info_version }}-docker${{ matrix.suffix }}
tags: |
jasongdove/ersatztv:${{ inputs.base_version }}${{ matrix.suffix }}
jasongdove/ersatztv:${{ inputs.tag_version }}${{ matrix.suffix }}
ghcr.io/ersatztv/ersatztv:${{ inputs.base_version }}${{ matrix.suffix }}
ghcr.io/ersatztv/ersatztv:${{ inputs.tag_version }}${{ matrix.suffix }}
if: ${{ matrix.name == 'arm64' }}
- name: Build and push
uses: docker/build-push-action@v5
with:
builder: ${{ steps.docker-buildx.outputs.name }}
context: .
file: ./docker/${{ matrix.path }}Dockerfile
push: true
platforms: 'linux/arm/v7'
build-args: |
INFO_VERSION=${{ inputs.info_version }}-docker${{ matrix.suffix }}
tags: |
jasongdove/ersatztv:${{ inputs.base_version }}${{ matrix.suffix }}
jasongdove/ersatztv:${{ inputs.tag_version }}${{ matrix.suffix }}
ghcr.io/ersatztv/ersatztv:${{ inputs.base_version }}${{ matrix.suffix }}
ghcr.io/ersatztv/ersatztv:${{ inputs.tag_version }}${{ matrix.suffix }}
if: ${{ matrix.name == 'arm32v7' }}
+27
View File
@@ -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.
+81
View File
@@ -0,0 +1,81 @@
name: Pull Request
on:
pull_request:
jobs:
build_and_test_windows:
runs-on: windows-latest
steps:
- name: Get the sources
uses: actions/checkout@v4
- name: Setup .NET Core
uses: actions/setup-dotnet@v4
- 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
- name: Build Windows
run: |
cd ErsatzTV-Windows
cargo build --release --all-features
build_and_test_linux:
runs-on: ubuntu-latest
steps:
- name: Get the sources
uses: actions/checkout@v4
- name: Setup .NET Core
uses: actions/setup-dotnet@v4
- 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_mac:
runs-on: macos-13
steps:
- name: Get the sources
uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: true
- name: Setup .NET Core
uses: actions/setup-dotnet@v4
- 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
+53
View File
@@ -0,0 +1,53 @@
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 }}
build_and_push:
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 }}
-11
View File
@@ -3,13 +3,6 @@
project.lock.json
.DS_Store
*.pyc
.worktrees/
# Claude Code
.mcp/
.mcp.json
.agents/
plugins/
nupkg/
# Visual Studio Code
@@ -47,11 +40,7 @@ msbuild.wrn
core
scripts/generate-api-sdk/swagger.json
scripts/download-test-content.sh
docker-compose.override.yml
ErsatzTV/wwwroot/v2/
ErsatzTV/wwwroot/app/
web/dist/
web/node_modules/
-10
View File
@@ -1,10 +0,0 @@
# Enforce the CLAUDE.md protocol: every commit message must carry a Co-Authored-By
# trailer. Merge commits are exempt (their MERGE_MSG has no trailer and shouldn't be
# rewritten).
if git rev-parse -q --verify MERGE_HEAD >/dev/null 2>&1; then
exit 0
fi
grep -q '^Co-Authored-By:' "$1" || {
echo 'husky - commit message missing Co-Authored-By trailer'
exit 1
}
-15
View File
@@ -1,15 +0,0 @@
cd web && npx lint-staged || exit 1
cd ..
# dotnet format on staged .cs files (repo root). Scoped to the staged files so we
# don't pay the full-tree cost; skip entirely when no .cs is staged (avoids the
# ~20-40s sln load for web-only commits).
cs_files=$(git diff --cached --name-only --diff-filter=ACM -- '*.cs')
if [ -n "$cs_files" ]; then
echo "husky - dotnet format (verify) on staged .cs files"
# shellcheck disable=SC2086
dotnet format ErsatzTV.sln --verify-no-changes --include $cs_files || {
echo "husky - dotnet format found issues in staged .cs files; run 'dotnet format ErsatzTV.sln --include <files>' to fix"
exit 1
}
fi
-11
View File
@@ -1,11 +0,0 @@
# Git exports GIT_DIR/GIT_WORK_TREE/GIT_INDEX_FILE while running hooks. In a worktree
# (or any subdir), an explicit GIT_DIR makes nested `git` commands mislocate the working
# tree — notably `check:api`'s `git diff --exit-code` (run from web/) silently reports "no
# diff" and lets drift through. Unset them so nested git rediscovers the repo normally.
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE
# CI-parity checks: catch "green locally, red in CI" before the push leaves the machine.
# check:api guards the generated OpenAPI types (v1.json / v1.d.ts drift); the full
# lint/typecheck/build catch a staged change that breaks an UNstaged file (lint-staged
# only sees staged files).
cd web && npm run check:api && npm run lint && npm run typecheck && npm run build
-15
View File
@@ -1,15 +0,0 @@
# Codex Instructions
## Verification Commands
Run .NET restore, build, and test commands outside the sandbox by default in this repo. Sandboxed .NET commands can stall on NuGet/package/compiler cache access, while the same commands complete normally with approved unsandboxed execution.
Preferred verification commands:
```bash
TZ=UTC dotnet restore ErsatzTV.sln -v minimal
TZ=UTC dotnet build ErsatzTV.sln --no-restore -v minimal
TZ=UTC dotnet test ErsatzTV.sln --no-build -v minimal
```
Use scoped escalated execution for these commands rather than first trying a sandboxed run.
+4 -999
View File
File diff suppressed because it is too large Load Diff
-110
View File
@@ -1,110 +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
- **UI**: ChicoryTV React SPA (`web/`, Vite, served at `/app`) over the REST API — the default UI; root `/` and migrated legacy routes 302 there (`ErsatzTV/LegacyUiRedirects.cs`). The legacy Blazor Server UI (MudBlazor) still serves the remaining un-migrated admin screens — playback troubleshooting, multi/rerun collections, and playlist editing depth; Blazor home = `/system/health`, reachable via the Settings → System "Classic UI" link. Media detail pages + image folder browser landed in the SPA via #141 (PR #183); its removal is #91 phase (b), gated on #145 (playback troubleshooting) and API gaps #151/#152/#153/#155 (scheduling parity #144/#162 DONE 2026-07-07: blocks/templates/decos/deco-templates/playout editors all in the SPA; #141/#158/#161/#180 also DONE)
- **Pattern**: CQRS via MediatR — queries/commands in `ErsatzTV.Application/`
- **Database**: EF Core (SQLite default, MySQL optional) — context in `ErsatzTV.Infrastructure/Data/TvContext.cs`
- **Media**: FFmpeg via CliWrap, SkiaSharp for logo generation
- **Functional C#**: Language Ext (Option, Either monads throughout)
### Project Layout
| Project | Role |
|---------|------|
| `ErsatzTV/` | ASP.NET Core host, API controllers, SPA static hosting, legacy Blazor pages, DI setup |
| `web/` | ChicoryTV React SPA (Vite + TypeScript; builds into `ErsatzTV/wwwroot/app`) |
| `ErsatzTV.Application/` | MediatR handlers (business logic) |
| `ErsatzTV.Core/` | Domain entities, interfaces, no infrastructure deps |
| `ErsatzTV.Infrastructure/` | EF Core repos, data access |
| `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 deploys via **Komodo GitOps**: the `media-servers` compose in `timothy/server-management` (`docker/bumblebee/stacks/media-servers/compose.yaml`) pins the version tag (currently `26.5.0`, deployed 2026-07-07); releasing = tag here, wait for the image build, bump that pin and push (the Komodo pre-deploy hook backs up before recreating). Test container tracks `:latest`. Pipeline details: `docs/ci-cd.md`.
## Development
```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.
- **Docs-first is a HARD RULE — read before you explore**: before ANY API / SPA / E2E / parity / scheduling work, read `docs/README.md` (index) → the convention docs (`api-conventions`, `spa-conventions`, `e2e-local`, `domain-model`, `blazor-route-parity`, `decisions`). **Do NOT reverse-engineer conventions from source (Grep/Read) before reading these** — they exist precisely so you don't. Only recon the task-specific delta the docs deliberately don't freeze (a merged endpoint's exact DTO, a Blazor page's field list). **This applies to delegated subagents too**: tell each agent which doc section to read; never let one re-derive conventions from code.
- **Docs-update is part of "done" — same PR, never a follow-up**: any PR that changes a convention, adds/migrates/redirects a route, adds/changes a `/api/*` endpoint, or reverses a decision MUST update the relevant doc in that same PR:
| Change | Update in the same PR |
|---|---|
| Migrate / add / redirect a route (new `web/src/screens/*.tsx`, `LegacyUiRedirects.cs`) | `docs/blazor-route-parity.md` + `docs/domain-model.md` |
| Add / change a `/api/*` endpoint | `docs/api-conventions.md` checklist, then regenerate `v1.json` + `endpoint-index.md` via `./scripts/update-openapi.sh` |
| Change a SPA screen convention | `docs/spa-conventions.md` |
| Establish / reverse a convention or decision | `docs/decisions.md` (append-only) + the affected doc |
| Add / remove / retitle a doc | `docs/README.md` index |
The `docs-reminder` CI job flags a screen/route change that skips `blazor-route-parity.md`, but it's a **non-blocking** nudge — the rule is on you, not the check.
- Follow existing MediatR CQRS pattern for new features
- Domain logic in `ErsatzTV.Core`, infrastructure in `ErsatzTV.Infrastructure`
- Keep UI thin: the SPA talks to `/api/*` only; legacy Blazor pages delegate to MediatR handlers. New screens go in the SPA (`web/`), never in Blazor
- Test with **NUnit** + Shouldly + NSubstitute (the existing `*.Tests` projects); xUnit is **not** used here
- **Dependencies use Central Package Management**: versions live in the repo-root `Directory.Packages.props`; csproj reference packages by name only. Add/upgrade by editing the central `<PackageVersion>` — never put `Version=` back on a `<PackageReference>` (trips `NU1008`). See `docs/ci-cd.md` → Dependency management.
- **DB migrations target BOTH providers**: a `TvContext` model change needs a migration in `ErsatzTV.Infrastructure.Sqlite` **and** `ErsatzTV.Infrastructure.MySql` — run `scripts/add-migration.sh <Name>` (does both). CI's `migrations` job enforces model-drift + apply-to-fresh-DB per provider. See `docs/ci-cd.md` → Migration integrity.
- **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).
+1 -12
View File
@@ -2,16 +2,5 @@
<PropertyGroup>
<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>
</Project>
-41
View File
@@ -1,41 +0,0 @@
<Project>
<PropertyGroup>
<EnableThreadingAnalyzers Condition="'$(EnableThreadingAnalyzers)' == ''">false</EnableThreadingAnalyzers>
</PropertyGroup>
<ItemGroup>
<PackageReference
Include="Microsoft.VisualStudio.Threading.Analyzers"
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>
-113
View File
@@ -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="6.0.2" />
<PackageVersion Include="Blurhash.SkiaSharp" Version="2.0.0" />
<PackageVersion Include="Chronic.Core" Version="0.4.0" />
<PackageVersion Include="CliWrap" Version="3.10.2" />
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
<PackageVersion Include="Dapper" Version="2.1.79" />
<PackageVersion Include="Destructurama.Attributed" Version="5.2.0" />
<PackageVersion Include="EFCore.BulkExtensions" Version="[9.0.2,10)" />
<PackageVersion Include="EFCore.BulkExtensions.MySql" Version="[9.0.2,10)" />
<PackageVersion Include="EFCore.BulkExtensions.Sqlite" Version="[9.0.2,10)" />
<PackageVersion Include="Elastic.Clients.Elasticsearch" Version="9.3.0" />
<PackageVersion Include="EntityFrameworkProfiler.Appender" Version="6.0.6053" />
<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="7.2.5" />
<PackageVersion Include="Serilog" Version="4.3.0" />
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageVersion Include="Serilog.Extensions.Hosting" Version="10.0.0" />
<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>
+2
View File
@@ -0,0 +1,2 @@
target/
+1035
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "ersatztv_windows"
version = "0.1.0"
edition = "2021"
[dependencies]
tray-item = { git = "https://github.com/olback/tray-item-rs" }
special-folder = { git = "https://github.com/masinc/special-folder-rs" }
process_path = "0.1.4"
[dependencies.windows]
version = "0.43.0"
features = [
"Win32_System_Console",
"Win32_Foundation"
]
[build-dependencies]
windres = "*"
static_vcruntime = "2.0"
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+6
View File
@@ -0,0 +1,6 @@
use windres::Build;
fn main() {
static_vcruntime::metabuild();
Build::new().compile("ersatztv_windows.rc").unwrap();
}
+2
View File
@@ -0,0 +1,2 @@
id ICON "ersatztv.ico"
ersatztv-icon ICON "ersatztv.ico"
+109
View File
@@ -0,0 +1,109 @@
#![windows_subsystem = "windows"]
use special_folder::SpecialFolder;
use std::fs;
use std::os::windows::process::CommandExt;
use std::process::Child;
use std::process::Command;
use std::process::Stdio;
use windows::Win32::System::Console;
use {std::sync::mpsc, tray_item::TrayItem};
const CREATE_NO_WINDOW: u32 = 0x08000000;
enum Message {
Exit,
}
fn main() {
let mut tray = TrayItem::new("ErsatzTV", "ersatztv-icon").unwrap();
let (tx, rx) = mpsc::channel();
tray.add_menu_item("Launch Web UI", || {
let _ = Command::new("cmd")
.creation_flags(CREATE_NO_WINDOW)
.arg("/C")
.arg("start")
.arg("http://localhost:8409")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
})
.unwrap();
tray.add_menu_item("Show Logs", || {
let path = SpecialFolder::LocalApplicationData
.get()
.unwrap()
.join("ersatztv")
.join("logs");
match path.to_str() {
None => {}
Some(folder) => {
fs::create_dir_all(folder).unwrap();
let _ = Command::new("explorer.exe")
.arg(folder)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
}
}
})
.unwrap();
tray.inner_mut().add_separator().unwrap();
tray.add_menu_item("Exit", move || {
tx.send(Message::Exit).unwrap();
})
.unwrap();
let path = process_path::get_executable_path();
let mut child: Option<Child> = None;
match path {
None => {}
Some(path) => {
let etv = path.parent().unwrap().join("ErsatzTV.exe");
if etv.exists() {
match etv.to_str() {
None => {}
Some(etv) => {
child = Some(
Command::new(etv)
.creation_flags(CREATE_NO_WINDOW)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.unwrap(),
);
}
}
}
}
}
loop {
match rx.recv() {
Ok(Message::Exit) => {
match child {
None => {}
Some(mut child) => {
unsafe {
if Console::AttachConsole(child.id()) == true
{
Console::GenerateConsoleCtrlEvent(Console::CTRL_C_EVENT, 0);
}
}
child.wait().unwrap();
}
}
break;
}
_ => {}
}
}
}
+3 -4
View File
@@ -29,10 +29,9 @@ internal static class Mapper
CultureInfo[] allCultures = CultureInfo.GetCultures(CultureTypes.NeutralCultures);
return languages
.Map(lang => allCultures.Filter(ci => string.Equals(
ci.ThreeLetterISOLanguageName,
lang,
StringComparison.OrdinalIgnoreCase)))
.Map(
lang => allCultures.Filter(
ci => string.Equals(ci.ThreeLetterISOLanguageName, lang, StringComparison.OrdinalIgnoreCase)))
.Flatten()
.Distinct()
.ToList();
@@ -1,26 +1,30 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using static ErsatzTV.Application.Artists.Mapper;
namespace ErsatzTV.Application.Artists;
public class GetArtistByIdHandler(
IArtistRepository artistRepository,
ISearchRepository searchRepository,
ILanguageCodeService languageCodeService)
: IRequestHandler<GetArtistById, Option<ArtistViewModel>>
public class GetArtistByIdHandler : IRequestHandler<GetArtistById, Option<ArtistViewModel>>
{
private readonly IArtistRepository _artistRepository;
private readonly ISearchRepository _searchRepository;
public GetArtistByIdHandler(IArtistRepository artistRepository, ISearchRepository searchRepository)
{
_artistRepository = artistRepository;
_searchRepository = searchRepository;
}
public async Task<Option<ArtistViewModel>> Handle(
GetArtistById request,
CancellationToken cancellationToken)
{
Option<Artist> maybeArtist = await artistRepository.GetArtist(request.ArtistId);
Option<Artist> maybeArtist = await _artistRepository.GetArtist(request.ArtistId);
return await maybeArtist.Match<Task<Option<ArtistViewModel>>>(
async artist =>
{
List<string> mediaCodes = await searchRepository.GetLanguagesForArtist(artist);
List<string> languageCodes = languageCodeService.GetAllLanguageCodes(mediaCodes);
List<string> mediaCodes = await _searchRepository.GetLanguagesForArtist(artist);
List<string> languageCodes = await _searchRepository.GetAllThreeLetterLanguageCodes(mediaCodes);
return ProjectToViewModel(artist, languageCodes);
},
() => Task.FromResult(Option<ArtistViewModel>.None));
@@ -1,17 +0,0 @@
using System.Net;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Artworks;
public record ArtworkContentTypeModel(string Path, string ContentType)
{
public static readonly ArtworkContentTypeModel None = new(string.Empty, string.Empty);
public bool IsExternalUrl => Artwork.IsExternalUrl(Path);
public bool HasContentType => !string.IsNullOrWhiteSpace(ContentType);
public string UrlWithContentType => string.IsNullOrWhiteSpace(ContentType)
? Path
: $"{Path}?contentType={WebUtility.UrlEncode(ContentType)}";
}
@@ -1,13 +0,0 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Artwork;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Artworks;
/// <summary>
/// Validates and stores an uploaded image as channel logo or watermark artwork,
/// landing it in the same on-disk cache the Blazor UI uses (via <c>IImageCache</c>),
/// so the returned path is equivalent to a Blazor-uploaded image.
/// </summary>
public record UploadArtwork(Stream Stream, string ContentType, ArtworkKind ArtworkKind)
: IRequest<Either<BaseError, ArtworkUploadResponseModel>>;
@@ -1,53 +0,0 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Artwork;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Images;
namespace ErsatzTV.Application.Artworks;
public class UploadArtworkHandler : IRequestHandler<UploadArtwork, Either<BaseError, ArtworkUploadResponseModel>>
{
// png/jpeg/gif/webp are all decoded by SkiaSharp and read by FFmpeg, matching the
// formats the Blazor logo/watermark upload already accepts. Format expansion is ersatztv#66.
private static readonly System.Collections.Generic.HashSet<string> AcceptedContentTypes = new(StringComparer.OrdinalIgnoreCase)
{
"image/png",
"image/jpeg",
"image/gif",
"image/webp"
};
private readonly IImageCache _imageCache;
public UploadArtworkHandler(IImageCache imageCache) => _imageCache = imageCache;
public async Task<Either<BaseError, ArtworkUploadResponseModel>> Handle(
UploadArtwork request,
CancellationToken cancellationToken)
{
string contentType = (request.ContentType ?? string.Empty).Trim();
if (!AcceptedContentTypes.Contains(contentType))
{
return BaseError.New(
$"Unsupported image content type '{contentType}'; supported types are: {string.Join(", ", AcceptedContentTypes)}");
}
Either<BaseError, string> maybeFileName = await _imageCache.SaveArtworkToCache(
request.Stream,
request.ArtworkKind);
return maybeFileName.Map(fileName => new ArtworkUploadResponseModel(
BuildPath(request.ArtworkKind, fileName),
contentType));
}
// Mirror the on-disk conventions the Blazor editors use so the returned path is a drop-in
// for ArtworkContentTypeModel.Path: channel logos are addressed as "iptv/logos/{file}"
// (see ChannelEditor.UploadLogo), watermarks by the bare cache file name (see WatermarkEditor).
private static string BuildPath(ArtworkKind artworkKind, string fileName) =>
artworkKind switch
{
ArtworkKind.Logo => $"iptv/logos/{fileName}",
_ => fileName
};
}
@@ -6,25 +6,24 @@ using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Artworks;
public class GetArtworkHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetArtwork, Either<BaseError, Artwork>>
public class GetArtworkHandler(IDbContextFactory<TvContext> dbContextFactory) : IRequestHandler<GetArtwork, Either<BaseError, Artwork>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory = dbContextFactory;
public async Task<Either<BaseError, Artwork>> Handle(
GetArtwork request,
GetArtwork request,
CancellationToken cancellationToken)
{
try
{
try {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<Artwork> artwork = await dbContext.Artwork
.AsNoTracking()
.SelectOneAsync(a => a.Id, a => a.Id == request.Id, cancellationToken)
.SelectOneAsync(a => a.Id, a => a.Id == request.Id)
.MapT(Project);
return artwork.ToEither(BaseError.New("Artwork not found"));
}
catch (Exception ex)
{
@@ -32,11 +31,12 @@ public class GetArtworkHandler(IDbContextFactory<TvContext> dbContextFactory)
}
}
private static Artwork Project(Artwork artwork) =>
new()
{
private static Artwork Project(Artwork artwork)
{
return new Artwork {
Id = artwork.Id,
Path = artwork.Path,
ArtworkKind = artwork.ArtworkKind
};
}
}
@@ -1,24 +0,0 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
namespace ErsatzTV.Application.ChannelTemplates;
internal static class ChannelTemplateDefault
{
public static async Task<int?> GetDefaultTemplateId(
IConfigElementRepository configElementRepository,
CancellationToken cancellationToken)
{
Option<int> maybeDefault =
await configElementRepository.GetValue<int>(
ConfigElementKey.ChannelTemplatesDefaultTemplateId,
cancellationToken);
int? result = null;
foreach (int id in maybeDefault)
{
result = id;
}
return result;
}
}
@@ -1,38 +0,0 @@
using ErsatzTV.Core.Api.ChannelTemplates;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.ChannelTemplates;
public static class ChannelTemplateMapper
{
public static ChannelTemplateResponseModel ProjectToResponseModel(ChannelTemplate template, int? defaultTemplateId) =>
new(
template.Id,
template.Name,
template.Description,
template.IsSystem,
defaultTemplateId == template.Id,
template.FFmpegProfileId,
template.WatermarkId,
template.FallbackFillerId,
template.PreRollFillerId,
template.MidRollFillerId,
template.PostRollFillerId,
template.StreamSelectorMode,
template.StreamSelector,
template.PreferredAudioLanguageCode,
template.PreferredAudioTitle,
template.PlayoutSource,
template.PlayoutMode,
template.StreamingMode,
template.PreferredSubtitleLanguageCode,
template.SubtitleMode,
template.MusicVideoCreditsMode,
template.MusicVideoCreditsTemplate,
template.SongVideoMode,
template.TranscodeMode,
template.IdleBehavior,
template.ShuffleScheduleItems,
template.RandomStartPoint,
template.FixedStartTimeBehavior);
}
@@ -1,155 +0,0 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.ChannelTemplates;
public abstract record ChannelTemplateCommandBase(
string Name,
string Description,
int FFmpegProfileId,
int? WatermarkId,
int? FallbackFillerId,
int? PreRollFillerId,
int? MidRollFillerId,
int? PostRollFillerId,
ChannelStreamSelectorMode StreamSelectorMode,
string StreamSelector,
string PreferredAudioLanguageCode,
string PreferredAudioTitle,
ChannelPlayoutSource PlayoutSource,
ChannelPlayoutMode PlayoutMode,
StreamingMode StreamingMode,
string PreferredSubtitleLanguageCode,
ChannelSubtitleMode SubtitleMode,
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
string MusicVideoCreditsTemplate,
ChannelSongVideoMode SongVideoMode,
ChannelTranscodeMode TranscodeMode,
ChannelIdleBehavior IdleBehavior,
bool ShuffleScheduleItems,
bool RandomStartPoint,
FixedStartTimeBehavior FixedStartTimeBehavior)
{
internal static async Task<Option<BaseError>> ValidateCommon(
TvContext dbContext,
ChannelTemplateCommandBase request,
int? existingTemplateId,
CancellationToken cancellationToken)
{
string name = NormalizeName(request.Name);
if (string.IsNullOrWhiteSpace(name))
{
return BaseError.New("Name is required.");
}
if (name.Length > 50)
{
return BaseError.New("Name must be 50 characters or less.");
}
if (request.Description?.Length > 500)
{
return BaseError.New("Description must be 500 characters or less.");
}
bool duplicateName = await dbContext.ChannelTemplates
.AnyAsync(t => t.Id != existingTemplateId && t.Name == name, cancellationToken);
if (duplicateName)
{
return BaseError.New("Channel template name must be unique.");
}
bool ffmpegProfileExists = await dbContext.FFmpegProfiles
.AnyAsync(p => p.Id == request.FFmpegProfileId, cancellationToken);
if (!ffmpegProfileExists)
{
return new NotFoundError($"FFmpegProfile {request.FFmpegProfileId} does not exist.");
}
foreach (int watermarkId in Optional(request.WatermarkId))
{
bool watermarkExists = await dbContext.ChannelWatermarks
.AnyAsync(w => w.Id == watermarkId, cancellationToken);
if (!watermarkExists)
{
return new NotFoundError($"Watermark {watermarkId} does not exist.");
}
}
Option<BaseError> maybeFillerError =
await FillerMustExist(dbContext, request.FallbackFillerId, FillerKind.Fallback, cancellationToken);
if (maybeFillerError.IsSome)
{
return maybeFillerError;
}
maybeFillerError = await FillerMustExist(dbContext, request.PreRollFillerId, FillerKind.PreRoll, cancellationToken);
if (maybeFillerError.IsSome)
{
return maybeFillerError;
}
maybeFillerError = await FillerMustExist(dbContext, request.MidRollFillerId, FillerKind.MidRoll, cancellationToken);
if (maybeFillerError.IsSome)
{
return maybeFillerError;
}
return await FillerMustExist(dbContext, request.PostRollFillerId, FillerKind.PostRoll, cancellationToken);
}
internal void ApplyTo(ChannelTemplate template)
{
template.Name = NormalizeName(Name);
template.Description = Description ?? string.Empty;
template.FFmpegProfileId = FFmpegProfileId;
template.WatermarkId = WatermarkId;
template.FallbackFillerId = FallbackFillerId;
template.PreRollFillerId = PreRollFillerId;
template.MidRollFillerId = MidRollFillerId;
template.PostRollFillerId = PostRollFillerId;
template.StreamSelectorMode = StreamSelectorMode;
template.StreamSelector = StreamSelector ?? string.Empty;
template.PreferredAudioLanguageCode = PreferredAudioLanguageCode ?? string.Empty;
template.PreferredAudioTitle = PreferredAudioTitle ?? string.Empty;
template.PlayoutSource = PlayoutSource;
template.PlayoutMode = PlayoutMode;
template.StreamingMode = StreamingMode;
template.PreferredSubtitleLanguageCode = PreferredSubtitleLanguageCode ?? string.Empty;
template.SubtitleMode = SubtitleMode;
template.MusicVideoCreditsMode = MusicVideoCreditsMode;
template.MusicVideoCreditsTemplate = MusicVideoCreditsTemplate ?? string.Empty;
template.SongVideoMode = SongVideoMode;
template.TranscodeMode = TranscodeMode;
template.IdleBehavior = IdleBehavior;
template.ShuffleScheduleItems = ShuffleScheduleItems;
template.RandomStartPoint = RandomStartPoint;
template.FixedStartTimeBehavior = FixedStartTimeBehavior;
}
internal static string NormalizeName(string name) => (name ?? string.Empty).Trim();
private static async Task<Option<BaseError>> FillerMustExist(
TvContext dbContext,
int? fillerPresetId,
FillerKind fillerKind,
CancellationToken cancellationToken)
{
foreach (int id in Optional(fillerPresetId))
{
bool exists = await dbContext.FillerPresets
.AnyAsync(f => f.Id == id && f.FillerKind == fillerKind, cancellationToken);
if (!exists)
{
return new NotFoundError($"{fillerKind} filler {id} does not exist.");
}
}
return Option<BaseError>.None;
}
}
@@ -1,60 +0,0 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Api.ChannelTemplates;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Application.ChannelTemplates;
public record CreateChannelTemplate(
string Name,
string Description,
int FFmpegProfileId,
int? WatermarkId,
int? FallbackFillerId,
int? PreRollFillerId,
int? MidRollFillerId,
int? PostRollFillerId,
ChannelStreamSelectorMode StreamSelectorMode,
string StreamSelector,
string PreferredAudioLanguageCode,
string PreferredAudioTitle,
ChannelPlayoutSource PlayoutSource,
ChannelPlayoutMode PlayoutMode,
StreamingMode StreamingMode,
string PreferredSubtitleLanguageCode,
ChannelSubtitleMode SubtitleMode,
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
string MusicVideoCreditsTemplate,
ChannelSongVideoMode SongVideoMode,
ChannelTranscodeMode TranscodeMode,
ChannelIdleBehavior IdleBehavior,
bool ShuffleScheduleItems,
bool RandomStartPoint,
FixedStartTimeBehavior FixedStartTimeBehavior)
: ChannelTemplateCommandBase(
Name,
Description,
FFmpegProfileId,
WatermarkId,
FallbackFillerId,
PreRollFillerId,
MidRollFillerId,
PostRollFillerId,
StreamSelectorMode,
StreamSelector,
PreferredAudioLanguageCode,
PreferredAudioTitle,
PlayoutSource,
PlayoutMode,
StreamingMode,
PreferredSubtitleLanguageCode,
SubtitleMode,
MusicVideoCreditsMode,
MusicVideoCreditsTemplate,
SongVideoMode,
TranscodeMode,
IdleBehavior,
ShuffleScheduleItems,
RandomStartPoint,
FixedStartTimeBehavior),
IRequest<Either<BaseError, ChannelTemplateResponseModel>>;
@@ -1,36 +0,0 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Api.ChannelTemplates;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.ChannelTemplates;
public class CreateChannelTemplateHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository)
: IRequestHandler<CreateChannelTemplate, Either<BaseError, ChannelTemplateResponseModel>>
{
public async Task<Either<BaseError, ChannelTemplateResponseModel>> Handle(
CreateChannelTemplate request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<BaseError> maybeError =
await ChannelTemplateCommandBase.ValidateCommon(dbContext, request, null, cancellationToken);
foreach (BaseError error in maybeError)
{
return error;
}
var template = new ChannelTemplate();
request.ApplyTo(template);
await dbContext.ChannelTemplates.AddAsync(template, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
int? defaultTemplateId =
await ChannelTemplateDefault.GetDefaultTemplateId(configElementRepository, cancellationToken);
return ChannelTemplateMapper.ProjectToResponseModel(template, defaultTemplateId);
}
}
@@ -1,5 +0,0 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.ChannelTemplates;
public record DeleteChannelTemplate(int ChannelTemplateId) : IRequest<Either<BaseError, Unit>>;
@@ -1,42 +0,0 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.ChannelTemplates;
public class DeleteChannelTemplateHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository)
: IRequestHandler<DeleteChannelTemplate, Either<BaseError, Unit>>
{
public async Task<Either<BaseError, Unit>> Handle(DeleteChannelTemplate request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<ChannelTemplate> maybeTemplate = await dbContext.ChannelTemplates
.SelectOneAsync(t => t.Id, t => t.Id == request.ChannelTemplateId, cancellationToken);
foreach (ChannelTemplate template in maybeTemplate)
{
if (template.IsSystem)
{
return BaseError.New("System templates cannot be deleted.");
}
int? defaultTemplateId =
await ChannelTemplateDefault.GetDefaultTemplateId(configElementRepository, cancellationToken);
if (defaultTemplateId == template.Id)
{
return BaseError.New("Default channel template cannot be deleted.");
}
dbContext.ChannelTemplates.Remove(template);
await dbContext.SaveChangesAsync(cancellationToken);
return Unit.Default;
}
return new NotFoundError($"ChannelTemplate {request.ChannelTemplateId} does not exist.");
}
}
@@ -1,6 +0,0 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Api.ChannelTemplates;
namespace ErsatzTV.Application.ChannelTemplates;
public record SetDefaultChannelTemplate(int ChannelTemplateId) : IRequest<Either<BaseError, ChannelTemplateResponseModel>>;
@@ -1,36 +0,0 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Api.ChannelTemplates;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.ChannelTemplates;
public class SetDefaultChannelTemplateHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository)
: IRequestHandler<SetDefaultChannelTemplate, Either<BaseError, ChannelTemplateResponseModel>>
{
public async Task<Either<BaseError, ChannelTemplateResponseModel>> Handle(
SetDefaultChannelTemplate request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<ChannelTemplate> maybeTemplate = await dbContext.ChannelTemplates
.AsNoTracking()
.SelectOneAsync(t => t.Id, t => t.Id == request.ChannelTemplateId, cancellationToken);
foreach (ChannelTemplate template in maybeTemplate)
{
await configElementRepository.Upsert(
ConfigElementKey.ChannelTemplatesDefaultTemplateId,
template.Id,
cancellationToken);
return ChannelTemplateMapper.ProjectToResponseModel(template, template.Id);
}
return new NotFoundError($"ChannelTemplate {request.ChannelTemplateId} does not exist.");
}
}
@@ -1,61 +0,0 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Api.ChannelTemplates;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Application.ChannelTemplates;
public record UpdateChannelTemplate(
int ChannelTemplateId,
string Name,
string Description,
int FFmpegProfileId,
int? WatermarkId,
int? FallbackFillerId,
int? PreRollFillerId,
int? MidRollFillerId,
int? PostRollFillerId,
ChannelStreamSelectorMode StreamSelectorMode,
string StreamSelector,
string PreferredAudioLanguageCode,
string PreferredAudioTitle,
ChannelPlayoutSource PlayoutSource,
ChannelPlayoutMode PlayoutMode,
StreamingMode StreamingMode,
string PreferredSubtitleLanguageCode,
ChannelSubtitleMode SubtitleMode,
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
string MusicVideoCreditsTemplate,
ChannelSongVideoMode SongVideoMode,
ChannelTranscodeMode TranscodeMode,
ChannelIdleBehavior IdleBehavior,
bool ShuffleScheduleItems,
bool RandomStartPoint,
FixedStartTimeBehavior FixedStartTimeBehavior)
: ChannelTemplateCommandBase(
Name,
Description,
FFmpegProfileId,
WatermarkId,
FallbackFillerId,
PreRollFillerId,
MidRollFillerId,
PostRollFillerId,
StreamSelectorMode,
StreamSelector,
PreferredAudioLanguageCode,
PreferredAudioTitle,
PlayoutSource,
PlayoutMode,
StreamingMode,
PreferredSubtitleLanguageCode,
SubtitleMode,
MusicVideoCreditsMode,
MusicVideoCreditsTemplate,
SongVideoMode,
TranscodeMode,
IdleBehavior,
ShuffleScheduleItems,
RandomStartPoint,
FixedStartTimeBehavior),
IRequest<Either<BaseError, ChannelTemplateResponseModel>>;
@@ -1,51 +0,0 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Api.ChannelTemplates;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.ChannelTemplates;
public class UpdateChannelTemplateHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository)
: IRequestHandler<UpdateChannelTemplate, Either<BaseError, ChannelTemplateResponseModel>>
{
public async Task<Either<BaseError, ChannelTemplateResponseModel>> Handle(
UpdateChannelTemplate request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<ChannelTemplate> maybeTemplate = await dbContext.ChannelTemplates
.SelectOneAsync(t => t.Id, t => t.Id == request.ChannelTemplateId, cancellationToken);
foreach (ChannelTemplate template in maybeTemplate)
{
if (template.IsSystem)
{
return BaseError.New("System templates cannot be updated.");
}
Option<BaseError> maybeError =
await ChannelTemplateCommandBase.ValidateCommon(
dbContext,
request,
request.ChannelTemplateId,
cancellationToken);
foreach (BaseError error in maybeError)
{
return error;
}
request.ApplyTo(template);
await dbContext.SaveChangesAsync(cancellationToken);
int? defaultTemplateId =
await ChannelTemplateDefault.GetDefaultTemplateId(configElementRepository, cancellationToken);
return ChannelTemplateMapper.ProjectToResponseModel(template, defaultTemplateId);
}
return new NotFoundError($"ChannelTemplate {request.ChannelTemplateId} does not exist.");
}
}
@@ -1,5 +0,0 @@
using ErsatzTV.Core.Api.ChannelTemplates;
namespace ErsatzTV.Application.ChannelTemplates;
public record GetAllChannelTemplates : IRequest<List<ChannelTemplateResponseModel>>;
@@ -1,28 +0,0 @@
using ErsatzTV.Core.Api.ChannelTemplates;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.ChannelTemplates;
public class GetAllChannelTemplatesHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository)
: IRequestHandler<GetAllChannelTemplates, List<ChannelTemplateResponseModel>>
{
public async Task<List<ChannelTemplateResponseModel>> Handle(
GetAllChannelTemplates request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
int? defaultTemplateId =
await ChannelTemplateDefault.GetDefaultTemplateId(configElementRepository, cancellationToken);
return await dbContext.ChannelTemplates
.AsNoTracking()
.OrderBy(t => t.IsSystem ? 0 : 1)
.ThenBy(t => t.Name)
.Select(t => ChannelTemplateMapper.ProjectToResponseModel(t, defaultTemplateId))
.ToListAsync(cancellationToken);
}
}
@@ -1,5 +0,0 @@
using ErsatzTV.Core.Api.ChannelTemplates;
namespace ErsatzTV.Application.ChannelTemplates;
public record GetChannelTemplateById(int ChannelTemplateId) : IRequest<Option<ChannelTemplateResponseModel>>;
@@ -1,27 +0,0 @@
using ErsatzTV.Core.Api.ChannelTemplates;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.ChannelTemplates;
public class GetChannelTemplateByIdHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository)
: IRequestHandler<GetChannelTemplateById, Option<ChannelTemplateResponseModel>>
{
public async Task<Option<ChannelTemplateResponseModel>> Handle(
GetChannelTemplateById request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
int? defaultTemplateId =
await ChannelTemplateDefault.GetDefaultTemplateId(configElementRepository, cancellationToken);
Option<ChannelTemplate> maybeTemplate = await dbContext.ChannelTemplates
.AsNoTracking()
.SelectOneAsync(t => t.Id, t => t.Id == request.ChannelTemplateId, cancellationToken);
return maybeTemplate.Map(t => ChannelTemplateMapper.ProjectToResponseModel(t, defaultTemplateId));
}
}
@@ -1,5 +0,0 @@
using ErsatzTV.Core.Api.ChannelTemplates;
namespace ErsatzTV.Application.ChannelTemplates;
public record GetDefaultChannelTemplate : IRequest<Option<ChannelTemplateResponseModel>>;
@@ -1,40 +0,0 @@
using ErsatzTV.Core.Api.ChannelTemplates;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.ChannelTemplates;
public class GetDefaultChannelTemplateHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository)
: IRequestHandler<GetDefaultChannelTemplate, Option<ChannelTemplateResponseModel>>
{
public async Task<Option<ChannelTemplateResponseModel>> Handle(
GetDefaultChannelTemplate request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
int? defaultTemplateId =
await ChannelTemplateDefault.GetDefaultTemplateId(configElementRepository, cancellationToken);
foreach (int id in Optional(defaultTemplateId))
{
Option<ChannelTemplate> maybeConfigured = await dbContext.ChannelTemplates
.AsNoTracking()
.SelectOneAsync(t => t.Id, t => t.Id == id, cancellationToken);
foreach (ChannelTemplate template in maybeConfigured)
{
return ChannelTemplateMapper.ProjectToResponseModel(template, id);
}
}
ChannelTemplate fallback = await dbContext.ChannelTemplates
.AsNoTracking()
.Where(t => t.IsSystem)
.OrderBy(t => t.Name)
.FirstOrDefaultAsync(cancellationToken);
return Optional(fallback).Map(t => ChannelTemplateMapper.ProjectToResponseModel(t, t.Id));
}
}
@@ -1,71 +0,0 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Channels;
/// <summary>
/// Shared programme-metadata projection for guide output. Both the XMLTV cache builder
/// (<see cref="RefreshChannelDataHandler" />) and the JSON guide query
/// (<see cref="GetChannelGuideDataHandler" />) resolve the display title/subtitle/category from a
/// <see cref="PlayoutItem" /> here so the two representations stay consistent.
/// </summary>
public static class ChannelGuideMetadata
{
public static string GetTitle(PlayoutItem playoutItem)
{
if (!string.IsNullOrWhiteSpace(playoutItem.CustomTitle))
{
return playoutItem.CustomTitle;
}
return playoutItem.MediaItem switch
{
Movie m => m.MovieMetadata.HeadOrNone().Map(mm => mm.Title ?? string.Empty)
.IfNone("[unknown movie]"),
Episode e => e.Season.Show.ShowMetadata.HeadOrNone().Map(em => em.Title ?? string.Empty)
.IfNone("[unknown show]"),
MusicVideo mv => mv.Artist.ArtistMetadata.HeadOrNone().Map(am => am.Title ?? string.Empty)
.IfNone("[unknown artist]"),
OtherVideo ov => ov.OtherVideoMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty)
.IfNone("[unknown video]"),
RemoteStream rs => rs.RemoteStreamMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty)
.IfNone("[unknown remote stream]"),
_ => "[unknown]"
};
}
public static string GetSubtitle(PlayoutItem playoutItem)
{
if (!string.IsNullOrWhiteSpace(playoutItem.CustomTitle))
{
return string.Empty;
}
return playoutItem.MediaItem switch
{
Episode e => e.EpisodeMetadata.HeadOrNone().Match(
em => em.Title ?? string.Empty,
() => string.Empty),
MusicVideo mv => mv.MusicVideoMetadata.HeadOrNone().Match(
mvm => mvm.Title ?? string.Empty,
() => string.Empty),
Song s => s.SongMetadata.HeadOrNone().Match(
mvm => mvm.Title ?? string.Empty,
() => string.Empty),
_ => string.Empty
};
}
/// <summary>
/// The primary guide category, mirroring the fixed <c>&lt;category&gt;</c> the XMLTV templates
/// emit per media kind (Movie / Series / Music). Media kinds without a fixed category return null.
/// </summary>
public static string GetCategory(PlayoutItem playoutItem) =>
playoutItem.MediaItem switch
{
Movie => "Movie",
Episode => "Series",
MusicVideo => "Music",
Song => "Music",
_ => null
};
}
@@ -1,164 +0,0 @@
using ErsatzTV.Application.Configuration;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
namespace ErsatzTV.Application.Channels;
/// <summary>
/// A single guide programme resolved from one or more <see cref="PlayoutItem" />s: the
/// <see cref="DisplayItem" /> whose metadata is shown, plus the coalesced <see cref="Start" />/
/// <see cref="Stop" /> window and whether the originating item carried a custom title.
/// </summary>
public readonly record struct ChannelGuideEntry(
PlayoutItem DisplayItem,
DateTimeOffset Start,
DateTimeOffset Stop,
bool HasCustomTitle);
/// <summary>
/// Shared guide-group / filler-merge projection. This is the single source of truth for turning a
/// channel's sorted <see cref="PlayoutItem" />s into guide programmes; both the XMLTV cache builder
/// (<see cref="RefreshChannelDataHandler" />) and the JSON guide query
/// (<see cref="GetChannelGuideDataHandler" />) consume it so the two representations cannot drift.
/// The XMLTV path formats <see cref="ChannelGuideEntry.Start" />/<see cref="ChannelGuideEntry.Stop" />
/// into the XMLTV timestamp strings; the JSON path returns them (and the display item's
/// <see cref="FillerKind" />) directly and lets the UI decide how to render filler.
/// </summary>
public static class ChannelGuideProjector
{
public static IEnumerable<ChannelGuideEntry> Project(
PlayoutScheduleKind scheduleKind,
IReadOnlyList<PlayoutItem> sorted,
XmltvTimeZone timeZone,
XmltvBlockBehavior blockBehavior) =>
scheduleKind switch
{
PlayoutScheduleKind.Block => ProjectBlock(sorted, timeZone, blockBehavior),
_ => ProjectFlood(sorted, timeZone)
};
// Classic / Sequential / Scripted / ExternalJson: skip leading non-preroll filler, then coalesce
// each guide group (following filler) into a single programme using the display item's GuideFinish
// override when present.
private static IEnumerable<ChannelGuideEntry> ProjectFlood(
IReadOnlyList<PlayoutItem> sorted,
XmltvTimeZone timeZone)
{
// skip all filler that isn't pre-roll
var i = 0;
while (i < sorted.Count && sorted[i].FillerKind != FillerKind.None &&
sorted[i].FillerKind != FillerKind.PreRoll)
{
i++;
}
while (i < sorted.Count)
{
PlayoutItem startItem = sorted[i];
int j = i;
while (sorted[j].FillerKind != FillerKind.None && j + 1 < sorted.Count)
{
j++;
}
PlayoutItem displayItem = sorted[j];
bool hasCustomTitle = !string.IsNullOrWhiteSpace(startItem.CustomTitle);
int finishIndex = j;
while (finishIndex + 1 < sorted.Count && (sorted[finishIndex + 1].GuideGroup == startItem.GuideGroup
|| sorted[finishIndex + 1].FillerKind is FillerKind.GuideMode
or FillerKind.PostRoll or FillerKind.Tail
or FillerKind.Fallback or FillerKind.DecoDefault))
{
finishIndex++;
}
PlayoutItem finishItem = sorted[finishIndex];
i = finishIndex;
DateTimeOffset startTime = timeZone switch
{
XmltvTimeZone.Utc => new DateTimeOffset(startItem.Start, TimeSpan.Zero),
_ => startItem.StartOffset
};
DateTimeOffset stopTime = (timeZone, displayItem.GuideFinishOffset.HasValue) switch
{
(XmltvTimeZone.Utc, true) => new DateTimeOffset(displayItem.GuideFinish!.Value, TimeSpan.Zero),
(XmltvTimeZone.Utc, false) => new DateTimeOffset(finishItem.Finish, TimeSpan.Zero),
(_, true) => displayItem.GuideFinishOffset!.Value,
(_, false) => finishItem.FinishOffset
};
yield return new ChannelGuideEntry(displayItem, startTime, stopTime, hasCustomTitle);
i++;
}
}
// Block: group by guide window, drop filler entirely, then either use the items' actual times or
// split the group window evenly across the non-filler items.
private static IEnumerable<ChannelGuideEntry> ProjectBlock(
IReadOnlyList<PlayoutItem> sorted,
XmltvTimeZone timeZone,
XmltvBlockBehavior blockBehavior)
{
var groups = sorted.GroupBy(s => new { s.GuideStart, s.GuideFinish, s.GuideGroup });
foreach (var group in groups)
{
var itemsToInclude = group.Filter(g => g.FillerKind is FillerKind.None).ToList();
if (itemsToInclude.Count == 0)
{
continue;
}
switch (blockBehavior)
{
case XmltvBlockBehavior.UseActualTimes:
foreach (PlayoutItem item in itemsToInclude)
{
DateTimeOffset actualStart = timeZone switch
{
XmltvTimeZone.Utc => new DateTimeOffset(item.Start, TimeSpan.Zero),
_ => new DateTimeOffset(item.Start, TimeSpan.Zero).ToLocalTime()
};
DateTimeOffset actualFinish = timeZone switch
{
XmltvTimeZone.Utc => new DateTimeOffset(item.Finish, TimeSpan.Zero),
_ => new DateTimeOffset(item.Finish, TimeSpan.Zero).ToLocalTime()
};
yield return new ChannelGuideEntry(item, actualStart, actualFinish, false);
}
break;
case XmltvBlockBehavior.SplitTimeEvenly:
default:
DateTime groupStart = group.Key.GuideStart!.Value;
DateTime groupFinish = group.Key.GuideFinish!.Value;
TimeSpan groupDuration = groupFinish - groupStart;
TimeSpan perItem = groupDuration / itemsToInclude.Count;
DateTimeOffset currentStart = timeZone switch
{
XmltvTimeZone.Utc => new DateTimeOffset(groupStart, TimeSpan.Zero),
_ => new DateTimeOffset(groupStart, TimeSpan.Zero).ToLocalTime()
};
DateTimeOffset currentFinish = currentStart + perItem;
foreach (PlayoutItem item in itemsToInclude)
{
yield return new ChannelGuideEntry(item, currentStart, currentFinish, false);
currentStart = currentFinish;
currentFinish += perItem;
}
break;
}
}
}
}
@@ -1,10 +0,0 @@
namespace ErsatzTV.Application.Channels;
public class ChannelSortViewModel
{
public int Id { get; set; }
public string Number { get; set; }
public string Name { get; set; }
public string OriginalNumber { get; set; }
public bool HasChanged => OriginalNumber != Number;
}
@@ -1,11 +0,0 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Channels;
public record ChannelStreamingSpecsViewModel(
int Height,
int Width,
int Bitrate,
FFmpegProfileVideoFormat VideoFormat,
string VideoProfile,
FFmpegProfileAudioFormat AudioFormat);
@@ -1,6 +1,5 @@
using System.Net;
using ErsatzTV.Application.Artworks;
using ErsatzTV.Core.Domain;
using System.Net;
namespace ErsatzTV.Application.Channels;
@@ -11,16 +10,10 @@ public record ChannelViewModel(
string Group,
string Categories,
int FFmpegProfileId,
double? SlugSeconds,
ArtworkContentTypeModel Logo,
ChannelStreamSelectorMode StreamSelectorMode,
string StreamSelector,
string Logo,
string PreferredAudioLanguageCode,
string PreferredAudioTitle,
ChannelPlayoutSource PlayoutSource,
ChannelPlayoutMode PlayoutMode,
int? MirrorSourceChannelId,
TimeSpan? PlayoutOffset,
ChannelProgressMode ProgressMode,
StreamingMode StreamingMode,
int? WatermarkId,
int? FallbackFillerId,
@@ -29,11 +22,7 @@ public record ChannelViewModel(
ChannelSubtitleMode SubtitleMode,
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
string MusicVideoCreditsTemplate,
ChannelSongVideoMode SongVideoMode,
ChannelTranscodeMode TranscodeMode,
ChannelIdleBehavior IdleBehavior,
bool IsEnabled,
bool ShowInEpg)
ChannelSongVideoMode SongVideoMode)
{
public string WebEncodedName => WebUtility.UrlEncode(Name);
}
@@ -1,5 +0,0 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Channels;
public record BulkDeleteChannels(IReadOnlyList<int> ChannelIds) : IRequest<Either<BaseError, Unit>>;
@@ -1,64 +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);
}
}
// post-commit side effect runs on CancellationToken.None so a late request cancellation
// can't abort it after the commit landed (#254)
await workerChannel.WriteAsync(new RefreshChannelList(), CancellationToken.None);
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,64 +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();
// post-commit side effect runs on CancellationToken.None so a late request cancellation
// can't abort it after the commit landed (#254)
await workerChannel.WriteAsync(new RefreshChannelList(), CancellationToken.None);
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 =&gt; 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;
}
}
@@ -1,5 +1,4 @@
using ErsatzTV.Application.Artworks;
using ErsatzTV.Core;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Channels;
@@ -10,16 +9,10 @@ public record CreateChannel(
string Group,
string Categories,
int FFmpegProfileId,
double? SlugSeconds,
ArtworkContentTypeModel Logo,
ChannelStreamSelectorMode StreamSelectorMode,
string StreamSelector,
string Logo,
string PreferredAudioLanguageCode,
string PreferredAudioTitle,
ChannelPlayoutSource PlayoutSource,
ChannelPlayoutMode PlayoutMode,
int? MirrorSourceChannelId,
TimeSpan? PlayoutOffset,
ChannelProgressMode ProgressMode,
StreamingMode StreamingMode,
int? WatermarkId,
int? FallbackFillerId,
@@ -27,8 +20,4 @@ public record CreateChannel(
ChannelSubtitleMode SubtitleMode,
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
string MusicVideoCreditsTemplate,
ChannelSongVideoMode SongVideoMode,
ChannelTranscodeMode TranscodeMode,
ChannelIdleBehavior IdleBehavior,
bool IsEnabled,
bool ShowInEpg) : IRequest<Either<BaseError, CreateChannelResult>>;
ChannelSongVideoMode SongVideoMode) : IRequest<Either<BaseError, CreateChannelResult>>;
@@ -1,56 +0,0 @@
using ErsatzTV.Application.Artworks;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Application.Channels;
public record CreateChannelFromLineup(
string Name,
string Number,
string Group,
string Categories,
ArtworkContentTypeModel Logo,
bool IsEnabled,
bool ShowInEpg,
int TemplateId,
CreateChannelFromLineupAdvancedOptions Advanced,
List<CreateChannelFromLineupItem> Lineup) : IRequest<Either<BaseError, CreateChannelFromLineupResponseModel>>;
public record CreateChannelFromLineupAdvancedOptions(
PlaybackOrder? PlaybackOrder = null,
int? FFmpegProfileId = null,
int? WatermarkId = null,
int? FallbackFillerId = null,
int? PreRollFillerId = null,
int? MidRollFillerId = null,
int? PostRollFillerId = null,
ChannelStreamSelectorMode? StreamSelectorMode = null,
string StreamSelector = null,
string PreferredAudioLanguageCode = null,
string PreferredAudioTitle = null,
ChannelPlayoutSource? PlayoutSource = null,
ChannelPlayoutMode? PlayoutMode = null,
StreamingMode? StreamingMode = null,
string PreferredSubtitleLanguageCode = null,
ChannelSubtitleMode? SubtitleMode = null,
ChannelMusicVideoCreditsMode? MusicVideoCreditsMode = null,
string MusicVideoCreditsTemplate = null,
ChannelSongVideoMode? SongVideoMode = null,
ChannelTranscodeMode? TranscodeMode = null,
ChannelIdleBehavior? IdleBehavior = null,
bool? ShuffleScheduleItems = null,
bool? RandomStartPoint = null,
FixedStartTimeBehavior? FixedStartTimeBehavior = null);
public record CreateChannelFromLineupItem(
LibraryBrowseMediaType MediaType,
CollectionType CollectionType,
int? CollectionId,
int? MultiCollectionId,
int? SmartCollectionId,
int? RerunCollectionId,
int? MediaItemId,
int? PlaylistId);
@@ -1,757 +0,0 @@
using System.Globalization;
using System.Text.RegularExpressions;
using System.Threading.Channels;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Api.LibraryBrowse;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Channel = ErsatzTV.Core.Domain.Channel;
namespace ErsatzTV.Application.Channels;
public class CreateChannelFromLineupHandler(
ChannelWriter<IBackgroundServiceRequest> workerChannel,
IDbContextFactory<TvContext> dbContextFactory,
ISearchTargets searchTargets,
ILogger<CreateChannelFromLineupHandler> logger)
: IRequestHandler<CreateChannelFromLineup, Either<BaseError, CreateChannelFromLineupResponseModel>>
{
// The single system playlist group that holds every generated channel-lineup playlist.
// Matches the Trakt "Trakt Lists" precedent (DbInitializer + delete guards on IsSystem).
private const string SystemPlaylistGroupName = "Channel Lineups";
public async Task<Either<BaseError, CreateChannelFromLineupResponseModel>> Handle(
CreateChannelFromLineup request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Either<BaseError, PreparedCreate> validation = await Validate(dbContext, request, cancellationToken);
return await validation.Match(
Left: error => Task.FromResult<Either<BaseError, CreateChannelFromLineupResponseModel>>(error),
Right: prepared => PersistAndDispatch(dbContext, prepared, cancellationToken));
}
private async Task<Either<BaseError, CreateChannelFromLineupResponseModel>> PersistAndDispatch(
TvContext dbContext,
PreparedCreate prepared,
CancellationToken cancellationToken)
{
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
try
{
dbContext.Channels.Add(prepared.Channel);
if (prepared.Playlist is not null)
{
dbContext.Playlists.Add(prepared.Playlist);
}
dbContext.ProgramSchedules.Add(prepared.ProgramSchedule);
dbContext.Playouts.Add(prepared.Playout);
await dbContext.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
}
catch (DbUpdateException ex)
{
await transaction.RollbackAsync(cancellationToken);
logger.LogError(ex, "Failed to persist channel created from lineup");
return BaseError.New("Unable to create channel from lineup");
}
searchTargets.SearchTargetsChanged();
// post-commit side effect runs on CancellationToken.None so a late request cancellation
// can't abort it after the commit landed (#254)
await workerChannel.WriteAsync(
new BuildPlayout(prepared.Playout.Id, PlayoutBuildMode.Reset),
CancellationToken.None);
// Mirror CreateClassicPlayoutHandler: on-demand playouts must be time-shifted to "now" after build.
if (prepared.Channel.PlayoutMode is ChannelPlayoutMode.OnDemand)
{
await workerChannel.WriteAsync(
new TimeShiftOnDemandPlayout(prepared.Playout.Id, DateTimeOffset.Now, false),
CancellationToken.None);
}
await workerChannel.WriteAsync(new RefreshChannelList(), CancellationToken.None);
return new CreateChannelFromLineupResponseModel(
prepared.Channel.Id,
prepared.Playlist?.Id,
prepared.ProgramSchedule.Id,
prepared.Playout.Id);
}
private static async Task<Either<BaseError, PreparedCreate>> Validate(
TvContext dbContext,
CreateChannelFromLineup request,
CancellationToken cancellationToken)
{
string name = (request.Name ?? string.Empty).Trim();
string number = (request.Number ?? string.Empty).Trim();
string group = (request.Group ?? string.Empty).Trim();
string categories = (request.Categories ?? string.Empty).Trim();
CreateChannelFromLineupAdvancedOptions advanced = request.Advanced ?? new CreateChannelFromLineupAdvancedOptions();
if (string.IsNullOrWhiteSpace(name))
{
return BaseError.New("Channel name is required");
}
if (name.Length > 50)
{
return BaseError.New("Channel name must be 50 characters or fewer");
}
if (string.IsNullOrWhiteSpace(group))
{
return BaseError.New("Channel group is required");
}
if (!Regex.IsMatch(number, Channel.NumberValidator))
{
return BaseError.New("Invalid channel number; two decimals are allowed for subchannels");
}
if (await dbContext.Channels.AnyAsync(c => c.Number == number, cancellationToken))
{
return BaseError.New("Channel number must be unique");
}
if (!request.IsEnabled && request.ShowInEpg)
{
return BaseError.New("Disabled channels cannot be shown in EPG");
}
if (!string.IsNullOrWhiteSpace(request.Logo?.Path) &&
Uri.TryCreate(request.Logo.Path, UriKind.Absolute, out _) &&
!Artwork.IsExternalUrl(request.Logo.Path))
{
return BaseError.New("External logo url is invalid");
}
if (request.Lineup is null || request.Lineup.Count == 0)
{
return BaseError.New("Lineup must contain at least one item");
}
ChannelTemplate template = await dbContext.ChannelTemplates
.AsNoTracking()
.SingleOrDefaultAsync(t => t.Id == request.TemplateId, cancellationToken);
if (template is null)
{
return new NotFoundError($"Channel template {request.TemplateId} does not exist.");
}
int ffmpegProfileId = advanced.FFmpegProfileId ?? template.FFmpegProfileId;
int? fallbackFillerId = advanced.FallbackFillerId ?? template.FallbackFillerId;
int? preRollFillerId = advanced.PreRollFillerId ?? template.PreRollFillerId;
int? midRollFillerId = advanced.MidRollFillerId ?? template.MidRollFillerId;
int? postRollFillerId = advanced.PostRollFillerId ?? template.PostRollFillerId;
PlaybackOrder playbackOrder = advanced.PlaybackOrder ?? PlaybackOrder.Chronological;
ChannelPlayoutSource playoutSource = advanced.PlayoutSource ?? template.PlayoutSource;
// Mirror channels need special MirrorSourceChannelId plumbing (see CreateChannelHandler);
// this endpoint only builds generated playouts.
if (playoutSource is ChannelPlayoutSource.Mirror)
{
return BaseError.New("Mirror playout source is not supported by this endpoint");
}
Either<BaseError, Unit> referenceValidation = await ValidateReferences(
dbContext,
advanced,
template,
cancellationToken);
foreach (BaseError error in referenceValidation.LeftToSeq())
{
return error;
}
// Normalize + validate every lineup entry once, so validation and build see the same data.
var normalized = new List<NormalizedLineupItem>();
for (int i = 0; i < request.Lineup.Count; i++)
{
Either<BaseError, NormalizedLineupItem> itemValidation =
await NormalizeLineupItem(dbContext, request.Lineup[i], i, cancellationToken);
foreach (BaseError error in itemValidation.LeftToSeq())
{
return error;
}
foreach (NormalizedLineupItem item in itemValidation.RightToSeq())
{
normalized.Add(item);
}
}
bool multiItem = normalized.Count >= 2;
// MultiCollection entries only support Shuffle / ShuffleInOrder (mirrors PlayoutModeMustBeValid).
if (normalized.Any(i => i.CollectionType is CollectionType.MultiCollection) &&
playbackOrder is not (PlaybackOrder.Shuffle or PlaybackOrder.ShuffleInOrder))
{
return BaseError.New($"Invalid playback order for multi collection: '{playbackOrder}'");
}
if (multiItem)
{
// The generated playlist cannot express rerun collections or nested playlists
// (PlaylistItem + CollectionKey.ForPlaylistItem lack those fields).
for (int i = 0; i < normalized.Count; i++)
{
if (normalized[i].CollectionType is CollectionType.RerunFirstRun or CollectionType.Playlist)
{
return BaseError.New(
$"lineup[{normalized[i].Index}]: rerun collections and playlists are only " +
"supported as a single-item lineup");
}
}
}
Channel channel = BuildChannel(
request,
template,
advanced,
name,
number,
group,
categories,
ffmpegProfileId,
fallbackFillerId);
string scheduleName = await DeCollideName(
GeneratedName(number, name, "Schedule"),
(candidate, ct) => dbContext.ProgramSchedules.AnyAsync(ps => ps.Name == candidate, ct),
cancellationToken);
ProgramSchedule schedule = BuildProgramSchedule(scheduleName, template, advanced);
Playlist playlist = null;
ProgramScheduleItemFlood floodItem = BuildFloodBase(
playbackOrder,
advanced,
template,
fallbackFillerId,
preRollFillerId,
midRollFillerId,
postRollFillerId);
if (multiItem)
{
playlist = await BuildPlaylist(dbContext, number, name, normalized, playbackOrder, cancellationToken);
floodItem.CollectionType = CollectionType.Playlist;
floodItem.Playlist = playlist;
}
else
{
NormalizedLineupItem only = normalized[0];
floodItem.CollectionType = only.CollectionType;
floodItem.CollectionId = only.CollectionId;
floodItem.MultiCollectionId = only.MultiCollectionId;
floodItem.SmartCollectionId = only.SmartCollectionId;
floodItem.RerunCollectionId = only.RerunCollectionId;
floodItem.MediaItemId = only.MediaItemId;
floodItem.PlaylistId = only.PlaylistId;
}
schedule.Items = [floodItem];
var playout = new Playout
{
Channel = channel,
ProgramSchedule = schedule,
ScheduleKind = PlayoutScheduleKind.Classic
};
return new PreparedCreate(channel, playlist, schedule, playout);
}
private static async Task<Playlist> BuildPlaylist(
TvContext dbContext,
string channelNumber,
string channelName,
List<NormalizedLineupItem> lineup,
PlaybackOrder playbackOrder,
CancellationToken cancellationToken)
{
// Reuse the single system playlist group, creating it on first use.
PlaylistGroup playlistGroup = await dbContext.PlaylistGroups
.FirstOrDefaultAsync(pg => pg.IsSystem && pg.Name == SystemPlaylistGroupName, cancellationToken);
playlistGroup ??= new PlaylistGroup { Name = SystemPlaylistGroupName, IsSystem = true };
string playlistName = await DeCollideName(
GeneratedName(channelNumber, channelName, "Lineup"),
(candidate, ct) => dbContext.Playlists.AnyAsync(
p => p.PlaylistGroupId == playlistGroup.Id && p.Name == candidate,
ct),
cancellationToken);
var playlist = new Playlist
{
Name = playlistName,
IsSystem = true,
PlaylistGroup = playlistGroup,
PlaylistGroupId = playlistGroup.Id,
Items = []
};
int index = 1;
foreach (NormalizedLineupItem item in lineup)
{
playlist.Items.Add(new PlaylistItem
{
Playlist = playlist,
Index = index++,
CollectionType = item.CollectionType,
CollectionId = item.CollectionId,
MultiCollectionId = item.MultiCollectionId,
SmartCollectionId = item.SmartCollectionId,
MediaItemId = item.MediaItemId,
PlaybackOrder = playbackOrder,
// Play every item in each entry before advancing so lineup order is honored
// (PlaylistEnumerator round-robins one-per-entry unless PlayAll/Count).
PlayAll = true,
IncludeInProgramGuide = true
});
}
return playlist;
}
private static Channel BuildChannel(
CreateChannelFromLineup request,
ChannelTemplate template,
CreateChannelFromLineupAdvancedOptions advanced,
string name,
string number,
string group,
string categories,
int ffmpegProfileId,
int? fallbackFillerId)
{
var artwork = new List<Artwork>();
if (!string.IsNullOrWhiteSpace(request.Logo?.Path))
{
string logo = request.Logo.Path;
if (logo.StartsWith("iptv/logos/", StringComparison.Ordinal))
{
logo = logo.Replace("iptv/logos/", string.Empty);
}
artwork.Add(new Artwork
{
Path = logo,
ArtworkKind = ArtworkKind.Logo,
OriginalContentType = !string.IsNullOrEmpty(request.Logo.ContentType) ? request.Logo.ContentType : null,
DateAdded = DateTime.UtcNow,
DateUpdated = DateTime.UtcNow
});
}
return new Channel(Guid.NewGuid())
{
Name = name,
Number = number,
SortNumber = double.Parse(number, CultureInfo.InvariantCulture),
Group = group,
Categories = categories,
FFmpegProfileId = ffmpegProfileId,
SlugSeconds = null,
PlayoutSource = advanced.PlayoutSource ?? template.PlayoutSource,
PlayoutMode = advanced.PlayoutMode ?? template.PlayoutMode,
StreamingMode = advanced.StreamingMode ?? template.StreamingMode,
WatermarkId = advanced.WatermarkId ?? template.WatermarkId,
FallbackFillerId = fallbackFillerId,
Artwork = artwork,
StreamSelectorMode = advanced.StreamSelectorMode ?? template.StreamSelectorMode,
StreamSelector = advanced.StreamSelector ?? template.StreamSelector ?? string.Empty,
PreferredAudioLanguageCode =
advanced.PreferredAudioLanguageCode ?? template.PreferredAudioLanguageCode ?? string.Empty,
PreferredAudioTitle = advanced.PreferredAudioTitle ?? template.PreferredAudioTitle ?? string.Empty,
PreferredSubtitleLanguageCode =
advanced.PreferredSubtitleLanguageCode ?? template.PreferredSubtitleLanguageCode ?? string.Empty,
SubtitleMode = advanced.SubtitleMode ?? template.SubtitleMode,
MusicVideoCreditsMode = advanced.MusicVideoCreditsMode ?? template.MusicVideoCreditsMode,
MusicVideoCreditsTemplate =
advanced.MusicVideoCreditsTemplate ?? template.MusicVideoCreditsTemplate ?? string.Empty,
SongVideoMode = advanced.SongVideoMode ?? template.SongVideoMode,
TranscodeMode = advanced.TranscodeMode ?? template.TranscodeMode,
IdleBehavior = advanced.IdleBehavior ?? template.IdleBehavior,
IsEnabled = request.IsEnabled,
ShowInEpg = request.IsEnabled && request.ShowInEpg
};
}
private static ProgramSchedule BuildProgramSchedule(
string scheduleName,
ChannelTemplate template,
CreateChannelFromLineupAdvancedOptions advanced) =>
new()
{
Name = scheduleName,
KeepMultiPartEpisodesTogether = true,
TreatCollectionsAsShows = true,
ShuffleScheduleItems = advanced.ShuffleScheduleItems ?? template.ShuffleScheduleItems,
RandomStartPoint = advanced.RandomStartPoint ?? template.RandomStartPoint,
FixedStartTimeBehavior = advanced.FixedStartTimeBehavior ?? template.FixedStartTimeBehavior,
Items = []
};
private static ProgramScheduleItemFlood BuildFloodBase(
PlaybackOrder playbackOrder,
CreateChannelFromLineupAdvancedOptions advanced,
ChannelTemplate template,
int? fallbackFillerId,
int? preRollFillerId,
int? midRollFillerId,
int? postRollFillerId) =>
new()
{
Index = 1,
PlaybackOrder = playbackOrder,
GuideMode = GuideMode.Normal,
CustomTitle = string.Empty,
SearchTitle = string.Empty,
SearchQuery = string.Empty,
PreRollFillerId = preRollFillerId,
MidRollFillerId = midRollFillerId,
PostRollFillerId = postRollFillerId,
FallbackFillerId = fallbackFillerId,
PreferredAudioLanguageCode =
advanced.PreferredAudioLanguageCode ?? template.PreferredAudioLanguageCode ?? string.Empty,
PreferredAudioTitle = advanced.PreferredAudioTitle ?? template.PreferredAudioTitle ?? string.Empty,
PreferredSubtitleLanguageCode =
advanced.PreferredSubtitleLanguageCode ?? template.PreferredSubtitleLanguageCode ?? string.Empty,
SubtitleMode = advanced.SubtitleMode ?? template.SubtitleMode
};
private static string GeneratedName(string channelNumber, string channelName, string suffix)
{
string prefix = $"{channelNumber} {channelName}".Trim();
int maxPrefixLength = Math.Max(0, 50 - suffix.Length - 1);
if (prefix.Length > maxPrefixLength)
{
prefix = prefix[..maxPrefixLength].TrimEnd();
}
return $"{prefix} {suffix}".Trim();
}
// De-collide a generated (already <= 50 char) name against a unique index by appending " 2", " 3", ...
// rather than leaking a raw UNIQUE-constraint failure.
private static async Task<string> DeCollideName(
string baseName,
Func<string, CancellationToken, Task<bool>> exists,
CancellationToken cancellationToken)
{
if (!await exists(baseName, cancellationToken))
{
return baseName;
}
for (int n = 2; ; n++)
{
string suffix = $" {n}";
string candidate = baseName.Length + suffix.Length > 50
? baseName[..(50 - suffix.Length)].TrimEnd() + suffix
: baseName + suffix;
if (!await exists(candidate, cancellationToken))
{
return candidate;
}
}
}
private static async Task<Either<BaseError, Unit>> ValidateReferences(
TvContext dbContext,
CreateChannelFromLineupAdvancedOptions advanced,
ChannelTemplate template,
CancellationToken cancellationToken)
{
int ffmpegProfileId = advanced.FFmpegProfileId ?? template.FFmpegProfileId;
if (!await dbContext.FFmpegProfiles.AnyAsync(p => p.Id == ffmpegProfileId, cancellationToken))
{
return new NotFoundError($"FFmpegProfile {ffmpegProfileId} does not exist.");
}
Either<BaseError, Unit> channelReferences = await ValidateChannelReferences(
dbContext,
advanced.WatermarkId ?? template.WatermarkId,
advanced.FallbackFillerId ?? template.FallbackFillerId,
cancellationToken);
foreach (BaseError error in channelReferences.LeftToSeq())
{
return error;
}
Either<BaseError, Unit> itemFillers = await ValidateItemFillers(
dbContext,
advanced.PreRollFillerId ?? template.PreRollFillerId,
advanced.MidRollFillerId ?? template.MidRollFillerId,
advanced.PostRollFillerId ?? template.PostRollFillerId,
cancellationToken);
foreach (BaseError error in itemFillers.LeftToSeq())
{
return error;
}
return Unit.Default;
}
private static async Task<Either<BaseError, Unit>> ValidateChannelReferences(
TvContext dbContext,
int? watermarkId,
int? fallbackFillerId,
CancellationToken cancellationToken)
{
if (watermarkId.HasValue &&
!await dbContext.ChannelWatermarks.AnyAsync(w => w.Id == watermarkId.Value, cancellationToken))
{
return new NotFoundError($"Watermark {watermarkId.Value} does not exist.");
}
if (fallbackFillerId.HasValue && !await dbContext.FillerPresets.AnyAsync(
fp => fp.Id == fallbackFillerId.Value && fp.FillerKind == FillerKind.Fallback,
cancellationToken))
{
return new NotFoundError($"Fallback filler {fallbackFillerId.Value} does not exist.");
}
return Unit.Default;
}
private static async Task<Either<BaseError, Unit>> ValidateItemFillers(
TvContext dbContext,
int? preRollFillerId,
int? midRollFillerId,
int? postRollFillerId,
CancellationToken cancellationToken)
{
if (preRollFillerId.HasValue && !await FillerExists(dbContext, preRollFillerId.Value, FillerKind.PreRoll, cancellationToken))
{
return new NotFoundError($"Pre-roll filler {preRollFillerId.Value} does not exist.");
}
if (midRollFillerId.HasValue && !await FillerExists(dbContext, midRollFillerId.Value, FillerKind.MidRoll, cancellationToken))
{
return new NotFoundError($"Mid-roll filler {midRollFillerId.Value} does not exist.");
}
if (postRollFillerId.HasValue && !await FillerExists(dbContext, postRollFillerId.Value, FillerKind.PostRoll, cancellationToken))
{
return new NotFoundError($"Post-roll filler {postRollFillerId.Value} does not exist.");
}
return Unit.Default;
}
private static Task<bool> FillerExists(
TvContext dbContext,
int id,
FillerKind fillerKind,
CancellationToken cancellationToken) =>
dbContext.FillerPresets.AnyAsync(fp => fp.Id == id && fp.FillerKind == fillerKind, cancellationToken);
private static async Task<Either<BaseError, NormalizedLineupItem>> NormalizeLineupItem(
TvContext dbContext,
CreateChannelFromLineupItem item,
int index,
CancellationToken cancellationToken)
{
int providedIds = new int?[]
{
item.CollectionId,
item.MultiCollectionId,
item.SmartCollectionId,
item.RerunCollectionId,
item.MediaItemId,
item.PlaylistId
}.Count(id => id.HasValue);
if (providedIds != 1)
{
return BaseError.New($"lineup[{index}] must provide exactly one typed id.");
}
switch (item.MediaType)
{
case LibraryBrowseMediaType.Movie:
return await NormalizeMediaItem(
dbContext.Movies, item, CollectionType.Movie, index, "Movie", cancellationToken);
case LibraryBrowseMediaType.TelevisionShow:
return await NormalizeMediaItem(
dbContext.Shows, item, CollectionType.TelevisionShow, index, "TelevisionShow", cancellationToken);
case LibraryBrowseMediaType.TelevisionSeason:
return await NormalizeMediaItem(
dbContext.Seasons, item, CollectionType.TelevisionSeason, index, "TelevisionSeason", cancellationToken);
case LibraryBrowseMediaType.Artist:
return await NormalizeMediaItem(
dbContext.Artists, item, CollectionType.Artist, index, "Artist", cancellationToken);
case LibraryBrowseMediaType.Collection:
if (item.CollectionType != CollectionType.Collection)
{
return Mismatch(index, item);
}
if (!item.CollectionId.HasValue)
{
return WrongId(index, item.MediaType, "collectionId");
}
return await ExistsThen(
dbContext.Collections,
item.CollectionId.Value,
$"lineup[{index}] Collection",
new NormalizedLineupItem(index, CollectionType.Collection, CollectionId: item.CollectionId),
cancellationToken);
case LibraryBrowseMediaType.SmartCollection:
if (item.CollectionType != CollectionType.SmartCollection)
{
return Mismatch(index, item);
}
if (!item.SmartCollectionId.HasValue)
{
return WrongId(index, item.MediaType, "smartCollectionId");
}
return await ExistsThen(
dbContext.SmartCollections,
item.SmartCollectionId.Value,
$"lineup[{index}] SmartCollection",
new NormalizedLineupItem(index, CollectionType.SmartCollection, SmartCollectionId: item.SmartCollectionId),
cancellationToken);
case LibraryBrowseMediaType.MultiCollection:
if (item.CollectionType != CollectionType.MultiCollection)
{
return Mismatch(index, item);
}
if (!item.MultiCollectionId.HasValue)
{
return WrongId(index, item.MediaType, "multiCollectionId");
}
return await ExistsThen(
dbContext.MultiCollections,
item.MultiCollectionId.Value,
$"lineup[{index}] MultiCollection",
new NormalizedLineupItem(index, CollectionType.MultiCollection, MultiCollectionId: item.MultiCollectionId),
cancellationToken);
case LibraryBrowseMediaType.RerunCollection:
if (item.CollectionType != CollectionType.RerunFirstRun)
{
return Mismatch(index, item);
}
if (!item.RerunCollectionId.HasValue)
{
return WrongId(index, item.MediaType, "rerunCollectionId");
}
return await ExistsThen(
dbContext.RerunCollections,
item.RerunCollectionId.Value,
$"lineup[{index}] RerunCollection",
new NormalizedLineupItem(index, CollectionType.RerunFirstRun, RerunCollectionId: item.RerunCollectionId),
cancellationToken);
case LibraryBrowseMediaType.Playlist:
if (item.CollectionType != CollectionType.Playlist)
{
return Mismatch(index, item);
}
if (!item.PlaylistId.HasValue)
{
return WrongId(index, item.MediaType, "playlistId");
}
return await ExistsThen(
dbContext.Playlists,
item.PlaylistId.Value,
$"lineup[{index}] Playlist",
new NormalizedLineupItem(index, CollectionType.Playlist, PlaylistId: item.PlaylistId),
cancellationToken);
default:
return BaseError.New($"lineup[{index}] has an unsupported media type '{item.MediaType}'.");
}
}
private static async Task<Either<BaseError, NormalizedLineupItem>> NormalizeMediaItem<TEntity>(
DbSet<TEntity> set,
CreateChannelFromLineupItem item,
CollectionType expectedType,
int index,
string label,
CancellationToken cancellationToken)
where TEntity : class
{
if (item.CollectionType != expectedType)
{
return Mismatch(index, item);
}
if (!item.MediaItemId.HasValue)
{
return WrongId(index, item.MediaType, "mediaItemId");
}
return await ExistsThen(
set,
item.MediaItemId.Value,
$"lineup[{index}] {label}",
new NormalizedLineupItem(index, expectedType, MediaItemId: item.MediaItemId),
cancellationToken);
}
private static BaseError Mismatch(int index, CreateChannelFromLineupItem item) =>
BaseError.New(
$"lineup[{index}]: media type '{item.MediaType}' does not match collection type '{item.CollectionType}'.");
private static BaseError WrongId(int index, LibraryBrowseMediaType mediaType, string expectedField) =>
BaseError.New($"lineup[{index}]: media type '{mediaType}' requires a {expectedField}.");
private static async Task<Either<BaseError, NormalizedLineupItem>> ExistsThen<TEntity>(
DbSet<TEntity> set,
int id,
string label,
NormalizedLineupItem normalized,
CancellationToken cancellationToken)
where TEntity : class
{
bool exists = await set.AsNoTracking()
.AnyAsync(e => EF.Property<int>(e, "Id") == id, cancellationToken);
return exists
? normalized
: new NotFoundError($"{label} {id} does not exist.");
}
private sealed record NormalizedLineupItem(
int Index,
CollectionType CollectionType,
int? CollectionId = null,
int? MultiCollectionId = null,
int? SmartCollectionId = null,
int? RerunCollectionId = null,
int? MediaItemId = null,
int? PlaylistId = null);
private sealed record PreparedCreate(
Channel Channel,
Playlist Playlist,
ProgramSchedule ProgramSchedule,
Playout Playout);
}
@@ -1,5 +1,4 @@
using System.Globalization;
using System.Text.RegularExpressions;
using System.Text.RegularExpressions;
using System.Threading.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
@@ -8,7 +7,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;
@@ -24,7 +22,7 @@ public class CreateChannelHandler(
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Channel> validation = await Validate(dbContext, request, cancellationToken);
Validation<BaseError, Channel> validation = await Validate(dbContext, request);
return await validation.Apply(c => PersistChannel(dbContext, c));
}
@@ -37,104 +35,63 @@ 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),
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))
.Apply((
name,
number,
ffmpegProfileId,
watermarkId,
fillerPresetId,
_,
_,
_) =>
{
var artwork = new List<Artwork>();
if (!string.IsNullOrWhiteSpace(request.Logo?.Path))
private static async Task<Validation<BaseError, Channel>> Validate(TvContext dbContext, CreateChannel request) =>
(ValidateName(request), await ValidateNumber(dbContext, request),
await FFmpegProfileMustExist(dbContext, request),
await WatermarkMustExist(dbContext, request),
await FillerPresetMustExist(dbContext, request))
.Apply(
(
name,
number,
ffmpegProfileId,
watermarkId,
fillerPresetId) =>
{
string logo = request.Logo.Path;
if (logo.StartsWith("iptv/logos/", StringComparison.Ordinal))
var artwork = new List<Artwork>();
if (!string.IsNullOrWhiteSpace(request.Logo))
{
logo = logo.Replace("iptv/logos/", string.Empty);
artwork.Add(
new Artwork
{
Path = request.Logo,
ArtworkKind = ArtworkKind.Logo,
DateAdded = DateTime.UtcNow,
DateUpdated = DateTime.UtcNow
});
}
artwork.Add(
new Artwork
{
Path = logo,
ArtworkKind = ArtworkKind.Logo,
OriginalContentType = !string.IsNullOrEmpty(request.Logo.ContentType)
? request.Logo.ContentType
: null,
DateAdded = DateTime.UtcNow,
DateUpdated = DateTime.UtcNow
});
}
var channel = new Channel(Guid.NewGuid())
{
Name = name,
Number = number,
Group = request.Group,
Categories = request.Categories,
FFmpegProfileId = ffmpegProfileId,
ProgressMode = request.ProgressMode,
StreamingMode = request.StreamingMode,
Artwork = artwork,
PreferredAudioLanguageCode = request.PreferredAudioLanguageCode,
PreferredAudioTitle = request.PreferredAudioTitle,
PreferredSubtitleLanguageCode = request.PreferredSubtitleLanguageCode,
SubtitleMode = request.SubtitleMode,
MusicVideoCreditsMode = request.MusicVideoCreditsMode,
MusicVideoCreditsTemplate = request.MusicVideoCreditsTemplate,
SongVideoMode = request.SongVideoMode
};
var channel = new Channel(Guid.NewGuid())
{
Name = name,
Number = number,
SortNumber = double.Parse(number, CultureInfo.InvariantCulture),
Group = request.Group,
Categories = request.Categories,
FFmpegProfileId = ffmpegProfileId,
SlugSeconds = request.SlugSeconds,
PlayoutSource = request.PlayoutSource,
PlayoutMode = request.PlayoutMode,
MirrorSourceChannelId = request.MirrorSourceChannelId,
PlayoutOffset = request.PlayoutOffset,
StreamingMode = request.StreamingMode,
Artwork = artwork,
StreamSelectorMode = request.StreamSelectorMode,
StreamSelector = request.StreamSelector,
PreferredAudioLanguageCode = request.PreferredAudioLanguageCode,
PreferredAudioTitle = request.PreferredAudioTitle,
PreferredSubtitleLanguageCode = request.PreferredSubtitleLanguageCode,
SubtitleMode = request.SubtitleMode,
MusicVideoCreditsMode = request.MusicVideoCreditsMode,
MusicVideoCreditsTemplate = request.MusicVideoCreditsTemplate,
SongVideoMode = request.SongVideoMode,
TranscodeMode = request.TranscodeMode,
IdleBehavior = request.IdleBehavior,
IsEnabled = request.IsEnabled,
ShowInEpg = request.IsEnabled && request.ShowInEpg
};
foreach (int id in watermarkId)
{
channel.WatermarkId = id;
}
if (channel.PlayoutSource is ChannelPlayoutSource.Mirror)
{
channel.PlayoutMode = ChannelPlayoutMode.Continuous;
}
else
{
channel.MirrorSourceChannelId = null;
channel.PlayoutOffset = null;
}
foreach (int id in fillerPresetId)
{
channel.FallbackFillerId = id;
}
foreach (int id in watermarkId)
{
channel.WatermarkId = id;
}
foreach (int id in fillerPresetId)
{
channel.FallbackFillerId = id;
}
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);
}
return channel;
});
private static Validation<BaseError, string> ValidateName(CreateChannel createChannel) =>
createChannel.NotEmpty(c => c.Name)
@@ -142,11 +99,10 @@ public class CreateChannelHandler(
private static async Task<Validation<BaseError, string>> ValidateNumber(
TvContext dbContext,
CreateChannel createChannel,
CancellationToken cancellationToken)
CreateChannel createChannel)
{
Option<Channel> maybeExistingChannel = await dbContext.Channels
.SelectOneAsync(c => c.Number, c => c.Number == createChannel.Number, cancellationToken);
.SelectOneAsync(c => c.Number, c => c.Number == createChannel.Number);
return maybeExistingChannel.Match<Validation<BaseError, string>>(
_ => BaseError.New("Channel number must be unique"),
() =>
@@ -160,101 +116,50 @@ 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.");
}
CreateChannel createChannel) =>
dbContext.FFmpegProfiles
.CountAsync(p => p.Id == createChannel.FFmpegProfileId)
.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,
CreateChannel createChannel,
CancellationToken cancellationToken)
CreateChannel createChannel)
{
if (createChannel.WatermarkId is null)
{
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)
.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(
TvContext dbContext,
CreateChannel createChannel,
CancellationToken cancellationToken)
CreateChannel createChannel)
{
if (createChannel.FallbackFillerId is null)
{
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.");
}
private static async Task<Validation<BaseError, Unit>> MirrorSourceMustBeValid(
TvContext dbContext,
CreateChannel createChannel,
CancellationToken cancellationToken)
{
if (createChannel.PlayoutSource is not ChannelPlayoutSource.Mirror)
{
return Unit.Default;
}
Option<Channel> maybeMirrorSource = await dbContext.Channels
.AsNoTracking()
.SelectOneAsync(
c => c.Id == createChannel.MirrorSourceChannelId,
c => c.Id == createChannel.MirrorSourceChannelId,
cancellationToken);
if (maybeMirrorSource.IsNone)
{
return BaseError.New("Mirror source channel does not exist.");
}
foreach (var mirrorSource in maybeMirrorSource)
{
if (mirrorSource.PlayoutSource is not ChannelPlayoutSource.Generated)
{
return BaseError.New(
$"Mirror source channel {mirrorSource.Name} must use generated playout source");
}
}
foreach (TimeSpan playoutOffset in Optional(createChannel.PlayoutOffset))
{
if (playoutOffset < TimeSpan.FromHours(-12) || playoutOffset > TimeSpan.FromHours(12))
{
return BaseError.New("Playout offset must not be greater than 12 hours");
}
}
return Unit.Default;
.CountAsync(w => w.Id == createChannel.FallbackFillerId)
.Map(Optional)
.Filter(c => c > 0)
.MapT(_ => Optional(createChannel.FallbackFillerId))
.Map(
o => o.ToValidation<BaseError>(
$"Fallback filler {createChannel.FallbackFillerId} does not exist."));
}
}
@@ -1,7 +1,6 @@
using System.IO.Abstractions;
using System.Threading.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
@@ -13,59 +12,55 @@ namespace ErsatzTV.Application.Channels;
public class DeleteChannelHandler : IRequestHandler<DeleteChannel, Either<BaseError, Unit>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IFileSystem _fileSystem;
private readonly ILocalFileSystem _localFileSystem;
private readonly ISearchTargets _searchTargets;
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
public DeleteChannelHandler(
ChannelWriter<IBackgroundServiceRequest> workerChannel,
IDbContextFactory<TvContext> dbContextFactory,
IFileSystem fileSystem,
ILocalFileSystem localFileSystem,
ISearchTargets searchTargets)
{
_workerChannel = workerChannel;
_dbContextFactory = dbContextFactory;
_fileSystem = fileSystem;
_localFileSystem = localFileSystem;
_searchTargets = searchTargets;
}
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);
return await validation.Apply(c => DoDeletion(dbContext, c, cancellationToken));
}
private async Task<Unit> DoDeletion(TvContext dbContext, Channel channel, CancellationToken cancellationToken)
{
// Delete the guide cache file through the filesystem abstraction (so it's observable under a
// MockFileSystem) and BEFORE the commit: deleting after commit orphans {number}.xml if the
// process crashes in between (nothing reaps it, and GetChannelGuideHandler serves everything
// in the cache folder). The guide xml is regenerable on demand, so losing it pre-commit is safe (#254).
string cacheFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{channel.Number}.xml");
if (_fileSystem.File.Exists(cacheFile))
{
_fileSystem.File.Delete(cacheFile);
}
dbContext.Channels.Remove(channel);
await dbContext.SaveChangesAsync(cancellationToken);
_searchTargets.SearchTargetsChanged();
// refresh channel list to remove channel that has no playout — post-commit side effect runs on
// CancellationToken.None so a late request cancellation can't abort it after the delete committed (#254)
await _workerChannel.WriteAsync(new RefreshChannelList(), CancellationToken.None);
// delete channel data from channel guide cache
string cacheFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{channel.Number}.xml");
if (_localFileSystem.FileExists(cacheFile))
{
File.Delete(cacheFile);
}
// refresh channel list to remove channel that has no playout
await _workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
return Unit.Default;
}
private static async Task<Validation<BaseError, Channel>> ChannelMustExist(
TvContext dbContext,
DeleteChannel deleteChannel)
{
Option<Channel> maybeChannel = await dbContext.Channels
.SelectOneAsync(c => c.Id, c => c.Id == deleteChannel.ChannelId);
return maybeChannel.ToValidation<BaseError>($"Channel {deleteChannel.ChannelId} does not exist.");
}
}
@@ -1,5 +1,4 @@
using System.Globalization;
using System.IO.Abstractions;
using System.Xml;
using ErsatzTV.Application.Configuration;
using ErsatzTV.Core;
@@ -12,7 +11,6 @@ using ErsatzTV.Core.Iptv;
using ErsatzTV.Core.Jellyfin;
using ErsatzTV.Core.Streaming;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.IO;
@@ -27,7 +25,6 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
{
private readonly IConfigElementRepository _configElementRepository;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IFileSystem _fileSystem;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<RefreshChannelDataHandler> _logger;
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
@@ -35,14 +32,12 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
public RefreshChannelDataHandler(
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
IDbContextFactory<TvContext> dbContextFactory,
IFileSystem fileSystem,
ILocalFileSystem localFileSystem,
IConfigElementRepository configElementRepository,
ILogger<RefreshChannelDataHandler> logger)
{
_recyclableMemoryStreamManager = recyclableMemoryStreamManager;
_dbContextFactory = dbContextFactory;
_fileSystem = fileSystem;
_localFileSystem = localFileSystem;
_configElementRepository = configElementRepository;
_logger = logger;
@@ -50,204 +45,211 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
public async Task Handle(RefreshChannelData request, CancellationToken cancellationToken)
{
try
_logger.LogDebug("Refreshing channel data (XMLTV) for channel {Channel}", request.ChannelNumber);
_localFileSystem.EnsureFolderExists(FileSystemLayout.ChannelGuideCacheFolder);
string movieTemplateFileName = GetMovieTemplateFileName();
string episodeTemplateFileName = GetEpisodeTemplateFileName();
string musicVideoTemplateFileName = GetMusicVideoTemplateFileName();
string songTemplateFileName = GetSongTemplateFileName();
string otherVideoTemplateFileName = GetOtherVideoTemplateFileName();
if (movieTemplateFileName is null || episodeTemplateFileName is null || musicVideoTemplateFileName is null ||
songTemplateFileName is null || otherVideoTemplateFileName is null)
{
_logger.LogDebug("Refreshing channel data (XMLTV) for channel {Channel}", request.ChannelNumber);
_localFileSystem.EnsureFolderExists(FileSystemLayout.ChannelGuideCacheFolder);
string targetFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{request.ChannelNumber}.xml");
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
int hiddenCount = await dbContext.Channels
.Where(c => c.Number == request.ChannelNumber && c.ShowInEpg == false)
.CountAsync(cancellationToken);
if (hiddenCount > 0)
{
File.Delete(targetFile);
return;
}
string movieTemplateFileName = GetMovieTemplateFileName();
string episodeTemplateFileName = GetEpisodeTemplateFileName();
string musicVideoTemplateFileName = GetMusicVideoTemplateFileName();
string songTemplateFileName = GetSongTemplateFileName();
string otherVideoTemplateFileName = GetOtherVideoTemplateFileName();
string remoteStreamTemplateFileName = GetRemoteStreamTemplateFileName();
if (movieTemplateFileName is null || episodeTemplateFileName is null ||
musicVideoTemplateFileName is null ||
songTemplateFileName is null || otherVideoTemplateFileName is null ||
remoteStreamTemplateFileName is null)
{
return;
}
var minifier = new XmlMinifier(
new XmlMinificationSettings
{
MinifyWhitespace = true,
RemoveXmlComments = true,
CollapseTagsWithoutContent = true
});
var templateContext = new XmlTemplateContext();
string movieText = await File.ReadAllTextAsync(movieTemplateFileName, cancellationToken);
var movieTemplate = Template.Parse(movieText, movieTemplateFileName);
string episodeText = await File.ReadAllTextAsync(episodeTemplateFileName, cancellationToken);
var episodeTemplate = Template.Parse(episodeText, episodeTemplateFileName);
string musicVideoText = await File.ReadAllTextAsync(musicVideoTemplateFileName, cancellationToken);
var musicVideoTemplate = Template.Parse(musicVideoText, musicVideoTemplateFileName);
string songText = await File.ReadAllTextAsync(songTemplateFileName, cancellationToken);
var songTemplate = Template.Parse(songText, songTemplateFileName);
string otherVideoText = await File.ReadAllTextAsync(otherVideoTemplateFileName, cancellationToken);
var otherVideoTemplate = Template.Parse(otherVideoText, otherVideoTemplateFileName);
string remoteStreamText = await File.ReadAllTextAsync(remoteStreamTemplateFileName, cancellationToken);
var remoteStreamTemplate = Template.Parse(remoteStreamText, remoteStreamTemplateFileName);
TimeSpan playoutOffset = TimeSpan.Zero;
string mirrorChannelNumber = null;
Option<Channel> maybeChannel = await dbContext.Channels
.AsNoTracking()
.Include(c => c.MirrorSourceChannel)
.Filter(c => c.PlayoutSource == ChannelPlayoutSource.Mirror && c.MirrorSourceChannelId != null)
.SelectOneAsync(
c => c.Number == request.ChannelNumber,
c => c.Number == request.ChannelNumber,
cancellationToken);
foreach (Channel channel in maybeChannel)
{
mirrorChannelNumber = channel.MirrorSourceChannel.Number;
playoutOffset = channel.PlayoutOffset ?? TimeSpan.Zero;
}
List<Playout> playouts = await dbContext.Playouts
.AsNoTracking()
.Filter(pi => pi.Channel.Number == (mirrorChannelNumber ?? request.ChannelNumber))
.IncludeGuideMetadata()
.AsSplitQuery()
.ToListAsync(cancellationToken);
await using RecyclableMemoryStream ms = _recyclableMemoryStreamManager.GetStream();
await using var xml = XmlWriter.Create(
ms,
new XmlWriterSettings { Async = true, ConformanceLevel = ConformanceLevel.Fragment });
int daysToBuild = await _configElementRepository
.GetValue<int>(ConfigElementKey.XmltvDaysToBuild, cancellationToken)
.IfNoneAsync(2);
DateTimeOffset finish = DateTimeOffset.UtcNow.AddDays(daysToBuild);
foreach (Playout playout in playouts)
{
switch (playout.ScheduleKind)
{
case PlayoutScheduleKind.Classic:
case PlayoutScheduleKind.Sequential:
case PlayoutScheduleKind.Scripted:
var floodSorted = playouts
.Collect(p => p.Items)
.OrderBy(pi => pi.Start)
.Filter(pi => pi.StartOffset <= finish)
.ToList();
foreach (var item in floodSorted)
{
item.Start += playoutOffset;
item.Finish += playoutOffset;
}
await WriteScheduleXml(
request,
playout.ScheduleKind,
floodSorted,
templateContext,
movieTemplate,
episodeTemplate,
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
remoteStreamTemplate,
minifier,
xml,
cancellationToken);
break;
case PlayoutScheduleKind.Block:
var blockSorted = playouts
.Collect(p => p.Items)
.OrderBy(pi => pi.Start)
.Filter(pi => pi.StartOffset <= finish)
.ToList();
foreach (var item in blockSorted)
{
item.Start += playoutOffset;
item.Finish += playoutOffset;
}
await WriteScheduleXml(
request,
playout.ScheduleKind,
blockSorted,
templateContext,
movieTemplate,
episodeTemplate,
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
remoteStreamTemplate,
minifier,
xml,
cancellationToken);
break;
case PlayoutScheduleKind.ExternalJson:
var externalJsonSorted = (await CollectExternalJsonItems(playout.ScheduleFile))
.Filter(pi => pi.StartOffset <= finish)
.ToList();
foreach (var item in externalJsonSorted)
{
item.Start += playoutOffset;
item.Finish += playoutOffset;
}
await WriteScheduleXml(
request,
playout.ScheduleKind,
externalJsonSorted,
templateContext,
movieTemplate,
episodeTemplate,
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
remoteStreamTemplate,
minifier,
xml,
cancellationToken);
break;
}
}
await xml.FlushAsync();
string tempFile = Path.GetTempFileName();
await File.WriteAllBytesAsync(tempFile, ms.ToArray(), cancellationToken);
File.Move(tempFile, targetFile, true);
return;
}
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
var minifier = new XmlMinifier(
new XmlMinificationSettings
{
MinifyWhitespace = true,
RemoveXmlComments = true,
CollapseTagsWithoutContent = true
});
var templateContext = new XmlTemplateContext();
string movieText = await File.ReadAllTextAsync(movieTemplateFileName, cancellationToken);
var movieTemplate = Template.Parse(movieText, movieTemplateFileName);
string episodeText = await File.ReadAllTextAsync(episodeTemplateFileName, cancellationToken);
var episodeTemplate = Template.Parse(episodeText, episodeTemplateFileName);
string musicVideoText = await File.ReadAllTextAsync(musicVideoTemplateFileName, cancellationToken);
var musicVideoTemplate = Template.Parse(musicVideoText, musicVideoTemplateFileName);
string songText = await File.ReadAllTextAsync(songTemplateFileName, cancellationToken);
var songTemplate = Template.Parse(songText, songTemplateFileName);
string otherVideoText = await File.ReadAllTextAsync(otherVideoTemplateFileName, cancellationToken);
var otherVideoTemplate = Template.Parse(otherVideoText, otherVideoTemplateFileName);
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
List<Playout> playouts = await dbContext.Playouts
.AsNoTracking()
.Filter(pi => pi.Channel.Number == request.ChannelNumber)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Episode).EpisodeMetadata)
.ThenInclude(em => em.Guids)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Episode).Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Episode).Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Genres)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Episode).Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Guids)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Movie).MovieMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Movie).MovieMetadata)
.ThenInclude(mm => mm.Genres)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Movie).MovieMetadata)
.ThenInclude(mm => mm.Guids)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mvm => mvm.Genres)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mvm => mvm.Studios)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mvm => mvm.Directors)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mvm => mvm.Artists)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as MusicVideo).Artist)
.ThenInclude(a => a.ArtistMetadata)
.ThenInclude(am => am.Genres)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as OtherVideo).OtherVideoMetadata)
.ThenInclude(vm => vm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Song).SongMetadata)
.ThenInclude(vm => vm.Artwork)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Song).SongMetadata)
.ThenInclude(sm => sm.Genres)
.Include(p => p.Items)
.ThenInclude(i => i.MediaItem)
.ThenInclude(i => (i as Song).SongMetadata)
.ThenInclude(sm => sm.Studios)
.ToListAsync(cancellationToken);
await using RecyclableMemoryStream ms = _recyclableMemoryStreamManager.GetStream();
await using var xml = XmlWriter.Create(
ms,
new XmlWriterSettings { Async = true, ConformanceLevel = ConformanceLevel.Fragment });
int daysToBuild = await _configElementRepository
.GetValue<int>(ConfigElementKey.XmltvDaysToBuild)
.IfNoneAsync(2);
DateTimeOffset finish = DateTimeOffset.UtcNow.AddDays(daysToBuild);
foreach (Playout playout in playouts)
{
// do nothing
switch (playout.ProgramSchedulePlayoutType)
{
case ProgramSchedulePlayoutType.Flood:
case ProgramSchedulePlayoutType.Yaml:
var floodSorted = playouts
.Collect(p => p.Items)
.OrderBy(pi => pi.Start)
.Filter(pi => pi.StartOffset <= finish)
.ToList();
await WritePlayoutXml(
request,
floodSorted,
templateContext,
movieTemplate,
episodeTemplate,
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
minifier,
xml);
break;
case ProgramSchedulePlayoutType.Block:
var blockSorted = playouts
.Collect(p => p.Items)
.OrderBy(pi => pi.Start)
.Filter(pi => pi.StartOffset <= finish)
.ToList();
await WriteBlockPlayoutXml(
request,
blockSorted,
templateContext,
movieTemplate,
episodeTemplate,
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
minifier,
xml);
break;
case ProgramSchedulePlayoutType.ExternalJson:
var externalJsonSorted = (await CollectExternalJsonItems(playout.ExternalJsonFile))
.Filter(pi => pi.StartOffset <= finish)
.ToList();
await WritePlayoutXml(
request,
externalJsonSorted,
templateContext,
movieTemplate,
episodeTemplate,
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
minifier,
xml);
break;
}
}
await xml.FlushAsync();
string tempFile = Path.GetTempFileName();
await File.WriteAllBytesAsync(tempFile, ms.ToArray(), cancellationToken);
string targetFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{request.ChannelNumber}.xml");
File.Move(tempFile, targetFile, true);
}
private async Task WriteScheduleXml(
private async Task WritePlayoutXml(
RefreshChannelData request,
PlayoutScheduleKind scheduleKind,
List<PlayoutItem> sorted,
XmlTemplateContext templateContext,
Template movieTemplate,
@@ -255,49 +257,153 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
Template musicVideoTemplate,
Template songTemplate,
Template otherVideoTemplate,
Template remoteStreamTemplate,
XmlMinifier minifier,
XmlWriter xml,
CancellationToken cancellationToken)
XmlWriter xml)
{
XmltvTimeZone xmltvTimeZone = await _configElementRepository
.GetValue<XmltvTimeZone>(ConfigElementKey.XmltvTimeZone, cancellationToken)
.GetValue<XmltvTimeZone>(ConfigElementKey.XmltvTimeZone)
.IfNoneAsync(XmltvTimeZone.Local);
XmltvBlockBehavior xmltvBlockBehavior = await _configElementRepository
.GetValue<XmltvBlockBehavior>(ConfigElementKey.XmltvBlockBehavior, cancellationToken)
.IfNoneAsync(XmltvBlockBehavior.SplitTimeEvenly);
// guide-group / filler-merge logic is shared with the JSON guide query so the two cannot drift
foreach (ChannelGuideEntry entry in ChannelGuideProjector.Project(
scheduleKind,
sorted,
xmltvTimeZone,
xmltvBlockBehavior))
// skip all filler that isn't pre-roll
var i = 0;
while (i < sorted.Count && sorted[i].FillerKind != FillerKind.None &&
sorted[i].FillerKind != FillerKind.PreRoll)
{
string start = entry.Start
i++;
}
while (i < sorted.Count)
{
PlayoutItem startItem = sorted[i];
int j = i;
while (sorted[j].FillerKind != FillerKind.None && j + 1 < sorted.Count)
{
j++;
}
PlayoutItem displayItem = sorted[j];
bool hasCustomTitle = !string.IsNullOrWhiteSpace(startItem.CustomTitle);
int finishIndex = j;
while (finishIndex + 1 < sorted.Count && (sorted[finishIndex + 1].GuideGroup == startItem.GuideGroup
|| sorted[finishIndex + 1].FillerKind is FillerKind.GuideMode
or FillerKind.Tail or FillerKind.Fallback or FillerKind.DecoDefault))
{
finishIndex++;
}
int customShowId = -1;
if (displayItem.MediaItem is Episode ep)
{
customShowId = ep.Season.ShowId;
}
bool isSameCustomShow = hasCustomTitle;
for (int x = j; x <= finishIndex; x++)
{
isSameCustomShow = isSameCustomShow && sorted[x].MediaItem is Episode e &&
customShowId == e.Season.ShowId;
}
PlayoutItem finishItem = sorted[finishIndex];
i = finishIndex;
DateTimeOffset startTime = xmltvTimeZone switch
{
XmltvTimeZone.Utc => new DateTimeOffset(startItem.Start, TimeSpan.Zero),
_ => startItem.StartOffset
};
DateTimeOffset stopTime = (xmltvTimeZone, displayItem.GuideFinishOffset.HasValue) switch
{
(XmltvTimeZone.Utc, true) => new DateTimeOffset(displayItem.GuideFinish!.Value, TimeSpan.Zero),
(XmltvTimeZone.Utc, false) => new DateTimeOffset(finishItem.Finish, TimeSpan.Zero),
(_, true) => displayItem.GuideFinishOffset!.Value,
(_, false) => finishItem.FinishOffset
};
string start = startTime
.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
.Replace(":", string.Empty);
string stop = entry.Stop
string stop = stopTime
.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
.Replace(":", string.Empty);
await WriteItemToXml(
request,
entry.DisplayItem,
displayItem,
start,
stop,
entry.HasCustomTitle,
hasCustomTitle,
templateContext,
movieTemplate,
episodeTemplate,
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
remoteStreamTemplate,
minifier,
xml);
i++;
}
}
private static async Task WriteBlockPlayoutXml(
RefreshChannelData request,
List<PlayoutItem> sorted,
XmlTemplateContext templateContext,
Template movieTemplate,
Template episodeTemplate,
Template musicVideoTemplate,
Template songTemplate,
Template otherVideoTemplate,
XmlMinifier minifier,
XmlWriter xml)
{
var groups = sorted.GroupBy(s => new { s.GuideStart, s.GuideFinish, s.GuideGroup });
foreach (var group in groups)
{
DateTime groupStart = group.Key.GuideStart!.Value;
DateTime groupFinish = group.Key.GuideFinish!.Value;
TimeSpan groupDuration = groupFinish - groupStart;
var itemsToInclude = group.Filter(g => g.FillerKind is FillerKind.None).ToList();
if (itemsToInclude.Count == 0)
{
continue;
}
TimeSpan perItem = groupDuration / itemsToInclude.Count;
DateTimeOffset currentStart = new DateTimeOffset(groupStart, TimeSpan.Zero).ToLocalTime();
DateTimeOffset currentFinish = currentStart + perItem;
foreach (PlayoutItem item in itemsToInclude)
{
string start = currentStart.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
.Replace(":", string.Empty);
string stop = currentFinish.ToString("yyyyMMddHHmmss zzz", CultureInfo.InvariantCulture)
.Replace(":", string.Empty);
await WriteItemToXml(
request,
item,
start,
stop,
false,
templateContext,
movieTemplate,
episodeTemplate,
musicVideoTemplate,
songTemplate,
otherVideoTemplate,
minifier,
xml);
currentStart = currentFinish;
currentFinish += perItem;
}
}
}
@@ -313,12 +419,11 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
Template musicVideoTemplate,
Template songTemplate,
Template otherVideoTemplate,
Template remoteStreamTemplate,
XmlMinifier minifier,
XmlWriter xml)
{
string title = ChannelGuideMetadata.GetTitle(displayItem);
string subtitle = ChannelGuideMetadata.GetSubtitle(displayItem);
string title = GetTitle(displayItem);
string subtitle = GetSubtitle(displayItem);
Option<string> maybeTemplateOutput = displayItem.MediaItem switch
{
@@ -375,16 +480,6 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
title,
templateContext,
otherVideoTemplate),
RemoteStream templateRemoteStream => await ProcessRemoteStreamTemplate(
request,
templateRemoteStream,
start,
stop,
hasCustomTitle,
displayItem,
title,
templateContext,
remoteStreamTemplate),
_ => Option<string>.None
};
@@ -472,7 +567,6 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
showMetadata.Guids ??= [];
string artworkPath = GetPrioritizedArtworkPath(showMetadata);
string thumbnailPath = GetPrioritizedArtworkPath(metadata);
var data = new
{
@@ -493,8 +587,6 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
ShowGenres = showMetadata.Genres.Map(g => g.Name).OrderBy(n => n),
EpisodeHasArtwork = !string.IsNullOrWhiteSpace(artworkPath),
EpisodeArtworkUrl = artworkPath,
EpisodeHasThumbnail = !string.IsNullOrWhiteSpace(thumbnailPath),
EpisodeThumbnailUrl = thumbnailPath,
SeasonNumber = templateEpisode.Season?.SeasonNumber ?? 0,
metadata.EpisodeNumber,
ShowHasContentRating = !string.IsNullOrWhiteSpace(showMetadata.ContentRating),
@@ -651,8 +743,6 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
metadata.Genres ??= [];
metadata.Guids ??= [];
string artworkPath = GetPrioritizedArtworkPath(metadata);
var data = new
{
ProgrammeStart = start,
@@ -667,8 +757,6 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
OtherVideoPlot = metadata.Plot,
OtherVideoHasYear = metadata.Year.HasValue,
OtherVideoYear = metadata.Year,
OtherVideoHasArtwork = !string.IsNullOrWhiteSpace(artworkPath),
OtherVideoArtworkUrl = artworkPath,
OtherVideoGenres = metadata.Genres.Map(g => g.Name).OrderBy(n => n),
OtherVideoHasContentRating = !string.IsNullOrWhiteSpace(metadata.ContentRating),
OtherVideoContentRating = metadata.ContentRating
@@ -684,63 +772,18 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
return Option<string>.None;
}
private static async Task<Option<string>> ProcessRemoteStreamTemplate(
RefreshChannelData request,
RemoteStream templateRemoteStream,
string start,
string stop,
bool hasCustomTitle,
PlayoutItem displayItem,
string title,
XmlTemplateContext templateContext,
Template remoteStreamTemplate)
{
foreach (RemoteStreamMetadata metadata in templateRemoteStream.RemoteStreamMetadata.HeadOrNone())
{
metadata.Genres ??= [];
metadata.Guids ??= [];
string artworkPath = GetPrioritizedArtworkPath(metadata);
var data = new
{
ProgrammeStart = start,
ProgrammeStop = stop,
ChannelId = ChannelIdentifier.FromNumber(request.ChannelNumber),
ChannelIdLegacy = ChannelIdentifier.LegacyFromNumber(request.ChannelNumber),
request.ChannelNumber,
HasCustomTitle = hasCustomTitle,
displayItem.CustomTitle,
RemoteStreamTitle = title,
RemoteStreamHasPlot = !string.IsNullOrWhiteSpace(metadata.Plot),
RemoteStreamPlot = metadata.Plot,
RemoteStreamHasYear = metadata.Year.HasValue,
RemoteStreamYear = metadata.Year,
RemoteStreamHasArtwork = !string.IsNullOrWhiteSpace(artworkPath),
RemoteStreamArtworkUrl = artworkPath,
RemoteStreamGenres = metadata.Genres.Map(g => g.Name).OrderBy(n => n),
RemoteStreamHasContentRating = !string.IsNullOrWhiteSpace(metadata.ContentRating),
RemoteStreamContentRating = metadata.ContentRating
};
var scriptObject = new ScriptObject();
scriptObject.Import(data);
templateContext.PushGlobal(scriptObject);
return await remoteStreamTemplate.RenderAsync(templateContext);
}
return Option<string>.None;
}
private string GetMovieTemplateFileName()
{
string templateFileName = _localFileSystem.GetCustomOrDefaultFile(
FileSystemLayout.ChannelGuideTemplatesFolder,
"movie.sbntxt");
string templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "movie.sbntxt");
// fall back to default template
if (!_localFileSystem.FileExists(templateFileName))
{
templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "_movie.sbntxt");
}
// fail if file doesn't exist
if (!_fileSystem.File.Exists(templateFileName))
if (!_localFileSystem.FileExists(templateFileName))
{
_logger.LogError(
"Unable to generate movie XMLTV fragment without template file {File}; please restart ErsatzTV",
@@ -754,12 +797,16 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
private string GetEpisodeTemplateFileName()
{
string templateFileName = _localFileSystem.GetCustomOrDefaultFile(
FileSystemLayout.ChannelGuideTemplatesFolder,
"episode.sbntxt");
string templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "episode.sbntxt");
// fall back to default template
if (!_localFileSystem.FileExists(templateFileName))
{
templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "_episode.sbntxt");
}
// fail if file doesn't exist
if (!_fileSystem.File.Exists(templateFileName))
if (!_localFileSystem.FileExists(templateFileName))
{
_logger.LogError(
"Unable to generate episode XMLTV fragment without template file {File}; please restart ErsatzTV",
@@ -773,12 +820,16 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
private string GetMusicVideoTemplateFileName()
{
string templateFileName = _localFileSystem.GetCustomOrDefaultFile(
FileSystemLayout.ChannelGuideTemplatesFolder,
"musicVideo.sbntxt");
string templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "musicVideo.sbntxt");
// fall back to default template
if (!_localFileSystem.FileExists(templateFileName))
{
templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "_musicVideo.sbntxt");
}
// fail if file doesn't exist
if (!_fileSystem.File.Exists(templateFileName))
if (!_localFileSystem.FileExists(templateFileName))
{
_logger.LogError(
"Unable to generate music video XMLTV fragment without template file {File}; please restart ErsatzTV",
@@ -792,12 +843,16 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
private string GetSongTemplateFileName()
{
string templateFileName = _localFileSystem.GetCustomOrDefaultFile(
FileSystemLayout.ChannelGuideTemplatesFolder,
"song.sbntxt");
string templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "song.sbntxt");
// fall back to default template
if (!_localFileSystem.FileExists(templateFileName))
{
templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "_song.sbntxt");
}
// fail if file doesn't exist
if (!_fileSystem.File.Exists(templateFileName))
if (!_localFileSystem.FileExists(templateFileName))
{
_logger.LogError(
"Unable to generate song XMLTV fragment without template file {File}; please restart ErsatzTV",
@@ -811,12 +866,16 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
private string GetOtherVideoTemplateFileName()
{
string templateFileName = _localFileSystem.GetCustomOrDefaultFile(
FileSystemLayout.ChannelGuideTemplatesFolder,
"otherVideo.sbntxt");
string templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "otherVideo.sbntxt");
// fall back to default template
if (!_localFileSystem.FileExists(templateFileName))
{
templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "_otherVideo.sbntxt");
}
// fail if file doesn't exist
if (!_fileSystem.File.Exists(templateFileName))
if (!_localFileSystem.FileExists(templateFileName))
{
_logger.LogError(
"Unable to generate other video XMLTV fragment without template file {File}; please restart ErsatzTV",
@@ -828,25 +887,6 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
return templateFileName;
}
private string GetRemoteStreamTemplateFileName()
{
string templateFileName = _localFileSystem.GetCustomOrDefaultFile(
FileSystemLayout.ChannelGuideTemplatesFolder,
"remoteStream.sbntxt");
// fail if file doesn't exist
if (!_fileSystem.File.Exists(templateFileName))
{
_logger.LogError(
"Unable to generate remote stream XMLTV fragment without template file {File}; please restart ErsatzTV",
templateFileName);
return null;
}
return templateFileName;
}
private static string GetArtworkUrl(Artwork artwork, ArtworkKind artworkKind)
{
string artworkPath = artwork.Path;
@@ -885,6 +925,49 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
return artworkPath;
}
private static string GetTitle(PlayoutItem playoutItem)
{
if (!string.IsNullOrWhiteSpace(playoutItem.CustomTitle))
{
return playoutItem.CustomTitle;
}
return playoutItem.MediaItem switch
{
Movie m => m.MovieMetadata.HeadOrNone().Map(mm => mm.Title ?? string.Empty)
.IfNone("[unknown movie]"),
Episode e => e.Season.Show.ShowMetadata.HeadOrNone().Map(em => em.Title ?? string.Empty)
.IfNone("[unknown show]"),
MusicVideo mv => mv.Artist.ArtistMetadata.HeadOrNone().Map(am => am.Title ?? string.Empty)
.IfNone("[unknown artist]"),
OtherVideo ov => ov.OtherVideoMetadata.HeadOrNone().Map(vm => vm.Title ?? string.Empty)
.IfNone("[unknown video]"),
_ => "[unknown]"
};
}
private static string GetSubtitle(PlayoutItem playoutItem)
{
if (!string.IsNullOrWhiteSpace(playoutItem.CustomTitle))
{
return string.Empty;
}
return playoutItem.MediaItem switch
{
Episode e => e.EpisodeMetadata.HeadOrNone().Match(
em => em.Title ?? string.Empty,
() => string.Empty),
MusicVideo mv => mv.MusicVideoMetadata.HeadOrNone().Match(
mvm => mvm.Title ?? string.Empty,
() => string.Empty),
Song s => s.SongMetadata.HeadOrNone().Match(
mvm => mvm.Title ?? string.Empty,
() => string.Empty),
_ => string.Empty
};
}
private static string GetPrioritizedArtworkPath(Metadata metadata)
{
Option<string> maybeArtwork = Optional(metadata.Artwork).Flatten()
@@ -907,7 +990,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
{
var result = new List<PlayoutItem>();
if (_fileSystem.File.Exists(path))
if (_localFileSystem.FileExists(path))
{
Option<ExternalJsonChannel> maybeChannel = JsonConvert.DeserializeObject<ExternalJsonChannel>(
await File.ReadAllTextAsync(path));
@@ -1,10 +1,8 @@
using System.Data.Common;
using System.IO.Abstractions;
using System.Net;
using System.Xml;
using Dapper;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Iptv;
using ErsatzTV.Infrastructure.Data;
@@ -20,7 +18,6 @@ namespace ErsatzTV.Application.Channels;
public class RefreshChannelListHandler : IRequestHandler<RefreshChannelList>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IFileSystem _fileSystem;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<RefreshChannelListHandler> _logger;
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
@@ -28,13 +25,11 @@ public class RefreshChannelListHandler : IRequestHandler<RefreshChannelList>
public RefreshChannelListHandler(
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
IDbContextFactory<TvContext> dbContextFactory,
IFileSystem fileSystem,
ILocalFileSystem localFileSystem,
ILogger<RefreshChannelListHandler> logger)
{
_recyclableMemoryStreamManager = recyclableMemoryStreamManager;
_dbContextFactory = dbContextFactory;
_fileSystem = fileSystem;
_localFileSystem = localFileSystem;
_logger = logger;
}
@@ -48,13 +43,13 @@ public class RefreshChannelListHandler : IRequestHandler<RefreshChannelList>
string templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "channel.sbntxt");
// fall back to default template
if (!_fileSystem.File.Exists(templateFileName))
if (!_localFileSystem.FileExists(templateFileName))
{
templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "_channel.sbntxt");
}
// fail if file doesn't exist
if (!_fileSystem.File.Exists(templateFileName))
if (!_localFileSystem.FileExists(templateFileName))
{
_logger.LogError(
"Unable to generate channel list without template file {File}; please restart ErsatzTV",
@@ -82,9 +77,6 @@ public class RefreshChannelListHandler : IRequestHandler<RefreshChannelList>
await foreach (ChannelResult channel in GetChannels(dbContext).WithCancellation(cancellationToken))
{
bool hasLogo = !string.IsNullOrWhiteSpace(channel.ArtworkPath);
bool hasExternalLogo = hasLogo && Artwork.IsExternalUrl(channel.ArtworkPath);
var data = new
{
ChannelId = ChannelIdentifier.FromNumber(channel.Number),
@@ -92,8 +84,7 @@ public class RefreshChannelListHandler : IRequestHandler<RefreshChannelList>
ChannelNumber = channel.Number,
ChannelName = channel.Name,
ChannelCategories = GetCategories(channel.Categories),
ChannelHasExternalArtwork = hasExternalLogo,
ChannelHasArtwork = hasLogo,
ChannelHasArtwork = !string.IsNullOrWhiteSpace(channel.ArtworkPath),
ChannelArtworkPath = channel.ArtworkPath,
ChannelNameEncoded = WebUtility.UrlEncode(channel.Name)
};
@@ -122,7 +113,7 @@ public class RefreshChannelListHandler : IRequestHandler<RefreshChannelList>
const string QUERY = @"select C.Number, C.Name, C.Categories, A.Path as ArtworkPath
from Channel C
left outer join Artwork A on C.Id = A.ChannelId and A.ArtworkKind = 2
where (C.Id in (select ChannelId from Playout) or C.MirrorSourceChannelId in (select ChannelId from Playout)) and C.IsEnabled = 1 and C.ShowInEPG = 1
where C.Id in (select ChannelId from Playout)
order by CAST(C.Number as double)";
// TODO: this needs to be fixed for sqlite/mariadb
@@ -1,5 +1,4 @@
using ErsatzTV.Application.Artworks;
using ErsatzTV.Core;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Channels;
@@ -11,16 +10,10 @@ public record UpdateChannel(
string Group,
string Categories,
int FFmpegProfileId,
double? SlugSeconds,
ArtworkContentTypeModel Logo,
ChannelStreamSelectorMode StreamSelectorMode,
string StreamSelector,
string Logo,
string PreferredAudioLanguageCode,
string PreferredAudioTitle,
ChannelPlayoutSource PlayoutSource,
ChannelPlayoutMode PlayoutMode,
int? MirrorSourceChannelId,
TimeSpan? PlayoutOffset,
ChannelProgressMode ProgressMode,
StreamingMode StreamingMode,
int? WatermarkId,
int? FallbackFillerId,
@@ -28,8 +21,4 @@ public record UpdateChannel(
ChannelSubtitleMode SubtitleMode,
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
string MusicVideoCreditsTemplate,
ChannelSongVideoMode SongVideoMode,
ChannelTranscodeMode TranscodeMode,
ChannelIdleBehavior IdleBehavior,
bool IsEnabled,
bool ShowInEpg) : IRequest<Either<BaseError, ChannelViewModel>>;
ChannelSongVideoMode SongVideoMode) : IRequest<Either<BaseError, ChannelViewModel>>;
@@ -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,52 +24,17 @@ 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);
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request));
}
private async Task<ChannelViewModel> ApplyUpdateRequest(
TvContext dbContext,
Channel c,
UpdateChannel update,
CancellationToken cancellationToken)
private async Task<ChannelViewModel> ApplyUpdateRequest(TvContext dbContext, Channel c, UpdateChannel update)
{
// don't save mirror when playout exists
if (c.Playouts.Count > 0)
{
update = update with
{
PlayoutSource = ChannelPlayoutSource.Generated,
MirrorSourceChannelId = null
};
}
bool hasEpgChange = c.PlayoutSource != update.PlayoutSource || c.ShowInEpg != update.ShowInEpg;
c.Name = update.Name;
c.Number = update.Number;
c.SortNumber = double.Parse(update.Number, CultureInfo.InvariantCulture);
c.Group = update.Group;
c.Categories = update.Categories;
c.FFmpegProfileId = update.FFmpegProfileId;
c.SlugSeconds = update.SlugSeconds;
c.StreamSelectorMode = update.StreamSelectorMode;
c.StreamSelector = update.StreamSelector;
c.PreferredAudioLanguageCode = update.PreferredAudioLanguageCode;
c.PreferredAudioTitle = update.PreferredAudioTitle;
c.PreferredSubtitleLanguageCode = update.PreferredSubtitleLanguageCode;
@@ -80,226 +42,69 @@ public class UpdateChannelHandler(
c.MusicVideoCreditsMode = update.MusicVideoCreditsMode;
c.MusicVideoCreditsTemplate = update.MusicVideoCreditsTemplate;
c.SongVideoMode = update.SongVideoMode;
c.TranscodeMode = update.TranscodeMode;
c.IdleBehavior = update.IdleBehavior;
c.IsEnabled = update.IsEnabled;
c.ShowInEpg = update.IsEnabled && update.ShowInEpg;
c.Artwork ??= [];
c.Artwork ??= new List<Artwork>();
if (!string.IsNullOrWhiteSpace(update.Logo?.Path))
if (!string.IsNullOrWhiteSpace(update.Logo))
{
string logo = update.Logo.Path;
if (logo.StartsWith("iptv/logos/", StringComparison.Ordinal))
{
logo = logo.Replace("iptv/logos/", string.Empty);
}
Option<Artwork> maybeLogo =
Optional(c.Artwork).Flatten().FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Logo);
Option<Artwork> maybeLogo = c.Artwork.Where(a => a.ArtworkKind == ArtworkKind.Logo).HeadOrNone();
foreach (Artwork artwork in maybeLogo)
{
artwork.Path = logo;
artwork.OriginalContentType = !string.IsNullOrEmpty(update.Logo.ContentType)
? update.Logo.ContentType
: null;
artwork.DateUpdated = DateTime.UtcNow;
}
if (maybeLogo.IsNone)
{
var artwork = new Artwork
maybeLogo.Match(
artwork =>
{
Path = logo,
OriginalContentType = !string.IsNullOrEmpty(update.Logo.ContentType)
? update.Logo.ContentType
: null,
DateAdded = DateTime.UtcNow,
DateUpdated = DateTime.UtcNow,
ArtworkKind = ArtworkKind.Logo
};
c.Artwork.Add(artwork);
}
}
else
{
await dbContext.Entry(c)
.Collection(channel => channel.Artwork)
.LoadAsync(cancellationToken);
foreach (Artwork artwork in c.Artwork.Where(x => x.ArtworkKind is ArtworkKind.Logo).ToList())
{
c.Artwork.Remove(artwork);
dbContext.Artwork.Remove(artwork);
}
artwork.Path = update.Logo;
artwork.DateUpdated = DateTime.UtcNow;
},
() =>
{
var artwork = new Artwork
{
Path = update.Logo,
DateAdded = DateTime.UtcNow,
DateUpdated = DateTime.UtcNow,
ArtworkKind = ArtworkKind.Logo
};
c.Artwork.Add(artwork);
});
}
c.PlayoutSource = update.PlayoutSource;
c.PlayoutMode = update.PlayoutMode;
if (c.PlayoutSource is ChannelPlayoutSource.Mirror)
{
c.PlayoutMode = ChannelPlayoutMode.Continuous;
hasEpgChange |= c.MirrorSourceChannelId != update.MirrorSourceChannelId;
hasEpgChange |= c.PlayoutOffset != update.PlayoutOffset;
}
else
{
c.MirrorSourceChannelId = null;
c.PlayoutOffset = null;
}
c.MirrorSourceChannelId = update.MirrorSourceChannelId;
c.PlayoutOffset = update.PlayoutOffset;
c.ProgressMode = update.ProgressMode;
c.StreamingMode = update.StreamingMode;
c.WatermarkId = update.WatermarkId;
c.FallbackFillerId = update.FallbackFillerId;
await dbContext.SaveChangesAsync(cancellationToken);
await dbContext.SaveChangesAsync();
searchTargets.SearchTargetsChanged();
// post-commit side effect runs on CancellationToken.None so a late request cancellation
// can't abort it after the commit landed (#254)
if (c.SubtitleMode != ChannelSubtitleMode.None)
{
Option<Playout> maybePlayout = await dbContext.Playouts
.SelectOneAsync(p => p.ChannelId, p => p.ChannelId == c.Id, CancellationToken.None);
.SelectOneAsync(p => p.ChannelId, p => p.ChannelId == c.Id);
foreach (Playout playout in maybePlayout)
{
await workerChannel.WriteAsync(new ExtractEmbeddedSubtitles(playout.Id), CancellationToken.None);
await workerChannel.WriteAsync(new ExtractEmbeddedSubtitles(playout.Id));
}
}
await workerChannel.WriteAsync(new RefreshChannelList(), CancellationToken.None);
if (hasEpgChange)
{
await workerChannel.WriteAsync(new RefreshChannelData(c.Number), CancellationToken.None);
}
await workerChannel.WriteAsync(new RefreshChannelList());
return ProjectToViewModel(c, c.Playouts?.Count ?? 0);
return ProjectToViewModel(c);
}
private static async Task<Validation<BaseError, Channel>> Validate(
private static async Task<Validation<BaseError, Channel>> Validate(TvContext dbContext, UpdateChannel request) =>
(await ChannelMustExist(dbContext, request), ValidateName(request),
await ValidateNumber(dbContext, request))
.Apply((channelToUpdate, _, _) => channelToUpdate);
private static Task<Validation<BaseError, Channel>> ChannelMustExist(
TvContext dbContext,
UpdateChannel request,
Channel channel,
CancellationToken cancellationToken)
{
Validation<BaseError, Channel> channelValidation = (ValidateName(request),
await ValidateNumber(dbContext, request, cancellationToken),
await MirrorSourceMustBeValid(dbContext, request, cancellationToken),
ValidateShowInEpg(request.IsEnabled, request.ShowInEpg),
ValidateLogo(request.Logo?.Path))
.Apply((_, _, _, _, _) => channel);
// combine the page-only Group rule plus the FK existence checks (FFmpeg profile / watermark /
// fallback filler) with the channel validation; splitting keeps tuple arity within
// LanguageExt's supported applicative range while still accumulating all errors
return (ValidateGroup(request.Group),
await FFmpegProfileMustExist(dbContext, request, cancellationToken),
await WatermarkMustExist(dbContext, request, cancellationToken),
await FillerPresetMustExist(dbContext, request, cancellationToken),
channelValidation)
.Apply((_, _, _, _, c) => c);
}
private static async Task<Validation<BaseError, int>> FFmpegProfileMustExist(
TvContext dbContext,
UpdateChannel request,
CancellationToken cancellationToken)
{
bool exists = await dbContext.FFmpegProfiles
.AnyAsync(p => p.Id == request.FFmpegProfileId, cancellationToken);
if (exists)
{
return request.FFmpegProfileId;
}
return BaseError.New($"FFmpegProfile {request.FFmpegProfileId} does not exist.");
}
private static async Task<Validation<BaseError, Option<int>>> WatermarkMustExist(
TvContext dbContext,
UpdateChannel request,
CancellationToken cancellationToken)
{
if (request.WatermarkId is null)
{
return Option<int>.None;
}
bool exists = await dbContext.ChannelWatermarks
.AnyAsync(w => w.Id == request.WatermarkId, cancellationToken);
if (exists)
{
return Optional(request.WatermarkId);
}
return BaseError.New($"Watermark {request.WatermarkId} does not exist.");
}
private static async Task<Validation<BaseError, Option<int>>> FillerPresetMustExist(
TvContext dbContext,
UpdateChannel request,
CancellationToken cancellationToken)
{
if (request.FallbackFillerId is null)
{
return Option<int>.None;
}
bool exists = await dbContext.FillerPresets
.Filter(fp => fp.FillerKind == FillerKind.Fallback)
.AnyAsync(w => w.Id == request.FallbackFillerId, cancellationToken);
if (exists)
{
return Optional(request.FallbackFillerId);
}
return BaseError.New($"Fallback filler {request.FallbackFillerId} does not exist.");
}
private static async Task<Validation<BaseError, Unit>> MirrorSourceMustBeValid(
TvContext dbContext,
UpdateChannel request,
CancellationToken cancellationToken)
{
if (request.PlayoutSource is not ChannelPlayoutSource.Mirror)
{
return Unit.Default;
}
Option<Channel> maybeMirrorSource = await dbContext.Channels
.AsNoTracking()
.SelectOneAsync(
c => c.Id == request.MirrorSourceChannelId,
c => c.Id == request.MirrorSourceChannelId,
cancellationToken);
if (maybeMirrorSource.IsNone)
{
return BaseError.New("Mirror source channel does not exist.");
}
foreach (var mirrorSource in maybeMirrorSource)
{
if (mirrorSource.PlayoutSource is not ChannelPlayoutSource.Generated)
{
return BaseError.New(
$"Mirror source channel {mirrorSource.Name} must use generated playout source");
}
}
foreach (TimeSpan playoutOffset in Optional(request.PlayoutOffset))
{
if (playoutOffset < TimeSpan.FromHours(-12) || playoutOffset > TimeSpan.FromHours(12))
{
return BaseError.New("Playout offset must not be greater than 12 hours");
}
}
return Unit.Default;
}
UpdateChannel updateChannel) =>
dbContext.Channels
.Include(c => c.Artwork)
.Include(c => c.Watermark)
.SelectOneAsync(c => c.Id, c => c.Id == updateChannel.ChannelId)
.Map(o => o.ToValidation<BaseError>("Channel does not exist."));
private static Validation<BaseError, string> ValidateName(UpdateChannel updateChannel) =>
updateChannel.NotEmpty(c => c.Name)
@@ -307,11 +112,10 @@ public class UpdateChannelHandler(
private static async Task<Validation<BaseError, string>> ValidateNumber(
TvContext dbContext,
UpdateChannel updateChannel,
CancellationToken cancellationToken)
UpdateChannel updateChannel)
{
int matchId = await dbContext.Channels
.SelectOneAsync(c => c.Number, c => c.Number == updateChannel.Number, cancellationToken)
.SelectOneAsync(c => c.Number, c => c.Number == updateChannel.Number)
.Match(c => c.Id, () => updateChannel.ChannelId);
if (matchId == updateChannel.ChannelId)
@@ -1,5 +0,0 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Channels;
public record UpdateChannelNumbers(List<ChannelSortViewModel> Channels) : IRequest<Option<BaseError>>;
@@ -1,127 +0,0 @@
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;
namespace ErsatzTV.Application.Channels;
public class UpdateChannelNumbersHandler(
IDbContextFactory<TvContext> dbContextFactory,
ChannelWriter<IBackgroundServiceRequest> workerChannel)
: IRequestHandler<UpdateChannelNumbers, Option<BaseError>>
{
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();
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)
{
channel.Number = $"-{channel.Id}";
}
// save those changes
await dbContext.SaveChangesAsync(cancellationToken);
// give every channel the proper new number
foreach (var channel in channelsToUpdate)
{
channel.Number = numberUpdates[channel.Id];
if (double.TryParse(channel.Number, CultureInfo.InvariantCulture, out double sortNumber))
{
channel.SortNumber = sortNumber;
}
else
{
return BaseError.New($"Failed to parse channel number {channel.Number}");
}
}
// save those changes
await dbContext.SaveChangesAsync(cancellationToken);
// commit the transaction
await transaction.CommitAsync(cancellationToken);
// update channel list and xmltv
// post-commit side effect runs on CancellationToken.None so a late request cancellation
// can't abort it after the commit landed (#254)
await workerChannel.WriteAsync(new RefreshChannelList(), CancellationToken.None);
foreach (var channel in channelsToUpdate)
{
await workerChannel.WriteAsync(new RefreshChannelData(channel.Number), CancellationToken.None);
}
return Option<BaseError>.None;
}
catch (Exception ex)
{
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;
}
}
+12 -45
View File
@@ -1,12 +1,11 @@
using ErsatzTV.Application.Artworks;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Channels;
internal static class Mapper
{
internal static ChannelViewModel ProjectToViewModel(Channel channel, int playoutCount) =>
internal static ChannelViewModel ProjectToViewModel(Channel channel) =>
new(
channel.Id,
channel.Number,
@@ -14,71 +13,38 @@ internal static class Mapper
channel.Group,
channel.Categories,
channel.FFmpegProfileId,
channel.SlugSeconds,
GetLogo(channel),
channel.StreamSelectorMode,
channel.StreamSelector,
channel.PreferredAudioLanguageCode,
channel.PreferredAudioTitle,
channel.PlayoutSource,
channel.PlayoutMode,
channel.MirrorSourceChannelId,
channel.PlayoutOffset,
channel.ProgressMode,
channel.StreamingMode,
channel.WatermarkId,
channel.FallbackFillerId,
playoutCount,
channel.Playouts?.Count ?? 0,
channel.PreferredSubtitleLanguageCode,
channel.SubtitleMode,
channel.MusicVideoCreditsMode,
channel.MusicVideoCreditsTemplate,
channel.SongVideoMode,
channel.TranscodeMode,
channel.IdleBehavior,
channel.IsEnabled,
channel.ShowInEpg);
channel.SongVideoMode);
internal static ChannelResponseModel ProjectToResponseModel(Channel channel) =>
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);
internal static ChannelStreamingSpecsViewModel ProjectToSpecsViewModel(Channel channel) =>
new(
channel.FFmpegProfile.Resolution.Height,
channel.FFmpegProfile.Resolution.Width,
(int)((channel.FFmpegProfile.VideoBitrate * 1000 + channel.FFmpegProfile.AudioBitrate * 1000) * 1.2),
channel.FFmpegProfile.VideoFormat,
channel.FFmpegProfile.VideoProfile,
channel.FFmpegProfile.AudioFormat);
internal static ResolutionAndBitrateViewModel ProjectToViewModel(Resolution resolution, int bitrate) =>
new(resolution.Height, resolution.Width, bitrate);
private static ArtworkContentTypeModel GetLogo(Channel channel)
{
Option<Artwork> maybeArtwork = channel.Artwork
.Where(a => a.ArtworkKind == ArtworkKind.Logo)
.HeadOrNone();
foreach (Artwork artwork in maybeArtwork)
{
return artwork.IsExternalUrl()
? new ArtworkContentTypeModel(artwork.Path, string.Empty)
: new ArtworkContentTypeModel($"iptv/logos/{artwork.Path}", artwork.OriginalContentType);
}
return ArtworkContentTypeModel.None;
}
private static string GetLogo(Channel channel) =>
Optional(channel.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Logo))
.Match(a => a.Path, string.Empty);
private static string GetStreamingMode(Channel channel) =>
channel.StreamingMode switch
@@ -87,6 +53,7 @@ internal static class Mapper
StreamingMode.TransportStreamHybrid => "MPEG-TS",
StreamingMode.HttpLiveStreamingDirect => "HLS Direct",
StreamingMode.HttpLiveStreamingSegmenter => "HLS Segmenter",
StreamingMode.HttpLiveStreamingSegmenterV2 => "HLS Segmenter V2",
_ => throw new ArgumentOutOfRangeException(nameof(channel))
};
}
@@ -1,3 +1,3 @@
namespace ErsatzTV.Application.Channels;
public record GetAllChannels(bool ShowDisabled = true) : IRequest<List<ChannelViewModel>>;
public record GetAllChannels : IRequest<List<ChannelViewModel>>;
@@ -5,14 +5,17 @@ using static ErsatzTV.Application.Channels.Mapper;
namespace ErsatzTV.Application.Channels;
public class GetAllChannelsForApiHandler(IChannelRepository channelRepository)
: IRequestHandler<GetAllChannelsForApi, List<ChannelResponseModel>>
public class GetAllChannelsForApiHandler : IRequestHandler<GetAllChannelsForApi, List<ChannelResponseModel>>
{
private readonly IChannelRepository _channelRepository;
public GetAllChannelsForApiHandler(IChannelRepository channelRepository) => _channelRepository = channelRepository;
public async Task<List<ChannelResponseModel>> Handle(
GetAllChannelsForApi request,
CancellationToken cancellationToken)
{
IEnumerable<Channel> channels = Optional(await channelRepository.GetAll(cancellationToken)).Flatten();
IEnumerable<Channel> channels = Optional(await _channelRepository.GetAll()).Flatten();
return channels.Map(ProjectToResponseModel).ToList();
}
}
@@ -1,3 +0,0 @@
namespace ErsatzTV.Application.Channels;
public record GetAllChannelsForSort : IRequest<List<ChannelSortViewModel>>;
@@ -1,31 +0,0 @@
using System.Globalization;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Channels;
public class GetAllChannelsForSortHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetAllChannelsForSort, List<ChannelSortViewModel>>
{
public async Task<List<ChannelSortViewModel>> Handle(
GetAllChannelsForSort request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.Channels
.AsNoTracking()
.ToListAsync(cancellationToken)
.Map(list => list.Map(ProjectToSortViewModel)
.OrderBy(c => decimal.Parse(c.Number, CultureInfo.InvariantCulture)).ToList());
}
private static ChannelSortViewModel ProjectToSortViewModel(Channel channel)
=> new()
{
Id = channel.Id,
Number = channel.Number,
Name = channel.Name,
OriginalNumber = channel.Number
};
}
@@ -1,31 +1,14 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Repositories;
using static ErsatzTV.Application.Channels.Mapper;
namespace ErsatzTV.Application.Channels;
public class GetAllChannelsHandler(IChannelRepository channelRepository)
: IRequestHandler<GetAllChannels, List<ChannelViewModel>>
public class GetAllChannelsHandler : IRequestHandler<GetAllChannels, List<ChannelViewModel>>
{
private readonly IChannelRepository _channelRepository;
public GetAllChannelsHandler(IChannelRepository channelRepository) => _channelRepository = channelRepository;
public async Task<List<ChannelViewModel>> Handle(GetAllChannels request, CancellationToken cancellationToken) =>
await channelRepository.GetAll(cancellationToken)
.Map(list => list.Where(c => c.IsEnabled || request.ShowDisabled)
.Map(c => ProjectToViewModel(c, GetPlayoutsCount(c))).ToList());
private static int GetPlayoutsCount(Channel channel)
{
var result = 0;
if (channel.Playouts != null)
{
result += channel.Playouts.Count;
}
if (channel.PlayoutSource is ChannelPlayoutSource.Mirror && channel.MirrorSourceChannel?.Playouts != null)
{
result += channel.MirrorSourceChannel.Playouts.Count;
}
return result;
}
Optional(await _channelRepository.GetAll()).Flatten().Map(ProjectToViewModel).ToList();
}
@@ -3,10 +3,13 @@ using static ErsatzTV.Application.Channels.Mapper;
namespace ErsatzTV.Application.Channels;
public class GetChannelByIdHandler(IChannelRepository channelRepository)
: IRequestHandler<GetChannelById, Option<ChannelViewModel>>
public class GetChannelByIdHandler : IRequestHandler<GetChannelById, Option<ChannelViewModel>>
{
private readonly IChannelRepository _channelRepository;
public GetChannelByIdHandler(IChannelRepository channelRepository) => _channelRepository = channelRepository;
public Task<Option<ChannelViewModel>> Handle(GetChannelById request, CancellationToken cancellationToken) =>
channelRepository.GetChannel(request.Id)
.MapT(c => ProjectToViewModel(c, 0));
_channelRepository.GetChannel(request.Id)
.MapT(ProjectToViewModel);
}
@@ -3,9 +3,12 @@ using static ErsatzTV.Application.Channels.Mapper;
namespace ErsatzTV.Application.Channels;
public class GetChannelByNumberHandler(IChannelRepository channelRepository)
: IRequestHandler<GetChannelByNumber, Option<ChannelViewModel>>
public class GetChannelByNumberHandler : IRequestHandler<GetChannelByNumber, Option<ChannelViewModel>>
{
private readonly IChannelRepository _channelRepository;
public GetChannelByNumberHandler(IChannelRepository channelRepository) => _channelRepository = channelRepository;
public Task<Option<ChannelViewModel>> Handle(GetChannelByNumber request, CancellationToken cancellationToken) =>
channelRepository.GetByNumber(request.ChannelNumber).MapT(c => ProjectToViewModel(c, 0));
_channelRepository.GetByNumber(request.ChannelNumber).MapT(ProjectToViewModel);
}
@@ -1,3 +0,0 @@
namespace ErsatzTV.Application.Channels;
public record GetChannelByPlayoutId(int PlayoutId) : IRequest<Option<ChannelViewModel>>;
@@ -1,21 +0,0 @@
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.Channels.Mapper;
namespace ErsatzTV.Application.Channels;
public class GetChannelByPlayoutIdHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetChannelByPlayoutId, Option<ChannelViewModel>>
{
public async Task<Option<ChannelViewModel>> Handle(
GetChannelByPlayoutId request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.Playouts
.Include(p => p.Channel)
.ThenInclude(c => c.Artwork)
.SingleOrDefaultAsync(p => p.Id == request.PlayoutId, cancellationToken)
.Map(p => ProjectToViewModel(p.Channel, 1));
}
}
@@ -1,5 +1,3 @@
using ErsatzTV.FFmpeg;
namespace ErsatzTV.Application.Channels;
namespace ErsatzTV.Application.Channels;
public record GetChannelFramerate(string ChannelNumber) : IRequest<Option<FrameRate>>;
public record GetChannelFramerate(string ChannelNumber) : IRequest<Option<int>>;
@@ -1,118 +1,123 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.FFmpeg;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Channels;
public class GetChannelFramerateHandler(
IDbContextFactory<TvContext> dbContextFactory,
ILogger<GetChannelFramerateHandler> logger)
: IRequestHandler<GetChannelFramerate, Option<FrameRate>>
public class GetChannelFramerateHandler : IRequestHandler<GetChannelFramerate, Option<int>>
{
public async Task<Option<FrameRate>> Handle(GetChannelFramerate request, CancellationToken cancellationToken)
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ILogger<GetChannelFramerateHandler> _logger;
public GetChannelFramerateHandler(
IDbContextFactory<TvContext> dbContextFactory,
ILogger<GetChannelFramerateHandler> logger)
{
try
_dbContextFactory = dbContextFactory;
_logger = logger;
}
public async Task<Option<int>> Handle(GetChannelFramerate request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
FFmpegProfile ffmpegProfile = await dbContext.Channels
.Filter(c => c.Number == request.ChannelNumber)
.Include(c => c.FFmpegProfile)
.Map(c => c.FFmpegProfile)
.SingleAsync(cancellationToken);
if (!ffmpegProfile.NormalizeFramerate)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return Option<int>.None;
}
FFmpegProfile ffmpegProfile = await dbContext.Channels
.AsNoTracking()
.Filter(c => c.Number == request.ChannelNumber)
.Include(c => c.FFmpegProfile)
.Map(c => c.FFmpegProfile)
.SingleAsync(cancellationToken);
// TODO: expand to check everything in collection rather than what's scheduled?
_logger.LogDebug("Checking frame rates for channel {ChannelNumber}", request.ChannelNumber);
if (!ffmpegProfile.NormalizeFramerate)
List<Playout> playouts = await dbContext.Playouts
.Include(p => p.Items)
.ThenInclude(pi => pi.MediaItem)
.ThenInclude(mi => (mi as Movie).MediaVersions)
.Include(p => p.Items)
.ThenInclude(pi => pi.MediaItem)
.ThenInclude(mi => (mi as Episode).MediaVersions)
.Include(p => p.Items)
.ThenInclude(pi => pi.MediaItem)
.ThenInclude(mi => (mi as Song).MediaVersions)
.Include(p => p.Items)
.ThenInclude(pi => pi.MediaItem)
.ThenInclude(mi => (mi as MusicVideo).MediaVersions)
.Include(p => p.Items)
.ThenInclude(pi => pi.MediaItem)
.ThenInclude(mi => (mi as OtherVideo).MediaVersions)
.Filter(p => p.Channel.Number == request.ChannelNumber)
.ToListAsync(cancellationToken);
var frameRates = playouts.Map(p => p.Items.Map(i => i.MediaItem.GetHeadVersion()))
.Flatten()
.Map(mv => mv.RFrameRate)
.ToList();
var distinct = frameRates.Distinct().ToList();
if (distinct.Count > 1)
{
// TODO: something more intelligent than minimum framerate?
int result = frameRates.Map(ParseFrameRate).Min();
if (result < 24)
{
return Option<FrameRate>.None;
}
// TODO: expand to check everything in collection rather than what's scheduled?
logger.LogDebug("Checking frame rates for channel {ChannelNumber}", request.ChannelNumber);
List<Playout> playouts = await dbContext.Playouts
.AsNoTracking()
.Include(p => p.Items)
.ThenInclude(pi => pi.MediaItem)
.ThenInclude(mi => (mi as Movie).MediaVersions)
.Include(p => p.Items)
.ThenInclude(pi => pi.MediaItem)
.ThenInclude(mi => (mi as Episode).MediaVersions)
.Include(p => p.Items)
.ThenInclude(pi => pi.MediaItem)
.ThenInclude(mi => (mi as Song).MediaVersions)
.Include(p => p.Items)
.ThenInclude(pi => pi.MediaItem)
.ThenInclude(mi => (mi as MusicVideo).MediaVersions)
.Include(p => p.Items)
.ThenInclude(pi => pi.MediaItem)
.ThenInclude(mi => (mi as OtherVideo).MediaVersions)
.Include(p => p.Items)
.ThenInclude(pi => pi.MediaItem)
.ThenInclude(mi => (mi as Image).MediaVersions)
.Include(p => p.Items)
.ThenInclude(pi => pi.MediaItem)
.ThenInclude(mi => (mi as RemoteStream).MediaVersions)
.Filter(p => p.Channel.Number == request.ChannelNumber)
.ToListAsync(cancellationToken);
var frameRates = playouts.Map(p => p.Items.Map(i => i.MediaItem.GetHeadVersion()))
.Flatten()
.Map(mv => new FrameRate(mv.RFrameRate))
.ToList();
var distinct = frameRates.Distinct().ToList();
if (distinct.Count > 1)
{
// TODO: something more intelligent than minimum framerate?
var validFrameRates = frameRates.Where(fr => fr.ParsedFrameRate > 23).ToList();
if (validFrameRates.Count > 0)
{
FrameRate result = validFrameRates.MinBy(fr => fr.ParsedFrameRate);
logger.LogInformation(
"Normalizing frame rate for channel {ChannelNumber} from {Distinct} to {FrameRate}",
request.ChannelNumber,
distinct.Map(fr => fr.RFrameRate),
result.RFrameRate);
return result;
}
FrameRate minFrameRate = frameRates.MinBy(fr => fr.ParsedFrameRate);
logger.LogInformation(
_logger.LogInformation(
"Normalizing frame rate for channel {ChannelNumber} from {Distinct} to {FrameRate} instead of min value {MinFrameRate}",
request.ChannelNumber,
distinct.Map(fr => fr.RFrameRate),
FrameRate.DefaultFrameRate.RFrameRate,
minFrameRate.RFrameRate);
distinct,
24,
result);
return FrameRate.DefaultFrameRate;
return 24;
}
if (distinct.Count != 0)
{
logger.LogInformation(
"All content on channel {ChannelNumber} has the same frame rate of {FrameRate}; will not normalize",
request.ChannelNumber,
distinct[0].RFrameRate);
}
else
{
logger.LogInformation(
"No content on channel {ChannelNumber} has frame rate information; will not normalize",
request.ChannelNumber);
}
_logger.LogInformation(
"Normalizing frame rate for channel {ChannelNumber} from {Distinct} to {FrameRate}",
request.ChannelNumber,
distinct,
result);
return result;
}
catch (Exception ex)
if (distinct.Count != 0)
{
logger.LogWarning(
ex,
"Unexpected error checking frame rates on channel {ChannelNumber}",
_logger.LogInformation(
"All content on channel {ChannelNumber} has the same frame rate of {FrameRate}; will not normalize",
request.ChannelNumber,
distinct[0]);
}
else
{
_logger.LogInformation(
"No content on channel {ChannelNumber} has frame rate information; will not normalize",
request.ChannelNumber);
}
return None;
}
private int ParseFrameRate(string frameRate)
{
if (!int.TryParse(frameRate, out int fr))
{
string[] split = (frameRate ?? string.Empty).Split("/");
if (int.TryParse(split[0], out int left) && int.TryParse(split[1], out int right))
{
fr = (int)Math.Round(left / (double)right);
}
else
{
fr = 24;
}
}
return fr;
}
}
@@ -1,10 +0,0 @@
using ErsatzTV.Core.Api.Channels;
namespace ErsatzTV.Application.Channels;
/// <summary>
/// JSON channel-guide query for the EPG grid. <paramref name="Start" /> defaults to now and
/// <paramref name="End" /> defaults to now + the configured XmltvDaysToBuild window.
/// </summary>
public record GetChannelGuideData(DateTimeOffset? Start, DateTimeOffset? End)
: IRequest<ChannelGuideResponseModel>;
@@ -1,145 +0,0 @@
using System.Globalization;
using ErsatzTV.Application.Configuration;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Channels;
/// <summary>
/// Builds the JSON channel guide directly from <see cref="Playout" /> items, using the shared
/// <see cref="ChannelGuideProjector" /> guide-group/filler-merge logic (the same logic the XMLTV
/// cache builder uses) so the two representations cannot drift. Only channels with
/// <see cref="Channel.ShowInEpg" /> are included, mirroring <c>GetChannelGuideHandler</c>.
/// Unlike XMLTV, filler programmes are returned (with their <see cref="Core.Domain.Filler.FillerKind" />)
/// so the UI can decide how to render them.
/// </summary>
public class GetChannelGuideDataHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository)
: IRequestHandler<GetChannelGuideData, ChannelGuideResponseModel>
{
public async Task<ChannelGuideResponseModel> Handle(
GetChannelGuideData request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
int daysToBuild = await configElementRepository
.GetValue<int>(ConfigElementKey.XmltvDaysToBuild, cancellationToken)
.IfNoneAsync(2);
XmltvTimeZone xmltvTimeZone = await configElementRepository
.GetValue<XmltvTimeZone>(ConfigElementKey.XmltvTimeZone, cancellationToken)
.IfNoneAsync(XmltvTimeZone.Local);
XmltvBlockBehavior xmltvBlockBehavior = await configElementRepository
.GetValue<XmltvBlockBehavior>(ConfigElementKey.XmltvBlockBehavior, cancellationToken)
.IfNoneAsync(XmltvBlockBehavior.SplitTimeEvenly);
DateTimeOffset start = request.Start ?? DateTimeOffset.UtcNow;
DateTimeOffset end = request.End ?? start.AddDays(daysToBuild);
// Visible channels only (mirror GetChannelGuideHandler's ShowInEpg == false skip).
List<Channel> channels = await dbContext.Channels
.AsNoTracking()
.Where(c => c.ShowInEpg)
.Include(c => c.MirrorSourceChannel)
.ToListAsync(cancellationToken);
// Order channels by their decimal channel number so "2" precedes "10", matching
// ChannelGuide.ToXml (which orders XMLTV channels by decimal.Parse of the number).
channels = channels
.OrderBy(c => decimal.Parse(c.Number, CultureInfo.InvariantCulture))
.ToList();
var responseChannels = new List<ChannelGuideChannelResponseModel>();
foreach (Channel channel in channels)
{
bool isMirror = channel.PlayoutSource == ChannelPlayoutSource.Mirror
&& channel.MirrorSourceChannel is not null;
string sourceChannelNumber = isMirror ? channel.MirrorSourceChannel.Number : channel.Number;
TimeSpan playoutOffset = isMirror ? channel.PlayoutOffset ?? TimeSpan.Zero : TimeSpan.Zero;
List<Playout> playouts = await dbContext.Playouts
.AsNoTracking()
.Filter(p => p.Channel.Number == sourceChannelNumber)
.IncludeGuideMetadata()
.AsSplitQuery()
.ToListAsync(cancellationToken);
var programmes = new List<ChannelGuideProgrammeResponseModel>();
foreach (Playout playout in playouts)
{
// ExternalJson playouts materialize items from a file rather than Playout.Items; they are
// out of scope for the JSON guide (see issue #102 notes).
if (playout.ScheduleKind is PlayoutScheduleKind.ExternalJson)
{
continue;
}
// Filter to the window (on the pre-offset time, mirroring the XMLTV builder) then apply the
// mirror playout offset without mutating the loaded (shared, AsNoTracking) entities.
List<PlayoutItem> sorted = playout.Items
.OrderBy(pi => pi.Start)
.Filter(pi => pi.StartOffset <= end)
.Select(pi => playoutOffset == TimeSpan.Zero ? pi : WithPlayoutOffset(pi, playoutOffset))
.ToList();
foreach (ChannelGuideEntry entry in ChannelGuideProjector.Project(
playout.ScheduleKind,
sorted,
xmltvTimeZone,
xmltvBlockBehavior))
{
// drop programmes that finish before the requested window starts
if (entry.Stop <= start)
{
continue;
}
string subtitle = ChannelGuideMetadata.GetSubtitle(entry.DisplayItem);
programmes.Add(
new ChannelGuideProgrammeResponseModel(
entry.Start,
entry.Stop,
ChannelGuideMetadata.GetTitle(entry.DisplayItem),
string.IsNullOrWhiteSpace(subtitle) ? null : subtitle,
ChannelGuideMetadata.GetCategory(entry.DisplayItem),
entry.DisplayItem.FillerKind));
}
}
responseChannels.Add(
new ChannelGuideChannelResponseModel(
channel.Number,
channel.Name,
programmes.OrderBy(p => p.Start).ToList()));
}
return new ChannelGuideResponseModel(start, end, responseChannels);
}
// Copy (don't mutate) the loaded PlayoutItem when shifting by the mirror playout offset. The loaded
// entities are AsNoTracking and shared; mutating them in place would corrupt the guide projection.
// Mirrors the XMLTV builder, which shifts only Start/Finish (not the Guide* window).
private static PlayoutItem WithPlayoutOffset(PlayoutItem item, TimeSpan offset) =>
new()
{
MediaItem = item.MediaItem,
Start = item.Start + offset,
Finish = item.Finish + offset,
GuideStart = item.GuideStart,
GuideFinish = item.GuideFinish,
GuideGroup = item.GuideGroup,
FillerKind = item.FillerKind,
CustomTitle = item.CustomTitle
};
}
@@ -1,7 +1,4 @@
using System.Collections.Immutable;
using System.IO.Abstractions;
using System.Text;
using System.Text.RegularExpressions;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Iptv;
@@ -11,32 +8,35 @@ using Microsoft.IO;
namespace ErsatzTV.Application.Channels;
public partial class GetChannelGuideHandler(
IDbContextFactory<TvContext> dbContextFactory,
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
IFileSystem fileSystem,
ILocalFileSystem localFileSystem)
: IRequestHandler<GetChannelGuide, Either<BaseError, ChannelGuide>>
public class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, Either<BaseError, ChannelGuide>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ILocalFileSystem _localFileSystem;
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
public GetChannelGuideHandler(
IDbContextFactory<TvContext> dbContextFactory,
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
ILocalFileSystem localFileSystem)
{
_dbContextFactory = dbContextFactory;
_recyclableMemoryStreamManager = recyclableMemoryStreamManager;
_localFileSystem = localFileSystem;
}
public async Task<Either<BaseError, ChannelGuide>> Handle(
GetChannelGuide request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
var hiddenChannelNumbers = dbContext.Channels
.Where(c => c.ShowInEpg == false)
.Select(c => c.Number)
.AsEnumerable()
.Select(n => $"{n}.xml")
.ToImmutableHashSet();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
string channelsFile = fileSystem.Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, "channels.xml");
if (!fileSystem.File.Exists(channelsFile))
string channelsFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, "channels.xml");
if (!_localFileSystem.FileExists(channelsFile))
{
return BaseError.New($"Required file {channelsFile} is missing");
}
long mtime = fileSystem.File.GetLastWriteTime(channelsFile).Ticks;
long mtime = File.GetLastWriteTime(channelsFile).Ticks;
var accessTokenUri = $"?v={mtime}";
if (!string.IsNullOrWhiteSpace(request.AccessToken))
@@ -44,7 +44,7 @@ public partial class GetChannelGuideHandler(
accessTokenUri += $"&amp;access_token={request.AccessToken}";
}
string channelsFragment = await ReadAllTextShared(channelsFile, cancellationToken);
string channelsFragment = await File.ReadAllTextAsync(channelsFile, Encoding.UTF8, cancellationToken);
// TODO: is regex faster?
channelsFragment = channelsFragment
@@ -53,54 +53,22 @@ public partial class GetChannelGuideHandler(
var channelDataFragments = new Dictionary<string, string>();
foreach (string fileName in localFileSystem.ListFiles(FileSystemLayout.ChannelGuideCacheFolder))
foreach (string fileName in _localFileSystem.ListFiles(FileSystemLayout.ChannelGuideCacheFolder))
{
if (fileName.Contains("channels"))
{
continue;
}
if (hiddenChannelNumbers.Contains(fileSystem.Path.GetFileName(fileName)))
{
continue;
}
string channelDataFragment = await File.ReadAllTextAsync(fileName, Encoding.UTF8, cancellationToken);
try
{
string channelDataFragment = await ReadAllTextShared(fileName, cancellationToken);
channelDataFragment = channelDataFragment
.Replace("{RequestBase}", $"{request.Scheme}://{request.Host}{request.BaseUrl}")
.Replace("{AccessTokenUri}", accessTokenUri);
channelDataFragment = channelDataFragment
.Replace("{RequestBase}", $"{request.Scheme}://{request.Host}{request.BaseUrl}")
.Replace("{AccessTokenUri}", accessTokenUri);
channelDataFragment = EtvTagRegex().Replace(channelDataFragment, string.Empty);
channelDataFragments.Add(fileSystem.Path.GetFileNameWithoutExtension(fileName), channelDataFragment);
}
catch (FileNotFoundException)
{
// ignore this channel fragment
}
catch (IOException)
{
// ignore this channel fragment
}
channelDataFragments.Add(Path.GetFileNameWithoutExtension(fileName), channelDataFragment);
}
return new ChannelGuide(recyclableMemoryStreamManager, channelsFragment, channelDataFragments);
return new ChannelGuide(_recyclableMemoryStreamManager, channelsFragment, channelDataFragments);
}
private async Task<string> ReadAllTextShared(string fileName, CancellationToken cancellationToken)
{
await using var stream = fileSystem.FileStream.New(
fileName,
FileMode.Open,
FileAccess.Read,
FileShare.ReadWrite);
using var reader = new StreamReader(stream, Encoding.UTF8, leaveOpen: true);
return await reader.ReadToEndAsync(cancellationToken);
}
[GeneratedRegex(@"<etv:[^>]+?>.*?<\/etv:[^>]+?>|<etv:[^>]+?\/>", RegexOptions.Singleline)]
private static partial Regex EtvTagRegex();
}
@@ -10,7 +10,6 @@ public class GetChannelLineupHandler : IRequestHandler<GetChannelLineup, List<Li
public GetChannelLineupHandler(IChannelRepository channelRepository) => _channelRepository = channelRepository;
public Task<List<LineupItem>> Handle(GetChannelLineup request, CancellationToken cancellationToken) =>
_channelRepository.GetAll(cancellationToken)
.Map(channels => channels.Where(c => c.IsEnabled)
.Map(c => new LineupItem(request.Scheme, request.Host, c)).ToList());
_channelRepository.GetAll()
.Map(channels => channels.Map(c => new LineupItem(request.Scheme, request.Host, c)).ToList());
}
@@ -4,15 +4,19 @@ using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Channels;
public class GetChannelNameByPlayoutIdHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetChannelNameByPlayoutId, Option<string>>
public class GetChannelNameByPlayoutIdHandler : IRequestHandler<GetChannelNameByPlayoutId, Option<string>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public GetChannelNameByPlayoutIdHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<Option<string>> Handle(GetChannelNameByPlayoutId request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.Playouts
.Include(p => p.Channel)
.SelectOneAsync(p => p.Id, p => p.Id == request.PlayoutId, cancellationToken)
.SelectOneAsync(p => p.Id, p => p.Id == request.PlayoutId)
.MapT(p => p.Channel.Name);
}
}
@@ -4,36 +4,40 @@ using ErsatzTV.Core.Iptv;
namespace ErsatzTV.Application.Channels;
public class GetChannelPlaylistHandler(IChannelRepository channelRepository)
: IRequestHandler<GetChannelPlaylist, ChannelPlaylist>
public class GetChannelPlaylistHandler : IRequestHandler<GetChannelPlaylist, ChannelPlaylist>
{
private readonly IChannelRepository _channelRepository;
public GetChannelPlaylistHandler(IChannelRepository channelRepository) =>
_channelRepository = channelRepository;
public Task<ChannelPlaylist> Handle(GetChannelPlaylist request, CancellationToken cancellationToken) =>
channelRepository.GetAll(cancellationToken)
_channelRepository.GetAll()
.Map(channels => EnsureMode(channels, request.Mode))
.Map(channels => new ChannelPlaylist(
request.Scheme,
request.Host,
request.BaseUrl,
channels,
request.UserAgent,
request.AccessToken));
.Map(
channels => new ChannelPlaylist(
request.Scheme,
request.Host,
request.BaseUrl,
channels,
request.UserAgent,
request.AccessToken));
private static List<Channel> EnsureMode(IEnumerable<Channel> channels, string mode)
{
var result = new List<Channel>();
foreach (Channel channel in channels)
{
if (!channel.IsEnabled)
{
continue;
}
switch (mode.ToLowerInvariant())
{
case "segmenter":
channel.StreamingMode = StreamingMode.HttpLiveStreamingSegmenter;
result.Add(channel);
break;
case "segmenter-v2":
channel.StreamingMode = StreamingMode.HttpLiveStreamingSegmenterV2;
result.Add(channel);
break;
case "hls-direct":
channel.StreamingMode = StreamingMode.HttpLiveStreamingDirect;
result.Add(channel);
@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Channels;
public record GetChannelResolutionAndBitrate(string ChannelNumber) : IRequest<Option<ResolutionAndBitrateViewModel>>;
@@ -5,11 +5,11 @@ using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Channels;
public class GetChannelStreamingSpecsHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetChannelStreamingSpecs, Option<ChannelStreamingSpecsViewModel>>
public class GetChannelResolutionAndBitrateHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetChannelResolutionAndBitrate, Option<ResolutionAndBitrateViewModel>>
{
public async Task<Option<ChannelStreamingSpecsViewModel>> Handle(
GetChannelStreamingSpecs request,
public async Task<Option<ResolutionAndBitrateViewModel>> Handle(
GetChannelResolutionAndBitrate request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
@@ -18,8 +18,10 @@ public class GetChannelStreamingSpecsHandler(IDbContextFactory<TvContext> dbCont
.AsNoTracking()
.Include(c => c.FFmpegProfile)
.ThenInclude(ff => ff.Resolution)
.SelectOneAsync(c => c.Number, c => c.Number == request.ChannelNumber, cancellationToken);
.SelectOneAsync(c => c.Number, c => c.Number == request.ChannelNumber);
return maybeChannel.Map(Mapper.ProjectToSpecsViewModel);
return maybeChannel.Map(c => Mapper.ProjectToViewModel(
c.FFmpegProfile.Resolution,
(int)((c.FFmpegProfile.VideoBitrate * 1000 + c.FFmpegProfile.AudioBitrate * 1000) * 1.2)));
}
}
@@ -1,5 +0,0 @@
using ErsatzTV.Core.Api.Channels;
namespace ErsatzTV.Application.Channels;
public record GetChannelStatesForApi(DateTime Now) : IRequest<List<ChannelStateResponseModel>>;

Some files were not shown because too many files have changed in this diff Show More