Compare commits
74 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e58bb9af21 | |||
| aa6d8eae4c | |||
| f1e97b94a7 | |||
| 5034941a79 | |||
|
|
0d301df5e8 | ||
|
|
d26ae336cb | ||
|
|
875069b927 | ||
|
|
fd86cb55f9 | ||
|
|
0c30c47ba9 | ||
|
|
08cbf59527 | ||
|
|
a91de68a5c | ||
|
|
3e3bfbd5f5 | ||
|
|
31b07305ef | ||
|
|
49adcf7c37 | ||
|
|
c0b8ff1a06 | ||
|
|
c6d538e012 | ||
|
|
3dbde17f68 | ||
|
|
794d209941 | ||
|
|
7b9197d48d | ||
|
|
2ad6547349 | ||
|
|
4fa11b6943 | ||
|
|
440d9f708e | ||
|
|
4d469ec8fd | ||
|
|
a77a2d56ae | ||
|
|
240a329526 | ||
|
|
45e7d61676 | ||
|
|
93811876e0 | ||
|
|
607d9b0662 | ||
|
|
f47134d2d0 | ||
|
|
ae13db981d | ||
|
|
b7cc8499a3 | ||
|
|
36147b9e9c | ||
|
|
bf8c821012 | ||
|
|
a0f5d8d5d5 | ||
|
|
f1072b70c7 | ||
|
|
e10b28bc0b | ||
|
|
cd2bb0f2e0 | ||
|
|
e80f687612 | ||
|
|
317ca1967c | ||
|
|
b86f45844c | ||
|
|
353f029452 | ||
|
|
1754e7d5fb | ||
|
|
f96be8f99f | ||
|
|
08ceb53b2b | ||
|
|
3d81f760ee | ||
|
|
4ce87feac1 | ||
|
|
f217ba185b | ||
|
|
e925bd6913 | ||
|
|
3f4c9e063b | ||
|
|
7f361d1ea9 | ||
|
|
35d24ffea6 | ||
|
|
a2d023ee69 | ||
|
|
36f44f14bb | ||
|
|
ccb917d0df | ||
|
|
343a4619a6 | ||
|
|
e167c9318c | ||
|
|
de230f92db | ||
|
|
974020a98f | ||
|
|
da957c9377 | ||
|
|
b72d150775 | ||
|
|
b0b7bd17b3 | ||
|
|
1f2f04f3bd | ||
|
|
5bc90bb245 | ||
|
|
f73a32ec13 | ||
|
|
748ed1cf71 | ||
|
|
f2deaa6f7a | ||
|
|
3698fa5b7d | ||
|
|
dc92cb4ac3 | ||
|
|
69410b1a9b | ||
|
|
4aee03e066 | ||
|
|
e16d6c67f1 | ||
|
|
5d8877975d | ||
|
|
367305d960 | ||
|
|
aa08ad5765 |
167
.claude/skills/ersatztv/SKILL.md
Normal file
167
.claude/skills/ersatztv/SKILL.md
Normal file
@@ -0,0 +1,167 @@
|
||||
---
|
||||
name: ersatztv
|
||||
description: ErsatzTV custom IPTV channel management — REST API, SQLite DB, Jellyfin integration, FFmpeg profiles. Use when managing custom TV channels.
|
||||
---
|
||||
|
||||
# ErsatzTV Channel Management
|
||||
|
||||
Container: `ersatztv` | Port: `8409` | IP: `172.16.238.11` (may change on restart)
|
||||
Web UI: internal only (`http://localhost:8409` via SSH)
|
||||
SQLite DB: `~/downloadswarm/ersatztv/ersatztv.sqlite3` on jazz (owned by root — use `sudo sqlite3`)
|
||||
Image: `ghcr.io/ersatztv/ersatztv:latest` (v26.3.0, repo archived Feb 2026)
|
||||
|
||||
## Architecture
|
||||
|
||||
ErsatzTV uses **MediatR + Blazor** (not REST for mutations). The REST API is limited:
|
||||
- **GET endpoints**: channels, collections, schedules, playouts, shows, movies, artists, ffmpeg profiles, health, search, watermarks
|
||||
- **POST endpoints**: library scan, playout reset, show scan
|
||||
- **No REST CRUD for channels/collections/schedules** — must use SQLite DB directly
|
||||
|
||||
## REST API
|
||||
|
||||
```bash
|
||||
# Via docker exec
|
||||
docker exec ersatztv curl -s http://localhost:8409/api/ENDPOINT
|
||||
```
|
||||
|
||||
### Read Endpoints (GET)
|
||||
```
|
||||
/api/channels # List channels
|
||||
/api/collections # List collections
|
||||
/api/schedules # List schedules
|
||||
/api/playouts # List playouts
|
||||
/api/shows # List shows
|
||||
/api/movies # List movies
|
||||
/api/artists # List artists
|
||||
/api/search # Search items
|
||||
/api/ffmpeg/profiles # FFmpeg profiles
|
||||
/api/watermarks # Watermarks
|
||||
/iptv/channels.m3u # M3U playlist (for Jellyfin)
|
||||
/iptv/xmltv.xml # XMLTV guide data
|
||||
```
|
||||
|
||||
### Mutation Endpoints (POST)
|
||||
```bash
|
||||
# Library scan
|
||||
POST /api/libraries/{id}/scan
|
||||
|
||||
# Scan single show
|
||||
POST /api/libraries/{id}/scan-show \
|
||||
-H "Content-Type: application/json" -d '{"ShowTitle":"Name","DeepScan":false}'
|
||||
|
||||
# Reset channel playout (rebuilds schedule)
|
||||
POST /api/channels/{channelNumber}/playout/reset
|
||||
```
|
||||
|
||||
## SQLite DB Operations
|
||||
|
||||
```bash
|
||||
# Read queries (safe while running, WAL mode)
|
||||
sudo sqlite3 ~/downloadswarm/ersatztv/ersatztv.sqlite3 "QUERY"
|
||||
|
||||
# Write queries — stop container first
|
||||
docker stop ersatztv
|
||||
sudo sqlite3 ~/downloadswarm/ersatztv/ersatztv.sqlite3 "QUERY"
|
||||
docker start ersatztv
|
||||
```
|
||||
|
||||
### Key Queries
|
||||
```sql
|
||||
-- List channels
|
||||
SELECT Id, Number, Name FROM Channel ORDER BY CAST(Number AS INTEGER);
|
||||
|
||||
-- List collections with item counts
|
||||
SELECT c.Id, c.Name, COUNT(ci.Id) as items FROM Collection c LEFT JOIN CollectionItem ci ON ci.CollectionId = c.Id GROUP BY c.Id;
|
||||
|
||||
-- List schedules
|
||||
SELECT Id, Name FROM ProgramSchedule;
|
||||
|
||||
-- Playout (channel-schedule links)
|
||||
SELECT p.Id, c.Number, c.Name, ps.Name as Schedule FROM Playout p JOIN Channel c ON p.ChannelId = c.Id LEFT JOIN ProgramSchedule ps ON p.ProgramScheduleId = ps.Id;
|
||||
|
||||
-- Media counts
|
||||
SELECT 'Shows' as type, COUNT(*) FROM Show UNION ALL SELECT 'Movies', COUNT(*) FROM Movie UNION ALL SELECT 'Episodes', COUNT(*) FROM Episode UNION ALL SELECT 'MusicVideos', COUNT(*) FROM MusicVideo;
|
||||
|
||||
-- Jellyfin source
|
||||
SELECT jms.Id, jc.Address, jms.ServerName FROM JellyfinMediaSource jms JOIN JellyfinConnection jc ON jc.JellyfinMediaSourceId = jms.Id;
|
||||
|
||||
-- Library sync status
|
||||
SELECT l.Id, l.Name, l.MediaKind, jl.ShouldSyncItems FROM Library l JOIN JellyfinLibrary jl ON jl.Id = l.Id;
|
||||
```
|
||||
|
||||
### Channel Setup Workflow (DB)
|
||||
|
||||
**Show-specific channel** (single TV show, shuffled):
|
||||
```sql
|
||||
-- 1. Schedule
|
||||
INSERT INTO ProgramSchedule (Id, FixedStartTimeBehavior, KeepMultiPartEpisodesTogether, Name, RandomStartPoint, ShuffleScheduleItems, TreatCollectionsAsShows)
|
||||
VALUES (<id>, 0, 0, '<name>', 1, 0, 1);
|
||||
-- 2. Schedule item (CollectionType=1 for Show, PlaybackOrder=3 for Shuffle)
|
||||
INSERT INTO ProgramScheduleItem (Id, CollectionType, FillWithGroupMode, GuideMode, "Index", MarathonGroupBy, MarathonShuffleGroups, MarathonShuffleItems, MediaItemId, PlaybackOrder, ProgramScheduleId)
|
||||
VALUES (<id>, 1, 0, 0, 0, 0, 0, 0, <show_id>, 3, <schedule_id>);
|
||||
INSERT INTO ProgramScheduleOneItem (Id) VALUES (<item_id>);
|
||||
-- 3. Channel
|
||||
INSERT INTO Channel (Id, Categories, FFmpegProfileId, FallbackFillerId, "Group", IdleBehavior, IsEnabled, MirrorSourceChannelId, MusicVideoCreditsMode, MusicVideoCreditsTemplate, Name, Number, PlayoutMode, PlayoutOffset, PlayoutSource, PreferredAudioLanguageCode, PreferredAudioTitle, PreferredSubtitleLanguageCode, ShowInEpg, SongVideoMode, SortNumber, StreamSelector, StreamSelectorMode, StreamingMode, SubtitleMode, TranscodeMode, UniqueId, WatermarkId)
|
||||
VALUES (<id>, '', 1, NULL, '<category>', 0, 1, NULL, 0, NULL, '<name>', '<number>', 0, NULL, 0, NULL, NULL, 'eng', 1, 0, <number>.0, NULL, 0, 4, 2, 0, lower(hex(randomblob(4)))||'-'||lower(hex(randomblob(2)))||'-4'||substr(lower(hex(randomblob(2))),2)||'-'||lower(hex(randomblob(2)))||'-'||lower(hex(randomblob(6))), 1);
|
||||
-- 4. Playout
|
||||
INSERT INTO Playout (Id, ChannelId, ProgramScheduleId, ScheduleKind, Seed)
|
||||
VALUES (<id>, <channel_id>, <schedule_id>, 0, abs(random()) % 1000000);
|
||||
```
|
||||
|
||||
**Collection-based channel** (multiple shows, shuffled):
|
||||
```sql
|
||||
-- 1. Collection + items (MediaItemId = Show.Id)
|
||||
INSERT INTO Collection (Id, Name, UseCustomPlaybackOrder) VALUES (<id>, '<name>', 0);
|
||||
INSERT INTO CollectionItem (CollectionId, MediaItemId) VALUES (<coll_id>, <show_id>);
|
||||
-- 2. Schedule (same as above but CollectionType=0, CollectionId set instead of MediaItemId)
|
||||
INSERT INTO ProgramScheduleItem (Id, CollectionId, CollectionType, ..., PlaybackOrder, ProgramScheduleId)
|
||||
VALUES (<id>, <coll_id>, 0, ..., 3, <schedule_id>);
|
||||
-- 3-4. Channel + Playout same as show-specific
|
||||
```
|
||||
|
||||
After creating: `POST /api/channels/{number}/playout/reset`
|
||||
|
||||
## Volume Mounts (matches Jellyfin)
|
||||
|
||||
| Host Path | Container Path |
|
||||
|-----------|---------------|
|
||||
| `~/downloadswarm/ersatztv` | `/config` |
|
||||
| `/mnt/teramind/episodes` | `/data/tvshows` (ro) |
|
||||
| `/mnt/episodes` | `/data/episodes` (ro) |
|
||||
| `/mnt/media/movies` | `/data/movies` (ro) |
|
||||
| `/mnt/media/standup` | `/data/standup` (ro) |
|
||||
| `/mnt/media/music_videos` | `/data/music` (ro) |
|
||||
|
||||
## FFmpeg & Hardware
|
||||
|
||||
- QSV (Intel Quick Sync) hardware acceleration
|
||||
- Resolution: 1920x1080, H264, AAC stereo
|
||||
- Device: `/dev/dri` passed through
|
||||
- HardwareAccelerationKind: 0=None, 1=Qsv, 2=Nvenc, 3=Vaapi, 4=VideoToolbox, 5=Amf
|
||||
|
||||
## Jellyfin Integration
|
||||
|
||||
- Secrets: `/config/jellyfin-secrets.json` (`{"Address":"http://jellyfin:8096","ApiKey":"978033be716d46678a5d3c54ae0e0ff9"}`)
|
||||
- Libraries: Movies(10), TV Shows(11), Music Videos(8), Standup(9)
|
||||
- `JellyfinLibrary.ShouldSyncItems` must be `1` for scans to work
|
||||
|
||||
## Gotchas
|
||||
|
||||
- DB owned by root — always use `sudo sqlite3`
|
||||
- WAL mode: reads OK while running, stop container for writes
|
||||
- No REST API for channel/collection/schedule CRUD — DB scripting only
|
||||
- Secrets file uses PascalCase JSON (`Address`, `ApiKey`)
|
||||
- Scanner is separate binary (`ErsatzTV.Scanner`) — check with `docker top ersatztv | grep Scanner`
|
||||
- EF TPT inheritance: `ProgramScheduleItem` has subtype tables (`ProgramScheduleOneItem`, etc.) — MUST insert into subtype table
|
||||
- External URL logos work for M3U but NOT for watermark burn-in (code checks `File.Exists()`)
|
||||
- `/api/health` returns Blazor HTML, not JSON — use `/api/channels` to verify API
|
||||
- PlaybackOrder enum: 3=Shuffle, 6=SeasonEpisode (use 3 for all channels)
|
||||
- CollectionType enum: 0=Collection, 1=Show (direct show reference via MediaItemId)
|
||||
- SubtitleMode: 0=None, 2=Burn-in. Set to 2 with PreferredSubtitleLanguageCode='eng' for non-music channels
|
||||
- MediaItem.State: 0=Normal, 1=FileNotFound — clean up state=1 items by deleting cascading deps
|
||||
- ProgramSchedule required NOT NULL columns: FixedStartTimeBehavior, KeepMultiPartEpisodesTogether, RandomStartPoint, ShuffleScheduleItems, TreatCollectionsAsShows
|
||||
- Channel required NOT NULL columns: SongVideoMode (set 0), plus all standard columns (see Channel table schema)
|
||||
- After schedule changes, rebuild playout: `POST /api/channels/{number}/playout/reset`
|
||||
- Playout `ScheduleKind` must be `1` (not `0`/None) — `0` causes "Cannot build playout type None" error
|
||||
- M3U `tvg-logo` URLs hardcode `http://localhost:8409` — Jellyfin can't fetch these from inside its container. Fix by downloading logos from ETV and base64-uploading to Jellyfin (see `docs/Docker/ErsatzTV.md` for script). Tracked in issue #171
|
||||
- Repo archived Feb 2026, v26.3.0 is final stable version. Maintainer welcomes forks
|
||||
105
.claude/skills/jellyfin/SKILL.md
Normal file
105
.claude/skills/jellyfin/SKILL.md
Normal file
@@ -0,0 +1,105 @@
|
||||
---
|
||||
name: jellyfin
|
||||
description: Jellyfin media server management — API for libraries, items, streaming, users. Use when managing media library or checking Jellyfin status.
|
||||
---
|
||||
|
||||
# Jellyfin Management
|
||||
|
||||
Container: `jellyfin` | Port: `8096` | IP: `172.16.238.20` (may change on restart)
|
||||
API Token: `978033be716d46678a5d3c54ae0e0ff9`
|
||||
Web UI: `https://jellyfin.tblindustries.be` (NO Authelia — native login, password: `coup1802`)
|
||||
Config: `/home/timothy/downloadswarm/jellyfin/` on jazz
|
||||
|
||||
## Access Pattern
|
||||
|
||||
```bash
|
||||
docker exec jellyfin curl -s 'http://localhost:8096/ENDPOINT' \
|
||||
-H 'X-Emby-Token: 978033be716d46678a5d3c54ae0e0ff9'
|
||||
```
|
||||
|
||||
## Volume Mounts
|
||||
|
||||
| Host Path | Container Path | Content |
|
||||
|-----------|---------------|---------|
|
||||
| `/mnt/teramind/episodes` | `/data/tvshows` | TV shows |
|
||||
| `/mnt/episodes` | `/data/episodes` | More episodes |
|
||||
| `/mnt/media/movies` | `/data/movies` | Movies |
|
||||
| `/mnt/media/standup` | `/data/standup` | Standup |
|
||||
| `/mnt/media/music_videos` | `/data/music` | Music videos |
|
||||
| `/mnt/media/audio/music` | `/data/audio` | Music audio (ro) |
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### System
|
||||
```
|
||||
GET /System/Info # Server info, version
|
||||
GET /System/Info/Public # Public info (no auth needed)
|
||||
POST /System/Restart # Restart server
|
||||
```
|
||||
|
||||
### Items (Search & Browse)
|
||||
```bash
|
||||
# Search items
|
||||
GET /Items?includeItemTypes=Movie,Episode,Series&recursive=true&searchTerm=QUERY&fields=Path&limit=20
|
||||
|
||||
# Get item details
|
||||
GET /Items?ids=ITEM_ID&fields=Path,MediaStreams,Overview
|
||||
|
||||
# Get all movies
|
||||
GET /Items?includeItemTypes=Movie&recursive=true&fields=Path&limit=1000
|
||||
|
||||
# Get series
|
||||
GET /Items?includeItemTypes=Series&recursive=true&fields=Path
|
||||
|
||||
# Get episodes for a series
|
||||
GET /Shows/{seriesId}/Episodes?fields=Path,MediaStreams
|
||||
|
||||
# Filter by library (parentId)
|
||||
GET /Items?parentId=LIBRARY_ID&recursive=true&fields=Path
|
||||
```
|
||||
|
||||
### Libraries
|
||||
```
|
||||
GET /Library/VirtualFolders # List all libraries
|
||||
POST /Library/Refresh # Trigger full library scan
|
||||
POST /Items/{id}/Refresh # Refresh single item metadata
|
||||
```
|
||||
|
||||
### Streaming
|
||||
```bash
|
||||
# Test stream URL
|
||||
GET /Videos/{itemId}/stream?static=true
|
||||
|
||||
# Get playback info
|
||||
GET /Items/{itemId}/PlaybackInfo
|
||||
```
|
||||
|
||||
### Users
|
||||
```
|
||||
GET /Users # List users
|
||||
GET /Users/{userId} # User details
|
||||
```
|
||||
|
||||
## Library IDs
|
||||
|
||||
Check with: `curl -s -H "X-Emby-Token: TOKEN" http://localhost:8096/Library/VirtualFolders`
|
||||
|
||||
## Live TV
|
||||
|
||||
- **ErsatzTV** (channels <1000): M3U `http://ersatztv:8409/iptv/channels.m3u`, XMLTV `http://ersatztv:8409/iptv/xmltv.xml`
|
||||
- **Dispatcharr** (channels 1000+): IPTV stream manager on port 9191, separate tuner
|
||||
- Configured in Jellyfin Admin > Live TV
|
||||
- Guide refresh task ID: `bea9b218c97bbf98c5dc1303bdb9a0ca` — trigger via `POST /ScheduledTasks/Running/{id}`
|
||||
- **Logo fix after guide refresh**: ErsatzTV logos break (aspect ratio=0) because M3U uses `localhost:8409`. Fix script in `docs/Docker/ErsatzTV.md` downloads from ETV and base64-uploads to `POST /Items/{id}/Images/Primary` (body = base64, Content-Type = image/png)
|
||||
- **Image upload format**: Jellyfin expects base64-encoded body (NOT raw binary) for `POST /Items/{id}/Images/Primary`
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Passwords**: `coup1802` (NOT `ded89Lm4`) — Jellyfin has native auth, no Authelia
|
||||
- Auth header is `X-Emby-Token` (Jellyfin is an Emby fork)
|
||||
- Music videos are typed as "Movie" in Jellyfin
|
||||
- Music library at `/data/music` maps to `/mnt/media/music_videos` on host (not actual music)
|
||||
- Items return 404 on stream if source volume is unmounted
|
||||
- Jellyfin preserves item IDs across restarts unless files are renamed
|
||||
- Full library scan can take a long time — prefer targeted `/Items/{id}/Refresh`
|
||||
- `ffprobe` available in container for checking media streams: `docker exec jellyfin ffprobe -v quiet -print_format json -show_streams FILE`
|
||||
6
.github/ISSUE_TEMPLATE/config.yml
vendored
6
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -3,9 +3,9 @@ contact_links:
|
||||
- name: Feature Requests
|
||||
url: https://features.ersatztv.org
|
||||
about: Features
|
||||
- name: Discord
|
||||
url: https://discord.ersatztv.org
|
||||
about: Chat
|
||||
- name: Contact
|
||||
url: https://ersatztv.org/contact
|
||||
about: Chat Options
|
||||
- name: Community
|
||||
url: https://discuss.ersatztv.org
|
||||
about: Forum
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/issue.yml
vendored
2
.github/ISSUE_TEMPLATE/issue.yml
vendored
@@ -12,7 +12,7 @@ body:
|
||||
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://discord.ersatztv.org) first to troubleshoot with volunteers before creating a report.
|
||||
- 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
|
||||
|
||||
34
.github/workflows/artifacts.yml
vendored
34
.github/workflows/artifacts.yml
vendored
@@ -25,6 +25,15 @@ on:
|
||||
required: true
|
||||
gh_token:
|
||||
required: true
|
||||
azure_client_id:
|
||||
required: true
|
||||
azure_tenant_id:
|
||||
required: true
|
||||
azure_subscription_id:
|
||||
required: true
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
jobs:
|
||||
build_and_upload_mac:
|
||||
name: Mac Build & Upload
|
||||
@@ -246,7 +255,7 @@ jobs:
|
||||
|
||||
package_and_upload_windows:
|
||||
name: Package & Upload Windows
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: windows-latest
|
||||
needs: build_dotnet_windows
|
||||
steps:
|
||||
- name: Download dotnet artifacts
|
||||
@@ -255,6 +264,27 @@ jobs:
|
||||
name: dotnet-windows-build
|
||||
path: dotnet-build
|
||||
|
||||
- name: Azure login
|
||||
uses: azure/login@v2
|
||||
with:
|
||||
client-id: ${{ secrets.azure_client_id }}
|
||||
tenant-id: ${{ secrets.azure_tenant_id }}
|
||||
subscription-id: ${{ secrets.azure_subscription_id }}
|
||||
enable-AzPSSession: true
|
||||
|
||||
- name: Sign dotnet artifacts
|
||||
uses: azure/trusted-signing-action@v0
|
||||
with:
|
||||
endpoint: https://eus.codesigning.azure.net/
|
||||
trusted-signing-account-name: ArtifactSigning
|
||||
certificate-profile-name: ErsatzTV
|
||||
files-folder: ${{ github.workspace }}/dotnet-build
|
||||
files-folder-recurse: true
|
||||
files-folder-filter: ErsatzTV.exe,ErsatzTV.Scanner.exe
|
||||
file-digest: SHA256
|
||||
timestamp-rfc3161: http://timestamp.acs.microsoft.com
|
||||
timestamp-digest: SHA256
|
||||
|
||||
- name: Download rust launcher
|
||||
uses: suisei-cn/actions-download-file@v1.3.0
|
||||
with:
|
||||
@@ -285,7 +315,7 @@ jobs:
|
||||
7z e "ffmpeg/${{ steps.downloadffmpeg.outputs.filename }}" -o"$release_name" '*.exe' -r
|
||||
rm -f "$release_name/ffplay.exe"
|
||||
|
||||
(cd "${release_name}" && zip -r "../${release_name}.zip" .)
|
||||
(cd "${release_name}" && 7z a "../${release_name}.zip" .)
|
||||
|
||||
- name: Delete old release assets
|
||||
uses: mknejp/delete-release-assets@v1
|
||||
|
||||
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -46,6 +46,10 @@ jobs:
|
||||
ac_username: ${{ secrets.AC_USERNAME }}
|
||||
ac_password: ${{ secrets.AC_PASSWORD }}
|
||||
gh_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
azure_client_id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
azure_tenant_id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
azure_subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
build_images:
|
||||
uses: ersatztv/ersatztv/.github/workflows/docker.yml@main
|
||||
needs: calculate_version
|
||||
|
||||
5
.github/workflows/pr.yml
vendored
5
.github/workflows/pr.yml
vendored
@@ -52,11 +52,6 @@ jobs:
|
||||
|
||||
- name: Test
|
||||
run: dotnet test --blame-hang-timeout "2m" --no-restore --verbosity normal
|
||||
|
||||
- name: Build Windows
|
||||
run: |
|
||||
cd ErsatzTV-Windows
|
||||
cargo build --release --all-features
|
||||
build_and_test_linux:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
|
||||
3
.github/workflows/release.yml
vendored
3
.github/workflows/release.yml
vendored
@@ -41,6 +41,9 @@ jobs:
|
||||
ac_username: ${{ secrets.AC_USERNAME }}
|
||||
ac_password: ${{ secrets.AC_PASSWORD }}
|
||||
gh_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
azure_client_id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
azure_tenant_id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
azure_subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
build_images:
|
||||
uses: ersatztv/ersatztv/.github/workflows/docker.yml@main
|
||||
needs: calculate_version
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -3,6 +3,9 @@
|
||||
project.lock.json
|
||||
.DS_Store
|
||||
*.pyc
|
||||
|
||||
# Claude Code
|
||||
.mcp/
|
||||
nupkg/
|
||||
|
||||
# Visual Studio Code
|
||||
|
||||
56
.mcp.json
Normal file
56
.mcp.json
Normal file
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"docker-mcp": {
|
||||
"command": "uvx",
|
||||
"args": [
|
||||
"mcp-server-docker"
|
||||
],
|
||||
"env": {
|
||||
"DOCKER_HOST": "ssh://timothy@192.168.1.99"
|
||||
}
|
||||
},
|
||||
"ssh-mcp": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"ssh-mcp",
|
||||
"--",
|
||||
"--host=192.168.1.99",
|
||||
"--user=timothy"
|
||||
],
|
||||
"env": {}
|
||||
},
|
||||
"gitea": {
|
||||
"command": "gitea-mcp-server",
|
||||
"args": [
|
||||
"-t", "stdio",
|
||||
"-host", "http://192.168.1.95:3000",
|
||||
"-token", "8341af0733ab9ce084ea7adf38b76aa9ebc3bd67"
|
||||
],
|
||||
"env": {}
|
||||
},
|
||||
"csharp-lsp": {
|
||||
"command": "/usr/local/share/dotnet/dotnet",
|
||||
"args": [
|
||||
"run",
|
||||
"--project", "/Users/timothy/ersatztv/.mcp/csharp-lsp-mcp/csharp-lsp-mcp/src/CSharpLspMcp",
|
||||
"-c", "Release"
|
||||
],
|
||||
"env": {
|
||||
"PATH": "/usr/local/share/dotnet:/Users/timothy/.dotnet/tools:/usr/bin:/bin:/usr/sbin:/sbin"
|
||||
}
|
||||
},
|
||||
"nuget": {
|
||||
"command": "/usr/local/share/dotnet/dotnet",
|
||||
"args": [
|
||||
"dnx",
|
||||
"NuGet.Mcp.Server",
|
||||
"--source", "https://api.nuget.org/v3/index.json",
|
||||
"--yes"
|
||||
],
|
||||
"env": {
|
||||
"DOTNET_ROOT": "/usr/local/share/dotnet"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
137
CHANGELOG.md
137
CHANGELOG.md
@@ -4,6 +4,124 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [Unreleased]
|
||||
### Changed
|
||||
- Remove BugSnag error reporting integration
|
||||
- Remove developer's personal Trakt API key
|
||||
- Users who want to continue to use Trakt must create an API app and set the `Client ID` as the environment variable `TRAKT__CLIENTID`
|
||||
|
||||
### Fixed
|
||||
- Support adding trakt lists using `app.trakt.tv` domain (instead of just `trakt.tv`)
|
||||
|
||||
## [26.3.0] - 2026-02-24
|
||||
### Added
|
||||
- Add log warnings when actual transcoding speed is potentially insufficient to support smooth playback
|
||||
- Log messages will include media item id, channel number and transcoding speed
|
||||
- Add UI language setting to **Settings** > **UI**
|
||||
- A small number of translations have been added for `Português (Brasil)` and `Polski`
|
||||
- Translation contributions are always welcome!
|
||||
- Add `Troubleshoot` button to playout details table to show info that may be helpful in determining the source of a playout item
|
||||
- Classic schedule info includes schedule, schedule item, scheduler, filler, playback order, random seed, collection index
|
||||
- Block schedule info includes block, block item, playback order, random seed, collection index
|
||||
- E.g. items with the same random seed are part of the same shuffle
|
||||
- Add channel setting `Slug Seconds`
|
||||
- This controls how many (optional) seconds of black video and silent audio to insert between *every* playout item
|
||||
- This will drift playback from the wall clock as slugs are not scheduled in the playout, but are inserted dynamically during playback
|
||||
- If this feature turns out to be popular, methods to correct the drift may be investigated
|
||||
- Add `ETV_INSTANCE_ID` environment variable to disambiguate EPG data from multiple ErsatzTV instances
|
||||
- When set, the value will be used in channel identifiers before the final `.ersatztv.org`
|
||||
- Show warning message when selecting audio format `aac (latm)` for general streaming use when it is only intended for DVB-C
|
||||
|
||||
### Changed
|
||||
- Move dark/light mode toggle to **Settings** > **UI**
|
||||
- Use latest (non-deprecated) authorization method with Jellyfin API
|
||||
- Replace direct Discord links with new contact page https://ersatztv.org/contact which also includes other options like Matrix
|
||||
- Lower GOP size and keyframe interval from four seconds to two seconds in accordance with HLS2 draft spec recommendations
|
||||
|
||||
### Fixed
|
||||
- Improve stability of playback orders `Shuffle` and `Shuffle in Order` over time
|
||||
- Fix Trakt list sync
|
||||
- Fix some cases of QSV audio/video desync when *not* seeking by using software decode
|
||||
- This only applies to content that *might* be problematic (using a heuristic)
|
||||
- NVIDIA: force software decode of 10-bit h264 content since hardware decode is unsupported by ffmpeg until version 8
|
||||
- Graphics engine: fix stream seek value used throughout graphics engine
|
||||
- This should fix loading EPG data when used with chapters/mid-roll
|
||||
- This should also fix graphics element visibility when using start_seconds on content with chapters/mid-roll
|
||||
- This bug was caused by stream seek including the playout item in-point (the chapter start time)
|
||||
- Stream seek should only be non-zero when first joining a channel (i.e. in the middle of a playout item or chapter)
|
||||
|
||||
## [26.2.0] - 2026-02-02
|
||||
### Added
|
||||
- Channel stream selector: add zero-based culture-specific `day_of_week` to `content_condition`, for example:
|
||||
- en-US can match sunday using `day_of_week = 0`
|
||||
- fr-FR can match sunday using `day_of_week = 6`
|
||||
- As a complete example, to match Saturday from 9pm (inclusive) to 11pm (exclusive), based on content start time
|
||||
- `content_condition: day_of_week = 6 and (time_of_day_seconds >= 75600 and time_of_day_seconds < 82800)`
|
||||
- Add `Pad Mode` to ffmpeg profile. Options are:
|
||||
- `Hardware If Possible` - default/existing behavior when hardware acceleration is properly configured
|
||||
- `Software` - force software padding
|
||||
- This can be used to work around buggy GPU driver behavior where padding is green instead of black
|
||||
- This is most often seen with VAAPI acceleration (radeonsi or i965 drivers)
|
||||
- Add API endpoint to clean artwork cache folder (on demand)
|
||||
- POST `/api/maintenance/clean_artwork`
|
||||
- Add health check to warn about unsupported empty (classic) schedules
|
||||
- Add health check to warn about incompatible ffmpeg due to missing filters
|
||||
- This is directly applicable to homebrew `ffmpeg` on MacOS, which is no longer compatible with ErsatzTV
|
||||
- `ffmpeg@7` or `ffmpeg-full` should be used instead
|
||||
- Add `Marathon Group By` option `Director`
|
||||
- This groups the *first* director on Movies, Episodes, Music Videos and Other Videos
|
||||
- This is supported in classic schedules and sequential schedules
|
||||
- Add FFmpeg Profile options:
|
||||
- `Normalize Audio` (default: true) - normalizes audio streams, or stream copies when disabled
|
||||
- `Normalize Video` (default: true) - normalizes video streams, or stream copies when disabled
|
||||
- `Normalize Colors` (default: true) - normalizes color parameters when enabled
|
||||
- Disabling any of these options may have a significant performance benefit *at the expense of stream stability*
|
||||
- Add chapter `title` to filler expression
|
||||
- This can be used to include or exclude chapters with specific (case-insensitive) titles
|
||||
- E.g. `title == 'here'`, `title != 'not here'`, `title like '%here%'`
|
||||
- Local movie libraries: load fanart from `backdrop` files (created by Jellyfin)
|
||||
|
||||
### Changed
|
||||
- Disable automatic artwork database cleanup
|
||||
- This will be re-enabled at some point in the future (after more testing)
|
||||
- For now, the API should be used to clean as needed
|
||||
- Classic Schedules: make multiple `count` an expression
|
||||
- The following parameters can be used:
|
||||
- `count`: the total number of items in the collection
|
||||
- `random`: a random number between zero and (count - 1)
|
||||
- For example:
|
||||
- `count / 2` will play half of the items in the collection
|
||||
- `random % 4 + 1` will play between 1 and 4 items
|
||||
- `2` (similar to before this change) will play exactly two items
|
||||
|
||||
### Fixed
|
||||
- Use code signing on all Windows executables (`ErsatzTV-Windows.exe`, `ErsatzTV.exe`, `ErsatzTV.Scanner.exe`)
|
||||
- Graphics engine:
|
||||
- Respect `z_index` (draw order) on all graphics element types
|
||||
- Fix bug with `z_index` sorting
|
||||
- Restore default UI font that was erroneously removed in v26.1.1
|
||||
- Classic schedules: fix building playouts when `Fill With Group Mode` schedule items also have graphics elements
|
||||
- Use configured searching log level on startup, instead of the default log level of `Information`
|
||||
- MySql: fix searching for shows and seasons in schedule items editor
|
||||
- Fix 500 errors when serving XMLTV due to concurrent file reads and writes
|
||||
- Fix playback of AC3 audio when targeting stereo output and input layout changes mid-stream
|
||||
- Use other video artwork in XMLTV template
|
||||
- Properly update (add or remove) artwork for all local media libraries when files have changed
|
||||
- Sync Plex library name changes
|
||||
- Sync Plex episode title, plot, year, date added, release date, episode number changes
|
||||
- Sync Jellyfin and Emby library name and type changes
|
||||
- Library type (movies, shows) can only be changed when synchronization is *disabled* for the library in ETV
|
||||
- Fix some sequential and scripted playout build failures when using playlists or marathons
|
||||
- Fix erasing playout items and history so all related data is also erased
|
||||
- This includes rerun history, unscheduled gaps, build status
|
||||
- Fix indexing collections when using Elasticsearch backend
|
||||
|
||||
## [26.1.1] - 2026-01-08
|
||||
### Fixed
|
||||
- Use code signing on Windows launcher (`ErsatzTV-Windows.exe`) to avoid antivirus false positive
|
||||
|
||||
### Changed
|
||||
- Optimize database check for orphaned artwork
|
||||
- Include web resources (CSS, JS) locally instead of relying on CDNs
|
||||
|
||||
## [26.1.0] - 2026-01-06
|
||||
### Added
|
||||
@@ -718,13 +836,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- `random` will start at a random point in the content
|
||||
- `2` (similar to before this change) will skip the first two items in the content
|
||||
- YAML playout: make `count` an expression
|
||||
- The following parameters can be used:
|
||||
- `count`: the total number of items in the content
|
||||
- `random`: a random number between zero and (count - 1)
|
||||
- For example:
|
||||
- `count / 2` will play half of the items in the content
|
||||
- `random % 4 + 1` will play between 1 and 4 items
|
||||
- `2` (similar to before this change) will play exactly two items
|
||||
- The following parameters can be used:
|
||||
- `count`: the total number of items in the content
|
||||
- `random`: a random number between zero and (count - 1)
|
||||
- For example:
|
||||
- `count / 2` will play half of the items in the content
|
||||
- `random % 4 + 1` will play between 1 and 4 items
|
||||
- `2` (similar to before this change) will play exactly two items
|
||||
- YAML playout: add `disable_watermarks` property to all content instructions
|
||||
- This property defaults to `false` (meaning watermarks are allowed by default)
|
||||
- Setting to `true` will prevent watermarks from ever appearing over the content
|
||||
@@ -3081,7 +3199,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- Initial release to facilitate testing outside of Docker.
|
||||
|
||||
|
||||
[Unreleased]: https://github.com/ErsatzTV/ErsatzTV/compare/v26.1.0...HEAD
|
||||
[Unreleased]: https://github.com/ErsatzTV/ErsatzTV/compare/v26.3.0...HEAD
|
||||
[26.3.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v26.2.0...v26.3.0
|
||||
[26.2.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v26.1.1...v26.2.0
|
||||
[26.1.1]: https://github.com/ErsatzTV/ErsatzTV/compare/v26.1.0...v26.1.1
|
||||
[26.1.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.9.0...v26.1.0
|
||||
[25.9.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.8.0...v25.9.0
|
||||
[25.8.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.7.1...v25.8.0
|
||||
|
||||
91
CLAUDE.md
Normal file
91
CLAUDE.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# 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)
|
||||
- **Image**: `ghcr.io/ersatztv/ersatztv:latest` (to be replaced with our fork's image)
|
||||
|
||||
## 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
|
||||
|
||||
- Follow existing MediatR CQRS pattern for new features
|
||||
- Domain logic in `ErsatzTV.Core`, infrastructure in `ErsatzTV.Infrastructure`
|
||||
- Keep Blazor pages thin — delegate to MediatR handlers
|
||||
- Test with xUnit (existing test projects)
|
||||
- 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).
|
||||
@@ -11,6 +11,7 @@ public record ChannelViewModel(
|
||||
string Group,
|
||||
string Categories,
|
||||
int FFmpegProfileId,
|
||||
double? SlugSeconds,
|
||||
ArtworkContentTypeModel Logo,
|
||||
ChannelStreamSelectorMode StreamSelectorMode,
|
||||
string StreamSelector,
|
||||
|
||||
@@ -10,6 +10,7 @@ public record CreateChannel(
|
||||
string Group,
|
||||
string Categories,
|
||||
int FFmpegProfileId,
|
||||
double? SlugSeconds,
|
||||
ArtworkContentTypeModel Logo,
|
||||
ChannelStreamSelectorMode StreamSelectorMode,
|
||||
string StreamSelector,
|
||||
|
||||
@@ -80,6 +80,7 @@ public class CreateChannelHandler(
|
||||
Group = request.Group,
|
||||
Categories = request.Categories,
|
||||
FFmpegProfileId = ffmpegProfileId,
|
||||
SlugSeconds = request.SlugSeconds,
|
||||
PlayoutSource = request.PlayoutSource,
|
||||
PlayoutMode = request.PlayoutMode,
|
||||
MirrorSourceChannelId = request.MirrorSourceChannelId,
|
||||
|
||||
@@ -883,6 +883,8 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
metadata.Genres ??= [];
|
||||
metadata.Guids ??= [];
|
||||
|
||||
string artworkPath = GetPrioritizedArtworkPath(metadata);
|
||||
|
||||
var data = new
|
||||
{
|
||||
ProgrammeStart = start,
|
||||
@@ -897,6 +899,8 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
|
||||
OtherVideoPlot = metadata.Plot,
|
||||
OtherVideoHasYear = metadata.Year.HasValue,
|
||||
OtherVideoYear = metadata.Year,
|
||||
OtherVideoHasArtwork = !string.IsNullOrWhiteSpace(artworkPath),
|
||||
OtherVideoArtworkUrl = artworkPath,
|
||||
OtherVideoGenres = metadata.Genres.Map(g => g.Name).OrderBy(n => n),
|
||||
OtherVideoHasContentRating = !string.IsNullOrWhiteSpace(metadata.ContentRating),
|
||||
OtherVideoContentRating = metadata.ContentRating
|
||||
|
||||
@@ -11,6 +11,7 @@ public record UpdateChannel(
|
||||
string Group,
|
||||
string Categories,
|
||||
int FFmpegProfileId,
|
||||
double? SlugSeconds,
|
||||
ArtworkContentTypeModel Logo,
|
||||
ChannelStreamSelectorMode StreamSelectorMode,
|
||||
string StreamSelector,
|
||||
|
||||
@@ -52,6 +52,7 @@ public class UpdateChannelHandler(
|
||||
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;
|
||||
|
||||
@@ -14,6 +14,7 @@ internal static class Mapper
|
||||
channel.Group,
|
||||
channel.Categories,
|
||||
channel.FFmpegProfileId,
|
||||
channel.SlugSeconds,
|
||||
GetLogo(channel),
|
||||
channel.StreamSelectorMode,
|
||||
channel.StreamSelector,
|
||||
|
||||
@@ -11,30 +11,18 @@ using Microsoft.IO;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public partial class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, Either<BaseError, ChannelGuide>>
|
||||
public partial class GetChannelGuideHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
|
||||
IFileSystem fileSystem,
|
||||
ILocalFileSystem localFileSystem)
|
||||
: IRequestHandler<GetChannelGuide, Either<BaseError, ChannelGuide>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
|
||||
private readonly IFileSystem _fileSystem;
|
||||
|
||||
public GetChannelGuideHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
|
||||
IFileSystem fileSystem,
|
||||
ILocalFileSystem localFileSystem)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_recyclableMemoryStreamManager = recyclableMemoryStreamManager;
|
||||
_fileSystem = fileSystem;
|
||||
_localFileSystem = localFileSystem;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, ChannelGuide>> Handle(
|
||||
GetChannelGuide request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
var hiddenChannelNumbers = dbContext.Channels
|
||||
.Where(c => c.ShowInEpg == false)
|
||||
.Select(c => c.Number)
|
||||
@@ -42,13 +30,13 @@ public partial class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, E
|
||||
.Select(n => $"{n}.xml")
|
||||
.ToImmutableHashSet();
|
||||
|
||||
string channelsFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, "channels.xml");
|
||||
if (!_fileSystem.File.Exists(channelsFile))
|
||||
string channelsFile = fileSystem.Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, "channels.xml");
|
||||
if (!fileSystem.File.Exists(channelsFile))
|
||||
{
|
||||
return BaseError.New($"Required file {channelsFile} is missing");
|
||||
}
|
||||
|
||||
long mtime = File.GetLastWriteTime(channelsFile).Ticks;
|
||||
long mtime = fileSystem.File.GetLastWriteTime(channelsFile).Ticks;
|
||||
|
||||
var accessTokenUri = $"?v={mtime}";
|
||||
if (!string.IsNullOrWhiteSpace(request.AccessToken))
|
||||
@@ -56,7 +44,7 @@ public partial class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, E
|
||||
accessTokenUri += $"&access_token={request.AccessToken}";
|
||||
}
|
||||
|
||||
string channelsFragment = await File.ReadAllTextAsync(channelsFile, Encoding.UTF8, cancellationToken);
|
||||
string channelsFragment = await ReadAllTextShared(channelsFile, cancellationToken);
|
||||
|
||||
// TODO: is regex faster?
|
||||
channelsFragment = channelsFragment
|
||||
@@ -65,30 +53,52 @@ public partial class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, E
|
||||
|
||||
var channelDataFragments = new Dictionary<string, string>();
|
||||
|
||||
foreach (string fileName in _localFileSystem.ListFiles(FileSystemLayout.ChannelGuideCacheFolder))
|
||||
foreach (string fileName in localFileSystem.ListFiles(FileSystemLayout.ChannelGuideCacheFolder))
|
||||
{
|
||||
if (fileName.Contains("channels"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (hiddenChannelNumbers.Contains(Path.GetFileName(fileName)))
|
||||
if (hiddenChannelNumbers.Contains(fileSystem.Path.GetFileName(fileName)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string channelDataFragment = await File.ReadAllTextAsync(fileName, Encoding.UTF8, cancellationToken);
|
||||
try
|
||||
{
|
||||
string channelDataFragment = await ReadAllTextShared(fileName, cancellationToken);
|
||||
|
||||
channelDataFragment = channelDataFragment
|
||||
.Replace("{RequestBase}", $"{request.Scheme}://{request.Host}{request.BaseUrl}")
|
||||
.Replace("{AccessTokenUri}", accessTokenUri);
|
||||
channelDataFragment = channelDataFragment
|
||||
.Replace("{RequestBase}", $"{request.Scheme}://{request.Host}{request.BaseUrl}")
|
||||
.Replace("{AccessTokenUri}", accessTokenUri);
|
||||
|
||||
channelDataFragment = EtvTagRegex().Replace(channelDataFragment, string.Empty);
|
||||
channelDataFragment = EtvTagRegex().Replace(channelDataFragment, string.Empty);
|
||||
|
||||
channelDataFragments.Add(Path.GetFileNameWithoutExtension(fileName), channelDataFragment);
|
||||
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);
|
||||
return new ChannelGuide(recyclableMemoryStreamManager, channelsFragment, channelDataFragments);
|
||||
}
|
||||
|
||||
private async Task<string> ReadAllTextShared(string fileName, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var stream = fileSystem.FileStream.New(
|
||||
fileName,
|
||||
FileMode.Open,
|
||||
FileAccess.Read,
|
||||
FileShare.ReadWrite);
|
||||
using var reader = new StreamReader(stream, Encoding.UTF8, leaveOpen: true);
|
||||
return await reader.ReadToEndAsync(cancellationToken);
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"<etv:[^>]+?>.*?<\/etv:[^>]+?>|<etv:[^>]+?\/>", RegexOptions.Singleline)]
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record GetSlugSecondsByChannelNumber(string ChannelNumber) : IRequest<Option<double>>;
|
||||
@@ -0,0 +1,17 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public record UpdateUiSettings(UiSettingsViewModel UiSettings) : IRequest<Either<BaseError, Unit>>;
|
||||
@@ -0,0 +1,28 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public record GetUiSettings : IRequest<UiSettingsViewModel>;
|
||||
@@ -0,0 +1,25 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class GetUiSettingsHandler(IConfigElementRepository configElementRepository)
|
||||
: IRequestHandler<GetUiSettings, UiSettingsViewModel>
|
||||
{
|
||||
public async Task<UiSettingsViewModel> Handle(GetUiSettings request, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<bool> pagesIsDarkMode = await configElementRepository.GetValue<bool>(
|
||||
ConfigElementKey.PagesIsDarkMode,
|
||||
cancellationToken);
|
||||
|
||||
Option<string> pagesLanguage = await configElementRepository.GetValue<string>(
|
||||
ConfigElementKey.PagesLanguage,
|
||||
cancellationToken);
|
||||
|
||||
return new UiSettingsViewModel
|
||||
{
|
||||
IsDarkMode = await pagesIsDarkMode.IfNoneAsync(true),
|
||||
Language = await pagesLanguage.IfNoneAsync("en")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class UiSettingsViewModel
|
||||
{
|
||||
public bool IsDarkMode { get; set; }
|
||||
|
||||
public string Language { get; set; }
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<NoWarn>VSTHRD200</NoWarn>
|
||||
<NoWarn>VSTHRD200,CA1873</NoWarn>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<AnalysisLevel>latest-Recommended</AnalysisLevel>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
@@ -10,12 +10,11 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Bugsnag" Version="4.1.0" />
|
||||
<PackageReference Include="CliWrap" Version="3.10.0" />
|
||||
<PackageReference Include="Humanizer.Core" Version="3.0.1" />
|
||||
<PackageReference Include="MediatR" Version="[12.5.0]" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.2" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<PackageReference Include="Serilog.Formatting.Compact.Reader" Version="4.0.0" />
|
||||
<PackageReference Include="WebMarkupMin.Core" Version="2.20.1" />
|
||||
|
||||
@@ -26,7 +26,7 @@ public class RefreshFFmpegCapabilitiesHandler(
|
||||
|
||||
foreach (string ffmpegPath in maybeFFmpegPath)
|
||||
{
|
||||
_ = await hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath);
|
||||
_ = await hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath, cancellationToken);
|
||||
|
||||
Option<string> maybeFFprobePath = await dbContext.ConfigElements
|
||||
.GetValue<string>(ConfigElementKey.FFprobePath, cancellationToken)
|
||||
|
||||
@@ -7,6 +7,8 @@ namespace ErsatzTV.Application.FFmpegProfiles;
|
||||
public record CreateFFmpegProfile(
|
||||
string Name,
|
||||
int ThreadCount,
|
||||
bool NormalizeAudio,
|
||||
bool NormalizeVideo,
|
||||
HardwareAccelerationKind HardwareAcceleration,
|
||||
string VaapiDisplay,
|
||||
VaapiDriver VaapiDriver,
|
||||
@@ -14,6 +16,7 @@ public record CreateFFmpegProfile(
|
||||
int? QsvExtraHardwareFrames,
|
||||
int ResolutionId,
|
||||
ScalingBehavior ScalingBehavior,
|
||||
FilterMode PadMode,
|
||||
FFmpegProfileVideoFormat VideoFormat,
|
||||
string VideoProfile,
|
||||
string VideoPreset,
|
||||
@@ -30,4 +33,5 @@ public record CreateFFmpegProfile(
|
||||
int AudioChannels,
|
||||
int AudioSampleRate,
|
||||
bool NormalizeFramerate,
|
||||
bool NormalizeColors,
|
||||
bool DeinterlaceVideo) : IRequest<Either<BaseError, CreateFFmpegProfileResult>>;
|
||||
|
||||
@@ -44,42 +44,63 @@ public class CreateFFmpegProfileHandler :
|
||||
CancellationToken cancellationToken) =>
|
||||
(ValidateName(request), ValidateThreadCount(request),
|
||||
await ResolutionMustExist(dbContext, request, cancellationToken))
|
||||
.Apply((name, threadCount, resolutionId) => new FFmpegProfile
|
||||
.Apply((name, threadCount, resolutionId) =>
|
||||
{
|
||||
Name = name,
|
||||
ThreadCount = threadCount,
|
||||
HardwareAcceleration = request.HardwareAcceleration,
|
||||
VaapiDriver = request.VaapiDriver,
|
||||
VaapiDevice = request.VaapiDevice,
|
||||
QsvExtraHardwareFrames = request.QsvExtraHardwareFrames,
|
||||
ResolutionId = resolutionId,
|
||||
ScalingBehavior = request.ScalingBehavior,
|
||||
VideoFormat = request.VideoFormat,
|
||||
VideoProfile = request.VideoProfile,
|
||||
VideoPreset = request.VideoPreset,
|
||||
AllowBFrames = request.AllowBFrames,
|
||||
var hwAccel = request.NormalizeVideo
|
||||
? request.HardwareAcceleration
|
||||
: HardwareAccelerationKind.None;
|
||||
|
||||
// mpeg2video only supports 8-bit content
|
||||
BitDepth = request.VideoFormat is FFmpegProfileVideoFormat.Mpeg2Video
|
||||
? FFmpegProfileBitDepth.EightBit
|
||||
: request.BitDepth,
|
||||
return new FFmpegProfile
|
||||
{
|
||||
Name = name,
|
||||
ThreadCount = threadCount,
|
||||
|
||||
VideoBitrate = request.VideoBitrate,
|
||||
VideoBufferSize = request.VideoBufferSize,
|
||||
TonemapAlgorithm = request.TonemapAlgorithm,
|
||||
AudioFormat = request.AudioFormat,
|
||||
AudioBitrate = request.AudioBitrate,
|
||||
AudioBufferSize = request.AudioBufferSize,
|
||||
NormalizeAudio = request.NormalizeAudio,
|
||||
NormalizeVideo = request.NormalizeVideo,
|
||||
|
||||
NormalizeLoudnessMode = request.NormalizeLoudnessMode,
|
||||
TargetLoudness = request.NormalizeLoudnessMode is NormalizeLoudnessMode.LoudNorm
|
||||
? request.TargetLoudness
|
||||
: null,
|
||||
HardwareAcceleration = hwAccel,
|
||||
VaapiDriver = request.VaapiDriver,
|
||||
VaapiDevice = request.VaapiDevice,
|
||||
QsvExtraHardwareFrames = request.QsvExtraHardwareFrames,
|
||||
ResolutionId = resolutionId,
|
||||
ScalingBehavior = request.ScalingBehavior,
|
||||
|
||||
AudioChannels = request.AudioChannels,
|
||||
AudioSampleRate = request.AudioSampleRate,
|
||||
NormalizeFramerate = request.NormalizeFramerate,
|
||||
DeinterlaceVideo = request.DeinterlaceVideo
|
||||
// only allow customization with VAAPI accel
|
||||
PadMode = hwAccel switch
|
||||
{
|
||||
HardwareAccelerationKind.None => FilterMode.Software,
|
||||
HardwareAccelerationKind.Vaapi => request.PadMode,
|
||||
_ => FilterMode.HardwareIfPossible
|
||||
},
|
||||
|
||||
VideoFormat = request.NormalizeVideo ? request.VideoFormat : FFmpegProfileVideoFormat.Copy,
|
||||
VideoProfile = request.VideoProfile,
|
||||
VideoPreset = request.VideoPreset,
|
||||
AllowBFrames = request.AllowBFrames,
|
||||
|
||||
// mpeg2video only supports 8-bit content
|
||||
BitDepth = request.VideoFormat is FFmpegProfileVideoFormat.Mpeg2Video
|
||||
? FFmpegProfileBitDepth.EightBit
|
||||
: request.BitDepth,
|
||||
|
||||
VideoBitrate = request.VideoBitrate,
|
||||
VideoBufferSize = request.VideoBufferSize,
|
||||
TonemapAlgorithm = request.TonemapAlgorithm,
|
||||
AudioFormat = request.NormalizeAudio ? request.AudioFormat : FFmpegProfileAudioFormat.Copy,
|
||||
AudioBitrate = request.AudioBitrate,
|
||||
AudioBufferSize = request.AudioBufferSize,
|
||||
|
||||
NormalizeLoudnessMode = request.NormalizeLoudnessMode,
|
||||
TargetLoudness = request.NormalizeLoudnessMode is NormalizeLoudnessMode.LoudNorm
|
||||
? request.TargetLoudness
|
||||
: null,
|
||||
|
||||
AudioChannels = request.AudioChannels,
|
||||
AudioSampleRate = request.AudioSampleRate,
|
||||
NormalizeFramerate = request.NormalizeFramerate,
|
||||
NormalizeColors = request.NormalizeColors,
|
||||
DeinterlaceVideo = request.DeinterlaceVideo
|
||||
};
|
||||
});
|
||||
|
||||
private static Validation<BaseError, string> ValidateName(CreateFFmpegProfile createFFmpegProfile) =>
|
||||
|
||||
@@ -8,6 +8,8 @@ public record UpdateFFmpegProfile(
|
||||
int FFmpegProfileId,
|
||||
string Name,
|
||||
int ThreadCount,
|
||||
bool NormalizeAudio,
|
||||
bool NormalizeVideo,
|
||||
HardwareAccelerationKind HardwareAcceleration,
|
||||
string VaapiDisplay,
|
||||
VaapiDriver VaapiDriver,
|
||||
@@ -15,6 +17,7 @@ public record UpdateFFmpegProfile(
|
||||
int? QsvExtraHardwareFrames,
|
||||
int ResolutionId,
|
||||
ScalingBehavior ScalingBehavior,
|
||||
FilterMode PadMode,
|
||||
FFmpegProfileVideoFormat VideoFormat,
|
||||
string VideoProfile,
|
||||
string VideoPreset,
|
||||
@@ -31,4 +34,5 @@ public record UpdateFFmpegProfile(
|
||||
int AudioChannels,
|
||||
int AudioSampleRate,
|
||||
bool NormalizeFramerate,
|
||||
bool NormalizeColors,
|
||||
bool DeinterlaceVideo) : IRequest<Either<BaseError, UpdateFFmpegProfileResult>>;
|
||||
|
||||
@@ -27,16 +27,23 @@ public class UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFa
|
||||
UpdateFFmpegProfile update,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var hwAccel = update.NormalizeVideo
|
||||
? update.HardwareAcceleration
|
||||
: HardwareAccelerationKind.None;
|
||||
|
||||
p.Name = update.Name;
|
||||
p.ThreadCount = update.ThreadCount;
|
||||
p.HardwareAcceleration = update.HardwareAcceleration;
|
||||
p.NormalizeAudio = update.NormalizeAudio;
|
||||
p.NormalizeVideo = update.NormalizeVideo;
|
||||
p.HardwareAcceleration = hwAccel;
|
||||
p.VaapiDisplay = update.VaapiDisplay;
|
||||
p.VaapiDriver = update.VaapiDriver;
|
||||
p.VaapiDevice = update.VaapiDevice;
|
||||
p.QsvExtraHardwareFrames = update.QsvExtraHardwareFrames;
|
||||
p.ResolutionId = update.ResolutionId;
|
||||
p.ScalingBehavior = update.ScalingBehavior;
|
||||
p.VideoFormat = update.VideoFormat;
|
||||
p.PadMode = update.PadMode;
|
||||
p.VideoFormat = update.NormalizeVideo ? update.VideoFormat : FFmpegProfileVideoFormat.Copy;
|
||||
p.VideoProfile = update.VideoProfile;
|
||||
p.VideoPreset = update.VideoPreset;
|
||||
p.AllowBFrames = update.AllowBFrames;
|
||||
@@ -53,10 +60,20 @@ public class UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFa
|
||||
p.VideoFormat = FFmpegProfileVideoFormat.Hevc;
|
||||
}
|
||||
|
||||
// only allow customization with VAAPI accel
|
||||
if (p.HardwareAcceleration is HardwareAccelerationKind.None)
|
||||
{
|
||||
p.PadMode = FilterMode.Software;
|
||||
}
|
||||
else if (p.HardwareAcceleration is not HardwareAccelerationKind.Vaapi)
|
||||
{
|
||||
p.PadMode = FilterMode.HardwareIfPossible;
|
||||
}
|
||||
|
||||
p.VideoBitrate = update.VideoBitrate;
|
||||
p.VideoBufferSize = update.VideoBufferSize;
|
||||
p.TonemapAlgorithm = update.TonemapAlgorithm;
|
||||
p.AudioFormat = update.AudioFormat;
|
||||
p.AudioFormat = update.NormalizeAudio ? update.AudioFormat : FFmpegProfileAudioFormat.Copy;
|
||||
p.AudioBitrate = update.AudioBitrate;
|
||||
p.AudioBufferSize = update.AudioBufferSize;
|
||||
|
||||
@@ -68,6 +85,7 @@ public class UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFa
|
||||
p.AudioChannels = update.AudioChannels;
|
||||
p.AudioSampleRate = update.AudioSampleRate;
|
||||
p.NormalizeFramerate = update.NormalizeFramerate;
|
||||
p.NormalizeColors = update.NormalizeColors;
|
||||
p.DeinterlaceVideo = update.DeinterlaceVideo;
|
||||
|
||||
// don't save invalid preset
|
||||
|
||||
@@ -8,6 +8,8 @@ public record FFmpegProfileViewModel(
|
||||
int Id,
|
||||
string Name,
|
||||
int ThreadCount,
|
||||
bool NormalizeAudio,
|
||||
bool NormalizeVideo,
|
||||
HardwareAccelerationKind HardwareAcceleration,
|
||||
string VaapiDisplay,
|
||||
VaapiDriver VaapiDriver,
|
||||
@@ -15,6 +17,7 @@ public record FFmpegProfileViewModel(
|
||||
int? QsvExtraHardwareFrames,
|
||||
ResolutionViewModel Resolution,
|
||||
ScalingBehavior ScalingBehavior,
|
||||
FilterMode PadMode,
|
||||
FFmpegProfileVideoFormat VideoFormat,
|
||||
string VideoProfile,
|
||||
string VideoPreset,
|
||||
@@ -31,4 +34,5 @@ public record FFmpegProfileViewModel(
|
||||
int AudioChannels,
|
||||
int AudioSampleRate,
|
||||
bool NormalizeFramerate,
|
||||
bool NormalizeColors,
|
||||
bool DeinterlaceVideo);
|
||||
|
||||
@@ -10,6 +10,8 @@ internal static class Mapper
|
||||
profile.Id,
|
||||
profile.Name,
|
||||
profile.ThreadCount,
|
||||
profile.NormalizeAudio,
|
||||
profile.NormalizeVideo,
|
||||
profile.HardwareAcceleration,
|
||||
profile.VaapiDisplay ?? "drm",
|
||||
profile.VaapiDriver,
|
||||
@@ -17,6 +19,7 @@ internal static class Mapper
|
||||
profile.QsvExtraHardwareFrames,
|
||||
Resolutions.Mapper.ProjectToViewModel(profile.Resolution),
|
||||
profile.ScalingBehavior,
|
||||
profile.PadMode,
|
||||
profile.VideoFormat,
|
||||
profile.VideoProfile,
|
||||
profile.VideoPreset ?? string.Empty,
|
||||
@@ -33,6 +36,7 @@ internal static class Mapper
|
||||
profile.AudioChannels,
|
||||
profile.AudioSampleRate,
|
||||
profile.NormalizeFramerate,
|
||||
profile.NormalizeColors,
|
||||
profile.DeinterlaceVideo == true);
|
||||
|
||||
internal static FFmpegProfileResponseModel ProjectToResponseModel(FFmpegProfile ffmpegProfile) =>
|
||||
|
||||
@@ -31,15 +31,18 @@ public class
|
||||
Validation<BaseError, string> validation = await Validate(dbContext, cancellationToken);
|
||||
|
||||
return await validation.Match(
|
||||
GetHardwareAccelerationKinds,
|
||||
ffmpegPath => GetHardwareAccelerationKinds(ffmpegPath, cancellationToken),
|
||||
_ => Task.FromResult(new List<HardwareAccelerationKind> { HardwareAccelerationKind.None }));
|
||||
}
|
||||
|
||||
private async Task<List<HardwareAccelerationKind>> GetHardwareAccelerationKinds(string ffmpegPath)
|
||||
private async Task<List<HardwareAccelerationKind>> GetHardwareAccelerationKinds(
|
||||
string ffmpegPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = new List<HardwareAccelerationKind> { HardwareAccelerationKind.None };
|
||||
|
||||
IFFmpegCapabilities ffmpegCapabilities = await _hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath);
|
||||
IFFmpegCapabilities ffmpegCapabilities =
|
||||
await _hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath, cancellationToken);
|
||||
|
||||
if (ffmpegCapabilities.HasHardwareAcceleration(HardwareAccelerationMode.Nvenc))
|
||||
{
|
||||
|
||||
@@ -74,7 +74,8 @@ public class
|
||||
ffmpegPath,
|
||||
originalPath,
|
||||
withExtension,
|
||||
request.MaxHeight.Value);
|
||||
request.MaxHeight.Value,
|
||||
cancellationToken);
|
||||
|
||||
CommandResult resize = await process.ExecuteAsync(cancellationToken);
|
||||
|
||||
|
||||
@@ -36,8 +36,10 @@ public class SaveJellyfinSecretsHandler : IRequestHandler<SaveJellyfinSecrets, E
|
||||
|
||||
private async Task<Validation<BaseError, Parameters>> Validate(SaveJellyfinSecrets request)
|
||||
{
|
||||
var connectionParameters = new JellyfinConnectionParameters(request.Secrets.Address, request.Secrets.ApiKey, 0);
|
||||
|
||||
Either<BaseError, JellyfinServerInformation> maybeServerInformation = await _jellyfinApiClient
|
||||
.GetServerInformation(request.Secrets.Address, request.Secrets.ApiKey);
|
||||
.GetServerInformation(connectionParameters.Address, connectionParameters.AuthorizationHeader);
|
||||
|
||||
return maybeServerInformation.Match(
|
||||
info => Validation<BaseError, Parameters>.Success(new Parameters(request.Secrets, info)),
|
||||
|
||||
@@ -38,7 +38,7 @@ public class
|
||||
.MapT(p => SynchronizeLibraries(p, cancellationToken))
|
||||
.Bind(v => v.ToEitherAsync());
|
||||
|
||||
private Task<Validation<BaseError, ConnectionParameters>> Validate(SynchronizeJellyfinLibraries request) =>
|
||||
private Task<Validation<BaseError, ConnectionAndSource>> Validate(SynchronizeJellyfinLibraries request) =>
|
||||
MediaSourceMustExist(request)
|
||||
.BindT(MediaSourceMustHaveActiveConnection)
|
||||
.BindT(MediaSourceMustHaveApiKey);
|
||||
@@ -48,43 +48,48 @@ public class
|
||||
_mediaSourceRepository.GetJellyfin(request.JellyfinMediaSourceId)
|
||||
.Map(o => o.ToValidation<BaseError>("Jellyfin media source does not exist."));
|
||||
|
||||
private Validation<BaseError, ConnectionParameters> MediaSourceMustHaveActiveConnection(
|
||||
private Validation<BaseError, ConnectionAndSource> MediaSourceMustHaveActiveConnection(
|
||||
JellyfinMediaSource jellyfinMediaSource)
|
||||
{
|
||||
Option<JellyfinConnection> maybeConnection = jellyfinMediaSource.Connections.HeadOrNone();
|
||||
return maybeConnection.Map(connection => new ConnectionParameters(jellyfinMediaSource, connection))
|
||||
return maybeConnection.Map(connection => new ConnectionAndSource(
|
||||
new JellyfinConnectionParameters(connection.Address, string.Empty, connection.JellyfinMediaSourceId),
|
||||
jellyfinMediaSource))
|
||||
.ToValidation<BaseError>("Jellyfin media source requires an active connection");
|
||||
}
|
||||
|
||||
private async Task<Validation<BaseError, ConnectionParameters>> MediaSourceMustHaveApiKey(
|
||||
ConnectionParameters connectionParameters)
|
||||
private async Task<Validation<BaseError, ConnectionAndSource>> MediaSourceMustHaveApiKey(
|
||||
ConnectionAndSource connectionAndSource)
|
||||
{
|
||||
JellyfinSecrets secrets = await _jellyfinSecretStore.ReadSecrets();
|
||||
return Optional(secrets.Address == connectionParameters.ActiveConnection.Address)
|
||||
return Optional(secrets.Address == connectionAndSource.ConnectionParameters.Address)
|
||||
.Where(match => match)
|
||||
.Map(_ => connectionParameters with { ApiKey = secrets.ApiKey })
|
||||
.Map(_ => connectionAndSource with
|
||||
{
|
||||
ConnectionParameters = connectionAndSource.ConnectionParameters with { ApiKey = secrets.ApiKey }
|
||||
})
|
||||
.ToValidation<BaseError>("Jellyfin media source requires an api key");
|
||||
}
|
||||
|
||||
private async Task<Unit> SynchronizeLibraries(
|
||||
ConnectionParameters connectionParameters,
|
||||
ConnectionAndSource connectionAndSource,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, List<JellyfinLibrary>> maybeLibraries = await _jellyfinApiClient.GetLibraries(
|
||||
connectionParameters.ActiveConnection.Address,
|
||||
connectionParameters.ApiKey);
|
||||
connectionAndSource.ConnectionParameters.Address,
|
||||
connectionAndSource.ConnectionParameters.AuthorizationHeader);
|
||||
|
||||
foreach (BaseError error in maybeLibraries.LeftToSeq())
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Unable to synchronize libraries from jellyfin server {JellyfinServer}: {Error}",
|
||||
connectionParameters.JellyfinMediaSource.ServerName,
|
||||
connectionAndSource.MediaSource.ServerName,
|
||||
error.Value);
|
||||
}
|
||||
|
||||
foreach (List<JellyfinLibrary> libraries in maybeLibraries.RightToSeq())
|
||||
{
|
||||
var existing = connectionParameters.JellyfinMediaSource.Libraries
|
||||
var existing = connectionAndSource.MediaSource.Libraries
|
||||
.OfType<JellyfinLibrary>()
|
||||
.ToList();
|
||||
var toAdd = libraries.Filter(library => existing.All(l => l.ItemId != library.ItemId)).ToList();
|
||||
@@ -92,7 +97,7 @@ public class
|
||||
var toUpdate = libraries
|
||||
.Filter(l => toAdd.All(a => a.ItemId != l.ItemId) && toRemove.All(r => r.ItemId != l.ItemId)).ToList();
|
||||
List<int> ids = await _mediaSourceRepository.UpdateLibraries(
|
||||
connectionParameters.JellyfinMediaSource.Id,
|
||||
connectionAndSource.MediaSource.Id,
|
||||
toAdd,
|
||||
toRemove,
|
||||
toUpdate,
|
||||
@@ -107,10 +112,7 @@ public class
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private sealed record ConnectionParameters(
|
||||
JellyfinMediaSource JellyfinMediaSource,
|
||||
JellyfinConnection ActiveConnection)
|
||||
{
|
||||
public string ApiKey { get; set; }
|
||||
}
|
||||
private sealed record ConnectionAndSource(
|
||||
JellyfinConnectionParameters ConnectionParameters,
|
||||
JellyfinMediaSource MediaSource);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,174 @@
|
||||
using ErsatzTV.Core;
|
||||
using System.Globalization;
|
||||
using System.IO.Abstractions;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Images;
|
||||
using Humanizer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Application.Maintenance;
|
||||
|
||||
public class DeleteOrphanedArtworkHandler : IRequestHandler<DeleteOrphanedArtwork, Either<BaseError, Unit>>
|
||||
public class DeleteOrphanedArtworkHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IArtworkRepository artworkRepository,
|
||||
IFileSystem fileSystem,
|
||||
ILogger<DeleteOrphanedArtworkHandler> logger)
|
||||
: IRequestHandler<DeleteOrphanedArtwork, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IArtworkRepository _artworkRepository;
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
DeleteOrphanedArtwork request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await CleanUpDatabase();
|
||||
await CleanUpFileSystem(cancellationToken);
|
||||
|
||||
public DeleteOrphanedArtworkHandler(IArtworkRepository artworkRepository) =>
|
||||
_artworkRepository = artworkRepository;
|
||||
return Unit.Default;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return BaseError.New(e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<Either<BaseError, Unit>>
|
||||
Handle(DeleteOrphanedArtwork request, CancellationToken cancellationToken) =>
|
||||
_artworkRepository.GetOrphanedArtwork()
|
||||
.Bind(_artworkRepository.Delete)
|
||||
.Map(_ => Right<BaseError, Unit>(Unit.Default));
|
||||
private async Task CleanUpDatabase()
|
||||
{
|
||||
List<int> ids = await artworkRepository.GetOrphanedArtworkIds();
|
||||
if (ids.Count > 0)
|
||||
{
|
||||
await artworkRepository.Delete(ids);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CleanUpFileSystem(CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
System.Collections.Generic.HashSet<string> validFiles = [];
|
||||
|
||||
List<string> watermarks = await dbContext.ChannelWatermarks
|
||||
.TagWithCallSite()
|
||||
.AsNoTracking()
|
||||
.Select(c => c.Image)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (string watermark in watermarks.Where(w => !string.IsNullOrWhiteSpace(w)))
|
||||
{
|
||||
validFiles.Add(watermark);
|
||||
}
|
||||
|
||||
var lastId = 0;
|
||||
while (true)
|
||||
{
|
||||
List<MinimalArtwork> result = await dbContext.Artwork
|
||||
.TagWithCallSite()
|
||||
.AsNoTracking()
|
||||
.Where(a => a.Id > lastId)
|
||||
.OrderBy(a => a.Id)
|
||||
.Take(1000)
|
||||
.Select(a => new MinimalArtwork(a.Id, a.Path, a.BlurHash43, a.BlurHash54, a.BlurHash64))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (result.Count == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
foreach (MinimalArtwork artwork in result)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(artwork.Path) && !artwork.Path.Contains('/'))
|
||||
{
|
||||
validFiles.Add(artwork.Path);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(artwork.BlurHash43))
|
||||
{
|
||||
validFiles.Add(ImageCache.GetBlurHashFileName(artwork.BlurHash43));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(artwork.BlurHash54))
|
||||
{
|
||||
validFiles.Add(ImageCache.GetBlurHashFileName(artwork.BlurHash54));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(artwork.BlurHash64))
|
||||
{
|
||||
validFiles.Add(ImageCache.GetBlurHashFileName(artwork.BlurHash64));
|
||||
}
|
||||
}
|
||||
|
||||
lastId = result.Last().Id;
|
||||
}
|
||||
|
||||
logger.LogDebug("Loaded {Count} artwork hashes (valid file names)", validFiles.Count);
|
||||
|
||||
var deleted = 0;
|
||||
long bytes = 0;
|
||||
foreach (string file in fileSystem.Directory.EnumerateFiles(
|
||||
FileSystemLayout.ArtworkCacheFolder,
|
||||
"*.*",
|
||||
SearchOption.AllDirectories))
|
||||
{
|
||||
string fileName = fileSystem.Path.GetFileName(file);
|
||||
if (!validFiles.Contains(fileName))
|
||||
{
|
||||
try
|
||||
{
|
||||
bytes += fileSystem.FileInfo.New(file).Length;
|
||||
|
||||
fileSystem.File.Delete(file);
|
||||
deleted++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Could not delete artwork file {File}", file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogDebug(
|
||||
"Deleted {Count} unused artwork cache files totaling {Size}",
|
||||
deleted,
|
||||
bytes.Bytes().Humanize(CultureInfo.CurrentCulture));
|
||||
|
||||
DeleteEmptySubfolders(FileSystemLayout.ArtworkCacheFolder);
|
||||
}
|
||||
|
||||
private void DeleteEmptySubfolders(string path)
|
||||
{
|
||||
if (!fileSystem.Directory.Exists(path))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (string sub in fileSystem.Directory.GetDirectories(path))
|
||||
{
|
||||
DeleteEmptySubfolders(sub);
|
||||
}
|
||||
|
||||
if (!fileSystem.Directory.EnumerateFileSystemEntries(path).Any())
|
||||
{
|
||||
try
|
||||
{
|
||||
// don't delete artwork cache folder or its direct children
|
||||
if (path != FileSystemLayout.ArtworkCacheFolder)
|
||||
{
|
||||
var parent = fileSystem.Directory.GetParent(path);
|
||||
if (parent?.FullName != FileSystemLayout.ArtworkCacheFolder)
|
||||
{
|
||||
fileSystem.Directory.Delete(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Could not delete empty cache folder {Folder}", path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record MinimalArtwork(int Id, string Path, string BlurHash43, string BlurHash54, string BlurHash64);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Search;
|
||||
@@ -8,16 +7,13 @@ namespace ErsatzTV.Application.Maintenance;
|
||||
|
||||
public class EmptyTrashHandler : IRequestHandler<EmptyTrash, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IClient _client;
|
||||
private readonly IMediaItemRepository _mediaItemRepository;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
|
||||
public EmptyTrashHandler(
|
||||
IClient client,
|
||||
IMediaItemRepository mediaItemRepository,
|
||||
ISearchIndex searchIndex)
|
||||
{
|
||||
_client = client;
|
||||
_mediaItemRepository = mediaItemRepository;
|
||||
_searchIndex = searchIndex;
|
||||
}
|
||||
@@ -27,7 +23,6 @@ public class EmptyTrashHandler : IRequestHandler<EmptyTrash, Either<BaseError, U
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
SearchResult result = await _searchIndex.Search(
|
||||
_client,
|
||||
"state:FileNotFound",
|
||||
string.Empty,
|
||||
0,
|
||||
|
||||
@@ -110,10 +110,10 @@ public partial class AddTraktListHandler : TraktCommandBase, IRequestHandler<Add
|
||||
return maybeList.Map(_ => Unit.Default);
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"https:\/\/trakt\.tv\/users\/([\w\-_]+)\/(?:lists\/)?([\w\-_]+)")]
|
||||
[GeneratedRegex(@"https:\/\/(?:app\.)?trakt\.tv\/users\/([\w\-_]+)\/(?:lists\/)?([\w\-_]+)")]
|
||||
private static partial Regex UriTraktListRegex();
|
||||
|
||||
[GeneratedRegex(@"https:\/\/trakt\.tv\/lists\/([\w\-_]+)\/([\w\-_]+)")]
|
||||
[GeneratedRegex(@"https:\/\/(?:app\.)?trakt\.tv\/lists\/([\w\-_]+)\/([\w\-_]+)")]
|
||||
private static partial Regex UriTraktListRegex2();
|
||||
|
||||
[GeneratedRegex(@"([\w\-_]+)\/(?:lists\/)?([\w\-_]+)")]
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Threading.Channels;
|
||||
using Bugsnag;
|
||||
using EFCore.BulkExtensions;
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Application.Subtitles;
|
||||
@@ -22,7 +21,6 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
|
||||
{
|
||||
private readonly IBlockPlayoutBuilder _blockPlayoutBuilder;
|
||||
private readonly IBlockPlayoutFillerBuilder _blockPlayoutFillerBuilder;
|
||||
private readonly IClient _client;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IEntityLocker _entityLocker;
|
||||
private readonly IExternalJsonPlayoutBuilder _externalJsonPlayoutBuilder;
|
||||
@@ -35,7 +33,6 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
|
||||
private readonly IScriptedPlayoutBuilder _scriptedPlayoutBuilder;
|
||||
|
||||
public BuildPlayoutHandler(
|
||||
IClient client,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IPlayoutBuilder playoutBuilder,
|
||||
IBlockPlayoutBuilder blockPlayoutBuilder,
|
||||
@@ -49,7 +46,6 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
|
||||
ChannelWriter<IBackgroundServiceRequest> workerChannel,
|
||||
ILogger<BuildPlayoutHandler> logger)
|
||||
{
|
||||
_client = client;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_playoutBuilder = playoutBuilder;
|
||||
_blockPlayoutBuilder = blockPlayoutBuilder;
|
||||
@@ -348,7 +344,6 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
|
||||
newBuildStatus.Success = false;
|
||||
newBuildStatus.Message = $"Timeout building playout for channel {channelName}";
|
||||
|
||||
_client.Notify(ex);
|
||||
return BaseError.New(
|
||||
$"Timeout building playout for channel {channelName}; this may be a bug!");
|
||||
}
|
||||
@@ -359,7 +354,6 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
|
||||
newBuildStatus.Success = false;
|
||||
newBuildStatus.Message = $"Unexpected error building playout for channel {channelName}: {ex}";
|
||||
|
||||
_client.Notify(ex);
|
||||
return BaseError.New(
|
||||
$"Unexpected error building playout for channel {channelName}: {ex.Message}");
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ internal static class Mapper
|
||||
playoutItem.StartOffset,
|
||||
playoutItem.FinishOffset,
|
||||
playoutItem.GetDisplayDuration(),
|
||||
playoutItem.SchedulingContext,
|
||||
Some(playoutItem.FillerKind));
|
||||
|
||||
internal static PlayoutAlternateScheduleViewModel ProjectToViewModel(
|
||||
|
||||
@@ -2,4 +2,10 @@ using ErsatzTV.Core.Domain.Filler;
|
||||
|
||||
namespace ErsatzTV.Application.Playouts;
|
||||
|
||||
public record PlayoutItemViewModel(string Title, DateTimeOffset Start, DateTimeOffset Finish, string Duration, Option<FillerKind> FillerKind);
|
||||
public record PlayoutItemViewModel(
|
||||
string Title,
|
||||
DateTimeOffset Start,
|
||||
DateTimeOffset Finish,
|
||||
string Duration,
|
||||
string SchedulingContext,
|
||||
Option<FillerKind> FillerKind);
|
||||
|
||||
@@ -112,7 +112,8 @@ public class GetFuturePlayoutItemsByIdHandler(IDbContextFactory<TvContext> dbCon
|
||||
gap.FinishOffset,
|
||||
TimeSpan.FromSeconds(Math.Round(gapDuration.TotalSeconds)).ToString(
|
||||
gapDuration.TotalHours >= 1 ? @"h\:mm\:ss" : @"mm\:ss",
|
||||
CultureInfo.CurrentUICulture.DateTimeFormat),
|
||||
CultureInfo.CurrentCulture.DateTimeFormat),
|
||||
string.Empty,
|
||||
None
|
||||
);
|
||||
}).ToList();
|
||||
|
||||
@@ -26,7 +26,7 @@ public record AddProgramScheduleItem(
|
||||
int? MarathonBatchSize,
|
||||
FillWithGroupMode FillWithGroupMode,
|
||||
MultipleMode MultipleMode,
|
||||
int? MultipleCount,
|
||||
string MultipleCount,
|
||||
TimeSpan? PlayoutDuration,
|
||||
TailMode TailMode,
|
||||
int? DiscardToFillAttempts,
|
||||
|
||||
@@ -24,7 +24,7 @@ public interface IProgramScheduleItemRequest
|
||||
int? MarathonBatchSize { get; }
|
||||
FillWithGroupMode FillWithGroupMode { get; }
|
||||
MultipleMode MultipleMode { get; }
|
||||
int? MultipleCount { get; }
|
||||
string MultipleCount { get; }
|
||||
TimeSpan? PlayoutDuration { get; }
|
||||
TailMode TailMode { get; }
|
||||
int? DiscardToFillAttempts { get; }
|
||||
|
||||
@@ -79,10 +79,10 @@ public abstract class ProgramScheduleItemCommandBase
|
||||
"[MultipleMode] cannot be [PlaylistItemSize] when collection is not a playlist");
|
||||
}
|
||||
|
||||
if (item.MultipleMode is MultipleMode.Count && item.MultipleCount.GetValueOrDefault() < 1)
|
||||
if (item.MultipleMode is MultipleMode.Count && string.IsNullOrWhiteSpace(item.MultipleCount))
|
||||
{
|
||||
return BaseError.New(
|
||||
"[MultipleCount] must be greater than 0 for playout mode 'multiple / count'");
|
||||
"[MultipleCount] must be valid for playout mode 'multiple / count'");
|
||||
}
|
||||
|
||||
break;
|
||||
@@ -298,7 +298,7 @@ public abstract class ProgramScheduleItemCommandBase
|
||||
MarathonBatchSize = item.MarathonBatchSize,
|
||||
FillWithGroupMode = item.FillWithGroupMode,
|
||||
MultipleMode = item.MultipleMode,
|
||||
Count = item.MultipleMode is MultipleMode.Count ? item.MultipleCount.GetValueOrDefault() : 0,
|
||||
Count = item.MultipleMode is MultipleMode.Count ? item.MultipleCount ?? "0" : "0",
|
||||
CustomTitle = item.CustomTitle,
|
||||
GuideMode = item.GuideMode,
|
||||
PreRollFillerId = item.PreRollFillerId,
|
||||
|
||||
@@ -26,7 +26,7 @@ public record ReplaceProgramScheduleItem(
|
||||
int? MarathonBatchSize,
|
||||
FillWithGroupMode FillWithGroupMode,
|
||||
MultipleMode MultipleMode,
|
||||
int? MultipleCount,
|
||||
string MultipleCount,
|
||||
TimeSpan? PlayoutDuration,
|
||||
TailMode TailMode,
|
||||
int? DiscardToFillAttempts,
|
||||
|
||||
@@ -32,7 +32,7 @@ public record ProgramScheduleItemMultipleViewModel : ProgramScheduleItemViewMode
|
||||
int? marathonBatchSize,
|
||||
FillWithGroupMode fillWithGroupMode,
|
||||
MultipleMode multipleMode,
|
||||
int count,
|
||||
string count,
|
||||
string customTitle,
|
||||
GuideMode guideMode,
|
||||
FillerPresetViewModel preRollFiller,
|
||||
@@ -87,5 +87,5 @@ public record ProgramScheduleItemMultipleViewModel : ProgramScheduleItemViewMode
|
||||
|
||||
public MultipleMode MultipleMode { get; set; }
|
||||
|
||||
public int Count { get; }
|
||||
public string Count { get; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.ProgramSchedules;
|
||||
|
||||
public record ProcessSchedulingContext(string SerializedContext) : IRequest<Option<string>>;
|
||||
@@ -0,0 +1,218 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using JsonSerializer = System.Text.Json.JsonSerializer;
|
||||
|
||||
namespace ErsatzTV.Application.ProgramSchedules;
|
||||
|
||||
public class ProcessSchedulingContextHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<ProcessSchedulingContext, Option<string>>
|
||||
{
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
public async Task<Option<string>> Handle(ProcessSchedulingContext request, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using JsonDocument doc = JsonDocument.Parse(request.SerializedContext);
|
||||
if (doc.RootElement.TryGetProperty(nameof(ClassicSchedulingContext.ScheduleId), out _))
|
||||
{
|
||||
var classicContext = JsonSerializer.Deserialize<ClassicSchedulingContext>(request.SerializedContext, Options);
|
||||
if (classicContext is not null && classicContext.ScheduleId > 0)
|
||||
{
|
||||
return await GetClassicDetails(classicContext, cancellationToken);
|
||||
}
|
||||
}
|
||||
else if (doc.RootElement.TryGetProperty(nameof(BlockSchedulingContext.BlockId), out _))
|
||||
{
|
||||
var blockContext = JsonSerializer.Deserialize<BlockSchedulingContext>(request.SerializedContext, Options);
|
||||
if (blockContext is not null && blockContext.BlockId > 0)
|
||||
{
|
||||
return await GetBlockDetails(blockContext, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// not a valid json string, or not a context we can process
|
||||
}
|
||||
|
||||
return request.SerializedContext;
|
||||
}
|
||||
|
||||
private async Task<Option<string>> GetClassicDetails(
|
||||
ClassicSchedulingContext classicContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
string scheduleName = await dbContext.ProgramSchedules
|
||||
.Where(s => s.Id == classicContext.ScheduleId)
|
||||
.Select(s => s.Name)
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
|
||||
var scheduleItem = await dbContext.ProgramScheduleItems
|
||||
.AsNoTracking()
|
||||
.AsSplitQuery()
|
||||
.Include(si => si.Collection)
|
||||
.Include(si => si.MultiCollection)
|
||||
.Include(si => si.Playlist)
|
||||
.Include(si => si.RerunCollection)
|
||||
.Include(si => si.SmartCollection)
|
||||
.SingleOrDefaultAsync(si => si.Id == classicContext.ItemId, cancellationToken);
|
||||
|
||||
var name = "Classic";
|
||||
ClassicContextFiller filler = null;
|
||||
if (classicContext.FillerPresetId is > 0)
|
||||
{
|
||||
name = "Classic - Filler";
|
||||
|
||||
var fillerPreset = await dbContext.FillerPresets
|
||||
.AsNoTracking()
|
||||
.AsSplitQuery()
|
||||
.Include(p => p.Collection)
|
||||
.Include(p => p.MultiCollection)
|
||||
.Include(p => p.Playlist)
|
||||
.Include(p => p.SmartCollection)
|
||||
.SingleOrDefaultAsync(p => p.Id == classicContext.FillerPresetId, cancellationToken);
|
||||
|
||||
if (fillerPreset is not null)
|
||||
{
|
||||
string collectionName = fillerPreset.CollectionType switch
|
||||
{
|
||||
CollectionType.Collection => fillerPreset.Collection.Name,
|
||||
CollectionType.MultiCollection => fillerPreset.MultiCollection.Name,
|
||||
CollectionType.Playlist => fillerPreset.Playlist.Name,
|
||||
CollectionType.SmartCollection => fillerPreset.SmartCollection.Name,
|
||||
_ => null
|
||||
};
|
||||
|
||||
filler = new ClassicContextFiller(
|
||||
fillerPreset.Id,
|
||||
fillerPreset.Name,
|
||||
fillerPreset.FillerKind,
|
||||
fillerPreset.FillerMode,
|
||||
fillerPreset.CollectionType,
|
||||
collectionName);
|
||||
}
|
||||
}
|
||||
|
||||
ClassicContextScheduleItem item;
|
||||
if (scheduleItem is not null)
|
||||
{
|
||||
string collectionName = scheduleItem.CollectionType switch
|
||||
{
|
||||
CollectionType.Collection => scheduleItem.Collection.Name,
|
||||
CollectionType.MultiCollection => scheduleItem.MultiCollection.Name,
|
||||
CollectionType.Playlist => scheduleItem.Playlist.Name,
|
||||
CollectionType.RerunRerun or CollectionType.RerunFirstRun => scheduleItem.RerunCollection.Name,
|
||||
CollectionType.SmartCollection => scheduleItem.SmartCollection.Name,
|
||||
_ => null
|
||||
};
|
||||
|
||||
item = new ClassicContextScheduleItem(scheduleItem.Id, scheduleItem.CollectionType, collectionName);
|
||||
}
|
||||
else
|
||||
{
|
||||
item = new ClassicContextScheduleItem(classicContext.ItemId, null, null);
|
||||
}
|
||||
|
||||
var context = new ClassicContext(
|
||||
new ContextScheduler(name, classicContext.Scheduler),
|
||||
new ClassicContextSchedule(classicContext.ScheduleId, scheduleName ?? string.Empty),
|
||||
item,
|
||||
filler,
|
||||
new ContextEnumerator(classicContext.Enumerator, classicContext.Seed, classicContext.Index));
|
||||
|
||||
return JsonSerializer.Serialize(context, Options);
|
||||
}
|
||||
|
||||
private async Task<Option<string>> GetBlockDetails(BlockSchedulingContext blockContext, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
var block = await dbContext.Blocks
|
||||
.AsNoTracking()
|
||||
.Include(b => b.BlockGroup)
|
||||
.SingleOrDefaultAsync(s => s.Id == blockContext.BlockId, cancellationToken);
|
||||
|
||||
var blockItem = await dbContext.BlockItems
|
||||
.AsNoTracking()
|
||||
.AsSplitQuery()
|
||||
.Include(si => si.Collection)
|
||||
.Include(si => si.MultiCollection)
|
||||
.Include(si => si.SmartCollection)
|
||||
.SingleOrDefaultAsync(s => s.Id == blockContext.BlockItemId, cancellationToken);
|
||||
|
||||
BlockContextBlockItem item;
|
||||
if (blockItem is not null)
|
||||
{
|
||||
string collectionName = blockItem.CollectionType switch
|
||||
{
|
||||
CollectionType.Collection => blockItem.Collection.Name,
|
||||
CollectionType.MultiCollection => blockItem.MultiCollection.Name,
|
||||
CollectionType.SmartCollection => blockItem.SmartCollection.Name,
|
||||
_ => null
|
||||
};
|
||||
|
||||
item = new BlockContextBlockItem(blockItem.Id, blockItem.CollectionType, collectionName);
|
||||
}
|
||||
else
|
||||
{
|
||||
item = new BlockContextBlockItem(blockContext.BlockItemId, null, null);
|
||||
}
|
||||
|
||||
var context = new BlockContext(
|
||||
new ContextScheduler("Block", null),
|
||||
new BlockContextBlock(
|
||||
blockContext.BlockId,
|
||||
block?.BlockGroup?.Name ?? string.Empty,
|
||||
block?.Name ?? string.Empty),
|
||||
item,
|
||||
new ContextEnumerator(blockContext.Enumerator, blockContext.Seed, blockContext.Index));
|
||||
|
||||
return JsonSerializer.Serialize(context, Options);
|
||||
}
|
||||
|
||||
private sealed record ClassicContext(
|
||||
ContextScheduler Scheduler,
|
||||
ClassicContextSchedule Schedule,
|
||||
ClassicContextScheduleItem ScheduleItem,
|
||||
ClassicContextFiller Filler,
|
||||
ContextEnumerator Enumerator);
|
||||
|
||||
private sealed record ContextScheduler(string Type, string Mode);
|
||||
|
||||
private sealed record ClassicContextSchedule(int Id, string Name);
|
||||
|
||||
private sealed record ClassicContextScheduleItem(int Id, CollectionType? CollectionType, string CollectionName);
|
||||
|
||||
private sealed record ClassicContextFiller(
|
||||
int Id,
|
||||
string Name,
|
||||
FillerKind Kind,
|
||||
FillerMode Mode,
|
||||
CollectionType CollectionType,
|
||||
string CollectionName);
|
||||
|
||||
private sealed record ContextEnumerator(string Name, int Seed, int Index);
|
||||
|
||||
private sealed record BlockContext(
|
||||
ContextScheduler Scheduler,
|
||||
BlockContextBlock Block,
|
||||
BlockContextBlockItem BlockItem,
|
||||
ContextEnumerator Enumerator);
|
||||
|
||||
private sealed record BlockContextBlock(int Id, string Group, string Name);
|
||||
|
||||
private sealed record BlockContextBlockItem(int Id, CollectionType? CollectionType, string CollectionName);
|
||||
}
|
||||
@@ -21,27 +21,43 @@ public class ErasePlayoutHistoryHandler(IDbContextFactory<TvContext> dbContextFa
|
||||
|
||||
foreach (Playout playout in maybePlayout)
|
||||
{
|
||||
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
|
||||
|
||||
int nextSeed = new Random().Next();
|
||||
playout.Seed = nextSeed;
|
||||
|
||||
// this deletes the owned PlayoutAnchor
|
||||
playout.Anchor = null;
|
||||
|
||||
playout.OnDemandCheckpoint = null;
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await dbContext.Database.ExecuteSqlAsync(
|
||||
$"DELETE FROM PlayoutItem WHERE PlayoutId = {playout.Id}",
|
||||
cancellationToken);
|
||||
await dbContext.PlayoutItems
|
||||
.Where(pi => pi.PlayoutId == playout.Id)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
await dbContext.Database.ExecuteSqlAsync(
|
||||
$"DELETE FROM PlayoutHistory WHERE PlayoutId = {playout.Id}",
|
||||
cancellationToken);
|
||||
await dbContext.PlayoutHistory
|
||||
.Where(ph => ph.PlayoutId == playout.Id)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
await dbContext.Database.ExecuteSqlAsync(
|
||||
$"DELETE FROM PlayoutAnchor WHERE PlayoutId = {playout.Id}",
|
||||
cancellationToken);
|
||||
await dbContext.PlayoutProgramScheduleItemAnchors
|
||||
.Where(a => a.PlayoutId == playout.Id)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
await dbContext.Database.ExecuteSqlAsync(
|
||||
$"DELETE FROM PlayoutProgramScheduleAnchor WHERE PlayoutId = {playout.Id}",
|
||||
cancellationToken);
|
||||
await dbContext.RerunHistory
|
||||
.Where(rh => rh.PlayoutId == playout.Id)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
await dbContext.PlayoutGaps
|
||||
.Where(pg => pg.PlayoutId == playout.Id)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
await dbContext.PlayoutBuildStatus
|
||||
.Where(pb => pb.PlayoutId == playout.Id)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Scheduling;
|
||||
@@ -14,39 +12,49 @@ public class ErasePlayoutItemsHandler(IDbContextFactory<TvContext> dbContextFact
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
Option<Playout> maybePlayout = await dbContext.Playouts
|
||||
.Include(p => p.Items)
|
||||
.Include(p => p.PlayoutHistory)
|
||||
.TagWithCallSite()
|
||||
.AsNoTracking()
|
||||
.Filter(p => p.ScheduleKind == PlayoutScheduleKind.Block ||
|
||||
p.ScheduleKind == PlayoutScheduleKind.Sequential ||
|
||||
p.ScheduleKind == PlayoutScheduleKind.Scripted)
|
||||
.SelectOneAsync(p => p.Id, p => p.Id == request.PlayoutId, cancellationToken);
|
||||
.SingleOrDefaultAsync(p => p.Id == request.PlayoutId, cancellationToken);
|
||||
|
||||
foreach (Playout playout in maybePlayout)
|
||||
{
|
||||
// find the earliest item that finishes after "now"
|
||||
Option<PlayoutItem> maybeFirstItem = playout.Items
|
||||
.Filter(i => i.FinishOffset > DateTimeOffset.Now)
|
||||
.OrderBy(i => i.StartOffset)
|
||||
.HeadOrNone();
|
||||
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
|
||||
|
||||
// delete all history starting with that item
|
||||
// importantly, do NOT delete earlier history
|
||||
foreach (PlayoutItem item in maybeFirstItem)
|
||||
// find the earliest item that finishes after "now"
|
||||
Option<PlayoutItem> maybeFirstItem = await dbContext.PlayoutItems
|
||||
.TagWithCallSite()
|
||||
.AsNoTracking()
|
||||
.Where(pi => pi.PlayoutId == playout.Id)
|
||||
.Where(pi => pi.Finish > DateTime.UtcNow)
|
||||
.OrderBy(i => i.Start)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
foreach (PlayoutItem firstItem in maybeFirstItem)
|
||||
{
|
||||
var toRemove = playout.PlayoutHistory.Filter(h => h.When >= item.Start).ToList();
|
||||
foreach (PlayoutHistory history in toRemove)
|
||||
{
|
||||
playout.PlayoutHistory.Remove(history);
|
||||
}
|
||||
// delete all history starting with that item
|
||||
// importantly, do NOT delete earlier history
|
||||
await dbContext.PlayoutHistory
|
||||
.Where(ph => ph.PlayoutId == playout.Id)
|
||||
.Where(ph => ph.When >= firstItem.Start)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
}
|
||||
|
||||
// save history changes
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
await dbContext.PlayoutItems
|
||||
.Where(pi => pi.PlayoutId == playout.Id)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
// delete all playout items
|
||||
await dbContext.Database.ExecuteSqlAsync(
|
||||
$"DELETE FROM PlayoutItem WHERE PlayoutId = {playout.Id}",
|
||||
cancellationToken);
|
||||
await dbContext.PlayoutGaps
|
||||
.Where(pg => pg.PlayoutId == playout.Id)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
await dbContext.PlayoutBuildStatus
|
||||
.Where(pb => pb.PlayoutId == playout.Id)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Search;
|
||||
|
||||
namespace ErsatzTV.Application.Search;
|
||||
|
||||
public class QuerySearchIndexAllItemsHandler(IClient client, ISearchIndex searchIndex)
|
||||
public class QuerySearchIndexAllItemsHandler(ISearchIndex searchIndex)
|
||||
: IRequestHandler<QuerySearchIndexAllItems, SearchResultAllItemsViewModel>
|
||||
{
|
||||
public async Task<SearchResultAllItemsViewModel> Handle(
|
||||
@@ -23,7 +22,7 @@ public class QuerySearchIndexAllItemsHandler(IClient client, ISearchIndex search
|
||||
await GetIds(LuceneSearchIndex.RemoteStreamType, request.Query, cancellationToken));
|
||||
|
||||
private async Task<List<int>> GetIds(string type, string query, CancellationToken cancellationToken) =>
|
||||
(await searchIndex.Search(client, $"type:{type} AND ({query})", string.Empty, 0, 0, cancellationToken)).Items
|
||||
(await searchIndex.Search($"type:{type} AND ({query})", string.Empty, 0, 0, cancellationToken)).Items
|
||||
.Map(i => i.Id)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Application.MediaCards;
|
||||
using ErsatzTV.Application.MediaCards;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
@@ -9,7 +8,6 @@ using static ErsatzTV.Application.MediaCards.Mapper;
|
||||
namespace ErsatzTV.Application.Search;
|
||||
|
||||
public class QuerySearchIndexArtistsHandler(
|
||||
IClient client,
|
||||
ISearchIndex searchIndex,
|
||||
IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<QuerySearchIndexArtists, ArtistCardResultsViewModel>
|
||||
@@ -19,7 +17,6 @@ public class QuerySearchIndexArtistsHandler(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
SearchResult searchResult = await searchIndex.Search(
|
||||
client,
|
||||
request.Query,
|
||||
string.Empty,
|
||||
(request.PageNumber - 1) * request.PageSize,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Application.MediaCards;
|
||||
using ErsatzTV.Application.MediaCards;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.Interfaces.Emby;
|
||||
@@ -17,7 +16,6 @@ namespace ErsatzTV.Application.Search;
|
||||
|
||||
public class
|
||||
QuerySearchIndexEpisodesHandler(
|
||||
IClient client,
|
||||
ISearchIndex searchIndex,
|
||||
IPlexPathReplacementService plexPathReplacementService,
|
||||
IJellyfinPathReplacementService jellyfinPathReplacementService,
|
||||
@@ -31,7 +29,6 @@ public class
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
SearchResult searchResult = await searchIndex.Search(
|
||||
client,
|
||||
request.Query,
|
||||
string.Empty,
|
||||
(request.PageNumber - 1) * request.PageSize,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Application.MediaCards;
|
||||
using ErsatzTV.Application.MediaCards;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
@@ -9,7 +8,6 @@ using static ErsatzTV.Application.MediaCards.Mapper;
|
||||
namespace ErsatzTV.Application.Search;
|
||||
|
||||
public class QuerySearchIndexImagesHandler(
|
||||
IClient client,
|
||||
ISearchIndex searchIndex,
|
||||
IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<QuerySearchIndexImages, ImageCardResultsViewModel>
|
||||
@@ -19,7 +17,6 @@ public class QuerySearchIndexImagesHandler(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
SearchResult searchResult = await searchIndex.Search(
|
||||
client,
|
||||
request.Query,
|
||||
string.Empty,
|
||||
(request.PageNumber - 1) * request.PageSize,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Application.MediaCards;
|
||||
using ErsatzTV.Application.MediaCards;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Search;
|
||||
@@ -10,7 +9,6 @@ using static ErsatzTV.Application.MediaCards.Mapper;
|
||||
namespace ErsatzTV.Application.Search;
|
||||
|
||||
public class QuerySearchIndexMoviesHandler(
|
||||
IClient client,
|
||||
ISearchIndex searchIndex,
|
||||
IDbContextFactory<TvContext> dbContextFactory)
|
||||
: QuerySearchIndexHandlerBase, IRequestHandler<QuerySearchIndexMovies, MovieCardResultsViewModel>
|
||||
@@ -20,7 +18,6 @@ public class QuerySearchIndexMoviesHandler(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
SearchResult searchResult = await searchIndex.Search(
|
||||
client,
|
||||
request.Query,
|
||||
string.Empty,
|
||||
(request.PageNumber - 1) * request.PageSize,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Application.MediaCards;
|
||||
using ErsatzTV.Application.MediaCards;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.Interfaces.Emby;
|
||||
@@ -15,7 +14,6 @@ namespace ErsatzTV.Application.Search;
|
||||
|
||||
public class
|
||||
QuerySearchIndexMusicVideosHandler(
|
||||
IClient client,
|
||||
ISearchIndex searchIndex,
|
||||
IPlexPathReplacementService plexPathReplacementService,
|
||||
IJellyfinPathReplacementService jellyfinPathReplacementService,
|
||||
@@ -28,7 +26,6 @@ public class
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
SearchResult searchResult = await searchIndex.Search(
|
||||
client,
|
||||
request.Query,
|
||||
string.Empty,
|
||||
(request.PageNumber - 1) * request.PageSize,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Application.MediaCards;
|
||||
using ErsatzTV.Application.MediaCards;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
@@ -10,7 +9,6 @@ namespace ErsatzTV.Application.Search;
|
||||
|
||||
public class
|
||||
QuerySearchIndexOtherVideosHandler(
|
||||
IClient client,
|
||||
ISearchIndex searchIndex,
|
||||
IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<QuerySearchIndexOtherVideos, OtherVideoCardResultsViewModel>
|
||||
@@ -20,7 +18,6 @@ public class
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
SearchResult searchResult = await searchIndex.Search(
|
||||
client,
|
||||
request.Query,
|
||||
string.Empty,
|
||||
(request.PageNumber - 1) * request.PageSize,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Application.MediaCards;
|
||||
using ErsatzTV.Application.MediaCards;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
@@ -9,7 +8,6 @@ using static ErsatzTV.Application.MediaCards.Mapper;
|
||||
namespace ErsatzTV.Application.Search;
|
||||
|
||||
public class QuerySearchIndexRemoteStreamsHandler(
|
||||
IClient client,
|
||||
ISearchIndex searchIndex,
|
||||
IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<QuerySearchIndexRemoteStreams, RemoteStreamCardResultsViewModel>
|
||||
@@ -19,7 +17,6 @@ public class QuerySearchIndexRemoteStreamsHandler(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
SearchResult searchResult = await searchIndex.Search(
|
||||
client,
|
||||
request.Query,
|
||||
string.Empty,
|
||||
(request.PageNumber - 1) * request.PageSize,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Application.MediaCards;
|
||||
using ErsatzTV.Application.MediaCards;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Search;
|
||||
@@ -11,7 +10,6 @@ namespace ErsatzTV.Application.Search;
|
||||
|
||||
public class
|
||||
QuerySearchIndexSeasonsHandler(
|
||||
IClient client,
|
||||
ISearchIndex searchIndex,
|
||||
IDbContextFactory<TvContext> dbContextFactory)
|
||||
: QuerySearchIndexHandlerBase, IRequestHandler<QuerySearchIndexSeasons, TelevisionSeasonCardResultsViewModel>
|
||||
@@ -21,7 +19,6 @@ public class
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
SearchResult searchResult = await searchIndex.Search(
|
||||
client,
|
||||
request.Query,
|
||||
string.Empty,
|
||||
(request.PageNumber - 1) * request.PageSize,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Application.MediaCards;
|
||||
using ErsatzTV.Application.MediaCards;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Search;
|
||||
@@ -11,7 +10,6 @@ namespace ErsatzTV.Application.Search;
|
||||
|
||||
public class
|
||||
QuerySearchIndexShowsHandler(
|
||||
IClient client,
|
||||
ISearchIndex searchIndex,
|
||||
IDbContextFactory<TvContext> dbContextFactory)
|
||||
: QuerySearchIndexHandlerBase, IRequestHandler<QuerySearchIndexShows, TelevisionShowCardResultsViewModel>
|
||||
@@ -21,7 +19,6 @@ public class
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
SearchResult searchResult = await searchIndex.Search(
|
||||
client,
|
||||
request.Query,
|
||||
string.Empty,
|
||||
(request.PageNumber - 1) * request.PageSize,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Application.MediaCards;
|
||||
using ErsatzTV.Application.MediaCards;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
@@ -8,7 +7,7 @@ using static ErsatzTV.Application.MediaCards.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Search;
|
||||
|
||||
public class QuerySearchIndexSongsHandler(IClient client, ISearchIndex searchIndex, IDbContextFactory<TvContext> dbContextFactory)
|
||||
public class QuerySearchIndexSongsHandler(ISearchIndex searchIndex, IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<QuerySearchIndexSongs, SongCardResultsViewModel>
|
||||
{
|
||||
public async Task<SongCardResultsViewModel> Handle(
|
||||
@@ -16,7 +15,6 @@ public class QuerySearchIndexSongsHandler(IClient client, ISearchIndex searchInd
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
SearchResult searchResult = await searchIndex.Search(
|
||||
client,
|
||||
request.Query,
|
||||
string.Empty,
|
||||
(request.PageNumber - 1) * request.PageSize,
|
||||
|
||||
@@ -1,23 +1,30 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using ErsatzTV.Application.MediaItems;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Search;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Search;
|
||||
|
||||
public class SearchMoviesHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<SearchMovies, List<NamedMediaItemViewModel>>
|
||||
public class SearchMoviesHandler(
|
||||
ISearchIndex searchIndex,
|
||||
IDbContextFactory<TvContext> dbContextFactory)
|
||||
: SearchUsingSearchIndexHandler(searchIndex), IRequestHandler<SearchMovies, List<NamedMediaItemViewModel>>
|
||||
{
|
||||
public async Task<List<NamedMediaItemViewModel>> Handle(SearchMovies request, CancellationToken cancellationToken)
|
||||
{
|
||||
ImmutableHashSet<int> ids = await Search(LuceneSearchIndex.MovieType, request.Query, cancellationToken);
|
||||
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
return await dbContext.MovieMetadata
|
||||
.TagWithCallSite()
|
||||
.AsNoTracking()
|
||||
.Where(m => EF.Functions.Like(m.Title + " " + m.Year, $"%{request.Query}%"))
|
||||
.OrderBy(m => m.Title)
|
||||
.ThenBy(m => m.Year)
|
||||
.Take(10)
|
||||
.Where(mm => ids.Contains(mm.MovieId))
|
||||
.OrderBy(mm => mm.Title)
|
||||
.ThenBy(mm => mm.Year)
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(list => list.Map(ToNamedMediaItem).ToList());
|
||||
}
|
||||
|
||||
@@ -1,30 +1,43 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using ErsatzTV.Application.MediaItems;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Search;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Search;
|
||||
|
||||
public class SearchTelevisionSeasonsHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<SearchTelevisionSeasons, List<NamedMediaItemViewModel>>
|
||||
public class SearchTelevisionSeasonsHandler(
|
||||
ISearchIndex searchIndex,
|
||||
IDbContextFactory<TvContext> dbContextFactory)
|
||||
: SearchUsingSearchIndexHandler(searchIndex),
|
||||
IRequestHandler<SearchTelevisionSeasons, List<NamedMediaItemViewModel>>
|
||||
{
|
||||
public async Task<List<NamedMediaItemViewModel>> Handle(
|
||||
SearchTelevisionSeasons request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ImmutableHashSet<int> ids = await Search(LuceneSearchIndex.SeasonType, request.Query, cancellationToken);
|
||||
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
return await (from season in dbContext.Set<Season>()
|
||||
join seasonMetadata in dbContext.Set<SeasonMetadata>()
|
||||
on season.Id equals seasonMetadata.SeasonId
|
||||
join showMetadata in dbContext.Set<ShowMetadata>()
|
||||
on season.ShowId equals showMetadata.ShowId
|
||||
where EF.Functions.Like(showMetadata.Title + " " + seasonMetadata.Title, $"%{request.Query}%")
|
||||
orderby showMetadata.Title, season.SeasonNumber
|
||||
select new TelevisionSeason(season.Id, showMetadata.Title, showMetadata.Year, season.SeasonNumber))
|
||||
.Take(20)
|
||||
return await dbContext.SeasonMetadata
|
||||
.TagWithCallSite()
|
||||
.AsNoTracking()
|
||||
.Include(s => s.Season)
|
||||
.ThenInclude(s => s.Show)
|
||||
.ThenInclude(s => s.ShowMetadata)
|
||||
.Where(sm => ids.Contains(sm.SeasonId))
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(list => list.Map(ToNamedMediaItem).ToList());
|
||||
.Map(list => list.Map(sm => new TelevisionSeason(
|
||||
sm.SeasonId,
|
||||
sm.Season.Show.ShowMetadata.HeadOrNone().Match(s => s.Title, string.Empty),
|
||||
sm.Year,
|
||||
sm.Season.SeasonNumber))
|
||||
.OrderBy(s => s.Title)
|
||||
.ThenBy(s => s.SeasonNumber)
|
||||
.Map(ToNamedMediaItem)
|
||||
.ToList());
|
||||
}
|
||||
|
||||
private static NamedMediaItemViewModel ToNamedMediaItem(TelevisionSeason season) =>
|
||||
|
||||
@@ -1,25 +1,33 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using ErsatzTV.Application.MediaItems;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Search;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Search;
|
||||
|
||||
public class SearchTelevisionShowsHandler(IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<SearchTelevisionShows, List<NamedMediaItemViewModel>>
|
||||
public class SearchTelevisionShowsHandler(
|
||||
ISearchIndex searchIndex,
|
||||
IDbContextFactory<TvContext> dbContextFactory)
|
||||
: SearchUsingSearchIndexHandler(searchIndex),
|
||||
IRequestHandler<SearchTelevisionShows, List<NamedMediaItemViewModel>>
|
||||
{
|
||||
public async Task<List<NamedMediaItemViewModel>> Handle(
|
||||
SearchTelevisionShows request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ImmutableHashSet<int> ids = await Search(LuceneSearchIndex.ShowType, request.Query, cancellationToken);
|
||||
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
return await dbContext.ShowMetadata
|
||||
.TagWithCallSite()
|
||||
.AsNoTracking()
|
||||
.Where(s => EF.Functions.Like(s.Title + " " + s.Year, $"%{request.Query}%"))
|
||||
.OrderBy(s => s.Title)
|
||||
.ThenBy(s => s.Year)
|
||||
.Take(10)
|
||||
.Where(sm => ids.Contains(sm.ShowId))
|
||||
.OrderBy(sm => sm.Title)
|
||||
.ThenBy(sm => sm.Year)
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(list => list.Map(ToNamedMediaItem).ToList());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.Collections.Immutable;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Search;
|
||||
|
||||
namespace ErsatzTV.Application.Search;
|
||||
|
||||
public abstract class SearchUsingSearchIndexHandler(ISearchIndex searchIndex)
|
||||
{
|
||||
private const int PageSize = 10;
|
||||
|
||||
protected async Task<ImmutableHashSet<int>> Search(string type, string query, CancellationToken cancellationToken)
|
||||
{
|
||||
var searchResult = await searchIndex.Search(
|
||||
$"type:{type} AND *{query.Replace(" ", @"\ ")}*",
|
||||
string.Empty,
|
||||
0,
|
||||
PageSize,
|
||||
[LuceneSearchIndex.TitleAndYearSearchField],
|
||||
cancellationToken);
|
||||
|
||||
return searchResult.Items.Select(i => i.Id).ToImmutableHashSet();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
using System.IO.Abstractions;
|
||||
using System.Threading.Channels;
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Application.Graphics;
|
||||
using ErsatzTV.Application.Maintenance;
|
||||
@@ -22,7 +21,6 @@ namespace ErsatzTV.Application.Streaming;
|
||||
|
||||
public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IClient _client;
|
||||
private readonly IFileSystem _fileSystem;
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
|
||||
@@ -42,7 +40,6 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
|
||||
IHlsInitSegmentCache hlsInitSegmentCache,
|
||||
IServiceScopeFactory serviceScopeFactory,
|
||||
IMediator mediator,
|
||||
IClient client,
|
||||
IFileSystem fileSystem,
|
||||
ILocalFileSystem localFileSystem,
|
||||
ILogger<StartFFmpegSessionHandler> logger,
|
||||
@@ -57,7 +54,6 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
|
||||
_hlsInitSegmentCache = hlsInitSegmentCache;
|
||||
_serviceScopeFactory = serviceScopeFactory;
|
||||
_mediator = mediator;
|
||||
_client = client;
|
||||
_fileSystem = fileSystem;
|
||||
_localFileSystem = localFileSystem;
|
||||
_logger = logger;
|
||||
@@ -129,7 +125,6 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
|
||||
_ => new HlsSessionWorker(
|
||||
_serviceScopeFactory,
|
||||
_graphicsEngine,
|
||||
_client,
|
||||
OutputFormatKind.Hls,
|
||||
_hlsPlaylistFilter,
|
||||
_hlsInitSegmentCache,
|
||||
|
||||
@@ -6,5 +6,7 @@ public enum HlsSessionState
|
||||
ZeroAndWorkAhead,
|
||||
SeekAndRealtime,
|
||||
ZeroAndRealtime,
|
||||
SlugAndWorkAhead,
|
||||
SlugAndRealtime,
|
||||
PlayoutUpdated
|
||||
}
|
||||
|
||||
@@ -5,9 +5,9 @@ using System.IO.Abstractions;
|
||||
using System.IO.Pipelines;
|
||||
using System.Text;
|
||||
using System.Timers;
|
||||
using Bugsnag;
|
||||
using CliWrap;
|
||||
using CliWrap.Buffered;
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
@@ -27,7 +27,6 @@ namespace ErsatzTV.Application.Streaming;
|
||||
public class HlsSessionWorker : IHlsSessionWorker
|
||||
{
|
||||
private static int _workAheadCount;
|
||||
private readonly IClient _client;
|
||||
private readonly OutputFormatKind _outputFormatKind;
|
||||
private readonly IHlsInitSegmentCache _hlsInitSegmentCache;
|
||||
private readonly Dictionary<long, int> _discontinuityMap = [];
|
||||
@@ -54,11 +53,11 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
private Timer _timer;
|
||||
private DateTimeOffset _transcodedUntil;
|
||||
private string _workingDirectory;
|
||||
private Option<double> _slugSeconds;
|
||||
|
||||
public HlsSessionWorker(
|
||||
IServiceScopeFactory serviceScopeFactory,
|
||||
IGraphicsEngine graphicsEngine,
|
||||
IClient client,
|
||||
OutputFormatKind outputFormatKind,
|
||||
IHlsPlaylistFilter hlsPlaylistFilter,
|
||||
IHlsInitSegmentCache hlsInitSegmentCache,
|
||||
@@ -71,7 +70,6 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
_serviceScope = serviceScopeFactory.CreateScope();
|
||||
_mediator = _serviceScope.ServiceProvider.GetRequiredService<IMediator>();
|
||||
_graphicsEngine = graphicsEngine;
|
||||
_client = client;
|
||||
_outputFormatKind = outputFormatKind;
|
||||
_hlsInitSegmentCache = hlsInitSegmentCache;
|
||||
_hlsPlaylistFilter = hlsPlaylistFilter;
|
||||
@@ -215,6 +213,10 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
new GetPlayoutIdByChannelNumber(_channelNumber),
|
||||
cancellationToken);
|
||||
|
||||
_slugSeconds = await _mediator.Send(
|
||||
new GetSlugSecondsByChannelNumber(_channelNumber),
|
||||
cancellationToken);
|
||||
|
||||
// time shift on-demand playout if needed
|
||||
foreach (int playoutId in maybePlayoutId)
|
||||
{
|
||||
@@ -307,9 +309,6 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
var sw = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
DateTimeOffset start = DateTimeOffset.Now;
|
||||
DateTimeOffset finish = start.AddSeconds(8);
|
||||
|
||||
string playlistFileName = Path.Combine(_workingDirectory, "live.m3u8");
|
||||
|
||||
_logger.LogDebug("Waiting for playlist to exist");
|
||||
@@ -320,6 +319,10 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
|
||||
_logger.LogDebug("Playlist exists");
|
||||
|
||||
// start the segment-wait deadline only after the playlist file appears,
|
||||
// so slow pipeline setup (e.g. h264 profile probing) doesn't consume the budget
|
||||
DateTimeOffset finish = DateTimeOffset.Now.AddSeconds(8);
|
||||
|
||||
var segmentCount = 0;
|
||||
int lastSegmentCount = -1;
|
||||
while (DateTimeOffset.Now < finish && segmentCount < initialSegmentCount)
|
||||
@@ -382,6 +385,14 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
// after seeking and NOT completing the item, seek again, transcode method will accelerate if needed
|
||||
HlsSessionState.SeekAndWorkAhead when !isComplete => HlsSessionState.SeekAndRealtime,
|
||||
|
||||
// switch back to normal item after slug
|
||||
HlsSessionState.SlugAndWorkAhead => HlsSessionState.ZeroAndWorkAhead,
|
||||
HlsSessionState.SlugAndRealtime => HlsSessionState.ZeroAndRealtime,
|
||||
|
||||
// after completing the item, insert a slug
|
||||
HlsSessionState.ZeroAndWorkAhead or HlsSessionState.SeekAndWorkAhead when isComplete && _slugSeconds.IsSome => HlsSessionState.SlugAndWorkAhead,
|
||||
HlsSessionState.ZeroAndRealtime or HlsSessionState.SeekAndRealtime when isComplete && _slugSeconds.IsSome => HlsSessionState.SlugAndRealtime,
|
||||
|
||||
// after seeking and completing the item, start at zero
|
||||
HlsSessionState.SeekAndWorkAhead => HlsSessionState.ZeroAndWorkAhead,
|
||||
|
||||
@@ -441,6 +452,7 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
{
|
||||
HlsSessionState.SeekAndWorkAhead => HlsSessionState.SeekAndRealtime,
|
||||
HlsSessionState.ZeroAndWorkAhead => HlsSessionState.ZeroAndRealtime,
|
||||
HlsSessionState.SlugAndWorkAhead => HlsSessionState.SlugAndRealtime,
|
||||
_ => _state
|
||||
};
|
||||
|
||||
@@ -456,19 +468,32 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
_logger.LogDebug("HLS session state: {State}", _state);
|
||||
|
||||
DateTimeOffset now = wasSeekAndWorkAhead ? DateTimeOffset.Now : _transcodedUntil;
|
||||
bool startAtZero = _state is HlsSessionState.ZeroAndWorkAhead or HlsSessionState.ZeroAndRealtime;
|
||||
bool startAtZero = _state is HlsSessionState.ZeroAndWorkAhead or HlsSessionState.ZeroAndRealtime
|
||||
or HlsSessionState.SlugAndWorkAhead or HlsSessionState.SlugAndRealtime;
|
||||
|
||||
var request = new GetPlayoutItemProcessByChannelNumber(
|
||||
_channelNumber,
|
||||
StreamingMode.HttpLiveStreamingSegmenter,
|
||||
now,
|
||||
startAtZero,
|
||||
realtime,
|
||||
_channelStart,
|
||||
ptsOffset,
|
||||
_targetFramerate,
|
||||
IsTroubleshooting: false,
|
||||
Option<int>.None);
|
||||
bool isSlug = _state is HlsSessionState.SlugAndWorkAhead or HlsSessionState.SlugAndRealtime;
|
||||
|
||||
FFmpegProcessRequest request = isSlug
|
||||
? new GetSlugProcessByChannelNumber(
|
||||
_channelNumber,
|
||||
StreamingMode.HttpLiveStreamingSegmenter,
|
||||
now,
|
||||
realtime,
|
||||
_channelStart,
|
||||
ptsOffset,
|
||||
_targetFramerate,
|
||||
_slugSeconds)
|
||||
: new GetPlayoutItemProcessByChannelNumber(
|
||||
_channelNumber,
|
||||
StreamingMode.HttpLiveStreamingSegmenter,
|
||||
now,
|
||||
startAtZero,
|
||||
realtime,
|
||||
_channelStart,
|
||||
ptsOffset,
|
||||
_targetFramerate,
|
||||
IsTroubleshooting: false,
|
||||
Option<int>.None);
|
||||
|
||||
// _logger.LogInformation("Request {@Request}", request);
|
||||
|
||||
@@ -528,9 +553,12 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
linkedCts.Token);
|
||||
}
|
||||
|
||||
var progressParser = new FFmpegProgress();
|
||||
|
||||
CommandResult commandResult = await processWithPipe
|
||||
.WithWorkingDirectory(_workingDirectory)
|
||||
.WithStandardErrorPipe(PipeTarget.ToStringBuilder(stdErrBuffer))
|
||||
.WithStandardOutputPipe(PipeTarget.ToDelegate(progressParser.ParseLine))
|
||||
.WithValidation(CommandResultValidation.None)
|
||||
.ExecuteAsync(linkedCts.Token);
|
||||
|
||||
@@ -538,12 +566,20 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
{
|
||||
_logger.LogDebug("HLS process has completed for channel {Channel}", _channelNumber);
|
||||
_logger.LogDebug(
|
||||
"Transcoded until: {Until} - Buffer: {Buffer} seconds",
|
||||
"Transcoded until: {Until} - Buffer: {Buffer} seconds - Speed {Speed}",
|
||||
processModel.Until,
|
||||
processModel.Until.Subtract(DateTimeOffset.Now).TotalSeconds);
|
||||
processModel.Until.Subtract(DateTimeOffset.Now).TotalSeconds,
|
||||
progressParser.Speed);
|
||||
_transcodedUntil = processModel.Until;
|
||||
_state = NextState(_state, processModel);
|
||||
_hasWrittenSegments = true;
|
||||
|
||||
progressParser.LogSpeed(
|
||||
processModel.MediaItemId,
|
||||
processModel.IsWorkingAhead,
|
||||
_channelNumber,
|
||||
_logger);
|
||||
|
||||
return true;
|
||||
}
|
||||
else
|
||||
@@ -620,15 +656,6 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
{
|
||||
_logger.LogError(ex, "Error transcoding channel {Channel} - {Message}", _channelNumber, ex.Message);
|
||||
|
||||
try
|
||||
{
|
||||
_client.Notify(ex);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
|
||||
@@ -36,7 +36,8 @@ public class GetConcatProcessByChannelNumberHandler : FFmpegProcessHandler<GetCo
|
||||
saveReports,
|
||||
channel,
|
||||
request.Scheme,
|
||||
request.Host);
|
||||
request.Host,
|
||||
cancellationToken);
|
||||
|
||||
return new PlayoutItemProcessModel(
|
||||
process,
|
||||
|
||||
@@ -32,7 +32,8 @@ public class GetErrorProcessHandler(
|
||||
channel.FFmpegProfile.VaapiDisplay,
|
||||
channel.FFmpegProfile.VaapiDriver,
|
||||
channel.FFmpegProfile.VaapiDevice,
|
||||
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames));
|
||||
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames),
|
||||
cancellationToken);
|
||||
|
||||
return new PlayoutItemProcessModel(
|
||||
process,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using System.IO.Abstractions;
|
||||
using System.Text;
|
||||
using Bugsnag;
|
||||
using CliWrap;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
@@ -13,7 +12,6 @@ using Newtonsoft.Json;
|
||||
namespace ErsatzTV.Application.Streaming;
|
||||
|
||||
public class GetLastPtsTimeHandler(
|
||||
IClient client,
|
||||
IFileSystem fileSystem,
|
||||
ITempFilePool tempFilePool,
|
||||
IConfigElementRepository configElementRepository,
|
||||
@@ -181,9 +179,9 @@ public class GetLastPtsTimeHandler(
|
||||
|
||||
logger.LogWarning("Transcode folder is in bad state; troubleshooting info saved to {File}", file);
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch (Exception)
|
||||
{
|
||||
client.Notify(ex);
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -321,7 +321,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
|
||||
if (request.IsTroubleshooting)
|
||||
{
|
||||
channel.Number = ".troubleshooting";
|
||||
channel.Number = FileSystemLayout.TranscodeTroubleshootingChannel;
|
||||
}
|
||||
|
||||
if (_isDebugNoSync)
|
||||
@@ -337,7 +337,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
channel.FFmpegProfile.VaapiDisplay,
|
||||
channel.FFmpegProfile.VaapiDriver,
|
||||
channel.FFmpegProfile.VaapiDevice,
|
||||
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames));
|
||||
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames),
|
||||
cancellationToken);
|
||||
|
||||
return new PlayoutItemProcessModel(
|
||||
doesNotExistProcess,
|
||||
@@ -498,7 +499,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
Option<TimeSpan> maybeDuration = maybeNextStart.Map(s => s - now);
|
||||
|
||||
// limit working ahead on errors to 1 minute
|
||||
if (!request.HlsRealtime && maybeDuration.IfNone(TimeSpan.FromMinutes(2)) > TimeSpan.FromMinutes(1))
|
||||
if (!request.HlsRealtime && await maybeDuration.IfNoneAsync(TimeSpan.FromMinutes(2)) > TimeSpan.FromMinutes(1))
|
||||
{
|
||||
maybeNextStart = now.AddMinutes(1);
|
||||
maybeDuration = TimeSpan.FromMinutes(1);
|
||||
@@ -508,7 +509,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
|
||||
if (request.IsTroubleshooting)
|
||||
{
|
||||
channel.Number = ".troubleshooting";
|
||||
channel.Number = FileSystemLayout.TranscodeTroubleshootingChannel;
|
||||
|
||||
maybeDuration = TimeSpan.FromSeconds(30);
|
||||
finish = now + TimeSpan.FromSeconds(30);
|
||||
@@ -534,7 +535,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
channel.FFmpegProfile.VaapiDisplay,
|
||||
channel.FFmpegProfile.VaapiDriver,
|
||||
channel.FFmpegProfile.VaapiDevice,
|
||||
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames));
|
||||
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames),
|
||||
cancellationToken);
|
||||
|
||||
return new PlayoutItemProcessModel(
|
||||
offlineProcess,
|
||||
@@ -558,7 +560,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
channel.FFmpegProfile.VaapiDisplay,
|
||||
channel.FFmpegProfile.VaapiDriver,
|
||||
channel.FFmpegProfile.VaapiDevice,
|
||||
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames));
|
||||
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames),
|
||||
cancellationToken);
|
||||
|
||||
return new PlayoutItemProcessModel(
|
||||
doesNotExistProcess,
|
||||
@@ -582,7 +585,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
channel.FFmpegProfile.VaapiDisplay,
|
||||
channel.FFmpegProfile.VaapiDriver,
|
||||
channel.FFmpegProfile.VaapiDevice,
|
||||
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames));
|
||||
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames),
|
||||
cancellationToken);
|
||||
|
||||
return new PlayoutItemProcessModel(
|
||||
errorProcess,
|
||||
@@ -766,7 +770,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// always play min(duration to next item, version.Duration)
|
||||
TimeSpan duration = maybeDuration.IfNone(version.Duration);
|
||||
TimeSpan duration = await maybeDuration.IfNoneAsync(version.Duration);
|
||||
if (version.Duration < duration)
|
||||
{
|
||||
duration = version.Duration;
|
||||
|
||||
@@ -21,19 +21,21 @@ public class GetSeekTextSubtitleProcessHandler(
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, string> validation = await Validate(dbContext, cancellationToken);
|
||||
return await validation.Match(
|
||||
ffmpegPath => GetProcess(request, ffmpegPath),
|
||||
ffmpegPath => GetProcess(request, ffmpegPath, cancellationToken),
|
||||
error => Task.FromResult<Either<BaseError, SeekTextSubtitleProcess>>(error.Join()));
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, SeekTextSubtitleProcess>> GetProcess(
|
||||
GetSeekTextSubtitleProcess request,
|
||||
string ffmpegPath)
|
||||
string ffmpegPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Command process = await ffmpegProcessService.SeekTextSubtitle(
|
||||
ffmpegPath,
|
||||
request.PathAndCodec.Path,
|
||||
request.PathAndCodec.Codec,
|
||||
request.Seek);
|
||||
request.Seek,
|
||||
cancellationToken);
|
||||
|
||||
return new SeekTextSubtitleProcess(process);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.FFmpeg;
|
||||
|
||||
namespace ErsatzTV.Application.Streaming;
|
||||
|
||||
public record GetSlugProcessByChannelNumber(
|
||||
string ChannelNumber,
|
||||
StreamingMode Mode,
|
||||
DateTimeOffset Now,
|
||||
bool HlsRealtime,
|
||||
DateTimeOffset ChannelStart,
|
||||
TimeSpan PtsOffset,
|
||||
Option<FrameRate> TargetFramerate,
|
||||
Option<double> SlugSeconds) : FFmpegProcessRequest(
|
||||
ChannelNumber,
|
||||
Mode,
|
||||
Now,
|
||||
StartAtZero: true,
|
||||
HlsRealtime,
|
||||
ChannelStart,
|
||||
PtsOffset,
|
||||
FFmpegProfileId: Option<int>.None);
|
||||
@@ -0,0 +1,53 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Streaming;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.Streaming;
|
||||
|
||||
public class GetSlugProcessByChannelNumberHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IFFmpegProcessService ffmpegProcessService)
|
||||
: FFmpegProcessHandler<GetSlugProcessByChannelNumber>(dbContextFactory)
|
||||
{
|
||||
protected override async Task<Either<BaseError, PlayoutItemProcessModel>> GetProcess(
|
||||
TvContext dbContext,
|
||||
GetSlugProcessByChannelNumber request,
|
||||
Channel channel,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var duration = TimeSpan.FromSeconds(await request.SlugSeconds.IfNoneAsync(0));
|
||||
if (duration <= TimeSpan.Zero)
|
||||
{
|
||||
return BaseError.New("Slug seconds must be non-zero");
|
||||
}
|
||||
|
||||
DateTimeOffset finish = request.Now.Add(duration);
|
||||
|
||||
var playoutItemResult = await ffmpegProcessService.Slug(
|
||||
ffmpegPath,
|
||||
channel,
|
||||
request.Now,
|
||||
duration,
|
||||
request.HlsRealtime,
|
||||
request.PtsOffset,
|
||||
cancellationToken);
|
||||
|
||||
var result = new PlayoutItemProcessModel(
|
||||
playoutItemResult,
|
||||
Option<GraphicsEngineContext>.None,
|
||||
duration,
|
||||
finish,
|
||||
isComplete: true,
|
||||
request.Now.ToUnixTimeSeconds(),
|
||||
Option<int>.None,
|
||||
Optional(channel.PlayoutOffset),
|
||||
!request.HlsRealtime);
|
||||
|
||||
return Right<BaseError, PlayoutItemProcessModel>(result);
|
||||
}
|
||||
}
|
||||
@@ -185,7 +185,7 @@ public class PrepareTroubleshootingPlaybackHandler(
|
||||
{
|
||||
Artwork = [],
|
||||
Name = "ETV",
|
||||
Number = ".troubleshooting",
|
||||
Number = FileSystemLayout.TranscodeTroubleshootingChannel,
|
||||
FFmpegProfile = ffmpegProfile,
|
||||
StreamingMode = request.StreamingMode,
|
||||
StreamSelectorMode = ChannelStreamSelectorMode.Troubleshooting,
|
||||
|
||||
@@ -2,10 +2,10 @@ using System.IO.Pipelines;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.RegularExpressions;
|
||||
using CliWrap;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Interfaces.Streaming;
|
||||
using ErsatzTV.Core.Interfaces.Troubleshooting;
|
||||
@@ -17,7 +17,7 @@ using Serilog.Events;
|
||||
|
||||
namespace ErsatzTV.Application.Troubleshooting;
|
||||
|
||||
public partial class StartTroubleshootingPlaybackHandler(
|
||||
public class StartTroubleshootingPlaybackHandler(
|
||||
ITroubleshootingNotifier notifier,
|
||||
IMediator mediator,
|
||||
IEntityLocker entityLocker,
|
||||
@@ -138,14 +138,23 @@ public partial class StartTroubleshootingPlaybackHandler(
|
||||
linkedCts.Token);
|
||||
}
|
||||
|
||||
var progressParser = new FFmpegProgress();
|
||||
|
||||
CommandResult commandResult = await processWithPipe
|
||||
.WithWorkingDirectory(FileSystemLayout.TranscodeTroubleshootingFolder)
|
||||
.WithStandardErrorPipe(PipeTarget.Null)
|
||||
.WithStandardOutputPipe(PipeTarget.ToDelegate(progressParser.ParseLine))
|
||||
.WithValidation(CommandResultValidation.None)
|
||||
.ExecuteAsync(linkedCts.Token);
|
||||
|
||||
logger.LogDebug("Troubleshooting playback completed with exit code {ExitCode}", commandResult.ExitCode);
|
||||
|
||||
progressParser.LogSpeed(
|
||||
request.MediaItemInfo.Map(i => i.Id),
|
||||
true,
|
||||
FileSystemLayout.TranscodeTroubleshootingChannel,
|
||||
logger);
|
||||
|
||||
try
|
||||
{
|
||||
IEnumerable<string> logs = logService.Sink.GetLogs(request.SessionId);
|
||||
@@ -160,26 +169,11 @@ public partial class StartTroubleshootingPlaybackHandler(
|
||||
// do nothing
|
||||
}
|
||||
|
||||
Option<double> maybeSpeed = Option<double>.None;
|
||||
Option<string> maybeFile = Directory.GetFiles(FileSystemLayout.TranscodeTroubleshootingFolder, "ffmpeg*.log").HeadOrNone();
|
||||
foreach (string file in maybeFile)
|
||||
{
|
||||
await foreach (string line in File.ReadLinesAsync(file, linkedCts.Token))
|
||||
{
|
||||
Match match = FFmpegSpeed().Match(line);
|
||||
if (match.Success && double.TryParse(match.Groups[1].Value, out double speed))
|
||||
{
|
||||
maybeSpeed = speed;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await mediator.Publish(
|
||||
new PlaybackTroubleshootingCompletedNotification(
|
||||
commandResult.ExitCode,
|
||||
Option<Exception>.None,
|
||||
maybeSpeed),
|
||||
progressParser.Speed),
|
||||
linkedCts.Token);
|
||||
|
||||
if (commandResult.ExitCode != 0)
|
||||
@@ -210,7 +204,4 @@ public partial class StartTroubleshootingPlaybackHandler(
|
||||
loggingLevelSwitches.StreamingLevelSwitch.MinimumLevel = currentStreamingLevel;
|
||||
}
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"speed=\s*([\d\.]+)x", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex FFmpegSpeed();
|
||||
}
|
||||
|
||||
@@ -161,7 +161,8 @@ public class GetTroubleshootingInfoHandler : IRequestHandler<GetTroubleshootingI
|
||||
videoToolboxCapabilities.AppendLine();
|
||||
}
|
||||
|
||||
var ffmpegCapabilities = await _hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath.Value);
|
||||
var ffmpegCapabilities =
|
||||
await _hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath.Value, cancellationToken);
|
||||
aviSynthDemuxer = ffmpegCapabilities.HasDemuxFormat(FFmpegKnownFormat.AviSynth);
|
||||
aviSynthInstalled = _hardwareCapabilitiesFactory.IsAviSynthInstalled();
|
||||
}
|
||||
|
||||
@@ -2,28 +2,27 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<NoWarn>VSTHRD200</NoWarn>
|
||||
<NoWarn>VSTHRD200,CA1873</NoWarn>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Bugsnag" Version="4.1.0" />
|
||||
<PackageReference Include="CliWrap" Version="3.10.0" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.4.9" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||
<PackageReference Include="NSubstitute" Version="5.3.0" />
|
||||
<PackageReference Include="NUnit" Version="4.4.0" />
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="6.0.1" />
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="6.1.0" />
|
||||
<PackageReference Include="Serilog" Version="4.3.0" />
|
||||
<PackageReference Include="Serilog.Extensions.Logging" Version="10.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Debug" Version="3.0.0" />
|
||||
<PackageReference Include="Shouldly" Version="4.3.0" />
|
||||
<PackageReference Include="Testably.Abstractions.Testing" Version="5.0.1" />
|
||||
<PackageReference Include="Testably.Abstractions.Testing" Version="5.1.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -7,6 +7,7 @@ using NUnit.Framework;
|
||||
using Serilog;
|
||||
using Shouldly;
|
||||
using Testably.Abstractions.Testing;
|
||||
using TimeZoneConverter;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.FFmpeg;
|
||||
|
||||
@@ -569,6 +570,289 @@ public class CustomStreamSelectorTests
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Content_Condition_Fail()
|
||||
{
|
||||
const string YAML =
|
||||
"""
|
||||
---
|
||||
items:
|
||||
- audio_language: ["ja"]
|
||||
subtitle_language: ["eng"]
|
||||
content_condition: "day_of_week = 1"
|
||||
|
||||
- audio_language: ["eng"]
|
||||
disable_subtitles: true
|
||||
""";
|
||||
|
||||
var fileSystem = new MockFileSystem();
|
||||
fileSystem.Initialize()
|
||||
.WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
|
||||
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
|
||||
|
||||
var tz = TZConvert.GetTimeZoneInfo("America/Chicago");
|
||||
var start = new DateTime(2026, 1, 11, 0, 0, 0, DateTimeKind.Unspecified); // sunday
|
||||
var dto = new DateTimeOffset(start, tz.GetUtcOffset(start));
|
||||
|
||||
StreamSelectorResult result = await streamSelector.SelectStreams(_channel, dto, _audioVersion, _subtitles);
|
||||
|
||||
result.AudioStream.IsSome.ShouldBeTrue();
|
||||
|
||||
foreach (MediaStream audioStream in result.AudioStream)
|
||||
{
|
||||
audioStream.Index.ShouldBe(1);
|
||||
audioStream.Language.ShouldBe("eng");
|
||||
}
|
||||
|
||||
result.Subtitle.IsSome.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Content_Condition_Match()
|
||||
{
|
||||
const string YAML =
|
||||
"""
|
||||
---
|
||||
items:
|
||||
- audio_language: ["ja"]
|
||||
subtitle_language: ["eng"]
|
||||
content_condition: "day_of_week = 0"
|
||||
|
||||
- audio_language: ["eng"]
|
||||
disable_subtitles: true
|
||||
""";
|
||||
|
||||
var fileSystem = new MockFileSystem();
|
||||
fileSystem.Initialize()
|
||||
.WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
|
||||
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
|
||||
|
||||
var tz = TZConvert.GetTimeZoneInfo("America/Chicago");
|
||||
var start = new DateTime(2026, 1, 11, 0, 0, 0, DateTimeKind.Unspecified); // sunday
|
||||
var dto = new DateTimeOffset(start, tz.GetUtcOffset(start));
|
||||
|
||||
StreamSelectorResult result = await streamSelector.SelectStreams(_channel, dto, _audioVersion, _subtitles);
|
||||
|
||||
result.AudioStream.IsSome.ShouldBeTrue();
|
||||
|
||||
foreach (MediaStream audioStream in result.AudioStream)
|
||||
{
|
||||
audioStream.Index.ShouldBe(0);
|
||||
audioStream.Language.ShouldBe("ja");
|
||||
}
|
||||
|
||||
result.Subtitle.IsSome.ShouldBeTrue();
|
||||
|
||||
foreach (Subtitle subtitle in result.Subtitle)
|
||||
{
|
||||
subtitle.Id.ShouldBe(2);
|
||||
subtitle.Language.ShouldBe("eng");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Fail_Before()
|
||||
{
|
||||
// saturday from 9pm-11pm
|
||||
const string YAML =
|
||||
"""
|
||||
---
|
||||
items:
|
||||
- audio_language: ["ja"]
|
||||
subtitle_language: ["eng"]
|
||||
content_condition: "day_of_week = 6 and (time_of_day_seconds >= 75600 and time_of_day_seconds < 82800)"
|
||||
|
||||
- audio_language: ["eng"]
|
||||
disable_subtitles: true
|
||||
""";
|
||||
|
||||
var fileSystem = new MockFileSystem();
|
||||
fileSystem.Initialize()
|
||||
.WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
|
||||
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
|
||||
|
||||
var tz = TZConvert.GetTimeZoneInfo("America/Chicago");
|
||||
var start = new DateTime(2026, 1, 10, 20, 59, 59, DateTimeKind.Unspecified); // saturday at 8:59:59pm
|
||||
var dto = new DateTimeOffset(start, tz.GetUtcOffset(start));
|
||||
|
||||
StreamSelectorResult result = await streamSelector.SelectStreams(_channel, dto, _audioVersion, _subtitles);
|
||||
|
||||
result.AudioStream.IsSome.ShouldBeTrue();
|
||||
|
||||
foreach (MediaStream audioStream in result.AudioStream)
|
||||
{
|
||||
audioStream.Index.ShouldBe(1);
|
||||
audioStream.Language.ShouldBe("eng");
|
||||
}
|
||||
|
||||
result.Subtitle.IsSome.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Fail_After()
|
||||
{
|
||||
// saturday from 9pm-11pm
|
||||
const string YAML =
|
||||
"""
|
||||
---
|
||||
items:
|
||||
- audio_language: ["ja"]
|
||||
subtitle_language: ["eng"]
|
||||
content_condition: "day_of_week = 6 and (time_of_day_seconds >= 75600 and time_of_day_seconds < 82800)"
|
||||
|
||||
- audio_language: ["eng"]
|
||||
disable_subtitles: true
|
||||
""";
|
||||
|
||||
var fileSystem = new MockFileSystem();
|
||||
fileSystem.Initialize()
|
||||
.WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
|
||||
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
|
||||
|
||||
var tz = TZConvert.GetTimeZoneInfo("America/Chicago");
|
||||
var start = new DateTime(2026, 1, 10, 23, 0, 0, DateTimeKind.Unspecified); // saturday at 11:00pm
|
||||
var dto = new DateTimeOffset(start, tz.GetUtcOffset(start));
|
||||
|
||||
StreamSelectorResult result = await streamSelector.SelectStreams(_channel, dto, _audioVersion, _subtitles);
|
||||
|
||||
result.AudioStream.IsSome.ShouldBeTrue();
|
||||
|
||||
foreach (MediaStream audioStream in result.AudioStream)
|
||||
{
|
||||
audioStream.Index.ShouldBe(1);
|
||||
audioStream.Language.ShouldBe("eng");
|
||||
}
|
||||
|
||||
result.Subtitle.IsSome.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Fail_Wrong_Day()
|
||||
{
|
||||
// saturday from 9pm-11pm
|
||||
const string YAML =
|
||||
"""
|
||||
---
|
||||
items:
|
||||
- audio_language: ["ja"]
|
||||
subtitle_language: ["eng"]
|
||||
content_condition: "day_of_week = 6 and (time_of_day_seconds >= 75600 and time_of_day_seconds < 82800)"
|
||||
|
||||
- audio_language: ["eng"]
|
||||
disable_subtitles: true
|
||||
""";
|
||||
|
||||
var fileSystem = new MockFileSystem();
|
||||
fileSystem.Initialize()
|
||||
.WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
|
||||
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
|
||||
|
||||
var tz = TZConvert.GetTimeZoneInfo("America/Chicago");
|
||||
var start = new DateTime(2026, 1, 11, 22, 0, 0, DateTimeKind.Unspecified); // sunday at 10:00pm
|
||||
var dto = new DateTimeOffset(start, tz.GetUtcOffset(start));
|
||||
|
||||
StreamSelectorResult result = await streamSelector.SelectStreams(_channel, dto, _audioVersion, _subtitles);
|
||||
|
||||
result.AudioStream.IsSome.ShouldBeTrue();
|
||||
|
||||
foreach (MediaStream audioStream in result.AudioStream)
|
||||
{
|
||||
audioStream.Index.ShouldBe(1);
|
||||
audioStream.Language.ShouldBe("eng");
|
||||
}
|
||||
|
||||
result.Subtitle.IsSome.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Match()
|
||||
{
|
||||
// saturday from 9pm-11pm
|
||||
const string YAML =
|
||||
"""
|
||||
---
|
||||
items:
|
||||
- audio_language: ["ja"]
|
||||
subtitle_language: ["eng"]
|
||||
content_condition: "day_of_week = 6 and (time_of_day_seconds >= 75600 and time_of_day_seconds < 82800)"
|
||||
|
||||
- audio_language: ["eng"]
|
||||
disable_subtitles: true
|
||||
""";
|
||||
|
||||
var fileSystem = new MockFileSystem();
|
||||
fileSystem.Initialize()
|
||||
.WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
|
||||
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
|
||||
|
||||
var tz = TZConvert.GetTimeZoneInfo("America/Chicago");
|
||||
var start = new DateTime(2026, 1, 10, 22, 0, 0, DateTimeKind.Unspecified); // saturday at 10:00pm
|
||||
var dto = new DateTimeOffset(start, tz.GetUtcOffset(start));
|
||||
|
||||
StreamSelectorResult result = await streamSelector.SelectStreams(_channel, dto, _audioVersion, _subtitles);
|
||||
|
||||
result.AudioStream.IsSome.ShouldBeTrue();
|
||||
|
||||
foreach (MediaStream audioStream in result.AudioStream)
|
||||
{
|
||||
audioStream.Index.ShouldBe(0);
|
||||
audioStream.Language.ShouldBe("ja");
|
||||
}
|
||||
|
||||
result.Subtitle.IsSome.ShouldBeTrue();
|
||||
|
||||
foreach (Subtitle subtitle in result.Subtitle)
|
||||
{
|
||||
subtitle.Id.ShouldBe(2);
|
||||
subtitle.Language.ShouldBe("eng");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
[SetCulture("fr-FR")]
|
||||
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Match_France()
|
||||
{
|
||||
// saturday from 9pm-11pm
|
||||
const string YAML =
|
||||
"""
|
||||
---
|
||||
items:
|
||||
- audio_language: ["ja"]
|
||||
subtitle_language: ["eng"]
|
||||
content_condition: "day_of_week = 5 and (time_of_day_seconds >= 75600 and time_of_day_seconds < 82800)"
|
||||
|
||||
- audio_language: ["eng"]
|
||||
disable_subtitles: true
|
||||
""";
|
||||
|
||||
var fileSystem = new MockFileSystem();
|
||||
fileSystem.Initialize()
|
||||
.WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
|
||||
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
|
||||
|
||||
var tz = TZConvert.GetTimeZoneInfo("America/Chicago");
|
||||
var start = new DateTime(2026, 1, 10, 22, 0, 0, DateTimeKind.Unspecified); // saturday at 10:00pm
|
||||
var dto = new DateTimeOffset(start, tz.GetUtcOffset(start));
|
||||
|
||||
StreamSelectorResult result = await streamSelector.SelectStreams(_channel, dto, _audioVersion, _subtitles);
|
||||
|
||||
result.AudioStream.IsSome.ShouldBeTrue();
|
||||
|
||||
foreach (MediaStream audioStream in result.AudioStream)
|
||||
{
|
||||
audioStream.Index.ShouldBe(0);
|
||||
audioStream.Language.ShouldBe("ja");
|
||||
}
|
||||
|
||||
result.Subtitle.IsSome.ShouldBeTrue();
|
||||
|
||||
foreach (Subtitle subtitle in result.Subtitle)
|
||||
{
|
||||
subtitle.Id.ShouldBe(2);
|
||||
subtitle.Language.ShouldBe("eng");
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Ignore_Blocked_Audio_Title()
|
||||
{
|
||||
|
||||
@@ -8,6 +8,7 @@ using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Serilog;
|
||||
using Shouldly;
|
||||
using Testably.Abstractions.Testing;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.FFmpeg;
|
||||
|
||||
@@ -65,8 +66,18 @@ public class WatermarkSelectorTests
|
||||
|
||||
var loggerFactory = new LoggerFactory().AddSerilog(Log.Logger);
|
||||
|
||||
// watermarks should always exist; effectively ignoring filesystem checks for now
|
||||
var mockFileSystem = new MockFileSystem();
|
||||
mockFileSystem.Initialize()
|
||||
.WithFile("/tmp/watermark");
|
||||
|
||||
var fakeImageCache = Substitute.For<IImageCache>();
|
||||
fakeImageCache.GetPathForImage(Arg.Any<string>(), Arg.Is(ArtworkKind.Watermark), Arg.Any<Option<int>>())
|
||||
.Returns(_ => "/tmp/watermark");
|
||||
|
||||
WatermarkSelector = new WatermarkSelector(
|
||||
Substitute.For<IImageCache>(),
|
||||
mockFileSystem,
|
||||
fakeImageCache,
|
||||
new DecoSelector(loggerFactory.CreateLogger<DecoSelector>()),
|
||||
loggerFactory.CreateLogger<WatermarkSelector>());
|
||||
|
||||
|
||||
@@ -76,6 +76,8 @@ public class FakeMediaCollectionRepository : IMediaCollectionRepository
|
||||
public Task<bool> IsCustomPlaybackOrder(int collectionId) => false.AsTask();
|
||||
public Task<Option<string>> GetNameFromKey(CollectionKey emptyCollection, CancellationToken cancellationToken) => Option<string>.None.AsTask();
|
||||
|
||||
public List<CollectionWithItems> GroupIntoFakeCollections(List<MediaItem> items, string fakeKey = null) =>
|
||||
throw new NotSupportedException();
|
||||
public List<CollectionWithItems> GroupIntoFakeCollections(List<MediaItem> items, string fakeKey = null)
|
||||
{
|
||||
return [new CollectionWithItems(1, 0, fakeKey, items, true, PlaybackOrder.Shuffle, false)];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
@@ -11,7 +9,7 @@ namespace ErsatzTV.Core.Tests.Metadata;
|
||||
public class FallbackMetadataProviderTests
|
||||
{
|
||||
[SetUp]
|
||||
public void SetUp() => _fallbackMetadataProvider = new FallbackMetadataProvider(Substitute.For<IClient>());
|
||||
public void SetUp() => _fallbackMetadataProvider = new FallbackMetadataProvider();
|
||||
|
||||
private FallbackMetadataProvider _fallbackMetadataProvider;
|
||||
|
||||
|
||||
@@ -666,7 +666,7 @@ public class ContinuePlayoutTests : PlayoutBuilderTestBase
|
||||
Collection = collectionOne,
|
||||
CollectionId = collectionOne.Id,
|
||||
StartTime = null,
|
||||
Count = 3,
|
||||
Count = "3",
|
||||
PlaybackOrder = PlaybackOrder.Chronological
|
||||
},
|
||||
new ProgramScheduleItemMultiple
|
||||
@@ -676,7 +676,7 @@ public class ContinuePlayoutTests : PlayoutBuilderTestBase
|
||||
Collection = collectionTwo,
|
||||
CollectionId = collectionTwo.Id,
|
||||
StartTime = null,
|
||||
Count = 3,
|
||||
Count = "3",
|
||||
PlaybackOrder = PlaybackOrder.Chronological
|
||||
}
|
||||
};
|
||||
|
||||
@@ -15,6 +15,103 @@ namespace ErsatzTV.Core.Tests.Scheduling.ClassicScheduling;
|
||||
[TestFixture]
|
||||
public class NewPlayoutTests : PlayoutBuilderTestBase
|
||||
{
|
||||
[Test]
|
||||
public async Task FillWithGroupMode_Should_Not_Fail()
|
||||
{
|
||||
var collection = new Collection
|
||||
{
|
||||
Id = 1,
|
||||
Name = "Multiple Items",
|
||||
MediaItems =
|
||||
[
|
||||
TestMovie(1, TimeSpan.FromHours(1), new DateTime(2020, 1, 1)),
|
||||
TestMovie(2, TimeSpan.FromHours(1), new DateTime(2020, 2, 1))
|
||||
]
|
||||
};
|
||||
|
||||
var fakeRepository = new FakeMediaCollectionRepository(
|
||||
Map((collection.Id, collection.MediaItems.ToList())));
|
||||
|
||||
var items = new List<ProgramScheduleItem>
|
||||
{
|
||||
new ProgramScheduleItemMultiple
|
||||
{
|
||||
Id = 1,
|
||||
Index = 1,
|
||||
//Collection = collection,
|
||||
CollectionId = collection.Id,
|
||||
StartTime = null,
|
||||
PlaybackOrder = PlaybackOrder.Chronological,
|
||||
MultipleMode = MultipleMode.Count,
|
||||
Count = "2",
|
||||
FillWithGroupMode = FillWithGroupMode.FillWithShuffledGroups,
|
||||
ProgramScheduleItemGraphicsElements = []
|
||||
}
|
||||
};
|
||||
|
||||
// having a graphics element reference the schedule item triggers the bug
|
||||
items[0].ProgramScheduleItemGraphicsElements.Add(new ProgramScheduleItemGraphicsElement
|
||||
{
|
||||
GraphicsElementId = 1,
|
||||
ProgramScheduleItem = items[0],
|
||||
ProgramScheduleItemId = items[0].Id
|
||||
});
|
||||
|
||||
var playout = new Playout
|
||||
{
|
||||
ProgramSchedule = new ProgramSchedule
|
||||
{
|
||||
Items = items
|
||||
},
|
||||
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
|
||||
ProgramScheduleAnchors = [],
|
||||
Items = [],
|
||||
ProgramScheduleAlternates = [],
|
||||
FillGroupIndices = []
|
||||
};
|
||||
|
||||
var referenceData =
|
||||
new PlayoutReferenceData(
|
||||
playout.Channel,
|
||||
Option<Deco>.None,
|
||||
[],
|
||||
[],
|
||||
playout.ProgramSchedule,
|
||||
[],
|
||||
[],
|
||||
TimeSpan.Zero);
|
||||
|
||||
IConfigElementRepository configRepo = Substitute.For<IConfigElementRepository>();
|
||||
var televisionRepo = new FakeTelevisionRepository();
|
||||
IArtistRepository artistRepo = Substitute.For<IArtistRepository>();
|
||||
IMultiEpisodeShuffleCollectionEnumeratorFactory factory =
|
||||
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
|
||||
IRerunHelper rerunHelper = Substitute.For<IRerunHelper>();
|
||||
var builder = new PlayoutBuilder(
|
||||
configRepo,
|
||||
fakeRepository,
|
||||
televisionRepo,
|
||||
artistRepo,
|
||||
factory,
|
||||
new MockFileSystem(),
|
||||
rerunHelper,
|
||||
Logger);
|
||||
|
||||
DateTimeOffset start = HoursAfterMidnight(0);
|
||||
DateTimeOffset finish = start + TimeSpan.FromHours(6);
|
||||
|
||||
Either<BaseError, PlayoutBuildResult> buildResult = await builder.Build(
|
||||
playout,
|
||||
referenceData,
|
||||
PlayoutBuildResult.Empty,
|
||||
PlayoutBuildMode.Reset,
|
||||
start,
|
||||
finish,
|
||||
CancellationToken);
|
||||
|
||||
buildResult.IsRight.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task OnlyZeroDurationItem_Should_Abort()
|
||||
{
|
||||
@@ -852,7 +949,7 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
|
||||
Collection = fixedCollection,
|
||||
CollectionId = fixedCollection.Id,
|
||||
StartTime = TimeSpan.FromHours(3),
|
||||
Count = 2,
|
||||
Count = "2",
|
||||
PlaybackOrder = PlaybackOrder.Chronological
|
||||
}
|
||||
};
|
||||
@@ -981,7 +1078,7 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
|
||||
Collection = fixedCollection,
|
||||
CollectionId = fixedCollection.Id,
|
||||
StartTime = TimeSpan.FromHours(3),
|
||||
Count = 2,
|
||||
Count = "2",
|
||||
PlaybackOrder = PlaybackOrder.Chronological
|
||||
}
|
||||
};
|
||||
@@ -1361,7 +1458,7 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
|
||||
Collection = multipleCollection,
|
||||
CollectionId = multipleCollection.Id,
|
||||
StartTime = null,
|
||||
Count = 2,
|
||||
Count = "2",
|
||||
PlaybackOrder = PlaybackOrder.Chronological
|
||||
},
|
||||
new ProgramScheduleItemDuration
|
||||
@@ -1494,7 +1591,7 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
|
||||
Collection = collectionOne,
|
||||
CollectionId = collectionOne.Id,
|
||||
StartTime = null,
|
||||
Count = 0,
|
||||
Count = "0",
|
||||
MultipleMode = MultipleMode.CollectionSize,
|
||||
PlaybackOrder = PlaybackOrder.Chronological
|
||||
},
|
||||
@@ -1505,7 +1602,7 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
|
||||
Collection = collectionTwo,
|
||||
CollectionId = collectionTwo.Id,
|
||||
StartTime = null,
|
||||
Count = 0,
|
||||
Count = "0",
|
||||
MultipleMode = MultipleMode.CollectionSize,
|
||||
PlaybackOrder = PlaybackOrder.Chronological
|
||||
}
|
||||
@@ -1777,7 +1874,7 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
|
||||
Collection = collectionOne,
|
||||
CollectionId = collectionOne.Id,
|
||||
StartTime = null,
|
||||
Count = 1,
|
||||
Count = "1",
|
||||
PlaybackOrder = PlaybackOrder.Chronological,
|
||||
PostRollFiller = new FillerPreset
|
||||
{
|
||||
|
||||
34
ErsatzTV.Core.Tests/Scheduling/CountExpressionTests.cs
Normal file
34
ErsatzTV.Core.Tests/Scheduling/CountExpressionTests.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using ErsatzTV.Core.Interfaces.Scheduling;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.Scheduling;
|
||||
|
||||
[TestFixture]
|
||||
public class CountExpressionTests
|
||||
{
|
||||
[Test]
|
||||
[TestCase("2", 2)]
|
||||
[TestCase("count", 10)]
|
||||
[TestCase("count / 2", 5)]
|
||||
[TestCase("count * 2", 20)]
|
||||
[TestCase("count + 1", 11)]
|
||||
[TestCase("count - 1", 9)]
|
||||
[TestCase("random % 4 + 1", 3)]
|
||||
[TestCase("invalid", 0)]
|
||||
[TestCase("count / 0", 0)]
|
||||
public void Should_Evaluate_Expression(string expression, int expected)
|
||||
{
|
||||
var enumerator = Substitute.For<IMediaCollectionEnumerator>();
|
||||
enumerator.Count.Returns(10);
|
||||
|
||||
var random = Substitute.For<Random>();
|
||||
random.Next().Returns(2);
|
||||
|
||||
int result = CountExpression.Evaluate(expression, enumerator, random, CancellationToken.None);
|
||||
|
||||
result.ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
@@ -75,4 +75,106 @@ public class FillerExpressionTests
|
||||
result[1].EndTime.ShouldBe(TimeSpan.FromMinutes(20));
|
||||
result[2].EndTime.ShouldBe(TimeSpan.FromMinutes(30));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Match_Case_Insensitive_Titles_Expression()
|
||||
{
|
||||
// 30 min content
|
||||
var playoutItem = new PlayoutItem { Start = DateTimeOffset.Now.UtcDateTime };
|
||||
playoutItem.Finish = playoutItem.Start + TimeSpan.FromMinutes(30);
|
||||
|
||||
// chapters every 5 min
|
||||
var chapters = new List<MediaChapter>
|
||||
{
|
||||
new() { ChapterId = 1, StartTime = TimeSpan.Zero, EndTime = TimeSpan.FromMinutes(5), Title = "Not Here" },
|
||||
new() { ChapterId = 2, StartTime = TimeSpan.FromMinutes(5), EndTime = TimeSpan.FromMinutes(10), Title = "Here" },
|
||||
new() { ChapterId = 3, StartTime = TimeSpan.FromMinutes(10), EndTime = TimeSpan.FromMinutes(15), Title = "Not Here" },
|
||||
new() { ChapterId = 4, StartTime = TimeSpan.FromMinutes(15), EndTime = TimeSpan.FromMinutes(20), Title = "Here" },
|
||||
new() { ChapterId = 5, StartTime = TimeSpan.FromMinutes(20), EndTime = TimeSpan.FromMinutes(25), Title = "Not Here" },
|
||||
new() { ChapterId = 6, StartTime = TimeSpan.FromMinutes(25), EndTime = TimeSpan.FromMinutes(30), Title = "Here" }
|
||||
};
|
||||
|
||||
var fillerPreset = new FillerPreset
|
||||
{
|
||||
FillerKind = FillerKind.MidRoll,
|
||||
Expression =
|
||||
"title == 'here'"
|
||||
};
|
||||
|
||||
List<MediaChapter> result = FillerExpression.FilterChapters(fillerPreset.Expression, chapters, playoutItem);
|
||||
|
||||
result.Count.ShouldBe(3);
|
||||
result[0].EndTime.ShouldBe(TimeSpan.FromMinutes(10));
|
||||
result[1].EndTime.ShouldBe(TimeSpan.FromMinutes(20));
|
||||
result[2].EndTime.ShouldBe(TimeSpan.FromMinutes(30));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Exclude_Case_Insensitive_Titles_Expression()
|
||||
{
|
||||
// 30 min content
|
||||
var playoutItem = new PlayoutItem { Start = DateTimeOffset.Now.UtcDateTime };
|
||||
playoutItem.Finish = playoutItem.Start + TimeSpan.FromMinutes(30);
|
||||
|
||||
// chapters every 5 min
|
||||
var chapters = new List<MediaChapter>
|
||||
{
|
||||
new() { ChapterId = 1, StartTime = TimeSpan.Zero, EndTime = TimeSpan.FromMinutes(5), Title = "Not Here" },
|
||||
new() { ChapterId = 2, StartTime = TimeSpan.FromMinutes(5), EndTime = TimeSpan.FromMinutes(10), Title = "Here" },
|
||||
new() { ChapterId = 3, StartTime = TimeSpan.FromMinutes(10), EndTime = TimeSpan.FromMinutes(15), Title = "Not Here" },
|
||||
new() { ChapterId = 4, StartTime = TimeSpan.FromMinutes(15), EndTime = TimeSpan.FromMinutes(20), Title = "Here" },
|
||||
new() { ChapterId = 5, StartTime = TimeSpan.FromMinutes(20), EndTime = TimeSpan.FromMinutes(25), Title = "Not Here" },
|
||||
new() { ChapterId = 6, StartTime = TimeSpan.FromMinutes(25), EndTime = TimeSpan.FromMinutes(30), Title = "Here" }
|
||||
};
|
||||
|
||||
var fillerPreset = new FillerPreset
|
||||
{
|
||||
FillerKind = FillerKind.MidRoll,
|
||||
Expression =
|
||||
"title != 'not here'"
|
||||
};
|
||||
|
||||
List<MediaChapter> result = FillerExpression.FilterChapters(fillerPreset.Expression, chapters, playoutItem);
|
||||
|
||||
result.Count.ShouldBe(3);
|
||||
result[0].EndTime.ShouldBe(TimeSpan.FromMinutes(10));
|
||||
result[1].EndTime.ShouldBe(TimeSpan.FromMinutes(20));
|
||||
result[2].EndTime.ShouldBe(TimeSpan.FromMinutes(30));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Include_Partial_Case_Insensitive_Titles_Expression()
|
||||
{
|
||||
// 30 min content
|
||||
var playoutItem = new PlayoutItem { Start = DateTimeOffset.Now.UtcDateTime };
|
||||
playoutItem.Finish = playoutItem.Start + TimeSpan.FromMinutes(30);
|
||||
|
||||
// chapters every 5 min
|
||||
var chapters = new List<MediaChapter>
|
||||
{
|
||||
new() { ChapterId = 1, StartTime = TimeSpan.Zero, EndTime = TimeSpan.FromMinutes(5), Title = "Not Here" },
|
||||
new() { ChapterId = 2, StartTime = TimeSpan.FromMinutes(5), EndTime = TimeSpan.FromMinutes(10), Title = "Here" },
|
||||
new() { ChapterId = 3, StartTime = TimeSpan.FromMinutes(10), EndTime = TimeSpan.FromMinutes(15), Title = "Not Here" },
|
||||
new() { ChapterId = 4, StartTime = TimeSpan.FromMinutes(15), EndTime = TimeSpan.FromMinutes(20), Title = "Here" },
|
||||
new() { ChapterId = 5, StartTime = TimeSpan.FromMinutes(20), EndTime = TimeSpan.FromMinutes(25), Title = "Not Here" },
|
||||
new() { ChapterId = 6, StartTime = TimeSpan.FromMinutes(25), EndTime = TimeSpan.FromMinutes(30), Title = "Here" }
|
||||
};
|
||||
|
||||
var fillerPreset = new FillerPreset
|
||||
{
|
||||
FillerKind = FillerKind.MidRoll,
|
||||
Expression =
|
||||
"title like \"%here%\""
|
||||
};
|
||||
|
||||
List<MediaChapter> result = FillerExpression.FilterChapters(fillerPreset.Expression, chapters, playoutItem);
|
||||
|
||||
result.Count.ShouldBe(6);
|
||||
result[0].EndTime.ShouldBe(TimeSpan.FromMinutes(5));
|
||||
result[1].EndTime.ShouldBe(TimeSpan.FromMinutes(10));
|
||||
result[2].EndTime.ShouldBe(TimeSpan.FromMinutes(15));
|
||||
result[3].EndTime.ShouldBe(TimeSpan.FromMinutes(20));
|
||||
result[4].EndTime.ShouldBe(TimeSpan.FromMinutes(25));
|
||||
result[5].EndTime.ShouldBe(TimeSpan.FromMinutes(30));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -796,7 +796,10 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
|
||||
ProgramScheduleItem scheduleItem,
|
||||
ProgramScheduleItem nextScheduleItem,
|
||||
DateTimeOffset hardStop,
|
||||
Random random,
|
||||
CancellationToken cancellationToken) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
protected override string SchedulingContextName => "Test";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,9 +13,14 @@ namespace ErsatzTV.Core.Tests.Scheduling;
|
||||
public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
|
||||
{
|
||||
[SetUp]
|
||||
public void SetUp() => _cancellationToken = new CancellationTokenSource(TimeSpan.FromSeconds(10)).Token;
|
||||
public void SetUp()
|
||||
{
|
||||
_cancellationToken = new CancellationTokenSource(TimeSpan.FromSeconds(10)).Token;
|
||||
_random = new Random();
|
||||
}
|
||||
|
||||
private CancellationToken _cancellationToken;
|
||||
private Random _random;
|
||||
private readonly ILogger<PlayoutModeSchedulerDuration> _logger;
|
||||
|
||||
public PlayoutModeSchedulerDurationTests()
|
||||
@@ -66,6 +71,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
|
||||
@@ -139,6 +145,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
|
||||
@@ -211,6 +218,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.Add(new TimeSpan(2, 45, 0)));
|
||||
@@ -280,6 +288,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
// duration block should end after exact duration, with gap
|
||||
@@ -363,6 +372,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
|
||||
@@ -450,6 +460,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
|
||||
@@ -549,6 +560,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
|
||||
@@ -665,6 +677,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
|
||||
@@ -816,6 +829,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddMinutes(30));
|
||||
@@ -880,6 +894,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutItems.ShouldBeEmpty();
|
||||
|
||||
@@ -12,9 +12,14 @@ namespace ErsatzTV.Core.Tests.Scheduling;
|
||||
public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
|
||||
{
|
||||
[SetUp]
|
||||
public void SetUp() => _cancellationToken = new CancellationTokenSource(TimeSpan.FromSeconds(10)).Token;
|
||||
public void SetUp()
|
||||
{
|
||||
_cancellationToken = new CancellationTokenSource(TimeSpan.FromSeconds(10)).Token;
|
||||
_random = new Random();
|
||||
}
|
||||
|
||||
private CancellationToken _cancellationToken;
|
||||
private Random _random;
|
||||
|
||||
[Test]
|
||||
public void Should_Fill_Exactly_To_Next_Schedule_Item()
|
||||
@@ -57,6 +62,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
|
||||
@@ -132,6 +138,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
scheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(6));
|
||||
@@ -229,6 +236,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
|
||||
@@ -314,6 +322,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
|
||||
@@ -402,6 +411,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.Add(new TimeSpan(2, 45, 0)));
|
||||
@@ -484,6 +494,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
|
||||
@@ -582,6 +593,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
|
||||
@@ -670,6 +682,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.Add(new TimeSpan(2, 57, 0)));
|
||||
@@ -784,6 +797,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
|
||||
@@ -896,6 +910,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
hardStop,
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(2));
|
||||
@@ -1002,6 +1017,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
hardStop,
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(2));
|
||||
@@ -1116,6 +1132,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
|
||||
@@ -1190,6 +1207,7 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutItems.ShouldBeEmpty();
|
||||
|
||||
@@ -12,9 +12,14 @@ namespace ErsatzTV.Core.Tests.Scheduling;
|
||||
public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
|
||||
{
|
||||
[SetUp]
|
||||
public void SetUp() => _cancellationToken = new CancellationTokenSource(TimeSpan.FromSeconds(10)).Token;
|
||||
public void SetUp()
|
||||
{
|
||||
_cancellationToken = new CancellationTokenSource(TimeSpan.FromSeconds(10)).Token;
|
||||
_random = new Random();
|
||||
}
|
||||
|
||||
private CancellationToken _cancellationToken;
|
||||
private Random _random;
|
||||
|
||||
[Test]
|
||||
public void Should_Respect_Fixed_Start_Time()
|
||||
@@ -32,7 +37,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
|
||||
PlaybackOrder = PlaybackOrder.Chronological,
|
||||
TailFiller = null,
|
||||
FallbackFiller = null,
|
||||
Count = 0,
|
||||
Count = "0",
|
||||
MultipleMode = MultipleMode.CollectionSize,
|
||||
CustomTitle = "CustomTitle"
|
||||
};
|
||||
@@ -59,6 +64,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
|
||||
@@ -134,7 +140,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
|
||||
PlaybackOrder = PlaybackOrder.Chronological,
|
||||
TailFiller = null,
|
||||
FallbackFiller = null,
|
||||
Count = 0,
|
||||
Count = "0",
|
||||
MultipleMode = MultipleMode.MultiEpisodeGroupSize,
|
||||
CustomTitle = "CustomTitle"
|
||||
};
|
||||
@@ -161,6 +167,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
|
||||
@@ -206,7 +213,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
|
||||
PlaybackOrder = PlaybackOrder.Chronological,
|
||||
TailFiller = null,
|
||||
FallbackFiller = null,
|
||||
Count = 3,
|
||||
Count = "3",
|
||||
CustomTitle = "CustomTitle"
|
||||
};
|
||||
|
||||
@@ -232,6 +239,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
|
||||
@@ -282,7 +290,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
|
||||
PlaybackOrder = PlaybackOrder.Chronological,
|
||||
TailFiller = null,
|
||||
FallbackFiller = null,
|
||||
Count = 3
|
||||
Count = "3"
|
||||
};
|
||||
|
||||
var scheduleItemsEnumerator = new OrderedScheduleItemsEnumerator(
|
||||
@@ -307,6 +315,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.Add(new TimeSpan(2, 45, 0)));
|
||||
@@ -360,7 +369,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
|
||||
CollectionId = collectionTwo.Id
|
||||
},
|
||||
FallbackFiller = null,
|
||||
Count = 3
|
||||
Count = "3"
|
||||
};
|
||||
|
||||
var scheduleItemsEnumerator = new OrderedScheduleItemsEnumerator(
|
||||
@@ -390,6 +399,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
|
||||
@@ -459,7 +469,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
|
||||
Collection = collectionTwo,
|
||||
CollectionId = collectionTwo.Id
|
||||
},
|
||||
Count = 3
|
||||
Count = "3"
|
||||
};
|
||||
|
||||
var scheduleItemsEnumerator = new OrderedScheduleItemsEnumerator(
|
||||
@@ -489,6 +499,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
|
||||
@@ -548,7 +559,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
|
||||
CollectionId = collectionTwo.Id
|
||||
},
|
||||
FallbackFiller = null,
|
||||
Count = 3
|
||||
Count = "3"
|
||||
};
|
||||
|
||||
var scheduleItemsEnumerator = new OrderedScheduleItemsEnumerator(
|
||||
@@ -578,6 +589,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.Add(new TimeSpan(2, 57, 0)));
|
||||
@@ -653,7 +665,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
|
||||
Collection = collectionThree,
|
||||
CollectionId = collectionThree.Id
|
||||
},
|
||||
Count = 3
|
||||
Count = "3"
|
||||
};
|
||||
|
||||
var scheduleItemsEnumerator = new OrderedScheduleItemsEnumerator(
|
||||
@@ -694,6 +706,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
|
||||
@@ -775,7 +788,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
|
||||
Collection = collectionThree,
|
||||
CollectionId = collectionThree.Id
|
||||
},
|
||||
Count = 3
|
||||
Count = "3"
|
||||
};
|
||||
|
||||
var scheduleItemsEnumerator = new OrderedScheduleItemsEnumerator(
|
||||
@@ -816,6 +829,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
|
||||
@@ -865,7 +879,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
|
||||
PlaybackOrder = PlaybackOrder.Chronological,
|
||||
TailFiller = null,
|
||||
FallbackFiller = null,
|
||||
Count = 2
|
||||
Count = "2"
|
||||
};
|
||||
|
||||
var enumerator = new ChronologicalMediaCollectionEnumerator(
|
||||
@@ -896,6 +910,7 @@ public class PlayoutModeSchedulerMultipleTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutItems.ShouldBeEmpty();
|
||||
|
||||
@@ -12,9 +12,14 @@ namespace ErsatzTV.Core.Tests.Scheduling;
|
||||
public class PlayoutModeSchedulerOneTests : SchedulerTestBase
|
||||
{
|
||||
[SetUp]
|
||||
public void SetUp() => _cancellationToken = new CancellationTokenSource(TimeSpan.FromSeconds(10)).Token;
|
||||
public void SetUp()
|
||||
{
|
||||
_cancellationToken = new CancellationTokenSource(TimeSpan.FromSeconds(10)).Token;
|
||||
_random = new Random();
|
||||
}
|
||||
|
||||
private CancellationToken _cancellationToken;
|
||||
private Random _random;
|
||||
|
||||
[Test]
|
||||
public void Should_Have_Gap_With_No_Tail_No_Fallback()
|
||||
@@ -51,6 +56,7 @@ public class PlayoutModeSchedulerOneTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(1));
|
||||
@@ -134,6 +140,7 @@ public class PlayoutModeSchedulerOneTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(1));
|
||||
@@ -202,6 +209,7 @@ public class PlayoutModeSchedulerOneTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
|
||||
@@ -284,6 +292,7 @@ public class PlayoutModeSchedulerOneTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
|
||||
@@ -356,6 +365,7 @@ public class PlayoutModeSchedulerOneTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.Add(new TimeSpan(2, 57, 0)));
|
||||
@@ -454,6 +464,7 @@ public class PlayoutModeSchedulerOneTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
|
||||
@@ -558,6 +569,7 @@ public class PlayoutModeSchedulerOneTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
|
||||
@@ -644,6 +656,7 @@ public class PlayoutModeSchedulerOneTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
|
||||
@@ -744,6 +757,7 @@ public class PlayoutModeSchedulerOneTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
|
||||
@@ -823,6 +837,7 @@ public class PlayoutModeSchedulerOneTests : SchedulerTestBase
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop(scheduleItemsEnumerator),
|
||||
_random,
|
||||
_cancellationToken);
|
||||
|
||||
playoutItems.ShouldBeEmpty();
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using Bugsnag;
|
||||
using Dapper;
|
||||
using Destructurama;
|
||||
using ErsatzTV.Core.Domain;
|
||||
@@ -93,8 +92,6 @@ public class ScheduleIntegrationTests
|
||||
services.AddSingleton<ISearchIndex, LuceneSearchIndex>();
|
||||
services.AddSingleton<ILanguageCodeCache, LanguageCodeCache>();
|
||||
|
||||
services.AddSingleton(_ => Substitute.For<IClient>());
|
||||
|
||||
ServiceProvider provider = services.BuildServiceProvider();
|
||||
|
||||
IDbContextFactory<TvContext> factory = provider.GetRequiredService<IDbContextFactory<TvContext>>();
|
||||
@@ -110,7 +107,6 @@ public class ScheduleIntegrationTests
|
||||
await searchIndex.Initialize(
|
||||
new LocalFileSystem(
|
||||
new MockFileSystem(),
|
||||
provider.GetRequiredService<IClient>(),
|
||||
provider.GetRequiredService<ILogger<LocalFileSystem>>()),
|
||||
provider.GetRequiredService<IConfigElementRepository>(),
|
||||
_cancellationToken);
|
||||
@@ -123,7 +119,7 @@ public class ScheduleIntegrationTests
|
||||
|
||||
var builder = new PlayoutBuilder(
|
||||
new ConfigElementRepository(factory),
|
||||
new MediaCollectionRepository(Substitute.For<IClient>(), searchIndex, factory),
|
||||
new MediaCollectionRepository(searchIndex, factory),
|
||||
new TelevisionRepository(factory, provider.GetRequiredService<ILogger<TelevisionRepository>>()),
|
||||
new ArtistRepository(factory),
|
||||
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(),
|
||||
@@ -319,7 +315,7 @@ public class ScheduleIntegrationTests
|
||||
|
||||
var builder = new PlayoutBuilder(
|
||||
new ConfigElementRepository(factory),
|
||||
new MediaCollectionRepository(Substitute.For<IClient>(), Substitute.For<ISearchIndex>(), factory),
|
||||
new MediaCollectionRepository(Substitute.For<ISearchIndex>(), factory),
|
||||
new TelevisionRepository(factory, provider.GetRequiredService<ILogger<TelevisionRepository>>()),
|
||||
new ArtistRepository(factory),
|
||||
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(),
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using LanguageExt.UnsafeValueAccess;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.Scheduling;
|
||||
|
||||
[TestFixture]
|
||||
public class ShuffleInOrderCollectionEnumeratorTests
|
||||
{
|
||||
[Test]
|
||||
public void Should_Not_Repeat_Items_Until_Cycle_Complete()
|
||||
{
|
||||
var collections = new List<CollectionWithItems>
|
||||
{
|
||||
new(
|
||||
0,
|
||||
0,
|
||||
"1",
|
||||
Enumerable.Range(1, 10).Select(i => new Movie { Id = i, MovieMetadata = [] }).Cast<MediaItem>().ToList(),
|
||||
true,
|
||||
PlaybackOrder.ShuffleInOrder,
|
||||
false),
|
||||
new(
|
||||
0,
|
||||
0,
|
||||
"2",
|
||||
Enumerable.Range(11, 20).Select(i => new Movie { Id = i, MovieMetadata = [] }).Cast<MediaItem>().ToList(),
|
||||
true,
|
||||
PlaybackOrder.ShuffleInOrder,
|
||||
false)
|
||||
};
|
||||
|
||||
var state = new CollectionEnumeratorState { Seed = 1234, Index = 0 };
|
||||
var enumerator = new ShuffleInOrderCollectionEnumerator(collections, state, false, CancellationToken.None);
|
||||
|
||||
var seenIds = new System.Collections.Generic.HashSet<int>();
|
||||
for (int i = 0; i < 20; i++)
|
||||
{
|
||||
enumerator.Current.IsSome.ShouldBeTrue();
|
||||
int id = enumerator.Current.ValueUnsafe().Id;
|
||||
seenIds.ShouldNotContain(id, $"at index {i}");
|
||||
seenIds.Add(id);
|
||||
enumerator.MoveNext(Option<DateTimeOffset>.None);
|
||||
}
|
||||
|
||||
seenIds.Count.ShouldBe(20);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Handle_Single_Collection()
|
||||
{
|
||||
var collections = new List<CollectionWithItems>
|
||||
{
|
||||
new(
|
||||
0,
|
||||
0,
|
||||
"1",
|
||||
Enumerable.Range(1, 10).Select(i => new Movie { Id = i, MovieMetadata = [] }).Cast<MediaItem>().ToList(),
|
||||
true,
|
||||
PlaybackOrder.ShuffleInOrder,
|
||||
false)
|
||||
};
|
||||
|
||||
var state = new CollectionEnumeratorState { Seed = 1234, Index = 0 };
|
||||
var enumerator = new ShuffleInOrderCollectionEnumerator(collections, state, false, CancellationToken.None);
|
||||
|
||||
var seenIds = new List<int>();
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
seenIds.Add(enumerator.Current.ValueUnsafe().Id);
|
||||
enumerator.MoveNext(Option<DateTimeOffset>.None);
|
||||
}
|
||||
|
||||
seenIds.Count.ShouldBe(10);
|
||||
seenIds.ShouldBeInOrder(SortDirection.Ascending);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Reshuffle_After_Cycle()
|
||||
{
|
||||
var collections = new List<CollectionWithItems>
|
||||
{
|
||||
new(
|
||||
0,
|
||||
0,
|
||||
"1",
|
||||
Enumerable.Range(1, 10).Select(i => new Movie { Id = i, MovieMetadata = [] }).Cast<MediaItem>().ToList(),
|
||||
true,
|
||||
PlaybackOrder.ShuffleInOrder,
|
||||
false)
|
||||
};
|
||||
|
||||
var state = new CollectionEnumeratorState { Seed = 1234, Index = 0 };
|
||||
var enumerator = new ShuffleInOrderCollectionEnumerator(collections, state, false, CancellationToken.None);
|
||||
|
||||
for (int i = 0; i < 10; i++)
|
||||
{
|
||||
enumerator.MoveNext(Option<DateTimeOffset>.None);
|
||||
}
|
||||
|
||||
enumerator.State.Index.ShouldBe(0);
|
||||
// Should have a new seed
|
||||
enumerator.State.Seed.ShouldNotBe(1234);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ResetState_Should_Update_Seed()
|
||||
{
|
||||
var collections = new List<CollectionWithItems>
|
||||
{
|
||||
new(
|
||||
0,
|
||||
0,
|
||||
"1",
|
||||
Enumerable.Range(1, 10).Select(i => new Movie { Id = i, MovieMetadata = [] }).Cast<MediaItem>().ToList(),
|
||||
true,
|
||||
PlaybackOrder.ShuffleInOrder,
|
||||
false)
|
||||
};
|
||||
|
||||
var state = new CollectionEnumeratorState { Seed = 1234, Index = 0 };
|
||||
var enumerator = new ShuffleInOrderCollectionEnumerator(collections, state, false, CancellationToken.None);
|
||||
|
||||
var newState = new CollectionEnumeratorState { Seed = 5678, Index = 5 };
|
||||
enumerator.ResetState(newState);
|
||||
|
||||
enumerator.State.Seed.ShouldBe(5678);
|
||||
enumerator.State.Index.ShouldBe(5);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ public class Channel
|
||||
public string Categories { get; set; }
|
||||
public int FFmpegProfileId { get; set; }
|
||||
public FFmpegProfile FFmpegProfile { get; set; }
|
||||
public double? SlugSeconds { get; set; }
|
||||
public int? WatermarkId { get; set; }
|
||||
public ChannelWatermark Watermark { get; set; }
|
||||
public int? FallbackFillerId { 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