Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f230adc3cb |
@@ -1,167 +0,0 @@
|
||||
---
|
||||
name: ersatztv
|
||||
description: ErsatzTV custom IPTV channel management — REST API, SQLite DB, Jellyfin integration, FFmpeg profiles. Use when managing custom TV channels.
|
||||
---
|
||||
|
||||
# ErsatzTV Channel Management
|
||||
|
||||
Container: `ersatztv` | Port: `8409` | IP: `172.16.238.11` (may change on restart)
|
||||
Web UI: internal only (`http://localhost:8409` via SSH)
|
||||
SQLite DB: `~/downloadswarm/ersatztv/ersatztv.sqlite3` on jazz (owned by root — use `sudo sqlite3`)
|
||||
Image: `ghcr.io/ersatztv/ersatztv:latest` (v26.3.0, repo archived Feb 2026)
|
||||
|
||||
## Architecture
|
||||
|
||||
ErsatzTV uses **MediatR + Blazor** (not REST for mutations). The REST API is limited:
|
||||
- **GET endpoints**: channels, collections, schedules, playouts, shows, movies, artists, ffmpeg profiles, health, search, watermarks
|
||||
- **POST endpoints**: library scan, playout reset, show scan
|
||||
- **No REST CRUD for channels/collections/schedules** — must use SQLite DB directly
|
||||
|
||||
## REST API
|
||||
|
||||
```bash
|
||||
# Via docker exec
|
||||
docker exec ersatztv curl -s http://localhost:8409/api/ENDPOINT
|
||||
```
|
||||
|
||||
### Read Endpoints (GET)
|
||||
```
|
||||
/api/channels # List channels
|
||||
/api/collections # List collections
|
||||
/api/schedules # List schedules
|
||||
/api/playouts # List playouts
|
||||
/api/shows # List shows
|
||||
/api/movies # List movies
|
||||
/api/artists # List artists
|
||||
/api/search # Search items
|
||||
/api/ffmpeg/profiles # FFmpeg profiles
|
||||
/api/watermarks # Watermarks
|
||||
/iptv/channels.m3u # M3U playlist (for Jellyfin)
|
||||
/iptv/xmltv.xml # XMLTV guide data
|
||||
```
|
||||
|
||||
### Mutation Endpoints (POST)
|
||||
```bash
|
||||
# Library scan
|
||||
POST /api/libraries/{id}/scan
|
||||
|
||||
# Scan single show
|
||||
POST /api/libraries/{id}/scan-show \
|
||||
-H "Content-Type: application/json" -d '{"ShowTitle":"Name","DeepScan":false}'
|
||||
|
||||
# Reset channel playout (rebuilds schedule)
|
||||
POST /api/channels/{channelNumber}/playout/reset
|
||||
```
|
||||
|
||||
## SQLite DB Operations
|
||||
|
||||
```bash
|
||||
# Read queries (safe while running, WAL mode)
|
||||
sudo sqlite3 ~/downloadswarm/ersatztv/ersatztv.sqlite3 "QUERY"
|
||||
|
||||
# Write queries — stop container first
|
||||
docker stop ersatztv
|
||||
sudo sqlite3 ~/downloadswarm/ersatztv/ersatztv.sqlite3 "QUERY"
|
||||
docker start ersatztv
|
||||
```
|
||||
|
||||
### Key Queries
|
||||
```sql
|
||||
-- List channels
|
||||
SELECT Id, Number, Name FROM Channel ORDER BY CAST(Number AS INTEGER);
|
||||
|
||||
-- List collections with item counts
|
||||
SELECT c.Id, c.Name, COUNT(ci.Id) as items FROM Collection c LEFT JOIN CollectionItem ci ON ci.CollectionId = c.Id GROUP BY c.Id;
|
||||
|
||||
-- List schedules
|
||||
SELECT Id, Name FROM ProgramSchedule;
|
||||
|
||||
-- Playout (channel-schedule links)
|
||||
SELECT p.Id, c.Number, c.Name, ps.Name as Schedule FROM Playout p JOIN Channel c ON p.ChannelId = c.Id LEFT JOIN ProgramSchedule ps ON p.ProgramScheduleId = ps.Id;
|
||||
|
||||
-- Media counts
|
||||
SELECT 'Shows' as type, COUNT(*) FROM Show UNION ALL SELECT 'Movies', COUNT(*) FROM Movie UNION ALL SELECT 'Episodes', COUNT(*) FROM Episode UNION ALL SELECT 'MusicVideos', COUNT(*) FROM MusicVideo;
|
||||
|
||||
-- Jellyfin source
|
||||
SELECT jms.Id, jc.Address, jms.ServerName FROM JellyfinMediaSource jms JOIN JellyfinConnection jc ON jc.JellyfinMediaSourceId = jms.Id;
|
||||
|
||||
-- Library sync status
|
||||
SELECT l.Id, l.Name, l.MediaKind, jl.ShouldSyncItems FROM Library l JOIN JellyfinLibrary jl ON jl.Id = l.Id;
|
||||
```
|
||||
|
||||
### Channel Setup Workflow (DB)
|
||||
|
||||
**Show-specific channel** (single TV show, shuffled):
|
||||
```sql
|
||||
-- 1. Schedule
|
||||
INSERT INTO ProgramSchedule (Id, FixedStartTimeBehavior, KeepMultiPartEpisodesTogether, Name, RandomStartPoint, ShuffleScheduleItems, TreatCollectionsAsShows)
|
||||
VALUES (<id>, 0, 0, '<name>', 1, 0, 1);
|
||||
-- 2. Schedule item (CollectionType=1 for Show, PlaybackOrder=3 for Shuffle)
|
||||
INSERT INTO ProgramScheduleItem (Id, CollectionType, FillWithGroupMode, GuideMode, "Index", MarathonGroupBy, MarathonShuffleGroups, MarathonShuffleItems, MediaItemId, PlaybackOrder, ProgramScheduleId)
|
||||
VALUES (<id>, 1, 0, 0, 0, 0, 0, 0, <show_id>, 3, <schedule_id>);
|
||||
INSERT INTO ProgramScheduleOneItem (Id) VALUES (<item_id>);
|
||||
-- 3. Channel
|
||||
INSERT INTO Channel (Id, Categories, FFmpegProfileId, FallbackFillerId, "Group", IdleBehavior, IsEnabled, MirrorSourceChannelId, MusicVideoCreditsMode, MusicVideoCreditsTemplate, Name, Number, PlayoutMode, PlayoutOffset, PlayoutSource, PreferredAudioLanguageCode, PreferredAudioTitle, PreferredSubtitleLanguageCode, ShowInEpg, SongVideoMode, SortNumber, StreamSelector, StreamSelectorMode, StreamingMode, SubtitleMode, TranscodeMode, UniqueId, WatermarkId)
|
||||
VALUES (<id>, '', 1, NULL, '<category>', 0, 1, NULL, 0, NULL, '<name>', '<number>', 0, NULL, 0, NULL, NULL, 'eng', 1, 0, <number>.0, NULL, 0, 4, 2, 0, lower(hex(randomblob(4)))||'-'||lower(hex(randomblob(2)))||'-4'||substr(lower(hex(randomblob(2))),2)||'-'||lower(hex(randomblob(2)))||'-'||lower(hex(randomblob(6))), 1);
|
||||
-- 4. Playout
|
||||
INSERT INTO Playout (Id, ChannelId, ProgramScheduleId, ScheduleKind, Seed)
|
||||
VALUES (<id>, <channel_id>, <schedule_id>, 0, abs(random()) % 1000000);
|
||||
```
|
||||
|
||||
**Collection-based channel** (multiple shows, shuffled):
|
||||
```sql
|
||||
-- 1. Collection + items (MediaItemId = Show.Id)
|
||||
INSERT INTO Collection (Id, Name, UseCustomPlaybackOrder) VALUES (<id>, '<name>', 0);
|
||||
INSERT INTO CollectionItem (CollectionId, MediaItemId) VALUES (<coll_id>, <show_id>);
|
||||
-- 2. Schedule (same as above but CollectionType=0, CollectionId set instead of MediaItemId)
|
||||
INSERT INTO ProgramScheduleItem (Id, CollectionId, CollectionType, ..., PlaybackOrder, ProgramScheduleId)
|
||||
VALUES (<id>, <coll_id>, 0, ..., 3, <schedule_id>);
|
||||
-- 3-4. Channel + Playout same as show-specific
|
||||
```
|
||||
|
||||
After creating: `POST /api/channels/{number}/playout/reset`
|
||||
|
||||
## Volume Mounts (matches Jellyfin)
|
||||
|
||||
| Host Path | Container Path |
|
||||
|-----------|---------------|
|
||||
| `~/downloadswarm/ersatztv` | `/config` |
|
||||
| `/mnt/teramind/episodes` | `/data/tvshows` (ro) |
|
||||
| `/mnt/episodes` | `/data/episodes` (ro) |
|
||||
| `/mnt/media/movies` | `/data/movies` (ro) |
|
||||
| `/mnt/media/standup` | `/data/standup` (ro) |
|
||||
| `/mnt/media/music_videos` | `/data/music` (ro) |
|
||||
|
||||
## FFmpeg & Hardware
|
||||
|
||||
- QSV (Intel Quick Sync) hardware acceleration
|
||||
- Resolution: 1920x1080, H264, AAC stereo
|
||||
- Device: `/dev/dri` passed through
|
||||
- HardwareAccelerationKind: 0=None, 1=Qsv, 2=Nvenc, 3=Vaapi, 4=VideoToolbox, 5=Amf
|
||||
|
||||
## Jellyfin Integration
|
||||
|
||||
- Secrets: `/config/jellyfin-secrets.json` (`{"Address":"http://jellyfin:8096","ApiKey":"978033be716d46678a5d3c54ae0e0ff9"}`)
|
||||
- Libraries: Movies(10), TV Shows(11), Music Videos(8), Standup(9)
|
||||
- `JellyfinLibrary.ShouldSyncItems` must be `1` for scans to work
|
||||
|
||||
## Gotchas
|
||||
|
||||
- DB owned by root — always use `sudo sqlite3`
|
||||
- WAL mode: reads OK while running, stop container for writes
|
||||
- No REST API for channel/collection/schedule CRUD — DB scripting only
|
||||
- Secrets file uses PascalCase JSON (`Address`, `ApiKey`)
|
||||
- Scanner is separate binary (`ErsatzTV.Scanner`) — check with `docker top ersatztv | grep Scanner`
|
||||
- EF TPT inheritance: `ProgramScheduleItem` has subtype tables (`ProgramScheduleOneItem`, etc.) — MUST insert into subtype table
|
||||
- External URL logos work for M3U but NOT for watermark burn-in (code checks `File.Exists()`)
|
||||
- `/api/health` returns Blazor HTML, not JSON — use `/api/channels` to verify API
|
||||
- PlaybackOrder enum: 3=Shuffle, 6=SeasonEpisode (use 3 for all channels)
|
||||
- CollectionType enum: 0=Collection, 1=Show (direct show reference via MediaItemId)
|
||||
- SubtitleMode: 0=None, 2=Burn-in. Set to 2 with PreferredSubtitleLanguageCode='eng' for non-music channels
|
||||
- MediaItem.State: 0=Normal, 1=FileNotFound — clean up state=1 items by deleting cascading deps
|
||||
- ProgramSchedule required NOT NULL columns: FixedStartTimeBehavior, KeepMultiPartEpisodesTogether, RandomStartPoint, ShuffleScheduleItems, TreatCollectionsAsShows
|
||||
- Channel required NOT NULL columns: SongVideoMode (set 0), plus all standard columns (see Channel table schema)
|
||||
- After schedule changes, rebuild playout: `POST /api/channels/{number}/playout/reset`
|
||||
- Playout `ScheduleKind` must be `1` (not `0`/None) — `0` causes "Cannot build playout type None" error
|
||||
- M3U `tvg-logo` URLs hardcode `http://localhost:8409` — Jellyfin can't fetch these from inside its container. Fix by downloading logos from ETV and base64-uploading to Jellyfin (see `docs/Docker/ErsatzTV.md` for script). Tracked in issue #171
|
||||
- Repo archived Feb 2026, v26.3.0 is final stable version. Maintainer welcomes forks
|
||||
@@ -1,105 +0,0 @@
|
||||
---
|
||||
name: jellyfin
|
||||
description: Jellyfin media server management — API for libraries, items, streaming, users. Use when managing media library or checking Jellyfin status.
|
||||
---
|
||||
|
||||
# Jellyfin Management
|
||||
|
||||
Container: `jellyfin` | Port: `8096` | IP: `172.16.238.20` (may change on restart)
|
||||
API Token: `978033be716d46678a5d3c54ae0e0ff9`
|
||||
Web UI: `https://jellyfin.tblindustries.be` (NO Authelia — native login, password: `coup1802`)
|
||||
Config: `/home/timothy/downloadswarm/jellyfin/` on jazz
|
||||
|
||||
## Access Pattern
|
||||
|
||||
```bash
|
||||
docker exec jellyfin curl -s 'http://localhost:8096/ENDPOINT' \
|
||||
-H 'X-Emby-Token: 978033be716d46678a5d3c54ae0e0ff9'
|
||||
```
|
||||
|
||||
## Volume Mounts
|
||||
|
||||
| Host Path | Container Path | Content |
|
||||
|-----------|---------------|---------|
|
||||
| `/mnt/teramind/episodes` | `/data/tvshows` | TV shows |
|
||||
| `/mnt/episodes` | `/data/episodes` | More episodes |
|
||||
| `/mnt/media/movies` | `/data/movies` | Movies |
|
||||
| `/mnt/media/standup` | `/data/standup` | Standup |
|
||||
| `/mnt/media/music_videos` | `/data/music` | Music videos |
|
||||
| `/mnt/media/audio/music` | `/data/audio` | Music audio (ro) |
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### System
|
||||
```
|
||||
GET /System/Info # Server info, version
|
||||
GET /System/Info/Public # Public info (no auth needed)
|
||||
POST /System/Restart # Restart server
|
||||
```
|
||||
|
||||
### Items (Search & Browse)
|
||||
```bash
|
||||
# Search items
|
||||
GET /Items?includeItemTypes=Movie,Episode,Series&recursive=true&searchTerm=QUERY&fields=Path&limit=20
|
||||
|
||||
# Get item details
|
||||
GET /Items?ids=ITEM_ID&fields=Path,MediaStreams,Overview
|
||||
|
||||
# Get all movies
|
||||
GET /Items?includeItemTypes=Movie&recursive=true&fields=Path&limit=1000
|
||||
|
||||
# Get series
|
||||
GET /Items?includeItemTypes=Series&recursive=true&fields=Path
|
||||
|
||||
# Get episodes for a series
|
||||
GET /Shows/{seriesId}/Episodes?fields=Path,MediaStreams
|
||||
|
||||
# Filter by library (parentId)
|
||||
GET /Items?parentId=LIBRARY_ID&recursive=true&fields=Path
|
||||
```
|
||||
|
||||
### Libraries
|
||||
```
|
||||
GET /Library/VirtualFolders # List all libraries
|
||||
POST /Library/Refresh # Trigger full library scan
|
||||
POST /Items/{id}/Refresh # Refresh single item metadata
|
||||
```
|
||||
|
||||
### Streaming
|
||||
```bash
|
||||
# Test stream URL
|
||||
GET /Videos/{itemId}/stream?static=true
|
||||
|
||||
# Get playback info
|
||||
GET /Items/{itemId}/PlaybackInfo
|
||||
```
|
||||
|
||||
### Users
|
||||
```
|
||||
GET /Users # List users
|
||||
GET /Users/{userId} # User details
|
||||
```
|
||||
|
||||
## Library IDs
|
||||
|
||||
Check with: `curl -s -H "X-Emby-Token: TOKEN" http://localhost:8096/Library/VirtualFolders`
|
||||
|
||||
## Live TV
|
||||
|
||||
- **ErsatzTV** (channels <1000): M3U `http://ersatztv:8409/iptv/channels.m3u`, XMLTV `http://ersatztv:8409/iptv/xmltv.xml`
|
||||
- **Dispatcharr** (channels 1000+): IPTV stream manager on port 9191, separate tuner
|
||||
- Configured in Jellyfin Admin > Live TV
|
||||
- Guide refresh task ID: `bea9b218c97bbf98c5dc1303bdb9a0ca` — trigger via `POST /ScheduledTasks/Running/{id}`
|
||||
- **Logo fix after guide refresh**: ErsatzTV logos break (aspect ratio=0) because M3U uses `localhost:8409`. Fix script in `docs/Docker/ErsatzTV.md` downloads from ETV and base64-uploads to `POST /Items/{id}/Images/Primary` (body = base64, Content-Type = image/png)
|
||||
- **Image upload format**: Jellyfin expects base64-encoded body (NOT raw binary) for `POST /Items/{id}/Images/Primary`
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Passwords**: `coup1802` (NOT `ded89Lm4`) — Jellyfin has native auth, no Authelia
|
||||
- Auth header is `X-Emby-Token` (Jellyfin is an Emby fork)
|
||||
- Music videos are typed as "Movie" in Jellyfin
|
||||
- Music library at `/data/music` maps to `/mnt/media/music_videos` on host (not actual music)
|
||||
- Items return 404 on stream if source volume is unmounted
|
||||
- Jellyfin preserves item IDs across restarts unless files are renamed
|
||||
- Full library scan can take a long time — prefer targeted `/Items/{id}/Refresh`
|
||||
- `ffprobe` available in container for checking media streams: `docker exec jellyfin ffprobe -v quiet -print_format json -show_streams FILE`
|
||||
@@ -3,11 +3,16 @@
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"jetbrains.resharper.globaltools": {
|
||||
"version": "2025.3.0.2",
|
||||
"version": "2021.2.2",
|
||||
"commands": [
|
||||
"jb"
|
||||
],
|
||||
"rollForward": false
|
||||
]
|
||||
},
|
||||
"swashbuckle.aspnetcore.cli": {
|
||||
"version": "5.6.2",
|
||||
"commands": [
|
||||
"swagger"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-43
@@ -1,8 +1,9 @@
|
||||
|
||||
[*]
|
||||
charset=utf-8
|
||||
charset=utf-8-bom
|
||||
end_of_line=lf
|
||||
trim_trailing_whitespace=true
|
||||
insert_final_newline=true
|
||||
trim_trailing_whitespace=false
|
||||
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
|
||||
@@ -84,39 +83,3 @@ tab_width=4
|
||||
[*.yml]
|
||||
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
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
|
||||
*.cs text diff=csharp
|
||||
*.cshtml text diff=html
|
||||
*.csx text diff=csharp
|
||||
*.sln text eol=crlf
|
||||
*.csproj text eol=crlf
|
||||
@@ -1,58 +0,0 @@
|
||||
name: Dependency vulnerability scan
|
||||
|
||||
# Scheduled NuGet advisory scan — a Gitea-native stand-in for Dependabot (ersatztv#14).
|
||||
# Surfaces vulnerable direct/transitive packages on a schedule instead of only when a
|
||||
# `dotnet restore` happens to break. This is DETECTION ONLY; automated update PRs are
|
||||
# tracked separately (self-hosted Renovate — server-management#484).
|
||||
#
|
||||
# Scans the FULL solution (including the Scanner project, which the image build strips)
|
||||
# so coverage isn't narrower than the code we ship.
|
||||
#
|
||||
# NOTE: Gitea runs `schedule` triggers only from the default branch (main); the workflow
|
||||
# must be merged to main before the cron registers. Use `workflow_dispatch` to run on demand.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
# Mondays 06:00 UTC
|
||||
- cron: '0 6 * * 1'
|
||||
|
||||
# Independent of the build pipeline's concurrency group; a stale scan can be cancelled.
|
||||
concurrency:
|
||||
group: ersatztv-depscan
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
scan:
|
||||
name: NuGet vulnerable packages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore ErsatzTV.sln
|
||||
|
||||
- name: Scan for vulnerable packages (direct + transitive)
|
||||
# bash + `set -euo pipefail` so a failing `dotnet list` (e.g. the audit source
|
||||
# is unreachable while restore served from cache) fails the job instead of
|
||||
# falling through to a false "no vulnerable packages" green.
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "Running: dotnet list package --vulnerable --include-transitive"
|
||||
dotnet list ErsatzTV.sln package --vulnerable --include-transitive 2>&1 | tee depscan.txt
|
||||
# `dotnet list package --vulnerable` exits 0 even when advisories exist, so detect
|
||||
# findings by the report marker and fail the run if any are present. Expect this to
|
||||
# be RED until ersatztv#8 clears the current NCalcSync / SQLitePCLRaw advisories;
|
||||
# after that, a red run means a NEW advisory has appeared.
|
||||
if grep -q "has the following vulnerable packages" depscan.txt; then
|
||||
echo "::error::Vulnerable NuGet packages detected — see report above (tracked: ersatztv#8)."
|
||||
exit 1
|
||||
fi
|
||||
echo "No vulnerable packages found."
|
||||
@@ -1,251 +0,0 @@
|
||||
name: Build ErsatzTV Image
|
||||
|
||||
# Builds the fork's own amd64 image and pushes it to the Gitea container registry.
|
||||
# pull_request -> test job only (no image build/push)
|
||||
# push to main -> :latest + :<short-sha> (test image; does NOT touch prod)
|
||||
# push tag v* -> :prod + :<version> + :<short-sha> (prod release)
|
||||
# workflow_dispatch -> manual run; only publishes when the ref is main or a v* tag
|
||||
#
|
||||
# Runner + registry provisioned in server-management#172. The Gitea registry is
|
||||
# HTTP-only, so BuildKit needs the inline `http = true` config below (it does not
|
||||
# inherit the host daemon's insecure-registries setting).
|
||||
#
|
||||
# `:latest` is intentionally the test/dev channel (per ersatztv#3); prod pins
|
||||
# `:prod`, never `:latest` (enforced in the prod compose — server-management#481).
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
# Single runner on jazz: serialize all runs so the push-main-then-tag release
|
||||
# flow can't collide on the shared :buildcache tag or the smoke container.
|
||||
concurrency:
|
||||
group: ersatztv-build
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
REGISTRY: 192.168.1.95:3000
|
||||
IMAGE: 192.168.1.95:3000/timothy/ersatztv
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Build & test (.NET)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore
|
||||
|
||||
- name: Strip Scanner project ref (matches Docker build)
|
||||
run: sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||
|
||||
- name: Build
|
||||
run: dotnet build --configuration Release --no-restore
|
||||
|
||||
- name: Test
|
||||
run: dotnet test --configuration Release --no-build --blame-hang-timeout "2m" --verbosity normal
|
||||
|
||||
migrations:
|
||||
name: EF migration integrity (SQLite + MySql)
|
||||
runs-on: ubuntu-latest
|
||||
# Independent gate (not a 'needs' of build yet) so the new MySql-service dependency
|
||||
# can't block image builds until it's proven reliable on the runner. Promote to a
|
||||
# required check / build dependency once green. (ersatztv#13)
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:8.4
|
||||
env:
|
||||
MYSQL_ROOT_PASSWORD: ersatztv
|
||||
MYSQL_DATABASE: ersatztv_migrations
|
||||
ports:
|
||||
- 3306:3306
|
||||
options: >-
|
||||
--health-cmd="mysqladmin ping -h 127.0.0.1 -uroot -persatztv --silent"
|
||||
--health-interval=5s
|
||||
--health-timeout=5s
|
||||
--health-retries=30
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore
|
||||
|
||||
- name: Build
|
||||
run: dotnet build --configuration Release --no-restore
|
||||
|
||||
- name: Install dotnet-ef
|
||||
run: dotnet tool install --global dotnet-ef --version 9.0.12
|
||||
|
||||
# SQLite is the prod provider; both checks validated locally.
|
||||
- name: SQLite — model drift + apply all migrations to a fresh DB
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export PATH="$PATH:$HOME/.dotnet/tools"
|
||||
echo "::group::SQLite model drift (has-pending-model-changes)"
|
||||
dotnet ef migrations has-pending-model-changes --no-build --configuration Release \
|
||||
--context TvContext --startup-project ErsatzTV --project ErsatzTV.Infrastructure.Sqlite -- --provider Sqlite
|
||||
echo "::endgroup::"
|
||||
echo "::group::SQLite apply all migrations to a fresh DB"
|
||||
export ETV_CONFIG_FOLDER="$(mktemp -d)" ETV_TRANSCODE_FOLDER="$(mktemp -d)"
|
||||
dotnet ef database update --no-build --configuration Release \
|
||||
--context TvContext --startup-project ErsatzTV --project ErsatzTV.Infrastructure.Sqlite -- --provider Sqlite
|
||||
echo "::endgroup::"
|
||||
|
||||
# MySql uses ServerVersion.AutoDetect (connects at config time), so it runs against the
|
||||
# service container above. MySql__ConnectionString maps to config key "MySql:ConnectionString".
|
||||
- name: MySql — model drift + apply all migrations to a fresh DB
|
||||
env:
|
||||
MySql__ConnectionString: "Server=mysql;Port=3306;Database=ersatztv_migrations;Uid=root;Pwd=ersatztv;"
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export PATH="$PATH:$HOME/.dotnet/tools"
|
||||
echo "::group::MySql model drift (has-pending-model-changes)"
|
||||
dotnet ef migrations has-pending-model-changes --no-build --configuration Release \
|
||||
--context TvContext --startup-project ErsatzTV --project ErsatzTV.Infrastructure.MySql -- --provider MySql
|
||||
echo "::endgroup::"
|
||||
echo "::group::MySql apply all migrations to a fresh DB"
|
||||
dotnet ef database update --no-build --configuration Release \
|
||||
--context TvContext --startup-project ErsatzTV --project ErsatzTV.Infrastructure.MySql -- --provider MySql
|
||||
echo "::endgroup::"
|
||||
|
||||
build:
|
||||
name: Build & push image (amd64)
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test, migrations]
|
||||
if: github.event_name != 'pull_request'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Compute version and tags
|
||||
id: meta
|
||||
run: |
|
||||
SHORT=$(git rev-parse --short HEAD)
|
||||
if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
INFO_VERSION="${VERSION}"
|
||||
TAGS=("${IMAGE}:prod" "${IMAGE}:${VERSION}" "${IMAGE}:${SHORT}")
|
||||
else
|
||||
DESC=$(git describe --tags --abbrev=0 2>/dev/null || echo v0.0.0)
|
||||
INFO_VERSION="${DESC#v}-${SHORT}"
|
||||
TAGS=("${IMAGE}:latest" "${IMAGE}:${SHORT}")
|
||||
fi
|
||||
echo "info_version=${INFO_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "short=${SHORT}" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "tags<<__EOT__"
|
||||
printf '%s\n' "${TAGS[@]}"
|
||||
echo "__EOT__"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
echo "INFO_VERSION=${INFO_VERSION}"
|
||||
printf 'tag: %s\n' "${TAGS[@]}"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
buildkitd-config-inline: |
|
||||
[registry."192.168.1.95:3000"]
|
||||
http = true
|
||||
|
||||
- name: Login to Gitea registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/Dockerfile
|
||||
platforms: linux/amd64
|
||||
# only publish from main or a v* tag; other refs (e.g. branch dispatch) build only
|
||||
push: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') }}
|
||||
provenance: false
|
||||
build-args: |
|
||||
INFO_VERSION=${{ steps.meta.outputs.info_version }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
cache-from: type=registry,ref=192.168.1.95:3000/timothy/ersatztv:buildcache
|
||||
cache-to: type=registry,ref=192.168.1.95:3000/timothy/ersatztv:buildcache,mode=max,ignore-error=true
|
||||
|
||||
- name: Smoke + IPTV E2E (assert key endpoints)
|
||||
if: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') }}
|
||||
run: |
|
||||
IMG="${IMAGE}:${{ steps.meta.outputs.short }}"
|
||||
NAME="etv-smoke-${{ github.run_id }}"
|
||||
trap 'docker rm -f "$NAME" >/dev/null 2>&1 || true' EXIT
|
||||
echo "Pulling ${IMG}"
|
||||
docker pull "$IMG"
|
||||
docker run -d --name "$NAME" --memory 2g \
|
||||
-e ETV_CONFIG_FOLDER=/tmp/etv/config \
|
||||
-e ETV_TRANSCODE_FOLDER=/tmp/etv/transcode \
|
||||
"$IMG"
|
||||
# probe ErsatzTV's web server from inside the container (image ships python3)
|
||||
cat > probe.py <<'PY'
|
||||
import urllib.request, urllib.error, sys
|
||||
try:
|
||||
urllib.request.urlopen("http://localhost:8409/", timeout=3)
|
||||
except urllib.error.HTTPError:
|
||||
pass # any HTTP status means the server is serving
|
||||
except Exception:
|
||||
sys.exit(1) # not listening yet
|
||||
PY
|
||||
ok=0
|
||||
for _ in $(seq 1 60); do
|
||||
if [ -z "$(docker ps -q --filter name="$NAME" --filter status=running)" ]; then
|
||||
echo "Container exited early"; break
|
||||
fi
|
||||
if docker exec -i "$NAME" python3 - < probe.py >/dev/null 2>&1; then
|
||||
ok=1; break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
if [ "$ok" != "1" ]; then
|
||||
echo "===== container logs (tail) ====="; docker logs "$NAME" 2>&1 | tail -n 40 || true
|
||||
echo "Smoke test FAILED: ErsatzTV did not serve HTTP on :8409"
|
||||
exit 1
|
||||
fi
|
||||
echo "HTTP ready; asserting key IPTV endpoints (ersatztv#16)"
|
||||
# E2E: assert the real Jellyfin-facing surfaces serve a valid playlist + guide, not just
|
||||
# that the app answers HTTP. xmltv.xml needs channels.xml, which the scheduler writes a
|
||||
# few seconds after boot, so poll each endpoint until it returns 2xx with the right shape.
|
||||
# urlopen() returns only on 2xx (raises on 4xx/5xx), so reaching sys.exit means status OK.
|
||||
check() {
|
||||
local path="$1" needle="$2" i
|
||||
for i in $(seq 1 20); do
|
||||
if docker exec "$NAME" python3 -c "import urllib.request,sys; b=urllib.request.urlopen('http://localhost:8409$path',timeout=5).read(512).decode('utf-8','replace'); sys.exit(0 if '$needle' in b else 1)" 2>/dev/null; then
|
||||
echo " OK $path (2xx, contains '$needle')"; return 0
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
echo " FAIL $path (no 2xx containing '$needle' within timeout)"; return 1
|
||||
}
|
||||
if check "/iptv/channels.m3u" "#EXTM3U" && check "/iptv/xmltv.xml" "<tv"; then
|
||||
echo "Smoke + IPTV E2E passed: channels.m3u + xmltv.xml serve a valid playlist + guide"
|
||||
else
|
||||
echo "===== container logs (tail) ====="; docker logs "$NAME" 2>&1 | tail -n 40 || true
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,70 +0,0 @@
|
||||
name: Renovate
|
||||
|
||||
# Self-hosted Renovate for the ErsatzTV fork (server-management#484).
|
||||
#
|
||||
# Opens dependency-update PRs against this repo (managers: nuget via CPM, github-actions).
|
||||
# Runs on the shared Gitea act_runner (bumblebee). It supersedes the *proposing* half that
|
||||
# the dependency-scan.yml (ersatztv#14) deliberately left out — that scan stays as a cheap
|
||||
# in-repo detector for now.
|
||||
#
|
||||
# Config: repo-root renovate.json (package rules, grouping, automerge policy).
|
||||
# Bot identity + tokens are injected from repo Actions secrets:
|
||||
# RENOVATE_TOKEN — PAT of the dedicated `renovate` Gitea bot (write:repository,
|
||||
# read:user, write:issue, read:organization)
|
||||
# GH_COM_TOKEN — no-scope github.com PAT for changelog/release-note fetching
|
||||
# (Renovate needs this on non-GitHub platforms; optional, degrades
|
||||
# gracefully to anonymous if unset). Named GH_, not GITHUB_, because
|
||||
# Gitea reserves the GITHUB_ secret-name prefix.
|
||||
#
|
||||
# NOTE: Gitea runs `schedule` triggers ONLY from the default branch (main); this file must
|
||||
# be on main before the cron registers. Use workflow_dispatch to run on demand — it defaults
|
||||
# to a DRY RUN (logs only, no PRs); dispatch with "Dry run" cleared to create real PRs.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dryRun:
|
||||
description: 'Dry run (full = log only, no PRs; clear for a live run)'
|
||||
type: choice
|
||||
options:
|
||||
- 'full'
|
||||
- ''
|
||||
default: 'full'
|
||||
logLevel:
|
||||
description: 'Log level'
|
||||
type: choice
|
||||
options:
|
||||
- 'info'
|
||||
- 'debug'
|
||||
default: 'info'
|
||||
schedule:
|
||||
# Mondays 03:00 UTC — ahead of the 06:00 vulnerability scan
|
||||
- cron: '0 3 * * 1'
|
||||
|
||||
concurrency:
|
||||
group: ersatztv-renovate
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
renovate:
|
||||
name: Renovate
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: renovate/renovate:43
|
||||
steps:
|
||||
- name: Run Renovate
|
||||
env:
|
||||
RENOVATE_PLATFORM: gitea
|
||||
RENOVATE_ENDPOINT: http://192.168.1.95:3000/api/v1
|
||||
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
||||
RENOVATE_GITHUB_COM_TOKEN: ${{ secrets.GH_COM_TOKEN }}
|
||||
RENOVATE_REPOSITORIES: timothy/ersatztv
|
||||
RENOVATE_AUTODISCOVER: 'false'
|
||||
RENOVATE_GIT_AUTHOR: 'Renovate Bot <renovate@tblindustries.be>'
|
||||
# Let the dockerfile manager query our HTTP-only Gitea container registry for the
|
||||
# ersatztv-ffmpeg base image. Creds (reused from the image-push secrets) + insecureRegistry
|
||||
# live here, NOT in renovate.json, so they stay out of the committed config.
|
||||
RENOVATE_HOST_RULES: '[{"matchHost":"192.168.1.95:3000","hostType":"docker","username":"${{ secrets.REGISTRY_USER }}","password":"${{ secrets.REGISTRY_PASSWORD }}","insecureRegistry":true}]'
|
||||
RENOVATE_DRY_RUN: ${{ inputs.dryRun }}
|
||||
LOG_LEVEL: ${{ inputs.logLevel || 'info' }}
|
||||
run: renovate
|
||||
@@ -0,0 +1,2 @@
|
||||
github: jasongdove
|
||||
custom: "https://www.paypal.me/jasongdove"
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -0,0 +1,8 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: nuget
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: daily
|
||||
assignees:
|
||||
- jasongdove
|
||||
@@ -0,0 +1,110 @@
|
||||
name: Build
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
jobs:
|
||||
build_and_test:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ windows-latest, ubuntu-latest, macos-latest ]
|
||||
steps:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Setup .NET Core
|
||||
uses: actions/setup-dotnet@v1
|
||||
with:
|
||||
dotnet-version: 6.0.x
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
|
||||
- name: Install dependencies
|
||||
run: dotnet restore
|
||||
|
||||
- name: Build
|
||||
run: dotnet build --configuration Release --no-restore
|
||||
|
||||
- name: Test
|
||||
run: dotnet test --no-restore --verbosity normal
|
||||
build_and_push:
|
||||
name: Build & Publish to Docker Hub
|
||||
needs: build_and_test
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push' && !contains(github.event.head_commit.message, '[no docker]')
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Extract Git Tag
|
||||
shell: bash
|
||||
run: |
|
||||
tag=$(git describe --tags --abbrev=0)
|
||||
tag2="${tag:1}"
|
||||
short=$(git rev-parse --short HEAD)
|
||||
final="${tag2/alpha/$short}"
|
||||
echo "GIT_TAG=${final}" >> $GITHUB_ENV
|
||||
|
||||
- name: Set up Docker Buildx Base
|
||||
uses: docker/setup-buildx-action@v1
|
||||
id: builder-base
|
||||
|
||||
- name: Set up Docker Buildx NVIDIA
|
||||
uses: docker/setup-buildx-action@v1
|
||||
id: builder-nvidia
|
||||
|
||||
- name: Set up Docker Buildx VAAPI
|
||||
uses: docker/setup-buildx-action@v1
|
||||
id: builder-vaapi
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
|
||||
|
||||
- name: Build and push base
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
builder: ${{ steps.builder-base.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/Dockerfile
|
||||
push: true
|
||||
build-args: |
|
||||
INFO_VERSION=${{ env.GIT_TAG }}-docker
|
||||
tags: |
|
||||
jasongdove/ersatztv:develop
|
||||
jasongdove/ersatztv:${{ github.sha }}
|
||||
|
||||
- name: Build and push nvidia
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
builder: ${{ steps.builder-nvidia.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/nvidia/Dockerfile
|
||||
push: true
|
||||
build-args: |
|
||||
INFO_VERSION=${{ env.GIT_TAG }}-docker-nvidia
|
||||
tags: |
|
||||
jasongdove/ersatztv:develop-nvidia
|
||||
jasongdove/ersatztv:${{ github.sha }}-nvidia
|
||||
|
||||
- name: Build and push vaapi
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
builder: ${{ steps.builder-vaapi.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/vaapi/Dockerfile
|
||||
push: true
|
||||
build-args: |
|
||||
INFO_VERSION=${{ env.GIT_TAG }}-docker-vaapi
|
||||
tags: |
|
||||
jasongdove/ersatztv:develop-vaapi
|
||||
jasongdove/ersatztv:${{ github.sha }}-vaapi
|
||||
@@ -0,0 +1,19 @@
|
||||
name: Publish docs via GitHub Pages
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Deploy docs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout master
|
||||
uses: actions/checkout@v1
|
||||
|
||||
- name: Deploy docs
|
||||
uses: mhausenblas/mkdocs-deploy-gh-pages@master
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CUSTOM_DOMAIN: ersatztv.org
|
||||
@@ -0,0 +1,145 @@
|
||||
name: Publish
|
||||
on:
|
||||
release:
|
||||
types: [ published ]
|
||||
jobs:
|
||||
release:
|
||||
name: Release
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
kind: linux
|
||||
target: linux-x64
|
||||
- os: ubuntu-latest
|
||||
kind: linux
|
||||
target: linux-arm
|
||||
- os: windows-latest
|
||||
kind: windows
|
||||
target: win-x64
|
||||
- os: macos-latest
|
||||
kind: macOS
|
||||
target: osx-x64
|
||||
- os: macos-latest
|
||||
kind: macOS
|
||||
target: osx-arm64
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Setup .NET Core
|
||||
uses: actions/setup-dotnet@v1
|
||||
with:
|
||||
dotnet-version: 6.0.x
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
|
||||
- name: Install dependencies
|
||||
run: dotnet restore
|
||||
|
||||
- name: Build
|
||||
shell: bash
|
||||
run: |
|
||||
# Define some variables for things we need
|
||||
tag=$(git describe --tags --abbrev=0)
|
||||
release_name="ErsatzTV-$tag-${{ matrix.target }}"
|
||||
|
||||
# Build everything
|
||||
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net6.0 --runtime "${{ matrix.target }}" -c Release -o "$release_name" /property:InformationalVersion="${tag:1}-${{ matrix.target }}" /property:EnableCompressionInSingleFile=true /property:PublishSingleFile=true --self-contained true
|
||||
|
||||
# Pack files
|
||||
if [ "${{ matrix.target }}" == "win-x64" ]; then
|
||||
7z a -tzip "${release_name}.zip" "./${release_name}/*"
|
||||
elif [ "${{ matrix.target }}" == "linux-arm" ]; then
|
||||
cp lib/linux-arm/* "$release_name/"
|
||||
tar czvf "${release_name}.tar.gz" "$release_name"
|
||||
else
|
||||
tar czvf "${release_name}.tar.gz" "$release_name"
|
||||
fi
|
||||
|
||||
# Delete output directory
|
||||
rm -r "$release_name"
|
||||
|
||||
- name: Publish
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
prerelease: true
|
||||
files: |
|
||||
ErsatzTV*.zip
|
||||
ErsatzTV*.tar.gz
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
build_and_push:
|
||||
name: Build & Publish to Docker Hub
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Extract Git Tag
|
||||
shell: bash
|
||||
run: |
|
||||
tag=$(git describe --tags --abbrev=0)
|
||||
echo "GIT_TAG=${tag:1}" >> $GITHUB_ENV
|
||||
echo "DOCKER_TAG=${tag/-alpha/}" >> $GITHUB_ENV
|
||||
|
||||
- name: Set up Docker Buildx Base
|
||||
uses: docker/setup-buildx-action@v1
|
||||
id: builder-base
|
||||
|
||||
- name: Set up Docker Buildx NVIDIA
|
||||
uses: docker/setup-buildx-action@v1
|
||||
id: builder-nvidia
|
||||
|
||||
- name: Set up Docker Buildx VAAPI
|
||||
uses: docker/setup-buildx-action@v1
|
||||
id: builder-vaapi
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
|
||||
|
||||
- name: Build and push base
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
builder: ${{ steps.builder-base.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/Dockerfile
|
||||
push: true
|
||||
build-args: |
|
||||
INFO_VERSION=${{ env.GIT_TAG }}-docker
|
||||
tags: |
|
||||
jasongdove/ersatztv:latest
|
||||
jasongdove/ersatztv:${{ env.DOCKER_TAG }}
|
||||
|
||||
- name: Build and push nvidia
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
builder: ${{ steps.builder-nvidia.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/nvidia/Dockerfile
|
||||
push: true
|
||||
build-args: |
|
||||
INFO_VERSION=${{ env.GIT_TAG }}-docker-nvidia
|
||||
tags: |
|
||||
jasongdove/ersatztv:latest-nvidia
|
||||
jasongdove/ersatztv:${{ env.DOCKER_TAG }}-nvidia
|
||||
|
||||
- name: Build and push vaapi
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
builder: ${{ steps.builder-vaapi.outputs.name }}
|
||||
context: .
|
||||
file: ./docker/vaapi/Dockerfile
|
||||
push: true
|
||||
build-args: |
|
||||
INFO_VERSION=${{ env.GIT_TAG }}-docker-vaapi
|
||||
tags: |
|
||||
jasongdove/ersatztv:latest-vaapi
|
||||
jasongdove/ersatztv:${{ env.DOCKER_TAG }}-vaapi
|
||||
@@ -3,9 +3,6 @@
|
||||
project.lock.json
|
||||
.DS_Store
|
||||
*.pyc
|
||||
|
||||
# Claude Code
|
||||
.mcp/
|
||||
nupkg/
|
||||
|
||||
# Visual Studio Code
|
||||
@@ -43,8 +40,6 @@ msbuild.wrn
|
||||
core
|
||||
|
||||
scripts/generate-api-sdk/swagger.json
|
||||
scripts/download-test-content.sh
|
||||
|
||||
docker-compose.override.yml
|
||||
|
||||
ErsatzTV/wwwroot/v2/
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
[submodule "ErsatzTV-macOS"]
|
||||
path = ErsatzTV-macOS
|
||||
url = git@github.com:ErsatzTV/ErsatzTV-macOS.git
|
||||
@@ -1,56 +0,0 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"docker-mcp": {
|
||||
"command": "uvx",
|
||||
"args": [
|
||||
"mcp-server-docker"
|
||||
],
|
||||
"env": {
|
||||
"DOCKER_HOST": "ssh://timothy@192.168.1.99"
|
||||
}
|
||||
},
|
||||
"ssh-mcp": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"ssh-mcp",
|
||||
"--",
|
||||
"--host=192.168.1.99",
|
||||
"--user=timothy"
|
||||
],
|
||||
"env": {}
|
||||
},
|
||||
"gitea": {
|
||||
"command": "gitea-mcp-server",
|
||||
"args": [
|
||||
"-t", "stdio",
|
||||
"-host", "http://192.168.1.95:3000",
|
||||
"-token", "8341af0733ab9ce084ea7adf38b76aa9ebc3bd67"
|
||||
],
|
||||
"env": {}
|
||||
},
|
||||
"csharp-lsp": {
|
||||
"command": "/usr/local/share/dotnet/dotnet",
|
||||
"args": [
|
||||
"run",
|
||||
"--project", "/Users/timothy/ersatztv/.mcp/csharp-lsp-mcp/csharp-lsp-mcp/src/CSharpLspMcp",
|
||||
"-c", "Release"
|
||||
],
|
||||
"env": {
|
||||
"PATH": "/usr/local/share/dotnet:/Users/timothy/.dotnet/tools:/usr/bin:/bin:/usr/sbin:/sbin"
|
||||
}
|
||||
},
|
||||
"nuget": {
|
||||
"command": "/usr/local/share/dotnet/dotnet",
|
||||
"args": [
|
||||
"dnx",
|
||||
"NuGet.Mcp.Server",
|
||||
"--source", "https://api.nuget.org/v3/index.json",
|
||||
"--yes"
|
||||
],
|
||||
"env": {
|
||||
"DOTNET_ROOT": "/usr/local/share/dotnet"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+81
-2509
File diff suppressed because it is too large
Load Diff
@@ -1,96 +0,0 @@
|
||||
# ErsatzTV Fork
|
||||
|
||||
Custom IPTV channel server for Jellyfin. Forked from [ErsatzTV/ErsatzTV](https://github.com/ErsatzTV/ErsatzTV) after upstream archival (Feb 2026, v26.3.0). Our fork lives on [Gitea](http://192.168.1.95:3000/timothy/ersatztv).
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Language**: C# / .NET 10, Blazor Server UI (MudBlazor)
|
||||
- **Pattern**: CQRS via MediatR — queries/commands in `ErsatzTV.Application/`
|
||||
- **Database**: EF Core (SQLite default, MySQL optional) — context in `ErsatzTV.Infrastructure/Data/TvContext.cs`
|
||||
- **Media**: FFmpeg via CliWrap, SkiaSharp for logo generation
|
||||
- **Functional C#**: Language Ext (Option, Either monads throughout)
|
||||
|
||||
### Project Layout
|
||||
|
||||
| Project | Role |
|
||||
|---------|------|
|
||||
| `ErsatzTV/` | ASP.NET Core host, Blazor pages, API controllers, DI setup |
|
||||
| `ErsatzTV.Application/` | MediatR handlers (business logic) |
|
||||
| `ErsatzTV.Core/` | Domain entities, interfaces, no infrastructure deps |
|
||||
| `ErsatzTV.Infrastructure/` | EF Core repos, data access |
|
||||
| `ErsatzTV.Infrastructure.Sqlite/` | SQLite-specific implementations |
|
||||
| `ErsatzTV.FFmpeg/` | FFmpeg process wrapper |
|
||||
| `ErsatzTV.Scanner/` | Media library scanning |
|
||||
|
||||
### Key Files
|
||||
|
||||
- **M3U generation**: `ErsatzTV.Core/Iptv/ChannelPlaylist.cs` → `ToM3U()`
|
||||
- **XMLTV generation**: `ErsatzTV.Application/Channels/Queries/GetChannelGuideHandler.cs`
|
||||
- **IPTV controller**: `ErsatzTV/Controllers/IptvController.cs` — `/iptv/*` routes
|
||||
- **Logo generation**: `ErsatzTV.Core/Images/ChannelLogoGenerator.cs`
|
||||
- **Channel entities**: `ErsatzTV.Core/Domain/Channel.cs`
|
||||
- **DB context**: `ErsatzTV.Infrastructure/Data/TvContext.cs`
|
||||
|
||||
## Deployment
|
||||
|
||||
- **Docker host**: jazz (192.168.1.99), container `ersatztv`, port 8409
|
||||
- **Config volume**: `~/downloadswarm/ersatztv/` on jazz → `/config` in container
|
||||
- **SQLite DB**: `/config/ersatztv.sqlite3` (WAL mode, root-owned)
|
||||
- **Images** (our fork, built by `.gitea/workflows/docker-build.yml` → `192.168.1.95:3000/timothy/ersatztv`): push to `main` → `:latest` + `:<sha>` (test image); push `v*` tag → `:prod` + `:<version>` + `:<sha>`. Prod container still runs upstream `ghcr.io/ersatztv/ersatztv:latest` pending cutover (server-management#481). Pipeline details: `docs/ci-cd.md`.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Build
|
||||
dotnet build ErsatzTV.sln
|
||||
|
||||
# Run locally (needs FFmpeg in PATH)
|
||||
dotnet run --project ErsatzTV
|
||||
|
||||
# Docker build
|
||||
docker build -f docker/Dockerfile -t ersatztv:dev .
|
||||
```
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Read [`docs/contributing.md`](docs/contributing.md)** before non-trivial changes — it documents the established patterns (layering, CQRS handlers, LanguageExt, Blazor/MudBlazor, EF Core + dual-provider migrations, the FFmpeg pipeline, analyzers, testing) and the **deviation policy**: match the established style; diverge only with a concrete, stated reason.
|
||||
- Follow existing MediatR CQRS pattern for new features
|
||||
- Domain logic in `ErsatzTV.Core`, infrastructure in `ErsatzTV.Infrastructure`
|
||||
- Keep Blazor pages thin — delegate to MediatR handlers
|
||||
- Test with **NUnit** + Shouldly + NSubstitute (the existing `*.Tests` projects); xUnit is **not** used here
|
||||
- **Dependencies use Central Package Management**: versions live in the repo-root `Directory.Packages.props`; csproj reference packages by name only. Add/upgrade by editing the central `<PackageVersion>` — never put `Version=` back on a `<PackageReference>` (trips `NU1008`). See `docs/ci-cd.md` → Dependency management.
|
||||
- **DB migrations target BOTH providers**: a `TvContext` model change needs a migration in `ErsatzTV.Infrastructure.Sqlite` **and** `ErsatzTV.Infrastructure.MySql` — run `scripts/add-migration.sh <Name>` (does both). CI's `migrations` job enforces model-drift + apply-to-fresh-DB per provider. See `docs/ci-cd.md` → Migration integrity.
|
||||
- **Renovate** is live (`.gitea/workflows/renovate.yml`, weekly + `workflow_dispatch`): opens dependency-update + OSV vuln-fix PRs and a Dependency Dashboard issue; patch bumps to test/dev-only packages auto-merge once `Build & test` passes, the rest are manual. Cross-repo rollout: server-management#484. See `docs/ci-cd.md` → Dependency management.
|
||||
- **Versioning**: release tags are `vYY.<release-seq>.<patch>` (year · sequential release-within-year · patch) — inherited from upstream, **not** year.month. `v26.3.1` = our infra rebuild of upstream 26.3.0 (no app changes); `v26.4.0` is reserved for the first release with app changes. Never `[skip ci]` a commit you'll tag (it suppresses the release build). Full policy: `docs/ci-cd.md` → Versioning & releases.
|
||||
- Backlog tracked via [Gitea Issues](http://192.168.1.95:3000/timothy/ersatztv/issues)
|
||||
|
||||
## Task Completion Protocol
|
||||
|
||||
Every task that closes a Gitea issue MUST complete ALL of these before it is considered done. Use `/done <issue>` to run through this automatically.
|
||||
|
||||
1. **Root cause** (bug fixes / incidents only): Document WHY the problem existed, not just what was changed. If root cause is unknown, say so explicitly and open a follow-up investigation issue. Fixing symptoms without understanding causes creates recurring problems.
|
||||
2. **Comment on issues** as you work — what you found, what approach you're taking, any deviations from the suggested fix.
|
||||
3. **Push changes**: `git push` all commits before closing. Use `fixes #N` in commit messages to auto-close where appropriate.
|
||||
4. **Close comment**: Add a structured closing comment on the issue covering: what was done, root cause (if applicable), files changed, anything deferred, follow-up issues created, and which docs were updated.
|
||||
5. **Close the issue** via API or `fixes #N` commit. Leave open with a comment only if partially addressed.
|
||||
6. **Update docs**: If the change affects operational behavior, update the relevant Obsidian docs (`~/homelab-docs/`), MEMORY.md, or CLAUDE.md inline — not as a follow-up.
|
||||
7. **Reply to reviewer** (if from adversarial review): Summary of done/deferred/questions. This triggers the next review cycle.
|
||||
|
||||
## Project Boundaries
|
||||
|
||||
**ersatztv OWNS**: ErsatzTV fork code (C#/.NET), channel/collection/schedule management, M3U/XMLTV generation, the ErsatzTV skill in server-management.
|
||||
|
||||
**ersatztv does NOT own**:
|
||||
- Docker compose configs → server-management (`~/downloadswarm/stacks/ersatztv/`)
|
||||
- NFS mounts, Ansible, DNS, networking → server-management
|
||||
- Content sourcing (yt-dlp downloads, Sonarr/Radarr libraries) → media-management (planned)
|
||||
- Jellyfin skill → server-management (symlinked)
|
||||
|
||||
**For infrastructure changes** (Docker, NFS, ports, Authelia): open an issue in `timothy/server-management`.
|
||||
|
||||
**For content/media sourcing questions** (what goes into channels, yt-dlp pipelines): open an issue in `timothy/media-management` once it exists; for now, `timothy/server-management`.
|
||||
|
||||
**For plan/audit reviews**: open `~/adversarial-reviewer` before significant architecture changes.
|
||||
|
||||
**Full cross-project rules**: `~/homelab-docs/Operations/Project Boundaries.md` (https://docs.tblindustries.be).
|
||||
**ErsatzTV docs**: `~/homelab-docs/Docker/ErsatzTV.md` + project-local `docs/` (fork strategy, channels, M3U/XMLTV).
|
||||
+1
-13
@@ -1,17 +1,5 @@
|
||||
<Project>
|
||||
<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>
|
||||
@@ -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>
|
||||
@@ -1,113 +0,0 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="AsyncFixer" Version="2.1.0" />
|
||||
<PackageVersion Include="Blazored.FluentValidation" Version="2.2.0" />
|
||||
<PackageVersion Include="BlazorSortable" Version="5.2.1" />
|
||||
<PackageVersion Include="Blurhash.SkiaSharp" Version="2.0.0" />
|
||||
<PackageVersion Include="Chronic.Core" Version="0.4.0" />
|
||||
<PackageVersion Include="CliWrap" Version="3.10.0" />
|
||||
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageVersion Include="Dapper" Version="2.1.66" />
|
||||
<PackageVersion Include="Destructurama.Attributed" Version="5.2.0" />
|
||||
<PackageVersion Include="EFCore.BulkExtensions" Version="[9.0.2,10)" />
|
||||
<PackageVersion Include="EFCore.BulkExtensions.MySql" Version="[9.0.2,10)" />
|
||||
<PackageVersion Include="EFCore.BulkExtensions.Sqlite" Version="[9.0.2,10)" />
|
||||
<PackageVersion Include="Elastic.Clients.Elasticsearch" Version="9.3.0" />
|
||||
<PackageVersion Include="EntityFrameworkProfiler.Appender" Version="6.0.6049" />
|
||||
<PackageVersion Include="FluentValidation" Version="12.1.1" />
|
||||
<PackageVersion Include="FluentValidation.AspNetCore" Version="11.3.1" />
|
||||
<PackageVersion Include="Flurl" Version="4.0.0" />
|
||||
<PackageVersion Include="Hardware.Info" Version="101.1.1.1" />
|
||||
<PackageVersion Include="Heron.MudCalendar" Version="3.4.0" />
|
||||
<PackageVersion Include="HtmlSanitizer" Version="9.0.892" />
|
||||
<PackageVersion Include="Humanizer.Core" Version="3.0.1" />
|
||||
<PackageVersion Include="Jint" Version="4.5.0" />
|
||||
<PackageVersion Include="JsonSchema.Net" Version="9.0.0" />
|
||||
<PackageVersion Include="LanguageExt.Core" Version="4.4.9" />
|
||||
<PackageVersion Include="LanguageExt.Transformers" Version="4.4.8" />
|
||||
<PackageVersion Include="Lennox.NvEncSharp" Version="2.0.0" />
|
||||
<PackageVersion Include="Lucene.Net" Version="4.8.0-beta00017" />
|
||||
<PackageVersion Include="Lucene.Net.Analysis.Common" Version="4.8.0-beta00017" />
|
||||
<PackageVersion Include="Lucene.Net.QueryParser" Version="4.8.0-beta00017" />
|
||||
<PackageVersion Include="Markdig" Version="0.44.0" />
|
||||
<PackageVersion Include="MediatR" Version="[12.5.0]" />
|
||||
<PackageVersion Include="MediatR.Courier.DependencyInjection" Version="5.0.0" />
|
||||
<PackageVersion Include="Meziantou.Analyzer" Version="3.0.115" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="[9.0.12,10)" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="[9.0.12,10)" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="[9.0.12,10)" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="[9.0.12,10)" />
|
||||
<PackageVersion Include="Microsoft.Extensions.ApiDescription.Server" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyModel" Version="[8.0.2]" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Debug" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||
<PackageVersion Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.14.15" />
|
||||
<PackageVersion Include="MudBlazor" Version="8.15.0" />
|
||||
<PackageVersion Include="NaturalSort.Extension" Version="4.4.1" />
|
||||
<PackageVersion Include="NCalcSync" Version="6.3.2" />
|
||||
<PackageVersion Include="NetArchTest.eNhancedEdition" Version="1.4.5" />
|
||||
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<PackageVersion Include="Newtonsoft.Json.Schema" Version="4.0.1" />
|
||||
<PackageVersion Include="NSubstitute" Version="5.3.0" />
|
||||
<PackageVersion Include="NUnit" Version="4.4.0" />
|
||||
<PackageVersion Include="NUnit.Analyzers" Version="4.11.2" />
|
||||
<PackageVersion Include="NUnit3TestAdapter" Version="6.1.0" />
|
||||
<PackageVersion Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
|
||||
<PackageVersion Include="Refit" Version="9.0.2" />
|
||||
<PackageVersion Include="Refit.HttpClientFactory" Version="9.0.2" />
|
||||
<PackageVersion Include="Refit.Newtonsoft.Json" Version="9.0.2" />
|
||||
<PackageVersion Include="Refit.Xml" Version="9.0.2" />
|
||||
<PackageVersion Include="RichTextKit.Stbear" Version="0.4.167.3" />
|
||||
<PackageVersion Include="Roslynator.Analyzers" Version="4.15.0" />
|
||||
<PackageVersion Include="Scalar.AspNetCore" Version="2.12.32" />
|
||||
<PackageVersion Include="Scriban.Signed" Version="6.5.2" />
|
||||
<PackageVersion Include="Serilog" Version="4.3.0" />
|
||||
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||
<PackageVersion Include="Serilog.Extensions.Hosting" Version="10.0.0" />
|
||||
<PackageVersion Include="Serilog.Extensions.Logging" Version="10.0.0" />
|
||||
<PackageVersion Include="Serilog.Formatting.Compact" Version="3.0.0" />
|
||||
<PackageVersion Include="Serilog.Formatting.Compact.Reader" Version="4.0.0" />
|
||||
<PackageVersion Include="Serilog.Settings.Configuration" Version="10.0.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||
<PackageVersion Include="Serilog.Sinks.Debug" Version="3.0.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||
<PackageVersion Include="Shouldly" Version="4.3.0" />
|
||||
<PackageVersion Include="SixLabors.ImageSharp" Version="3.1.12" />
|
||||
<PackageVersion Include="SkiaSharp" Version="3.119.1" />
|
||||
<PackageVersion Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="3.119.1" />
|
||||
<PackageVersion Include="SonarAnalyzer.CSharp" Version="10.27.0.140913" />
|
||||
<!-- Direct pin to override EF Core 9's transitive SQLitePCLRaw 2.1.10 (vulnerable
|
||||
bundled SQLite, GHSA-2m69-gcr7-jv3q). The 3.x line ships the patched native
|
||||
(lib.e_sqlite3 3.50.3); core 3.0.3 satisfies Microsoft.Data.Sqlite's `>= 2.1.10`. (#8) -->
|
||||
<PackageVersion Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.3" />
|
||||
<PackageVersion Include="System.CommandLine" Version="2.0.2" />
|
||||
<PackageVersion Include="TagLibSharp" Version="2.3.0" />
|
||||
<PackageVersion Include="Testably.Abstractions" Version="10.0.0" />
|
||||
<PackageVersion Include="Testably.Abstractions.Testing" Version="5.1.0" />
|
||||
<PackageVersion Include="TimeSpanParserUtil" Version="1.2.0" />
|
||||
<PackageVersion Include="TimeZoneConverter" Version="7.2.0" />
|
||||
<PackageVersion Include="VueCliMiddleware" Version="6.0.0" />
|
||||
<PackageVersion Include="WebMarkupMin.Core" Version="2.20.1" />
|
||||
<PackageVersion Include="Winista.MimeDetect" Version="1.1.0" />
|
||||
<PackageVersion Include="YamlDotNet" Version="16.3.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Submodule ErsatzTV-macOS deleted from 8dbe1e22f2
@@ -1,5 +0,0 @@
|
||||
[*.cs]
|
||||
# disable CA1711: Identifiers should not have incorrect suffix
|
||||
dotnet_diagnostic.ca1711.severity = none
|
||||
# disable CA1848: Use the LoggerMessage delegates
|
||||
dotnet_diagnostic.ca1848.severity = none
|
||||
@@ -1,14 +1,16 @@
|
||||
using System.Globalization;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
|
||||
namespace ErsatzTV.Application.Artists;
|
||||
|
||||
public record ArtistViewModel(
|
||||
string Name,
|
||||
string Disambiguation,
|
||||
string Biography,
|
||||
string Thumbnail,
|
||||
string FanArt,
|
||||
List<string> Genres,
|
||||
List<string> Styles,
|
||||
List<string> Moods,
|
||||
List<CultureInfo> Languages);
|
||||
namespace ErsatzTV.Application.Artists
|
||||
{
|
||||
public record ArtistViewModel(
|
||||
string Name,
|
||||
string Disambiguation,
|
||||
string Biography,
|
||||
string Thumbnail,
|
||||
string FanArt,
|
||||
List<string> Genres,
|
||||
List<string> Styles,
|
||||
List<string> Moods,
|
||||
List<CultureInfo> Languages);
|
||||
}
|
||||
|
||||
@@ -1,40 +1,46 @@
|
||||
using System.Globalization;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using LanguageExt;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Application.Artists;
|
||||
|
||||
internal static class Mapper
|
||||
namespace ErsatzTV.Application.Artists
|
||||
{
|
||||
internal static ArtistViewModel ProjectToViewModel(Artist artist, List<string> languages)
|
||||
internal static class Mapper
|
||||
{
|
||||
ArtistMetadata metadata = Optional(artist.ArtistMetadata).Flatten().Head();
|
||||
return new ArtistViewModel(
|
||||
metadata.Title,
|
||||
metadata.Disambiguation,
|
||||
metadata.Biography,
|
||||
Artwork(metadata, ArtworkKind.Thumbnail),
|
||||
Artwork(metadata, ArtworkKind.FanArt),
|
||||
metadata.Genres.Map(g => g.Name).ToList(),
|
||||
metadata.Styles.Map(s => s.Name).ToList(),
|
||||
metadata.Moods.Map(m => m.Name).ToList(),
|
||||
LanguagesForArtist(languages));
|
||||
}
|
||||
internal static ArtistViewModel ProjectToViewModel(Artist artist, List<string> languages)
|
||||
{
|
||||
ArtistMetadata metadata = Optional(artist.ArtistMetadata).Flatten().Head();
|
||||
return new ArtistViewModel(
|
||||
metadata.Title,
|
||||
metadata.Disambiguation,
|
||||
metadata.Biography,
|
||||
Artwork(metadata, ArtworkKind.Thumbnail),
|
||||
Artwork(metadata, ArtworkKind.FanArt),
|
||||
metadata.Genres.Map(g => g.Name).ToList(),
|
||||
metadata.Styles.Map(s => s.Name).ToList(),
|
||||
metadata.Moods.Map(m => m.Name).ToList(),
|
||||
LanguagesForArtist(languages));
|
||||
}
|
||||
|
||||
private static string Artwork(Metadata metadata, ArtworkKind artworkKind) =>
|
||||
Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == artworkKind))
|
||||
.Match(a => a.Path, string.Empty);
|
||||
private static string Artwork(Metadata metadata, ArtworkKind artworkKind) =>
|
||||
Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == artworkKind))
|
||||
.Match(a => a.Path, string.Empty);
|
||||
|
||||
private static List<CultureInfo> LanguagesForArtist(List<string> languages)
|
||||
{
|
||||
CultureInfo[] allCultures = CultureInfo.GetCultures(CultureTypes.NeutralCultures);
|
||||
private static List<CultureInfo> LanguagesForArtist(List<string> languages)
|
||||
{
|
||||
CultureInfo[] allCultures = CultureInfo.GetCultures(CultureTypes.NeutralCultures);
|
||||
|
||||
return languages
|
||||
.Map(lang => allCultures.Filter(ci => string.Equals(
|
||||
ci.ThreeLetterISOLanguageName,
|
||||
lang,
|
||||
StringComparison.OrdinalIgnoreCase)))
|
||||
.Flatten()
|
||||
.Distinct()
|
||||
.ToList();
|
||||
return languages
|
||||
.Distinct()
|
||||
.Map(
|
||||
lang => allCultures.Filter(
|
||||
ci => string.Equals(ci.ThreeLetterISOLanguageName, lang, StringComparison.OrdinalIgnoreCase)))
|
||||
.Sequence()
|
||||
.Flatten()
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
using ErsatzTV.Application.MediaItems;
|
||||
using System.Collections.Generic;
|
||||
using ErsatzTV.Application.MediaItems;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Artists;
|
||||
|
||||
public record GetAllArtists : IRequest<List<NamedMediaItemViewModel>>;
|
||||
namespace ErsatzTV.Application.Artists.Queries
|
||||
{
|
||||
public record GetAllArtists : IRequest<List<NamedMediaItemViewModel>>;
|
||||
}
|
||||
|
||||
@@ -1,41 +1,29 @@
|
||||
using ErsatzTV.Application.MediaItems;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Application.MediaItems;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using static ErsatzTV.Application.MediaItems.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Artists;
|
||||
|
||||
public class GetAllArtistsHandler : IRequestHandler<GetAllArtists, List<NamedMediaItemViewModel>>
|
||||
namespace ErsatzTV.Application.Artists.Queries
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public GetAllArtistsHandler(IDbContextFactory<TvContext> dbContextFactory) => _dbContextFactory = dbContextFactory;
|
||||
|
||||
public async Task<List<NamedMediaItemViewModel>> Handle(
|
||||
GetAllArtists request,
|
||||
CancellationToken cancellationToken)
|
||||
public class GetAllArtistsHandler : IRequestHandler<GetAllArtists, List<NamedMediaItemViewModel>>
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
private readonly IArtistRepository _artistRepository;
|
||||
|
||||
List<Artist> allArtists = await dbContext.Artists
|
||||
.AsNoTracking()
|
||||
.Include(a => a.ArtistMetadata)
|
||||
.ToListAsync(cancellationToken);
|
||||
public GetAllArtistsHandler(IArtistRepository artistRepository) => _artistRepository = artistRepository;
|
||||
|
||||
return allArtists.Bind(a => ProjectArtist(a)).ToList();
|
||||
}
|
||||
|
||||
private static Option<NamedMediaItemViewModel> ProjectArtist(Artist a)
|
||||
{
|
||||
foreach (ArtistMetadata metadata in a.ArtistMetadata.HeadOrNone())
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(metadata.Title))
|
||||
{
|
||||
return ProjectToViewModel(a);
|
||||
}
|
||||
}
|
||||
|
||||
return None;
|
||||
public Task<List<NamedMediaItemViewModel>> Handle(
|
||||
GetAllArtists request,
|
||||
CancellationToken cancellationToken) =>
|
||||
_artistRepository.GetAllArtists()
|
||||
.Map(
|
||||
list => list.Filter(
|
||||
a => !string.IsNullOrWhiteSpace(
|
||||
a.ArtistMetadata.HeadOrNone().Match(am => am.Title, () => string.Empty))))
|
||||
.Map(list => list.Map(ProjectToViewModel).ToList());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
namespace ErsatzTV.Application.Artists;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
|
||||
public record GetArtistById(int ArtistId) : IRequest<Option<ArtistViewModel>>;
|
||||
namespace ErsatzTV.Application.Artists.Queries
|
||||
{
|
||||
public record GetArtistById(int ArtistId) : IRequest<Option<ArtistViewModel>>;
|
||||
}
|
||||
|
||||
@@ -1,28 +1,38 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using static ErsatzTV.Application.Artists.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Artists;
|
||||
|
||||
public class GetArtistByIdHandler(
|
||||
IArtistRepository artistRepository,
|
||||
ISearchRepository searchRepository,
|
||||
ILanguageCodeService languageCodeService)
|
||||
: IRequestHandler<GetArtistById, Option<ArtistViewModel>>
|
||||
namespace ErsatzTV.Application.Artists.Queries
|
||||
{
|
||||
public async Task<Option<ArtistViewModel>> Handle(
|
||||
GetArtistById request,
|
||||
CancellationToken cancellationToken)
|
||||
public class GetArtistByIdHandler : IRequestHandler<GetArtistById, Option<ArtistViewModel>>
|
||||
{
|
||||
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);
|
||||
return ProjectToViewModel(artist, languageCodes);
|
||||
},
|
||||
() => Task.FromResult(Option<ArtistViewModel>.None));
|
||||
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);
|
||||
return await maybeArtist.Match<Task<Option<ArtistViewModel>>>(
|
||||
async artist =>
|
||||
{
|
||||
List<string> mediaCodes = await _searchRepository.GetLanguagesForArtist(artist);
|
||||
List<string> languageCodes = await _searchRepository.GetAllLanguageCodes(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,6 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.Artworks;
|
||||
|
||||
public record GetArtwork(int Id) : IRequest<Either<BaseError, Artwork>>;
|
||||
@@ -1,42 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Artworks;
|
||||
|
||||
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,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
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)
|
||||
.MapT(Project);
|
||||
|
||||
return artwork.ToEither(BaseError.New("Artwork not found"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New(ex.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
private static Artwork Project(Artwork artwork) =>
|
||||
new()
|
||||
{
|
||||
Id = artwork.Id,
|
||||
Path = artwork.Path,
|
||||
ArtworkKind = artwork.ArtworkKind
|
||||
};
|
||||
}
|
||||
@@ -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,39 +1,15 @@
|
||||
using System.Net;
|
||||
using ErsatzTV.Application.Artworks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record ChannelViewModel(
|
||||
int Id,
|
||||
string Number,
|
||||
string Name,
|
||||
string Group,
|
||||
string Categories,
|
||||
int FFmpegProfileId,
|
||||
double? SlugSeconds,
|
||||
ArtworkContentTypeModel Logo,
|
||||
ChannelStreamSelectorMode StreamSelectorMode,
|
||||
string StreamSelector,
|
||||
string PreferredAudioLanguageCode,
|
||||
string PreferredAudioTitle,
|
||||
ChannelPlayoutSource PlayoutSource,
|
||||
ChannelPlayoutMode PlayoutMode,
|
||||
int? MirrorSourceChannelId,
|
||||
TimeSpan? PlayoutOffset,
|
||||
StreamingMode StreamingMode,
|
||||
int? WatermarkId,
|
||||
int? FallbackFillerId,
|
||||
int PlayoutCount,
|
||||
string PreferredSubtitleLanguageCode,
|
||||
ChannelSubtitleMode SubtitleMode,
|
||||
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
|
||||
string MusicVideoCreditsTemplate,
|
||||
ChannelSongVideoMode SongVideoMode,
|
||||
ChannelTranscodeMode TranscodeMode,
|
||||
ChannelIdleBehavior IdleBehavior,
|
||||
bool IsEnabled,
|
||||
bool ShowInEpg)
|
||||
namespace ErsatzTV.Application.Channels
|
||||
{
|
||||
public string WebEncodedName => WebUtility.UrlEncode(Name);
|
||||
public record ChannelViewModel(
|
||||
int Id,
|
||||
string Number,
|
||||
string Name,
|
||||
int FFmpegProfileId,
|
||||
string Logo,
|
||||
string PreferredLanguageCode,
|
||||
StreamingMode StreamingMode,
|
||||
int? WatermarkId,
|
||||
int? FallbackFillerId);
|
||||
}
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Validation rules shared by <see cref="CreateChannelHandler" /> and
|
||||
/// <see cref="UpdateChannelHandler" />. These were previously enforced only by the Blazor page
|
||||
/// (<c>ChannelEditViewModelValidator</c>); porting them into the handlers makes them apply to
|
||||
/// the REST API as well. Failures map to HTTP 422.
|
||||
/// </summary>
|
||||
internal static class ChannelValidations
|
||||
{
|
||||
/// <summary>
|
||||
/// A channel must belong to a non-empty group; the value is used as the M3U
|
||||
/// <c>group-title</c>. Mirrors the Blazor rule <c>RuleFor(x => x.Group).NotEmpty()</c>.
|
||||
/// </summary>
|
||||
internal static Validation<BaseError, string> ValidateGroup(string group)
|
||||
{
|
||||
// Use explicit returns (not a ternary): BaseError has an implicit string conversion, so a
|
||||
// ternary would collapse both branches to BaseError and always produce a Fail.
|
||||
if (string.IsNullOrWhiteSpace(group))
|
||||
{
|
||||
return BaseError.New("Channel group is required");
|
||||
}
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
/// <summary>A disabled channel may not be shown in the EPG.</summary>
|
||||
internal static Validation<BaseError, bool> ValidateShowInEpg(bool isEnabled, bool showInEpg)
|
||||
{
|
||||
if (!isEnabled && showInEpg)
|
||||
{
|
||||
return BaseError.New("Disabled channels cannot be shown in EPG");
|
||||
}
|
||||
|
||||
return showInEpg;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A logo path that is an absolute URI must be a valid external (http/https) url.
|
||||
/// Relative/local logo paths and empty values are allowed.
|
||||
/// </summary>
|
||||
internal static Validation<BaseError, string> ValidateLogo(string logoPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(logoPath))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
bool isAbsoluteUri = Uri.TryCreate(logoPath, UriKind.Absolute, out _);
|
||||
if (isAbsoluteUri && !Artwork.IsExternalUrl(logoPath))
|
||||
{
|
||||
return BaseError.New("External logo url is invalid");
|
||||
}
|
||||
|
||||
return logoPath;
|
||||
}
|
||||
}
|
||||
@@ -1,34 +1,18 @@
|
||||
using ErsatzTV.Application.Artworks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record CreateChannel(
|
||||
string Name,
|
||||
string Number,
|
||||
string Group,
|
||||
string Categories,
|
||||
int FFmpegProfileId,
|
||||
double? SlugSeconds,
|
||||
ArtworkContentTypeModel Logo,
|
||||
ChannelStreamSelectorMode StreamSelectorMode,
|
||||
string StreamSelector,
|
||||
string PreferredAudioLanguageCode,
|
||||
string PreferredAudioTitle,
|
||||
ChannelPlayoutSource PlayoutSource,
|
||||
ChannelPlayoutMode PlayoutMode,
|
||||
int? MirrorSourceChannelId,
|
||||
TimeSpan? PlayoutOffset,
|
||||
StreamingMode StreamingMode,
|
||||
int? WatermarkId,
|
||||
int? FallbackFillerId,
|
||||
string PreferredSubtitleLanguageCode,
|
||||
ChannelSubtitleMode SubtitleMode,
|
||||
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
|
||||
string MusicVideoCreditsTemplate,
|
||||
ChannelSongVideoMode SongVideoMode,
|
||||
ChannelTranscodeMode TranscodeMode,
|
||||
ChannelIdleBehavior IdleBehavior,
|
||||
bool IsEnabled,
|
||||
bool ShowInEpg) : IRequest<Either<BaseError, CreateChannelResult>>;
|
||||
namespace ErsatzTV.Application.Channels.Commands
|
||||
{
|
||||
public record CreateChannel
|
||||
(
|
||||
string Name,
|
||||
string Number,
|
||||
int FFmpegProfileId,
|
||||
string Logo,
|
||||
string PreferredLanguageCode,
|
||||
StreamingMode StreamingMode,
|
||||
int? WatermarkId,
|
||||
int? FallbackFillerId) : IRequest<Either<BaseError, CreateChannelResult>>;
|
||||
}
|
||||
|
||||
@@ -1,260 +1,162 @@
|
||||
using System.Globalization;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.Channels.ChannelValidations;
|
||||
using Channel = ErsatzTV.Core.Domain.Channel;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class CreateChannelHandler(
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ISearchTargets searchTargets)
|
||||
: IRequestHandler<CreateChannel, Either<BaseError, CreateChannelResult>>
|
||||
namespace ErsatzTV.Application.Channels.Commands
|
||||
{
|
||||
public async Task<Either<BaseError, CreateChannelResult>> Handle(
|
||||
CreateChannel request,
|
||||
CancellationToken cancellationToken)
|
||||
public class CreateChannelHandler : IRequestHandler<CreateChannel, Either<BaseError, CreateChannelResult>>
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Channel> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(c => PersistChannel(dbContext, c));
|
||||
}
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
private async Task<CreateChannelResult> PersistChannel(TvContext dbContext, Channel channel)
|
||||
{
|
||||
await dbContext.Channels.AddAsync(channel);
|
||||
await dbContext.SaveChangesAsync();
|
||||
searchTargets.SearchTargetsChanged();
|
||||
await workerChannel.WriteAsync(new RefreshChannelList());
|
||||
return new CreateChannelResult(channel.Id);
|
||||
}
|
||||
public CreateChannelHandler(IDbContextFactory<TvContext> dbContextFactory) => _dbContextFactory = dbContextFactory;
|
||||
|
||||
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,
|
||||
_,
|
||||
_,
|
||||
_) =>
|
||||
public async Task<Either<BaseError, CreateChannelResult>> Handle(
|
||||
CreateChannel request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
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);
|
||||
}
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
Validation<BaseError, Channel> validation = await Validate(dbContext, request);
|
||||
return await validation.Apply(c => PersistChannel(dbContext, c));
|
||||
}
|
||||
|
||||
artwork.Add(
|
||||
new Artwork
|
||||
private static async Task<CreateChannelResult> PersistChannel(TvContext dbContext, Channel channel)
|
||||
{
|
||||
await dbContext.Channels.AddAsync(channel);
|
||||
await dbContext.SaveChangesAsync();
|
||||
return new CreateChannelResult(channel.Id);
|
||||
}
|
||||
|
||||
private static async Task<Validation<BaseError, Channel>> Validate(TvContext dbContext, CreateChannel request) =>
|
||||
(ValidateName(request), await ValidateNumber(dbContext, request),
|
||||
await FFmpegProfileMustExist(dbContext, request),
|
||||
ValidatePreferredLanguage(request),
|
||||
await WatermarkMustExist(dbContext, request),
|
||||
await FillerPresetMustExist(dbContext, request))
|
||||
.Apply(
|
||||
(name, number, ffmpegProfileId, preferredLanguageCode, watermarkId, fillerPresetId) =>
|
||||
{
|
||||
var artwork = new List<Artwork>();
|
||||
if (!string.IsNullOrWhiteSpace(request.Logo))
|
||||
{
|
||||
Path = logo,
|
||||
ArtworkKind = ArtworkKind.Logo,
|
||||
OriginalContentType = !string.IsNullOrEmpty(request.Logo.ContentType)
|
||||
? request.Logo.ContentType
|
||||
: null,
|
||||
DateAdded = DateTime.UtcNow,
|
||||
DateUpdated = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
artwork.Add(
|
||||
new Artwork
|
||||
{
|
||||
Path = request.Logo,
|
||||
ArtworkKind = ArtworkKind.Logo,
|
||||
DateAdded = DateTime.UtcNow,
|
||||
DateUpdated = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
var channel = new Channel(Guid.NewGuid())
|
||||
{
|
||||
Name = name,
|
||||
Number = number,
|
||||
FFmpegProfileId = ffmpegProfileId,
|
||||
StreamingMode = request.StreamingMode,
|
||||
Artwork = artwork,
|
||||
PreferredLanguageCode = preferredLanguageCode
|
||||
};
|
||||
|
||||
if (channel.PlayoutSource is ChannelPlayoutSource.Mirror)
|
||||
{
|
||||
channel.PlayoutMode = ChannelPlayoutMode.Continuous;
|
||||
}
|
||||
else
|
||||
{
|
||||
channel.MirrorSourceChannelId = null;
|
||||
channel.PlayoutOffset = null;
|
||||
}
|
||||
foreach (int id in watermarkId)
|
||||
{
|
||||
channel.WatermarkId = id;
|
||||
}
|
||||
|
||||
foreach (int id in watermarkId)
|
||||
{
|
||||
channel.WatermarkId = id;
|
||||
}
|
||||
foreach (int id in fillerPresetId)
|
||||
{
|
||||
channel.FallbackFillerId = id;
|
||||
}
|
||||
|
||||
foreach (int id in fillerPresetId)
|
||||
{
|
||||
channel.FallbackFillerId = id;
|
||||
}
|
||||
return channel;
|
||||
});
|
||||
|
||||
return channel;
|
||||
});
|
||||
private static Validation<BaseError, string> ValidateName(CreateChannel createChannel) =>
|
||||
createChannel.NotEmpty(c => c.Name)
|
||||
.Bind(_ => createChannel.NotLongerThan(50)(c => c.Name));
|
||||
|
||||
// combine the page-only Group rule with the channel validation (keeps tuple arity within
|
||||
// LanguageExt's supported applicative range while still accumulating all errors)
|
||||
return (ValidateGroup(request.Group), channelValidation).Apply((_, channel) => channel);
|
||||
}
|
||||
private static Validation<BaseError, string> ValidatePreferredLanguage(CreateChannel createChannel) =>
|
||||
Optional(createChannel.PreferredLanguageCode ?? string.Empty)
|
||||
.Filter(
|
||||
lc => string.IsNullOrWhiteSpace(lc) || CultureInfo.GetCultures(CultureTypes.NeutralCultures).Any(
|
||||
ci => string.Equals(ci.ThreeLetterISOLanguageName, lc, StringComparison.OrdinalIgnoreCase)))
|
||||
.ToValidation<BaseError>("Preferred language code is invalid");
|
||||
|
||||
private static Validation<BaseError, string> ValidateName(CreateChannel createChannel) =>
|
||||
createChannel.NotEmpty(c => c.Name)
|
||||
.Bind(_ => createChannel.NotLongerThan(50)(c => c.Name));
|
||||
|
||||
private static async Task<Validation<BaseError, string>> ValidateNumber(
|
||||
TvContext dbContext,
|
||||
CreateChannel createChannel,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<Channel> maybeExistingChannel = await dbContext.Channels
|
||||
.SelectOneAsync(c => c.Number, c => c.Number == createChannel.Number, cancellationToken);
|
||||
return maybeExistingChannel.Match<Validation<BaseError, string>>(
|
||||
_ => BaseError.New("Channel number must be unique"),
|
||||
() =>
|
||||
{
|
||||
if (Regex.IsMatch(createChannel.Number, Channel.NumberValidator))
|
||||
private static async Task<Validation<BaseError, string>> ValidateNumber(TvContext dbContext, CreateChannel createChannel)
|
||||
{
|
||||
Option<Channel> maybeExistingChannel = await dbContext.Channels
|
||||
.SelectOneAsync(c => c.Number, c => c.Number == createChannel.Number);
|
||||
return maybeExistingChannel.Match<Validation<BaseError, string>>(
|
||||
_ => BaseError.New("Channel number must be unique"),
|
||||
() =>
|
||||
{
|
||||
return createChannel.Number;
|
||||
}
|
||||
if (Regex.IsMatch(createChannel.Number, Channel.NumberValidator))
|
||||
{
|
||||
return createChannel.Number;
|
||||
}
|
||||
|
||||
return BaseError.New("Invalid channel number; two decimals are allowed for subchannels");
|
||||
});
|
||||
}
|
||||
|
||||
private static async 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("Invalid channel number; one decimal is allowed for subchannels");
|
||||
});
|
||||
}
|
||||
|
||||
return BaseError.New($"FFmpegProfile {createChannel.FFmpegProfileId} does not exist.");
|
||||
}
|
||||
private static Task<Validation<BaseError, int>> FFmpegProfileMustExist(
|
||||
TvContext dbContext,
|
||||
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)
|
||||
{
|
||||
if (createChannel.WatermarkId is null)
|
||||
private static async Task<Validation<BaseError, Option<int>>> WatermarkMustExist(
|
||||
TvContext dbContext,
|
||||
CreateChannel createChannel)
|
||||
{
|
||||
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.");
|
||||
}
|
||||
|
||||
private static async Task<Validation<BaseError, Option<int>>> FillerPresetMustExist(
|
||||
TvContext dbContext,
|
||||
CreateChannel createChannel,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (createChannel.FallbackFillerId is null)
|
||||
{
|
||||
return Option<int>.None;
|
||||
}
|
||||
|
||||
bool exists = 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)
|
||||
if (createChannel.WatermarkId is null)
|
||||
{
|
||||
return BaseError.New(
|
||||
$"Mirror source channel {mirrorSource.Name} must use generated playout source");
|
||||
return Option<int>.None;
|
||||
}
|
||||
|
||||
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."));
|
||||
}
|
||||
|
||||
foreach (TimeSpan playoutOffset in Optional(createChannel.PlayoutOffset))
|
||||
private static async Task<Validation<BaseError, Option<int>>> FillerPresetMustExist(
|
||||
TvContext dbContext,
|
||||
CreateChannel createChannel)
|
||||
{
|
||||
if (playoutOffset < TimeSpan.FromHours(-12) || playoutOffset > TimeSpan.FromHours(12))
|
||||
if (createChannel.FallbackFillerId is null)
|
||||
{
|
||||
return BaseError.New("Playout offset must not be greater than 12 hours");
|
||||
return Option<int>.None;
|
||||
}
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
return await dbContext.FillerPresets
|
||||
.Filter(fp => fp.FillerKind == FillerKind.Fallback)
|
||||
.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,3 +1,4 @@
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record CreateChannelResult(int ChannelId) : EntityIdResult(ChannelId);
|
||||
namespace ErsatzTV.Application.Channels.Commands
|
||||
{
|
||||
public record CreateChannelResult(int ChannelId) : EntityIdResult(ChannelId);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
using ErsatzTV.Core;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record DeleteChannel(int ChannelId) : IRequest<Either<BaseError, Unit>>;
|
||||
namespace ErsatzTV.Application.Channels.Commands
|
||||
{
|
||||
public record DeleteChannel(int ChannelId) : IRequest<Either<BaseError, Task>>;
|
||||
}
|
||||
|
||||
@@ -1,67 +1,28 @@
|
||||
using System.IO.Abstractions;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Channel = ErsatzTV.Core.Domain.Channel;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class DeleteChannelHandler : IRequestHandler<DeleteChannel, Either<BaseError, Unit>>
|
||||
namespace ErsatzTV.Application.Channels.Commands
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IFileSystem _fileSystem;
|
||||
private readonly ISearchTargets _searchTargets;
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
|
||||
|
||||
public DeleteChannelHandler(
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IFileSystem fileSystem,
|
||||
ISearchTargets searchTargets)
|
||||
public class DeleteChannelHandler : IRequestHandler<DeleteChannel, Either<BaseError, Task>>
|
||||
{
|
||||
_workerChannel = workerChannel;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_fileSystem = fileSystem;
|
||||
_searchTargets = searchTargets;
|
||||
}
|
||||
private readonly IChannelRepository _channelRepository;
|
||||
|
||||
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);
|
||||
public DeleteChannelHandler(IChannelRepository channelRepository) => _channelRepository = channelRepository;
|
||||
|
||||
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."))));
|
||||
}
|
||||
public async Task<Either<BaseError, Task>> Handle(DeleteChannel request, CancellationToken cancellationToken) =>
|
||||
(await ChannelMustExist(request))
|
||||
.Map(DoDeletion)
|
||||
.ToEither<Task>();
|
||||
|
||||
private async Task<Unit> DoDeletion(TvContext dbContext, Channel channel, CancellationToken cancellationToken)
|
||||
{
|
||||
dbContext.Channels.Remove(channel);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
private Task DoDeletion(int channelId) => _channelRepository.Delete(channelId);
|
||||
|
||||
_searchTargets.SearchTargetsChanged();
|
||||
|
||||
// delete channel data from channel guide cache
|
||||
string cacheFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, $"{channel.Number}.xml");
|
||||
if (_fileSystem.File.Exists(cacheFile))
|
||||
{
|
||||
File.Delete(cacheFile);
|
||||
}
|
||||
|
||||
// refresh channel list to remove channel that has no playout
|
||||
await _workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
|
||||
|
||||
return Unit.Default;
|
||||
private async Task<Validation<BaseError, int>> ChannelMustExist(DeleteChannel deleteChannel) =>
|
||||
(await _channelRepository.Get(deleteChannel.ChannelId))
|
||||
.ToValidation<BaseError>($"Channel {deleteChannel.ChannelId} does not exist.")
|
||||
.Map(c => c.Id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record RefreshChannelData(string ChannelNumber) : IRequest, IBackgroundServiceRequest;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +0,0 @@
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record RefreshChannelList : IRequest, IBackgroundServiceRequest;
|
||||
@@ -1,151 +0,0 @@
|
||||
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;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.IO;
|
||||
using Scriban;
|
||||
using Scriban.Runtime;
|
||||
using WebMarkupMin.Core;
|
||||
|
||||
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;
|
||||
|
||||
public RefreshChannelListHandler(
|
||||
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IFileSystem fileSystem,
|
||||
ILocalFileSystem localFileSystem,
|
||||
ILogger<RefreshChannelListHandler> logger)
|
||||
{
|
||||
_recyclableMemoryStreamManager = recyclableMemoryStreamManager;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_fileSystem = fileSystem;
|
||||
_localFileSystem = localFileSystem;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task Handle(RefreshChannelList request, CancellationToken cancellationToken)
|
||||
{
|
||||
_localFileSystem.EnsureFolderExists(FileSystemLayout.ChannelGuideCacheFolder);
|
||||
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
string templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "channel.sbntxt");
|
||||
|
||||
// fall back to default template
|
||||
if (!_fileSystem.File.Exists(templateFileName))
|
||||
{
|
||||
templateFileName = Path.Combine(FileSystemLayout.ChannelGuideTemplatesFolder, "_channel.sbntxt");
|
||||
}
|
||||
|
||||
// fail if file doesn't exist
|
||||
if (!_fileSystem.File.Exists(templateFileName))
|
||||
{
|
||||
_logger.LogError(
|
||||
"Unable to generate channel list without template file {File}; please restart ErsatzTV",
|
||||
templateFileName);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var minifier = new XmlMinifier(
|
||||
new XmlMinificationSettings
|
||||
{
|
||||
MinifyWhitespace = true,
|
||||
RemoveXmlComments = true,
|
||||
CollapseTagsWithoutContent = true
|
||||
});
|
||||
|
||||
string text = await File.ReadAllTextAsync(templateFileName, cancellationToken);
|
||||
var template = Template.Parse(text, templateFileName);
|
||||
var templateContext = new XmlTemplateContext();
|
||||
|
||||
await using RecyclableMemoryStream ms = _recyclableMemoryStreamManager.GetStream();
|
||||
await using var xml = XmlWriter.Create(
|
||||
ms,
|
||||
new XmlWriterSettings { Async = true, ConformanceLevel = ConformanceLevel.Fragment });
|
||||
|
||||
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),
|
||||
ChannelIdLegacy = ChannelIdentifier.LegacyFromNumber(channel.Number),
|
||||
ChannelNumber = channel.Number,
|
||||
ChannelName = channel.Name,
|
||||
ChannelCategories = GetCategories(channel.Categories),
|
||||
ChannelHasExternalArtwork = hasExternalLogo,
|
||||
ChannelHasArtwork = hasLogo,
|
||||
ChannelArtworkPath = channel.ArtworkPath,
|
||||
ChannelNameEncoded = WebUtility.UrlEncode(channel.Name)
|
||||
};
|
||||
|
||||
var scriptObject = new ScriptObject();
|
||||
scriptObject.Import(data);
|
||||
templateContext.PushGlobal(scriptObject);
|
||||
|
||||
string result = await template.RenderAsync(templateContext);
|
||||
|
||||
MarkupMinificationResult minified = minifier.Minify(result);
|
||||
await xml.WriteRawAsync(minified.MinifiedContent);
|
||||
}
|
||||
|
||||
await xml.FlushAsync();
|
||||
|
||||
string tempFile = Path.GetTempFileName();
|
||||
await File.WriteAllBytesAsync(tempFile, ms.ToArray(), cancellationToken);
|
||||
|
||||
string targetFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, "channels.xml");
|
||||
File.Move(tempFile, targetFile, true);
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<ChannelResult> GetChannels(TvContext dbContext)
|
||||
{
|
||||
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
|
||||
order by CAST(C.Number as double)";
|
||||
// TODO: this needs to be fixed for sqlite/mariadb
|
||||
|
||||
await using var reader = (DbDataReader)await dbContext.Connection.ExecuteReaderAsync(QUERY);
|
||||
Func<DbDataReader, ChannelResult> rowParser = reader.GetRowParser<ChannelResult>();
|
||||
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
yield return rowParser(reader);
|
||||
}
|
||||
|
||||
while (await reader.NextResultAsync())
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private static List<string> GetCategories(string categories) =>
|
||||
(categories ?? string.Empty).Split(',')
|
||||
.Map(s => s.Trim())
|
||||
.Filter(s => !string.IsNullOrWhiteSpace(s))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
// ReSharper disable once ClassNeverInstantiated.Local
|
||||
private sealed record ChannelResult(string Number, string Name, string Categories, string ArtworkPath);
|
||||
}
|
||||
@@ -1,35 +1,19 @@
|
||||
using ErsatzTV.Application.Artworks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record UpdateChannel(
|
||||
int ChannelId,
|
||||
string Name,
|
||||
string Number,
|
||||
string Group,
|
||||
string Categories,
|
||||
int FFmpegProfileId,
|
||||
double? SlugSeconds,
|
||||
ArtworkContentTypeModel Logo,
|
||||
ChannelStreamSelectorMode StreamSelectorMode,
|
||||
string StreamSelector,
|
||||
string PreferredAudioLanguageCode,
|
||||
string PreferredAudioTitle,
|
||||
ChannelPlayoutSource PlayoutSource,
|
||||
ChannelPlayoutMode PlayoutMode,
|
||||
int? MirrorSourceChannelId,
|
||||
TimeSpan? PlayoutOffset,
|
||||
StreamingMode StreamingMode,
|
||||
int? WatermarkId,
|
||||
int? FallbackFillerId,
|
||||
string PreferredSubtitleLanguageCode,
|
||||
ChannelSubtitleMode SubtitleMode,
|
||||
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
|
||||
string MusicVideoCreditsTemplate,
|
||||
ChannelSongVideoMode SongVideoMode,
|
||||
ChannelTranscodeMode TranscodeMode,
|
||||
ChannelIdleBehavior IdleBehavior,
|
||||
bool IsEnabled,
|
||||
bool ShowInEpg) : IRequest<Either<BaseError, ChannelViewModel>>;
|
||||
namespace ErsatzTV.Application.Channels.Commands
|
||||
{
|
||||
public record UpdateChannel
|
||||
(
|
||||
int ChannelId,
|
||||
string Name,
|
||||
string Number,
|
||||
int FFmpegProfileId,
|
||||
string Logo,
|
||||
string PreferredLanguageCode,
|
||||
StreamingMode StreamingMode,
|
||||
int? WatermarkId,
|
||||
int? FallbackFillerId) : IRequest<Either<BaseError, ChannelViewModel>>;
|
||||
}
|
||||
|
||||
@@ -1,327 +1,122 @@
|
||||
using System.Globalization;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Subtitles;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
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 LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.Channels.ChannelValidations;
|
||||
using static ErsatzTV.Application.Channels.Mapper;
|
||||
using Channel = ErsatzTV.Core.Domain.Channel;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class UpdateChannelHandler(
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ISearchTargets searchTargets)
|
||||
: IRequestHandler<UpdateChannel, Either<BaseError, ChannelViewModel>>
|
||||
namespace ErsatzTV.Application.Channels.Commands
|
||||
{
|
||||
public async Task<Either<BaseError, ChannelViewModel>> Handle(
|
||||
UpdateChannel request,
|
||||
CancellationToken cancellationToken)
|
||||
public class UpdateChannelHandler : IRequestHandler<UpdateChannel, Either<BaseError, ChannelViewModel>>
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
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);
|
||||
public UpdateChannelHandler(IDbContextFactory<TvContext> dbContextFactory) =>
|
||||
_dbContextFactory = dbContextFactory;
|
||||
|
||||
return await maybeChannel.Match(
|
||||
Some: async channel =>
|
||||
{
|
||||
Validation<BaseError, Channel> validation =
|
||||
await Validate(dbContext, request, channel, cancellationToken);
|
||||
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request, cancellationToken));
|
||||
},
|
||||
None: () => Task.FromResult(
|
||||
Left<BaseError, ChannelViewModel>(
|
||||
new NotFoundError($"Channel {request.ChannelId} does not exist."))));
|
||||
}
|
||||
|
||||
private async Task<ChannelViewModel> ApplyUpdateRequest(
|
||||
TvContext dbContext,
|
||||
Channel c,
|
||||
UpdateChannel update,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// don't save mirror when playout exists
|
||||
if (c.Playouts.Count > 0)
|
||||
public async Task<Either<BaseError, ChannelViewModel>> Handle(
|
||||
UpdateChannel request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
update = update with
|
||||
{
|
||||
PlayoutSource = ChannelPlayoutSource.Generated,
|
||||
MirrorSourceChannelId = null
|
||||
};
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
Validation<BaseError, Channel> validation = await Validate(dbContext, request);
|
||||
return await validation.Apply(c => ApplyUpdateRequest(dbContext, c, request));
|
||||
}
|
||||
|
||||
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;
|
||||
c.SubtitleMode = update.SubtitleMode;
|
||||
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 ??= [];
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(update.Logo?.Path))
|
||||
private async Task<ChannelViewModel> ApplyUpdateRequest(TvContext dbContext, Channel c, UpdateChannel update)
|
||||
{
|
||||
string logo = update.Logo.Path;
|
||||
if (logo.StartsWith("iptv/logos/", StringComparison.Ordinal))
|
||||
{
|
||||
logo = logo.Replace("iptv/logos/", string.Empty);
|
||||
}
|
||||
c.Name = update.Name;
|
||||
c.Number = update.Number;
|
||||
c.FFmpegProfileId = update.FFmpegProfileId;
|
||||
c.PreferredLanguageCode = update.PreferredLanguageCode;
|
||||
c.Artwork ??= new List<Artwork>();
|
||||
|
||||
Option<Artwork> maybeLogo = c.Artwork.Where(a => a.ArtworkKind == ArtworkKind.Logo).HeadOrNone();
|
||||
foreach (Artwork artwork in maybeLogo)
|
||||
if (!string.IsNullOrWhiteSpace(update.Logo))
|
||||
{
|
||||
artwork.Path = logo;
|
||||
artwork.OriginalContentType = !string.IsNullOrEmpty(update.Logo.ContentType)
|
||||
? update.Logo.ContentType
|
||||
: null;
|
||||
artwork.DateUpdated = DateTime.UtcNow;
|
||||
}
|
||||
Option<Artwork> maybeLogo =
|
||||
Optional(c.Artwork).Flatten().FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Logo);
|
||||
|
||||
if (maybeLogo.IsNone)
|
||||
maybeLogo.Match(
|
||||
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.StreamingMode = update.StreamingMode;
|
||||
c.WatermarkId = update.WatermarkId;
|
||||
c.FallbackFillerId = update.FallbackFillerId;
|
||||
await dbContext.SaveChangesAsync();
|
||||
return ProjectToViewModel(c);
|
||||
}
|
||||
|
||||
private async Task<Validation<BaseError, Channel>> Validate(TvContext dbContext, UpdateChannel request) =>
|
||||
(await ChannelMustExist(dbContext, request), ValidateName(request),
|
||||
await ValidateNumber(dbContext, request),
|
||||
ValidatePreferredLanguage(request))
|
||||
.Apply((channelToUpdate, _, _, _) => channelToUpdate);
|
||||
|
||||
private static Task<Validation<BaseError, Channel>> ChannelMustExist(
|
||||
TvContext dbContext,
|
||||
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)
|
||||
.Bind(_ => updateChannel.NotLongerThan(50)(c => c.Name));
|
||||
|
||||
private static async Task<Validation<BaseError, string>> ValidateNumber(
|
||||
TvContext dbContext,
|
||||
UpdateChannel updateChannel)
|
||||
{
|
||||
int matchId = await dbContext.Channels
|
||||
.SelectOneAsync(c => c.Number, c => c.Number == updateChannel.Number)
|
||||
.Match(c => c.Id, () => updateChannel.ChannelId);
|
||||
|
||||
if (matchId == updateChannel.ChannelId)
|
||||
{
|
||||
var artwork = new Artwork
|
||||
if (Regex.IsMatch(updateChannel.Number, Channel.NumberValidator))
|
||||
{
|
||||
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);
|
||||
return updateChannel.Number;
|
||||
}
|
||||
|
||||
foreach (Artwork artwork in c.Artwork.Where(x => x.ArtworkKind is ArtworkKind.Logo).ToList())
|
||||
{
|
||||
c.Artwork.Remove(artwork);
|
||||
dbContext.Artwork.Remove(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.StreamingMode = update.StreamingMode;
|
||||
c.WatermarkId = update.WatermarkId;
|
||||
c.FallbackFillerId = update.FallbackFillerId;
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
searchTargets.SearchTargetsChanged();
|
||||
|
||||
if (c.SubtitleMode != ChannelSubtitleMode.None)
|
||||
{
|
||||
Option<Playout> maybePlayout = await dbContext.Playouts
|
||||
.SelectOneAsync(p => p.ChannelId, p => p.ChannelId == c.Id, cancellationToken);
|
||||
|
||||
foreach (Playout playout in maybePlayout)
|
||||
{
|
||||
await workerChannel.WriteAsync(new ExtractEmbeddedSubtitles(playout.Id), cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
|
||||
if (hasEpgChange)
|
||||
{
|
||||
await workerChannel.WriteAsync(new RefreshChannelData(c.Number), cancellationToken);
|
||||
}
|
||||
|
||||
return ProjectToViewModel(c, c.Playouts?.Count ?? 0);
|
||||
}
|
||||
|
||||
private static async Task<Validation<BaseError, Channel>> Validate(
|
||||
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;
|
||||
}
|
||||
|
||||
private static Validation<BaseError, string> ValidateName(UpdateChannel updateChannel) =>
|
||||
updateChannel.NotEmpty(c => c.Name)
|
||||
.Bind(_ => updateChannel.NotLongerThan(50)(c => c.Name));
|
||||
|
||||
private static async Task<Validation<BaseError, string>> ValidateNumber(
|
||||
TvContext dbContext,
|
||||
UpdateChannel updateChannel,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
int matchId = await dbContext.Channels
|
||||
.SelectOneAsync(c => c.Number, c => c.Number == updateChannel.Number, cancellationToken)
|
||||
.Match(c => c.Id, () => updateChannel.ChannelId);
|
||||
|
||||
if (matchId == updateChannel.ChannelId)
|
||||
{
|
||||
if (Regex.IsMatch(updateChannel.Number, Channel.NumberValidator))
|
||||
{
|
||||
return updateChannel.Number;
|
||||
return BaseError.New("Invalid channel number; one decimal is allowed for subchannels");
|
||||
}
|
||||
|
||||
return BaseError.New("Invalid channel number; two decimals are allowed for subchannels");
|
||||
return BaseError.New("Channel number must be unique");
|
||||
}
|
||||
|
||||
return BaseError.New("Channel number must be unique");
|
||||
private static Validation<BaseError, string> ValidatePreferredLanguage(UpdateChannel updateChannel) =>
|
||||
Optional(updateChannel.PreferredLanguageCode ?? string.Empty)
|
||||
.Filter(
|
||||
lc => string.IsNullOrWhiteSpace(lc) || CultureInfo.GetCultures(CultureTypes.NeutralCultures).Any(
|
||||
ci => string.Equals(ci.ThreeLetterISOLanguageName, lc, StringComparison.OrdinalIgnoreCase)))
|
||||
.ToValidation<BaseError>("Preferred language code is invalid");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record UpdateChannelNumbers(List<ChannelSortViewModel> Channels) : IRequest<Option<BaseError>>;
|
||||
@@ -1,72 +0,0 @@
|
||||
using System.Globalization;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Core;
|
||||
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)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
var numberUpdates = request.Channels.ToDictionary(c => c.Id, c => c.Number);
|
||||
var channelIds = numberUpdates.Keys;
|
||||
|
||||
List<Channel> channelsToUpdate = await dbContext.Channels
|
||||
.Where(c => channelIds.Contains(c.Id))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// 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
|
||||
await workerChannel.WriteAsync(new RefreshChannelList(), cancellationToken);
|
||||
foreach (var channel in channelsToUpdate)
|
||||
{
|
||||
await workerChannel.WriteAsync(new RefreshChannelData(channel.Number), cancellationToken);
|
||||
}
|
||||
|
||||
return Option<BaseError>.None;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New("Failed to update channel numbers: " + ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,87 +1,29 @@
|
||||
using ErsatzTV.Application.Artworks;
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
using System.Linq;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
internal static class Mapper
|
||||
namespace ErsatzTV.Application.Channels
|
||||
{
|
||||
internal static ChannelViewModel ProjectToViewModel(Channel channel, int playoutCount) =>
|
||||
new(
|
||||
channel.Id,
|
||||
channel.Number,
|
||||
channel.Name,
|
||||
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.StreamingMode,
|
||||
channel.WatermarkId,
|
||||
channel.FallbackFillerId,
|
||||
playoutCount,
|
||||
channel.PreferredSubtitleLanguageCode,
|
||||
channel.SubtitleMode,
|
||||
channel.MusicVideoCreditsMode,
|
||||
channel.MusicVideoCreditsTemplate,
|
||||
channel.SongVideoMode,
|
||||
channel.TranscodeMode,
|
||||
channel.IdleBehavior,
|
||||
channel.IsEnabled,
|
||||
channel.ShowInEpg);
|
||||
|
||||
internal static ChannelResponseModel ProjectToResponseModel(Channel channel) =>
|
||||
new(
|
||||
channel.Id,
|
||||
channel.Number,
|
||||
channel.Name,
|
||||
channel.FFmpegProfile.Name,
|
||||
channel.PreferredAudioLanguageCode,
|
||||
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);
|
||||
|
||||
private static ArtworkContentTypeModel GetLogo(Channel channel)
|
||||
internal static class Mapper
|
||||
{
|
||||
Option<Artwork> maybeArtwork = channel.Artwork
|
||||
.Where(a => a.ArtworkKind == ArtworkKind.Logo)
|
||||
.HeadOrNone();
|
||||
internal static ChannelViewModel ProjectToViewModel(Channel channel) =>
|
||||
new(
|
||||
channel.Id,
|
||||
channel.Number,
|
||||
channel.Name,
|
||||
channel.FFmpegProfileId,
|
||||
GetLogo(channel),
|
||||
channel.PreferredLanguageCode,
|
||||
channel.StreamingMode,
|
||||
channel.WatermarkId,
|
||||
channel.FallbackFillerId);
|
||||
|
||||
foreach (Artwork artwork in maybeArtwork)
|
||||
{
|
||||
return artwork.IsExternalUrl()
|
||||
? new ArtworkContentTypeModel(artwork.Path, string.Empty)
|
||||
: new ArtworkContentTypeModel($"iptv/logos/{artwork.Path}", artwork.OriginalContentType);
|
||||
}
|
||||
private static string GetLogo(Channel channel) =>
|
||||
Optional(channel.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Logo))
|
||||
.Match(a => a.Path, string.Empty);
|
||||
|
||||
return ArtworkContentTypeModel.None;
|
||||
private static string GetWatermark(Channel channel) =>
|
||||
Optional(channel.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Watermark))
|
||||
.Match(a => a.Path, string.Empty);
|
||||
}
|
||||
|
||||
private static string GetStreamingMode(Channel channel) =>
|
||||
channel.StreamingMode switch
|
||||
{
|
||||
StreamingMode.TransportStream => "MPEG-TS (Legacy)",
|
||||
StreamingMode.TransportStreamHybrid => "MPEG-TS",
|
||||
StreamingMode.HttpLiveStreamingDirect => "HLS Direct",
|
||||
StreamingMode.HttpLiveStreamingSegmenter => "HLS Segmenter",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(channel))
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
using System.Collections.Generic;
|
||||
using MediatR;
|
||||
|
||||
public record GetAllChannels(bool ShowDisabled = true) : IRequest<List<ChannelViewModel>>;
|
||||
namespace ErsatzTV.Application.Channels.Queries
|
||||
{
|
||||
public record GetAllChannels : IRequest<List<ChannelViewModel>>;
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record GetAllChannelsForApi : IRequest<List<ChannelResponseModel>>;
|
||||
@@ -1,18 +0,0 @@
|
||||
using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using static ErsatzTV.Application.Channels.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class GetAllChannelsForApiHandler(IChannelRepository channelRepository)
|
||||
: IRequestHandler<GetAllChannelsForApi, List<ChannelResponseModel>>
|
||||
{
|
||||
public async Task<List<ChannelResponseModel>> Handle(
|
||||
GetAllChannelsForApi request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
IEnumerable<Channel> channels = Optional(await channelRepository.GetAll(cancellationToken)).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,21 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using MediatR;
|
||||
using static ErsatzTV.Application.Channels.Mapper;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class GetAllChannelsHandler(IChannelRepository channelRepository)
|
||||
: IRequestHandler<GetAllChannels, List<ChannelViewModel>>
|
||||
namespace ErsatzTV.Application.Channels.Queries
|
||||
{
|
||||
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)
|
||||
public class GetAllChannelsHandler : IRequestHandler<GetAllChannels, List<ChannelViewModel>>
|
||||
{
|
||||
var result = 0;
|
||||
private readonly IChannelRepository _channelRepository;
|
||||
|
||||
if (channel.Playouts != null)
|
||||
{
|
||||
result += channel.Playouts.Count;
|
||||
}
|
||||
public GetAllChannelsHandler(IChannelRepository channelRepository) => _channelRepository = channelRepository;
|
||||
|
||||
if (channel.PlayoutSource is ChannelPlayoutSource.Mirror && channel.MirrorSourceChannel?.Playouts != null)
|
||||
{
|
||||
result += channel.MirrorSourceChannel.Playouts.Count;
|
||||
}
|
||||
|
||||
return result;
|
||||
public async Task<List<ChannelViewModel>> Handle(GetAllChannels request, CancellationToken cancellationToken) =>
|
||||
Optional(await _channelRepository.GetAll()).Flatten().Map(ProjectToViewModel).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
|
||||
public record GetChannelById(int Id) : IRequest<Option<ChannelViewModel>>;
|
||||
namespace ErsatzTV.Application.Channels.Queries
|
||||
{
|
||||
public record GetChannelById(int Id) : IRequest<Option<ChannelViewModel>>;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using static ErsatzTV.Application.Channels.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class GetChannelByIdHandler(IChannelRepository channelRepository)
|
||||
: IRequestHandler<GetChannelById, Option<ChannelViewModel>>
|
||||
namespace ErsatzTV.Application.Channels.Queries
|
||||
{
|
||||
public Task<Option<ChannelViewModel>> Handle(GetChannelById request, CancellationToken cancellationToken) =>
|
||||
channelRepository.GetChannel(request.Id)
|
||||
.MapT(c => ProjectToViewModel(c, 0));
|
||||
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.Get(request.Id)
|
||||
.MapT(ProjectToViewModel);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record GetChannelByNumber(string ChannelNumber) : IRequest<Option<ChannelViewModel>>;
|
||||
@@ -1,11 +0,0 @@
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using static ErsatzTV.Application.Channels.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class GetChannelByNumberHandler(IChannelRepository channelRepository)
|
||||
: IRequestHandler<GetChannelByNumber, Option<ChannelViewModel>>
|
||||
{
|
||||
public Task<Option<ChannelViewModel>> Handle(GetChannelByNumber request, CancellationToken cancellationToken) =>
|
||||
channelRepository.GetByNumber(request.ChannelNumber).MapT(c => ProjectToViewModel(c, 0));
|
||||
}
|
||||
@@ -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 +0,0 @@
|
||||
using ErsatzTV.FFmpeg;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record GetChannelFramerate(string ChannelNumber) : IRequest<Option<FrameRate>>;
|
||||
@@ -1,118 +0,0 @@
|
||||
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 async Task<Option<FrameRate>> Handle(GetChannelFramerate request, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
FFmpegProfile ffmpegProfile = await dbContext.Channels
|
||||
.AsNoTracking()
|
||||
.Filter(c => c.Number == request.ChannelNumber)
|
||||
.Include(c => c.FFmpegProfile)
|
||||
.Map(c => c.FFmpegProfile)
|
||||
.SingleAsync(cancellationToken);
|
||||
|
||||
if (!ffmpegProfile.NormalizeFramerate)
|
||||
{
|
||||
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(
|
||||
"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);
|
||||
|
||||
return FrameRate.DefaultFrameRate;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(
|
||||
ex,
|
||||
"Unexpected error checking frame rates on channel {ChannelNumber}",
|
||||
request.ChannelNumber);
|
||||
}
|
||||
|
||||
return None;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Iptv;
|
||||
using ErsatzTV.Core.Iptv;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record GetChannelGuide(string Scheme, string Host, string BaseUrl, string AccessToken)
|
||||
: IRequest<Either<BaseError, ChannelGuide>>;
|
||||
namespace ErsatzTV.Application.Channels.Queries
|
||||
{
|
||||
public record GetChannelGuide(string Scheme, string Host) : IRequest<ChannelGuide>;
|
||||
}
|
||||
|
||||
@@ -1,106 +1,20 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.IO.Abstractions;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Iptv;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.IO;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public partial class GetChannelGuideHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
|
||||
IFileSystem fileSystem,
|
||||
ILocalFileSystem localFileSystem)
|
||||
: IRequestHandler<GetChannelGuide, Either<BaseError, ChannelGuide>>
|
||||
namespace ErsatzTV.Application.Channels.Queries
|
||||
{
|
||||
public async Task<Either<BaseError, ChannelGuide>> Handle(
|
||||
GetChannelGuide request,
|
||||
CancellationToken cancellationToken)
|
||||
public class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, ChannelGuide>
|
||||
{
|
||||
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();
|
||||
private readonly IChannelRepository _channelRepository;
|
||||
|
||||
string channelsFile = fileSystem.Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, "channels.xml");
|
||||
if (!fileSystem.File.Exists(channelsFile))
|
||||
{
|
||||
return BaseError.New($"Required file {channelsFile} is missing");
|
||||
}
|
||||
public GetChannelGuideHandler(IChannelRepository channelRepository) => _channelRepository = channelRepository;
|
||||
|
||||
long mtime = fileSystem.File.GetLastWriteTime(channelsFile).Ticks;
|
||||
|
||||
var accessTokenUri = $"?v={mtime}";
|
||||
if (!string.IsNullOrWhiteSpace(request.AccessToken))
|
||||
{
|
||||
accessTokenUri += $"&access_token={request.AccessToken}";
|
||||
}
|
||||
|
||||
string channelsFragment = await ReadAllTextShared(channelsFile, cancellationToken);
|
||||
|
||||
// TODO: is regex faster?
|
||||
channelsFragment = channelsFragment
|
||||
.Replace("{RequestBase}", $"{request.Scheme}://{request.Host}{request.BaseUrl}")
|
||||
.Replace("{AccessTokenUri}", accessTokenUri);
|
||||
|
||||
var channelDataFragments = new Dictionary<string, string>();
|
||||
|
||||
foreach (string fileName in localFileSystem.ListFiles(FileSystemLayout.ChannelGuideCacheFolder))
|
||||
{
|
||||
if (fileName.Contains("channels"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (hiddenChannelNumbers.Contains(fileSystem.Path.GetFileName(fileName)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
string channelDataFragment = await ReadAllTextShared(fileName, cancellationToken);
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
return new ChannelGuide(recyclableMemoryStreamManager, channelsFragment, channelDataFragments);
|
||||
public Task<ChannelGuide> Handle(GetChannelGuide request, CancellationToken cancellationToken) =>
|
||||
_channelRepository.GetAllForGuide()
|
||||
.Map(channels => new ChannelGuide(request.Scheme, request.Host, channels));
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
using ErsatzTV.Core.Hdhr;
|
||||
using System.Collections.Generic;
|
||||
using ErsatzTV.Core.Hdhr;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record GetChannelLineup(string Scheme, string Host) : IRequest<List<LineupItem>>;
|
||||
namespace ErsatzTV.Application.Channels.Queries
|
||||
{
|
||||
public record GetChannelLineup(string Scheme, string Host) : IRequest<List<LineupItem>>;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
using ErsatzTV.Core.Hdhr;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Hdhr;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class GetChannelLineupHandler : IRequestHandler<GetChannelLineup, List<LineupItem>>
|
||||
namespace ErsatzTV.Application.Channels.Queries
|
||||
{
|
||||
private readonly IChannelRepository _channelRepository;
|
||||
public class GetChannelLineupHandler : IRequestHandler<GetChannelLineup, List<LineupItem>>
|
||||
{
|
||||
private readonly IChannelRepository _channelRepository;
|
||||
|
||||
public GetChannelLineupHandler(IChannelRepository channelRepository) => _channelRepository = channelRepository;
|
||||
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());
|
||||
public Task<List<LineupItem>> Handle(GetChannelLineup request, CancellationToken cancellationToken) =>
|
||||
_channelRepository.GetAll()
|
||||
.Map(channels => channels.Map(c => new LineupItem(request.Scheme, request.Host, c)).ToList());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record GetChannelNameByPlayoutId(int PlayoutId) : IRequest<Option<string>>;
|
||||
@@ -1,18 +0,0 @@
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class GetChannelNameByPlayoutIdHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<GetChannelNameByPlayoutId, Option<string>>
|
||||
{
|
||||
public async Task<Option<string>> Handle(GetChannelNameByPlayoutId request, CancellationToken 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)
|
||||
.MapT(p => p.Channel.Name);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,7 @@
|
||||
using ErsatzTV.Core.Iptv;
|
||||
using ErsatzTV.Core.Iptv;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record GetChannelPlaylist(
|
||||
string Scheme,
|
||||
string Host,
|
||||
string BaseUrl,
|
||||
string Mode,
|
||||
string UserAgent,
|
||||
string AccessToken) : IRequest<ChannelPlaylist>;
|
||||
namespace ErsatzTV.Application.Channels.Queries
|
||||
{
|
||||
public record GetChannelPlaylist(string Scheme, string Host, string Mode) : IRequest<ChannelPlaylist>;
|
||||
}
|
||||
|
||||
@@ -1,57 +1,52 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Iptv;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class GetChannelPlaylistHandler(IChannelRepository channelRepository)
|
||||
: IRequestHandler<GetChannelPlaylist, ChannelPlaylist>
|
||||
namespace ErsatzTV.Application.Channels.Queries
|
||||
{
|
||||
public Task<ChannelPlaylist> Handle(GetChannelPlaylist request, CancellationToken cancellationToken) =>
|
||||
channelRepository.GetAll(cancellationToken)
|
||||
.Map(channels => EnsureMode(channels, request.Mode))
|
||||
.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)
|
||||
public class GetChannelPlaylistHandler : IRequestHandler<GetChannelPlaylist, ChannelPlaylist>
|
||||
{
|
||||
var result = new List<Channel>();
|
||||
foreach (Channel channel in channels)
|
||||
private readonly IChannelRepository _channelRepository;
|
||||
|
||||
public GetChannelPlaylistHandler(IChannelRepository channelRepository) =>
|
||||
_channelRepository = channelRepository;
|
||||
|
||||
public Task<ChannelPlaylist> Handle(GetChannelPlaylist request, CancellationToken cancellationToken) =>
|
||||
_channelRepository.GetAll()
|
||||
.Map(channels => EnsureMode(channels, request.Mode))
|
||||
.Map(channels => new ChannelPlaylist(request.Scheme, request.Host, channels));
|
||||
|
||||
private static List<Channel> EnsureMode(IEnumerable<Channel> channels, string mode)
|
||||
{
|
||||
if (!channel.IsEnabled)
|
||||
var result = new List<Channel>();
|
||||
foreach (Channel channel in channels)
|
||||
{
|
||||
continue;
|
||||
switch (mode.ToLowerInvariant())
|
||||
{
|
||||
case "segmenter":
|
||||
channel.StreamingMode = StreamingMode.HttpLiveStreamingSegmenter;
|
||||
result.Add(channel);
|
||||
break;
|
||||
case "hls-direct":
|
||||
channel.StreamingMode = StreamingMode.HttpLiveStreamingDirect;
|
||||
result.Add(channel);
|
||||
break;
|
||||
case "ts":
|
||||
channel.StreamingMode = StreamingMode.TransportStream;
|
||||
result.Add(channel);
|
||||
break;
|
||||
default:
|
||||
result.Add(channel);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
switch (mode.ToLowerInvariant())
|
||||
{
|
||||
case "segmenter":
|
||||
channel.StreamingMode = StreamingMode.HttpLiveStreamingSegmenter;
|
||||
result.Add(channel);
|
||||
break;
|
||||
case "hls-direct":
|
||||
channel.StreamingMode = StreamingMode.HttpLiveStreamingDirect;
|
||||
result.Add(channel);
|
||||
break;
|
||||
case "ts-legacy":
|
||||
channel.StreamingMode = StreamingMode.TransportStream;
|
||||
result.Add(channel);
|
||||
break;
|
||||
case "ts":
|
||||
channel.StreamingMode = StreamingMode.TransportStreamHybrid;
|
||||
result.Add(channel);
|
||||
break;
|
||||
default:
|
||||
result.Add(channel);
|
||||
break;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record GetChannelStreamSelectors : IRequest<List<string>>;
|
||||
@@ -1,14 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class GetChannelStreamSelectorsHandler(ILocalFileSystem localFileSystem)
|
||||
: IRequestHandler<GetChannelStreamSelectors, List<string>>
|
||||
{
|
||||
public Task<List<string>> Handle(GetChannelStreamSelectors request, CancellationToken cancellationToken) =>
|
||||
localFileSystem.ListFiles(FileSystemLayout.ChannelStreamSelectorsFolder)
|
||||
.Map(Path.GetFileName)
|
||||
.ToList()
|
||||
.AsTask();
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record GetChannelStreamingSpecs(string ChannelNumber) : IRequest<Option<ChannelStreamingSpecsViewModel>>;
|
||||
@@ -1,25 +0,0 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class GetChannelStreamingSpecsHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<GetChannelStreamingSpecs, Option<ChannelStreamingSpecsViewModel>>
|
||||
{
|
||||
public async Task<Option<ChannelStreamingSpecsViewModel>> Handle(
|
||||
GetChannelStreamingSpecs request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
Option<Channel> maybeChannel = await dbContext.Channels
|
||||
.AsNoTracking()
|
||||
.Include(c => c.FFmpegProfile)
|
||||
.ThenInclude(ff => ff.Resolution)
|
||||
.SelectOneAsync(c => c.Number, c => c.Number == request.ChannelNumber, cancellationToken);
|
||||
|
||||
return maybeChannel.Map(Mapper.ProjectToSpecsViewModel);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record GetSlugSecondsByChannelNumber(string ChannelNumber) : IRequest<Option<double>>;
|
||||
@@ -1,17 +0,0 @@
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class GetSlugSecondsByChannelNumberHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<GetSlugSecondsByChannelNumber, Option<double>>
|
||||
{
|
||||
public async Task<Option<double>> Handle(GetSlugSecondsByChannelNumber request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
return await dbContext.Channels
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(c => c.Number == request.ChannelNumber, cancellationToken)
|
||||
.Map(c => Optional(c?.SlugSeconds));
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record ResolutionViewModel(int Height, int Width);
|
||||
@@ -1,14 +0,0 @@
|
||||
using System.Net;
|
||||
using Scriban;
|
||||
using Scriban.Parsing;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class XmlTemplateContext : TemplateContext
|
||||
{
|
||||
public override TemplateContext Write(SourceSpan span, object textAsObject)
|
||||
=> base.Write(span, textAsObject is string text ? WebUtility.HtmlEncode(text) : textAsObject);
|
||||
|
||||
public override ValueTask<TemplateContext> WriteAsync(SourceSpan span, object textAsObject)
|
||||
=> base.WriteAsync(span, textAsObject is string text ? WebUtility.HtmlEncode(text) : textAsObject);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public record SaveConfigElementByKey(ConfigElementKey Key, string Value) : IRequest;
|
||||
namespace ErsatzTV.Application.Configuration.Commands
|
||||
{
|
||||
public record SaveConfigElementByKey(ConfigElementKey Key, string Value) : MediatR.IRequest<Unit>;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class SaveConfigElementByKeyHandler : IRequestHandler<SaveConfigElementByKey>
|
||||
namespace ErsatzTV.Application.Configuration.Commands
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
public class SaveConfigElementByKeyHandler : MediatR.IRequestHandler<SaveConfigElementByKey, Unit>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
|
||||
public SaveConfigElementByKeyHandler(IConfigElementRepository configElementRepository) =>
|
||||
_configElementRepository = configElementRepository;
|
||||
public SaveConfigElementByKeyHandler(IConfigElementRepository configElementRepository) =>
|
||||
_configElementRepository = configElementRepository;
|
||||
|
||||
public async Task Handle(SaveConfigElementByKey request, CancellationToken cancellationToken) =>
|
||||
await _configElementRepository.Upsert(request.Key, request.Value, cancellationToken);
|
||||
public async Task<Unit> Handle(SaveConfigElementByKey request, CancellationToken cancellationToken)
|
||||
{
|
||||
await _configElementRepository.Upsert(request.Key, request.Value);
|
||||
return Unit.Default;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public record UpdateLibraryRefreshInterval(int LibraryRefreshInterval) : IRequest<Either<BaseError, Unit>>;
|
||||
namespace ErsatzTV.Application.Configuration.Commands
|
||||
{
|
||||
public record UpdateLibraryRefreshInterval(int LibraryRefreshInterval) : MediatR.IRequest<Either<BaseError, Unit>>;
|
||||
}
|
||||
|
||||
+25
-23
@@ -1,31 +1,33 @@
|
||||
using ErsatzTV.Core;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class UpdateLibraryRefreshIntervalHandler :
|
||||
IRequestHandler<UpdateLibraryRefreshInterval, Either<BaseError, Unit>>
|
||||
namespace ErsatzTV.Application.Configuration.Commands
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
public class UpdateLibraryRefreshIntervalHandler :
|
||||
MediatR.IRequestHandler<UpdateLibraryRefreshInterval, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
|
||||
public UpdateLibraryRefreshIntervalHandler(IConfigElementRepository configElementRepository) =>
|
||||
_configElementRepository = configElementRepository;
|
||||
public UpdateLibraryRefreshIntervalHandler(IConfigElementRepository configElementRepository) =>
|
||||
_configElementRepository = configElementRepository;
|
||||
|
||||
public Task<Either<BaseError, Unit>> Handle(
|
||||
UpdateLibraryRefreshInterval request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Validate(request)
|
||||
.MapT(_ => _configElementRepository.Upsert(
|
||||
ConfigElementKey.LibraryRefreshInterval,
|
||||
request.LibraryRefreshInterval,
|
||||
cancellationToken))
|
||||
.Bind(v => v.ToEitherAsync());
|
||||
public Task<Either<BaseError, Unit>> Handle(
|
||||
UpdateLibraryRefreshInterval request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Validate(request)
|
||||
.MapT(_ => _configElementRepository.Upsert(ConfigElementKey.LibraryRefreshInterval, request.LibraryRefreshInterval))
|
||||
.Bind(v => v.ToEitherAsync());
|
||||
|
||||
private static Task<Validation<BaseError, Unit>> Validate(UpdateLibraryRefreshInterval request) =>
|
||||
Optional(request.LibraryRefreshInterval)
|
||||
.Where(lri => lri is >= 0 and < 1_000_000)
|
||||
.Map(_ => Unit.Default)
|
||||
.ToValidation<BaseError>("Library refresh interval must be zero or greater")
|
||||
.AsTask();
|
||||
private static Task<Validation<BaseError, Unit>> Validate(UpdateLibraryRefreshInterval request) =>
|
||||
Optional(request.LibraryRefreshInterval)
|
||||
.Where(lri => lri > 0)
|
||||
.Map(_ => Unit.Default)
|
||||
.ToValidation<BaseError>("Tuner count must be greater than zero")
|
||||
.AsTask();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public record UpdateLoggingSettings(LoggingSettingsViewModel LoggingSettings) : IRequest<Either<BaseError, Unit>>;
|
||||
@@ -1,56 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class UpdateLoggingSettingsHandler : IRequestHandler<UpdateLoggingSettings, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly LoggingLevelSwitches _loggingLevelSwitches;
|
||||
|
||||
public UpdateLoggingSettingsHandler(
|
||||
LoggingLevelSwitches loggingLevelSwitches,
|
||||
IConfigElementRepository configElementRepository)
|
||||
{
|
||||
_loggingLevelSwitches = loggingLevelSwitches;
|
||||
_configElementRepository = configElementRepository;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
UpdateLoggingSettings request,
|
||||
CancellationToken cancellationToken) => await ApplyUpdate(request.LoggingSettings, cancellationToken);
|
||||
|
||||
private async Task<Unit> ApplyUpdate(LoggingSettingsViewModel loggingSettings, CancellationToken cancellationToken)
|
||||
{
|
||||
await _configElementRepository.Upsert(ConfigElementKey.MinimumLogLevel, loggingSettings.DefaultMinimumLogLevel, cancellationToken);
|
||||
_loggingLevelSwitches.DefaultLevelSwitch.MinimumLevel = loggingSettings.DefaultMinimumLogLevel;
|
||||
|
||||
await _configElementRepository.Upsert(
|
||||
ConfigElementKey.MinimumLogLevelScanning,
|
||||
loggingSettings.ScanningMinimumLogLevel, cancellationToken);
|
||||
_loggingLevelSwitches.ScanningLevelSwitch.MinimumLevel = loggingSettings.ScanningMinimumLogLevel;
|
||||
|
||||
await _configElementRepository.Upsert(
|
||||
ConfigElementKey.MinimumLogLevelScheduling,
|
||||
loggingSettings.SchedulingMinimumLogLevel, cancellationToken);
|
||||
_loggingLevelSwitches.SchedulingLevelSwitch.MinimumLevel = loggingSettings.SchedulingMinimumLogLevel;
|
||||
|
||||
await _configElementRepository.Upsert(
|
||||
ConfigElementKey.MinimumLogLevelSearching,
|
||||
loggingSettings.SearchingMinimumLogLevel, cancellationToken);
|
||||
_loggingLevelSwitches.SearchingLevelSwitch.MinimumLevel = loggingSettings.SearchingMinimumLogLevel;
|
||||
|
||||
await _configElementRepository.Upsert(
|
||||
ConfigElementKey.MinimumLogLevelStreaming,
|
||||
loggingSettings.StreamingMinimumLogLevel, cancellationToken);
|
||||
_loggingLevelSwitches.StreamingLevelSwitch.MinimumLevel = loggingSettings.StreamingMinimumLogLevel;
|
||||
|
||||
await _configElementRepository.Upsert(
|
||||
ConfigElementKey.MinimumLogLevelHttp,
|
||||
loggingSettings.HttpMinimumLogLevel, cancellationToken);
|
||||
_loggingLevelSwitches.HttpLevelSwitch.MinimumLevel = loggingSettings.HttpMinimumLogLevel;
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration.Commands
|
||||
{
|
||||
public record UpdatePlayoutDaysToBuild(int DaysToBuild) : MediatR.IRequest<Either<BaseError, Unit>>;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Application.Playouts.Commands;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using LanguageExt;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration.Commands
|
||||
{
|
||||
public class
|
||||
UpdatePlayoutDaysToBuildHandler : MediatR.IRequestHandler<UpdatePlayoutDaysToBuild, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
|
||||
|
||||
public UpdatePlayoutDaysToBuildHandler(
|
||||
IConfigElementRepository configElementRepository,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel)
|
||||
{
|
||||
_configElementRepository = configElementRepository;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_workerChannel = workerChannel;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
UpdatePlayoutDaysToBuild request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
Validation<BaseError, Unit> validation = await Validate(request);
|
||||
return await validation.Apply<Unit, Unit>(_ => ApplyUpdate(dbContext, request.DaysToBuild));
|
||||
}
|
||||
|
||||
private async Task<Unit> ApplyUpdate(TvContext dbContext, int daysToBuild)
|
||||
{
|
||||
await _configElementRepository.Upsert(ConfigElementKey.PlayoutDaysToBuild, daysToBuild);
|
||||
|
||||
// build all playouts to proper number of days
|
||||
List<Playout> playouts = await dbContext.Playouts
|
||||
.Include(p => p.Channel)
|
||||
.ToListAsync();
|
||||
foreach (int playoutId in playouts.OrderBy(p => decimal.Parse(p.Channel.Number)).Map(p => p.Id))
|
||||
{
|
||||
await _workerChannel.WriteAsync(new BuildPlayout(playoutId));
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, Unit>> Validate(UpdatePlayoutDaysToBuild request) =>
|
||||
Optional(request.DaysToBuild)
|
||||
.Where(days => days > 0)
|
||||
.Map(_ => Unit.Default)
|
||||
.ToValidation<BaseError>("Days to build must be greater than zero")
|
||||
.AsTask();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public record UpdatePlayoutSettings(PlayoutSettingsViewModel PlayoutSettings) : IRequest<Either<BaseError, Unit>>;
|
||||
@@ -1,80 +0,0 @@
|
||||
using System.Globalization;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class UpdatePlayoutSettingsHandler : IRequestHandler<UpdatePlayoutSettings, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
|
||||
|
||||
public UpdatePlayoutSettingsHandler(
|
||||
IConfigElementRepository configElementRepository,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel)
|
||||
{
|
||||
_configElementRepository = configElementRepository;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_workerChannel = workerChannel;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
UpdatePlayoutSettings request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Unit> validation = await Validate(request);
|
||||
return await validation.Apply<Unit, Unit>(_ => ApplyUpdate(
|
||||
dbContext,
|
||||
request.PlayoutSettings,
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
private async Task<Unit> ApplyUpdate(
|
||||
TvContext dbContext,
|
||||
PlayoutSettingsViewModel playoutSettings,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await _configElementRepository.Upsert(
|
||||
ConfigElementKey.PlayoutDaysToBuild,
|
||||
playoutSettings.DaysToBuild,
|
||||
cancellationToken);
|
||||
|
||||
await _configElementRepository.Upsert(
|
||||
ConfigElementKey.PlayoutSkipMissingItems,
|
||||
playoutSettings.SkipMissingItems,
|
||||
cancellationToken);
|
||||
|
||||
await _configElementRepository.Upsert(
|
||||
ConfigElementKey.PlayoutScriptedScheduleTimeoutSeconds,
|
||||
playoutSettings.ScriptedScheduleTimeoutSeconds,
|
||||
cancellationToken);
|
||||
|
||||
// continue all playouts to proper number of days
|
||||
List<Playout> playouts = await dbContext.Playouts
|
||||
.Include(p => p.Channel)
|
||||
.ToListAsync(cancellationToken);
|
||||
foreach (int playoutId in playouts.OrderBy(p => decimal.Parse(p.Channel.Number, CultureInfo.InvariantCulture))
|
||||
.Map(p => p.Id))
|
||||
{
|
||||
await _workerChannel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Continue), cancellationToken);
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, Unit>> Validate(UpdatePlayoutSettings request) =>
|
||||
Optional(request.PlayoutSettings.DaysToBuild)
|
||||
.Where(days => days > 0)
|
||||
.Map(_ => Unit.Default)
|
||||
.ToValidation<BaseError>("Days to build must be greater than zero")
|
||||
.AsTask();
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public record UpdateUiSettings(UiSettingsViewModel UiSettings) : IRequest<Either<BaseError, Unit>>;
|
||||
@@ -1,28 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class UpdateUiSettingsHandler(IConfigElementRepository configElementRepository)
|
||||
: IRequestHandler<UpdateUiSettings, Either<BaseError, Unit>>
|
||||
{
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
UpdateUiSettings request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await ApplyUpdate(request.UiSettings, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<Unit> ApplyUpdate(UiSettingsViewModel uiSettings, CancellationToken cancellationToken)
|
||||
{
|
||||
await configElementRepository.Upsert(
|
||||
ConfigElementKey.PagesIsDarkMode,
|
||||
uiSettings.IsDarkMode,
|
||||
cancellationToken);
|
||||
|
||||
await configElementRepository.Upsert(ConfigElementKey.PagesLanguage, uiSettings.Language, cancellationToken);
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public record UpdateXmltvSettings(XmltvSettingsViewModel XmltvSettings) : IRequest<Either<BaseError, Unit>>;
|
||||
@@ -1,50 +0,0 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class UpdateXmltvSettingsHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel)
|
||||
: IRequestHandler<UpdateXmltvSettings, Either<BaseError, Unit>>
|
||||
{
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
UpdateXmltvSettings request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
int playoutDaysToBuild =
|
||||
await configElementRepository
|
||||
.GetValue<int>(ConfigElementKey.PlayoutDaysToBuild, cancellationToken)
|
||||
.IfNoneAsync(2);
|
||||
|
||||
if (playoutDaysToBuild < request.XmltvSettings.DaysToBuild)
|
||||
{
|
||||
return BaseError.New(
|
||||
$"XMLTV days to build ({request.XmltvSettings.DaysToBuild}) cannot be greater than Playout days to build ({playoutDaysToBuild})");
|
||||
}
|
||||
|
||||
return await ApplyUpdate(request.XmltvSettings, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<Unit> ApplyUpdate(XmltvSettingsViewModel xmltvSettings, CancellationToken cancellationToken)
|
||||
{
|
||||
await configElementRepository.Upsert(ConfigElementKey.XmltvTimeZone, xmltvSettings.TimeZone, cancellationToken);
|
||||
await configElementRepository.Upsert(ConfigElementKey.XmltvDaysToBuild, xmltvSettings.DaysToBuild, cancellationToken);
|
||||
await configElementRepository.Upsert(ConfigElementKey.XmltvBlockBehavior, xmltvSettings.BlockBehavior, cancellationToken);
|
||||
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
foreach (string channelNumber in await dbContext.Channels.Map(c => c.Number).ToListAsync(cancellationToken))
|
||||
{
|
||||
await workerChannel.WriteAsync(new RefreshChannelData(channelNumber), cancellationToken);
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public record ConfigElementViewModel(string Key, string Value);
|
||||
namespace ErsatzTV.Application.Configuration
|
||||
{
|
||||
public record ConfigElementViewModel(string Key, string Value);
|
||||
}
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
using Serilog.Events;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class LoggingSettingsViewModel
|
||||
{
|
||||
public LogEventLevel DefaultMinimumLogLevel { get; set; }
|
||||
public LogEventLevel ScanningMinimumLogLevel { get; set; }
|
||||
public LogEventLevel SchedulingMinimumLogLevel { get; set; }
|
||||
public LogEventLevel SearchingMinimumLogLevel { get; set; }
|
||||
public LogEventLevel StreamingMinimumLogLevel { get; set; }
|
||||
public LogEventLevel HttpMinimumLogLevel { get; set; }
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
internal static class Mapper
|
||||
namespace ErsatzTV.Application.Configuration
|
||||
{
|
||||
internal static ConfigElementViewModel ProjectToViewModel(ConfigElement element) =>
|
||||
new(element.Key, element.Value);
|
||||
internal static class Mapper
|
||||
{
|
||||
internal static ConfigElementViewModel ProjectToViewModel(ConfigElement element) =>
|
||||
new(element.Key, element.Value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class PlayoutSettingsViewModel
|
||||
{
|
||||
public int DaysToBuild { get; set; }
|
||||
public bool SkipMissingItems { get; set; }
|
||||
public int ScriptedScheduleTimeoutSeconds { get; set; }
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user