Compare commits
29 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 |
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
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
47
CHANGELOG.md
47
CHANGELOG.md
@@ -4,6 +4,50 @@ 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
|
||||
@@ -3155,7 +3199,8 @@ 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.2.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
|
||||
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,7 +10,6 @@
|
||||
</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]" />
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,6 +1,5 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Application.MediaItems;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
@@ -11,10 +10,9 @@ using Microsoft.EntityFrameworkCore;
|
||||
namespace ErsatzTV.Application.Search;
|
||||
|
||||
public class SearchMoviesHandler(
|
||||
IClient client,
|
||||
ISearchIndex searchIndex,
|
||||
IDbContextFactory<TvContext> dbContextFactory)
|
||||
: SearchUsingSearchIndexHandler(client, searchIndex), IRequestHandler<SearchMovies, List<NamedMediaItemViewModel>>
|
||||
: SearchUsingSearchIndexHandler(searchIndex), IRequestHandler<SearchMovies, List<NamedMediaItemViewModel>>
|
||||
{
|
||||
public async Task<List<NamedMediaItemViewModel>> Handle(SearchMovies request, CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Application.MediaItems;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
@@ -10,10 +9,9 @@ using Microsoft.EntityFrameworkCore;
|
||||
namespace ErsatzTV.Application.Search;
|
||||
|
||||
public class SearchTelevisionSeasonsHandler(
|
||||
IClient client,
|
||||
ISearchIndex searchIndex,
|
||||
IDbContextFactory<TvContext> dbContextFactory)
|
||||
: SearchUsingSearchIndexHandler(client, searchIndex),
|
||||
: SearchUsingSearchIndexHandler(searchIndex),
|
||||
IRequestHandler<SearchTelevisionSeasons, List<NamedMediaItemViewModel>>
|
||||
{
|
||||
public async Task<List<NamedMediaItemViewModel>> Handle(
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Globalization;
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Application.MediaItems;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
@@ -11,10 +10,9 @@ using Microsoft.EntityFrameworkCore;
|
||||
namespace ErsatzTV.Application.Search;
|
||||
|
||||
public class SearchTelevisionShowsHandler(
|
||||
IClient client,
|
||||
ISearchIndex searchIndex,
|
||||
IDbContextFactory<TvContext> dbContextFactory)
|
||||
: SearchUsingSearchIndexHandler(client, searchIndex),
|
||||
: SearchUsingSearchIndexHandler(searchIndex),
|
||||
IRequestHandler<SearchTelevisionShows, List<NamedMediaItemViewModel>>
|
||||
{
|
||||
public async Task<List<NamedMediaItemViewModel>> Handle(
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
using System.Collections.Immutable;
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Search;
|
||||
|
||||
namespace ErsatzTV.Application.Search;
|
||||
|
||||
public abstract class SearchUsingSearchIndexHandler(IClient client, ISearchIndex searchIndex)
|
||||
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(
|
||||
client,
|
||||
$"type:{type} AND *{query.Replace(" ", @"\ ")}*",
|
||||
string.Empty,
|
||||
0,
|
||||
|
||||
@@ -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,12 +2,11 @@
|
||||
|
||||
<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.2" />
|
||||
@@ -23,7 +22,7 @@
|
||||
<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.2" />
|
||||
<PackageReference Include="Testably.Abstractions.Testing" Version="5.1.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -799,5 +799,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
|
||||
Random random,
|
||||
CancellationToken cancellationToken) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
protected override string SchedulingContextName => "Test";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -32,6 +32,7 @@ public class ConfigElementKey
|
||||
public static ConfigElementKey HDHRTunerCount => new("hdhr.tuner_count");
|
||||
public static ConfigElementKey HDHRUUID => new("hdhr.uuid");
|
||||
public static ConfigElementKey PagesIsDarkMode => new("pages.is_dark_mode");
|
||||
public static ConfigElementKey PagesLanguage => new("pages.language");
|
||||
public static ConfigElementKey ChannelsPageSize => new("pages.channels.page_size");
|
||||
public static ConfigElementKey ChannelsShowDisabled => new("pages.channels.show_disabled");
|
||||
public static ConfigElementKey CollectionsPageSize => new("pages.collections.page_size");
|
||||
|
||||
@@ -37,6 +37,9 @@ public class PlayoutItem
|
||||
public DateTimeOffset StartOffset => new DateTimeOffset(Start, TimeSpan.Zero).ToLocalTime();
|
||||
public DateTimeOffset FinishOffset => new DateTimeOffset(Finish, TimeSpan.Zero).ToLocalTime();
|
||||
|
||||
// for troubleshooting
|
||||
public string SchedulingContext { get; set; }
|
||||
|
||||
public DateTimeOffset? GuideFinishOffset => GuideFinish.HasValue
|
||||
? new DateTimeOffset(GuideFinish.Value, TimeSpan.Zero).ToLocalTime()
|
||||
: null;
|
||||
@@ -81,7 +84,8 @@ public class PlayoutItem
|
||||
CollectionKey = CollectionKey,
|
||||
CollectionEtag = CollectionEtag,
|
||||
PlayoutItemWatermarks = watermarksCopy,
|
||||
PlayoutItemGraphicsElements = graphicsElementsCopy
|
||||
PlayoutItemGraphicsElements = graphicsElementsCopy,
|
||||
SchedulingContext = SchedulingContext
|
||||
};
|
||||
}
|
||||
|
||||
@@ -127,7 +131,8 @@ public class PlayoutItem
|
||||
CollectionKey = CollectionKey,
|
||||
CollectionEtag = CollectionEtag,
|
||||
PlayoutItemWatermarks = watermarksCopy,
|
||||
PlayoutItemGraphicsElements = graphicsElementsCopy
|
||||
PlayoutItemGraphicsElements = graphicsElementsCopy,
|
||||
SchedulingContext = SchedulingContext
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,7 +10,6 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Bugsnag" Version="4.1.0" />
|
||||
<PackageReference Include="Destructurama.Attributed" Version="5.2.0" />
|
||||
<PackageReference Include="Flurl" Version="4.0.0" />
|
||||
<PackageReference Include="Humanizer.Core" Version="3.0.1" />
|
||||
|
||||
@@ -233,6 +233,16 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
scanKind = await ProbeScanKind(ffmpegPath, videoVersion.MediaItem, cancellationToken);
|
||||
}
|
||||
|
||||
HardwareAccelerationMode hwAccel = GetHardwareAccelerationMode(playbackSettings);
|
||||
|
||||
// QSV may have sync issues with h264 files that have multiple profiles
|
||||
// check and flag here so software decoding can be used if needed
|
||||
bool hasMultipleProfiles = false;
|
||||
if (hwAccel is HardwareAccelerationMode.Qsv && videoStream.Codec is VideoFormat.H264)
|
||||
{
|
||||
hasMultipleProfiles = await ProbeHasMultipleProfiles(ffmpegPath, videoVersion.MediaItem, cancellationToken);
|
||||
}
|
||||
|
||||
var ffmpegVideoStream = new VideoStream(
|
||||
videoStream.Index,
|
||||
videoStream.Codec,
|
||||
@@ -248,7 +258,10 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
videoVersion.MediaVersion.DisplayAspectRatio,
|
||||
new FrameRate(videoVersion.MediaVersion.RFrameRate),
|
||||
videoPath != audioPath, // still image when paths are different
|
||||
scanKind);
|
||||
scanKind)
|
||||
{
|
||||
HasMultipleProfiles = hasMultipleProfiles
|
||||
};
|
||||
|
||||
var videoInputFile = new VideoInputFile(
|
||||
videoPath,
|
||||
@@ -405,8 +418,6 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
graphicsElementContexts.AddRange(watermarks.Map(wm => new WatermarkElementContext(wm)));
|
||||
}
|
||||
|
||||
HardwareAccelerationMode hwAccel = GetHardwareAccelerationMode(playbackSettings);
|
||||
|
||||
string videoFormat = GetVideoFormat(playbackSettings);
|
||||
Option<string> maybeVideoProfile = GetVideoProfile(videoFormat, channel.FFmpegProfile.VideoProfile);
|
||||
Option<string> maybeVideoPreset = GetVideoPreset(
|
||||
@@ -538,7 +549,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
frameRate,
|
||||
channelStartTime,
|
||||
start,
|
||||
await playbackSettings.StreamSeek.IfNoneAsync(TimeSpan.Zero),
|
||||
now > start ? now - start : TimeSpan.Zero,
|
||||
finish - now,
|
||||
originalContentDuration);
|
||||
|
||||
@@ -576,7 +587,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
videoVersion.MediaVersion is BackgroundImageMediaVersion { IsSongWithProgress: true },
|
||||
false,
|
||||
GetTonemapAlgorithm(playbackSettings),
|
||||
channel.Number == ".troubleshooting");
|
||||
channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel);
|
||||
|
||||
_logger.LogDebug("FFmpeg desired state {FrameState}", desiredState);
|
||||
|
||||
@@ -593,7 +604,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
VaapiDeviceName(hwAccel, vaapiDevice),
|
||||
await customReportsFolder.IfNoneAsync(FileSystemLayout.FFmpegReportsFolder),
|
||||
FileSystemLayout.FontsCacheFolder,
|
||||
ffmpegPath);
|
||||
ffmpegPath,
|
||||
cancellationToken);
|
||||
|
||||
FFmpegPipeline pipeline = pipelineBuilder.Build(ffmpegState, desiredState);
|
||||
|
||||
@@ -652,6 +664,19 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<bool> ProbeHasMultipleProfiles(
|
||||
string ffmpegPath,
|
||||
MediaItem mediaItem,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogDebug("Will probe for h264 profile count");
|
||||
|
||||
Option<int> profileCount =
|
||||
await _localStatisticsProvider.GetProfileCount(ffmpegPath, mediaItem, cancellationToken);
|
||||
|
||||
return await profileCount.IfNoneAsync(1) > 1;
|
||||
}
|
||||
|
||||
public async Task<Command> ForError(
|
||||
string ffmpegPath,
|
||||
Channel channel,
|
||||
@@ -663,9 +688,10 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
string vaapiDisplay,
|
||||
VaapiDriver vaapiDriver,
|
||||
string vaapiDevice,
|
||||
Option<int> qsvExtraHardwareFrames)
|
||||
Option<int> qsvExtraHardwareFrames,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
FFmpegPlaybackSettings playbackSettings = FFmpegPlaybackSettingsCalculator.CalculateErrorSettings(
|
||||
FFmpegPlaybackSettings playbackSettings = FFmpegPlaybackSettingsCalculator.CalculateGeneratedImageSettings(
|
||||
channel.StreamingMode,
|
||||
channel.FFmpegProfile,
|
||||
hlsRealtime);
|
||||
@@ -726,57 +752,6 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
playbackSettings.NormalizeColors,
|
||||
playbackSettings.Deinterlace);
|
||||
|
||||
OutputFormatKind outputFormat = OutputFormatKind.MpegTs;
|
||||
switch (channel.StreamingMode)
|
||||
{
|
||||
case StreamingMode.HttpLiveStreamingSegmenter:
|
||||
outputFormat = OutputFormatKind.Hls;
|
||||
break;
|
||||
}
|
||||
|
||||
Option<string> hlsPlaylistPath = outputFormat is OutputFormatKind.Hls or OutputFormatKind.HlsMp4
|
||||
? Path.Combine(FileSystemLayout.TranscodeFolder, channel.Number, "live.m3u8")
|
||||
: Option<string>.None;
|
||||
|
||||
long nowSeconds = now.ToUnixTimeSeconds();
|
||||
|
||||
Option<string> hlsSegmentTemplate = outputFormat switch
|
||||
{
|
||||
OutputFormatKind.Hls => Path.Combine(FileSystemLayout.TranscodeFolder, channel.Number, "live%06d.ts"),
|
||||
OutputFormatKind.HlsMp4 => Path.Combine(
|
||||
FileSystemLayout.TranscodeFolder,
|
||||
channel.Number,
|
||||
$"live_{nowSeconds}_%06d.m4s"),
|
||||
_ => Option<string>.None
|
||||
};
|
||||
|
||||
Option<string> hlsInitTemplate = outputFormat switch
|
||||
{
|
||||
OutputFormatKind.HlsMp4 => $"{nowSeconds}_init.mp4",
|
||||
_ => Option<string>.None
|
||||
};
|
||||
|
||||
Option<string> hlsSegmentOptions = Option<string>.None;
|
||||
if (outputFormat is OutputFormatKind.Hls)
|
||||
{
|
||||
string options = string.Empty;
|
||||
|
||||
if (ptsOffset == TimeSpan.Zero)
|
||||
{
|
||||
options += "+initial_discontinuity";
|
||||
}
|
||||
|
||||
if (audioFormat == AudioFormat.AacLatm)
|
||||
{
|
||||
options += "+latm";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options))
|
||||
{
|
||||
hlsSegmentOptions = $"mpegts_flags={options}";
|
||||
}
|
||||
}
|
||||
|
||||
string videoPath = _localFileSystem.GetCustomOrDefaultFile(
|
||||
FileSystemLayout.ResourcesCacheFolder,
|
||||
"background.png");
|
||||
@@ -802,8 +777,10 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
HardwareAccelerationMode hwAccel = GetHardwareAccelerationMode(playbackSettings);
|
||||
_logger.LogDebug("HW accel mode: {HwAccel}", hwAccel);
|
||||
|
||||
var hlsOptions = GetHlsOptions(channel, now, ptsOffset, audioFormat);
|
||||
|
||||
var ffmpegState = new FFmpegState(
|
||||
channel.Number == ".troubleshooting",
|
||||
channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel,
|
||||
HardwareAccelerationMode.None, // no hw accel decode since errors loop
|
||||
hwAccel,
|
||||
VaapiDriverName(hwAccel, vaapiDriver),
|
||||
@@ -816,18 +793,18 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
outputFormat,
|
||||
hlsPlaylistPath,
|
||||
hlsSegmentTemplate,
|
||||
hlsInitTemplate,
|
||||
hlsSegmentOptions,
|
||||
hlsOptions.OutputFormat,
|
||||
hlsOptions.HlsPlaylistPath,
|
||||
hlsOptions.HlsSegmentTemplate,
|
||||
hlsOptions.HlsInitTemplate,
|
||||
hlsOptions.HlsSegmentOptions,
|
||||
ptsOffset,
|
||||
Option<int>.None,
|
||||
qsvExtraHardwareFrames,
|
||||
false,
|
||||
false,
|
||||
GetTonemapAlgorithm(playbackSettings),
|
||||
channel.Number == ".troubleshooting");
|
||||
channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel);
|
||||
|
||||
var ffmpegSubtitleStream = new ErsatzTV.FFmpeg.MediaStream(0, "ass", StreamKind.Video);
|
||||
|
||||
@@ -851,11 +828,144 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
VaapiDisplayName(hwAccel, vaapiDisplay),
|
||||
VaapiDriverName(hwAccel, vaapiDriver),
|
||||
VaapiDeviceName(hwAccel, vaapiDevice),
|
||||
channel.Number == ".troubleshooting"
|
||||
channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel
|
||||
? FileSystemLayout.TranscodeTroubleshootingFolder
|
||||
: FileSystemLayout.FFmpegReportsFolder,
|
||||
FileSystemLayout.FontsCacheFolder,
|
||||
ffmpegPath);
|
||||
ffmpegPath,
|
||||
cancellationToken);
|
||||
|
||||
FFmpegPipeline pipeline = pipelineBuilder.Build(ffmpegState, desiredState);
|
||||
|
||||
return GetCommand(ffmpegPath, videoInputFile, audioInputFile, None, None, None, pipeline);
|
||||
}
|
||||
|
||||
public async Task<Command> Slug(
|
||||
string ffmpegPath,
|
||||
Channel channel,
|
||||
DateTimeOffset now,
|
||||
TimeSpan duration,
|
||||
bool hlsRealtime,
|
||||
TimeSpan ptsOffset,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
FFmpegPlaybackSettings playbackSettings = FFmpegPlaybackSettingsCalculator.CalculateGeneratedImageSettings(
|
||||
channel.StreamingMode,
|
||||
channel.FFmpegProfile,
|
||||
hlsRealtime);
|
||||
|
||||
Resolution desiredResolution = channel.FFmpegProfile.Resolution;
|
||||
|
||||
string audioFormat = playbackSettings.AudioFormat switch
|
||||
{
|
||||
FFmpegProfileAudioFormat.Ac3 => AudioFormat.Ac3,
|
||||
FFmpegProfileAudioFormat.AacLatm => AudioFormat.AacLatm,
|
||||
_ => AudioFormat.Aac
|
||||
};
|
||||
|
||||
var audioState = new AudioState(
|
||||
audioFormat,
|
||||
playbackSettings.AudioChannels,
|
||||
playbackSettings.AudioBitrate,
|
||||
playbackSettings.AudioBufferSize,
|
||||
playbackSettings.AudioSampleRate,
|
||||
false,
|
||||
AudioFilter.None,
|
||||
playbackSettings.TargetLoudness);
|
||||
|
||||
string videoFormat = GetVideoFormat(playbackSettings);
|
||||
|
||||
var desiredState = new FrameState(
|
||||
playbackSettings.RealtimeOutput,
|
||||
InfiniteLoop: false,
|
||||
videoFormat,
|
||||
GetVideoProfile(videoFormat, channel.FFmpegProfile.VideoProfile),
|
||||
VideoPreset.Unset,
|
||||
channel.FFmpegProfile.AllowBFrames,
|
||||
new PixelFormatYuv420P(),
|
||||
new FrameSize(desiredResolution.Width, desiredResolution.Height),
|
||||
new FrameSize(desiredResolution.Width, desiredResolution.Height),
|
||||
Option<FrameSize>.None,
|
||||
channel.FFmpegProfile.PadMode is FilterMode.HardwareIfPossible
|
||||
? FFmpegFilterMode.HardwareIfPossible
|
||||
: FFmpegFilterMode.Software,
|
||||
IsAnamorphic: false,
|
||||
Option<FrameRate>.None,
|
||||
playbackSettings.VideoBitrate,
|
||||
playbackSettings.VideoBufferSize,
|
||||
playbackSettings.VideoTrackTimeScale,
|
||||
playbackSettings.NormalizeColors,
|
||||
playbackSettings.Deinterlace);
|
||||
|
||||
var frameRate = await playbackSettings.FrameRate.IfNoneAsync(new FrameRate("24"));
|
||||
|
||||
var ffmpegVideoStream = new VideoStream(
|
||||
0,
|
||||
VideoFormat.GeneratedImage,
|
||||
string.Empty,
|
||||
new PixelFormatUnknown(), // leave this unknown so we convert to desired yuv420p
|
||||
ColorParams.Default,
|
||||
desiredState.PaddedSize,
|
||||
MaybeSampleAspectRatio: "1:1",
|
||||
DisplayAspectRatio: string.Empty,
|
||||
frameRate,
|
||||
true,
|
||||
ScanKind.Progressive);
|
||||
|
||||
var videoInputFile = new LavfiInputFile(
|
||||
$"color=c=black:s={desiredState.PaddedSize.Width}x{desiredState.PaddedSize.Height}:r={frameRate.FrameRateString}:d={duration.TotalSeconds}",
|
||||
ffmpegVideoStream);
|
||||
|
||||
HardwareAccelerationMode hwAccel = GetHardwareAccelerationMode(playbackSettings);
|
||||
|
||||
var hlsOptions = GetHlsOptions(channel, now, ptsOffset, audioFormat);
|
||||
|
||||
var ffmpegState = new FFmpegState(
|
||||
channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel,
|
||||
HardwareAccelerationMode.None, // no hw accel decode since errors loop
|
||||
hwAccel,
|
||||
VaapiDriverName(hwAccel, channel.FFmpegProfile.VaapiDriver),
|
||||
VaapiDeviceName(hwAccel, channel.FFmpegProfile.VaapiDevice),
|
||||
playbackSettings.StreamSeek,
|
||||
duration,
|
||||
channel.StreamingMode != StreamingMode.HttpLiveStreamingDirect,
|
||||
"ErsatzTV",
|
||||
channel.Name,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
hlsOptions.OutputFormat,
|
||||
hlsOptions.HlsPlaylistPath,
|
||||
hlsOptions.HlsSegmentTemplate,
|
||||
hlsOptions.HlsInitTemplate,
|
||||
hlsOptions.HlsSegmentOptions,
|
||||
ptsOffset,
|
||||
Option<int>.None,
|
||||
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames),
|
||||
false,
|
||||
false,
|
||||
GetTonemapAlgorithm(playbackSettings),
|
||||
channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel);
|
||||
|
||||
var audioInputFile = new NullAudioInputFile(audioState);
|
||||
|
||||
IPipelineBuilder pipelineBuilder = await _pipelineBuilderFactory.GetBuilder(
|
||||
hwAccel,
|
||||
videoInputFile,
|
||||
audioInputFile,
|
||||
Option<WatermarkInputFile>.None,
|
||||
Option<SubtitleInputFile>.None,
|
||||
Option<ConcatInputFile>.None,
|
||||
Option<GraphicsEngineInput>.None,
|
||||
VaapiDisplayName(hwAccel, channel.FFmpegProfile.VaapiDisplay),
|
||||
VaapiDriverName(hwAccel, channel.FFmpegProfile.VaapiDriver),
|
||||
VaapiDeviceName(hwAccel, channel.FFmpegProfile.VaapiDevice),
|
||||
channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel
|
||||
? FileSystemLayout.TranscodeTroubleshootingFolder
|
||||
: FileSystemLayout.FFmpegReportsFolder,
|
||||
FileSystemLayout.FontsCacheFolder,
|
||||
ffmpegPath,
|
||||
cancellationToken);
|
||||
|
||||
FFmpegPipeline pipeline = pipelineBuilder.Build(ffmpegState, desiredState);
|
||||
|
||||
@@ -867,7 +977,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
bool saveReports,
|
||||
Channel channel,
|
||||
string scheme,
|
||||
string host)
|
||||
string host,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var resolution = new FrameSize(channel.FFmpegProfile.Resolution.Width, channel.FFmpegProfile.Resolution.Height);
|
||||
|
||||
@@ -888,7 +999,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
Option<string>.None,
|
||||
FileSystemLayout.FFmpegReportsFolder,
|
||||
FileSystemLayout.FontsCacheFolder,
|
||||
ffmpegPath);
|
||||
ffmpegPath,
|
||||
cancellationToken);
|
||||
|
||||
FFmpegPipeline pipeline = pipelineBuilder.Concat(
|
||||
concatInputFile,
|
||||
@@ -958,7 +1070,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
Option<string>.None,
|
||||
FileSystemLayout.FFmpegReportsFolder,
|
||||
FileSystemLayout.FontsCacheFolder,
|
||||
ffmpegPath);
|
||||
ffmpegPath,
|
||||
cancellationToken);
|
||||
|
||||
FFmpegPipeline pipeline = pipelineBuilder.WrapSegmenter(
|
||||
concatInputFile,
|
||||
@@ -967,7 +1080,12 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
return GetCommand(ffmpegPath, None, None, None, concatInputFile, None, pipeline);
|
||||
}
|
||||
|
||||
public async Task<Command> ResizeImage(string ffmpegPath, string inputFile, string outputFile, int height)
|
||||
public async Task<Command> ResizeImage(
|
||||
string ffmpegPath,
|
||||
string inputFile,
|
||||
string outputFile,
|
||||
int height,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var videoInputFile = new VideoInputFile(
|
||||
inputFile,
|
||||
@@ -1000,7 +1118,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
Option<string>.None,
|
||||
FileSystemLayout.FFmpegReportsFolder,
|
||||
FileSystemLayout.FontsCacheFolder,
|
||||
ffmpegPath);
|
||||
ffmpegPath,
|
||||
cancellationToken);
|
||||
|
||||
FFmpegPipeline pipeline = pipelineBuilder.Resize(outputFile, new FrameSize(-1, height));
|
||||
|
||||
@@ -1036,7 +1155,12 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
watermarkWidthPercent,
|
||||
cancellationToken);
|
||||
|
||||
public async Task<Command> SeekTextSubtitle(string ffmpegPath, string inputFile, string codec, TimeSpan seek)
|
||||
public async Task<Command> SeekTextSubtitle(
|
||||
string ffmpegPath,
|
||||
string inputFile,
|
||||
string codec,
|
||||
TimeSpan seek,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var videoInputFile = new VideoInputFile(
|
||||
inputFile,
|
||||
@@ -1069,7 +1193,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
Option<string>.None,
|
||||
FileSystemLayout.FFmpegReportsFolder,
|
||||
FileSystemLayout.FontsCacheFolder,
|
||||
ffmpegPath);
|
||||
ffmpegPath,
|
||||
cancellationToken);
|
||||
|
||||
FFmpegPipeline pipeline = pipelineBuilder.Seek(inputFile, codec, seek);
|
||||
|
||||
@@ -1260,4 +1385,67 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static HlsOptions GetHlsOptions(Channel channel, DateTimeOffset now, TimeSpan ptsOffset, string audioFormat)
|
||||
{
|
||||
OutputFormatKind outputFormat = OutputFormatKind.MpegTs;
|
||||
switch (channel.StreamingMode)
|
||||
{
|
||||
case StreamingMode.HttpLiveStreamingSegmenter:
|
||||
outputFormat = OutputFormatKind.Hls;
|
||||
break;
|
||||
}
|
||||
|
||||
Option<string> hlsPlaylistPath = outputFormat is OutputFormatKind.Hls or OutputFormatKind.HlsMp4
|
||||
? Path.Combine(FileSystemLayout.TranscodeFolder, channel.Number, "live.m3u8")
|
||||
: Option<string>.None;
|
||||
|
||||
long nowSeconds = now.ToUnixTimeSeconds();
|
||||
|
||||
Option<string> hlsSegmentTemplate = outputFormat switch
|
||||
{
|
||||
OutputFormatKind.Hls => Path.Combine(FileSystemLayout.TranscodeFolder, channel.Number, "live%06d.ts"),
|
||||
OutputFormatKind.HlsMp4 => Path.Combine(
|
||||
FileSystemLayout.TranscodeFolder,
|
||||
channel.Number,
|
||||
$"live_{nowSeconds}_%06d.m4s"),
|
||||
_ => Option<string>.None
|
||||
};
|
||||
|
||||
Option<string> hlsInitTemplate = outputFormat switch
|
||||
{
|
||||
OutputFormatKind.HlsMp4 => $"{nowSeconds}_init.mp4",
|
||||
_ => Option<string>.None
|
||||
};
|
||||
|
||||
Option<string> hlsSegmentOptions = Option<string>.None;
|
||||
if (outputFormat is OutputFormatKind.Hls)
|
||||
{
|
||||
string options = string.Empty;
|
||||
|
||||
if (ptsOffset == TimeSpan.Zero)
|
||||
{
|
||||
options += "+initial_discontinuity";
|
||||
}
|
||||
|
||||
if (audioFormat == AudioFormat.AacLatm)
|
||||
{
|
||||
options += "+latm";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options))
|
||||
{
|
||||
hlsSegmentOptions = $"mpegts_flags={options}";
|
||||
}
|
||||
}
|
||||
|
||||
return new HlsOptions(outputFormat, hlsPlaylistPath, hlsSegmentTemplate, hlsInitTemplate, hlsSegmentOptions);
|
||||
}
|
||||
|
||||
private sealed record HlsOptions(
|
||||
OutputFormatKind OutputFormat,
|
||||
Option<string> HlsPlaylistPath,
|
||||
Option<string> HlsSegmentTemplate,
|
||||
Option<string> HlsInitTemplate,
|
||||
Option<string> HlsSegmentOptions);
|
||||
}
|
||||
|
||||
@@ -181,7 +181,7 @@ public static class FFmpegPlaybackSettingsCalculator
|
||||
return result;
|
||||
}
|
||||
|
||||
public static FFmpegPlaybackSettings CalculateErrorSettings(
|
||||
public static FFmpegPlaybackSettings CalculateGeneratedImageSettings(
|
||||
StreamingMode streamingMode,
|
||||
FFmpegProfile ffmpegProfile,
|
||||
bool hlsRealtime) =>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Diagnostics;
|
||||
using Bugsnag;
|
||||
using CliWrap;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
@@ -12,7 +11,6 @@ namespace ErsatzTV.Core.FFmpeg;
|
||||
|
||||
public class FFmpegProcessService
|
||||
{
|
||||
private readonly IClient _client;
|
||||
private readonly IFFmpegStreamSelector _ffmpegStreamSelector;
|
||||
private readonly ILogger<FFmpegProcessService> _logger;
|
||||
private readonly ITempFilePool _tempFilePool;
|
||||
@@ -20,12 +18,10 @@ public class FFmpegProcessService
|
||||
public FFmpegProcessService(
|
||||
IFFmpegStreamSelector ffmpegStreamSelector,
|
||||
ITempFilePool tempFilePool,
|
||||
IClient client,
|
||||
ILogger<FFmpegProcessService> logger)
|
||||
{
|
||||
_ffmpegStreamSelector = ffmpegStreamSelector;
|
||||
_tempFilePool = tempFilePool;
|
||||
_client = client;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -71,7 +67,7 @@ public class FFmpegProcessService
|
||||
}
|
||||
|
||||
FFmpegPlaybackSettings playbackSettings =
|
||||
FFmpegPlaybackSettingsCalculator.CalculateErrorSettings(
|
||||
FFmpegPlaybackSettingsCalculator.CalculateGeneratedImageSettings(
|
||||
StreamingMode.TransportStream,
|
||||
channel.FFmpegProfile,
|
||||
false);
|
||||
@@ -131,7 +127,6 @@ public class FFmpegProcessService
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error generating song image");
|
||||
_client.Notify(ex);
|
||||
return Left(BaseError.New(ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
55
ErsatzTV.Core/FFmpeg/FFmpegProgress.cs
Normal file
55
ErsatzTV.Core/FFmpeg/FFmpegProgress.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Core.FFmpeg;
|
||||
|
||||
public partial class FFmpegProgress
|
||||
{
|
||||
public Option<double> Speed { get; private set; } = Option<double>.None;
|
||||
|
||||
public void ParseLine(string line)
|
||||
{
|
||||
Match match = FFmpegSpeed().Match(line);
|
||||
if (match.Success && double.TryParse(match.Groups[1].Value, out double speed))
|
||||
{
|
||||
Speed = speed;
|
||||
}
|
||||
}
|
||||
|
||||
public void LogSpeed(Option<int> mediaItemId, bool isWorkingAhead, string channelNumber, ILogger logger)
|
||||
{
|
||||
foreach (double speed in Speed)
|
||||
{
|
||||
if (isWorkingAhead)
|
||||
{
|
||||
if (speed < 1.0)
|
||||
{
|
||||
logger.LogCritical(
|
||||
"Media item {MediaItemId} on channel {Channel} transcoded at {Speed}x (NOT throttled) which is NOT fast enough to support playback",
|
||||
mediaItemId,
|
||||
channelNumber,
|
||||
speed);
|
||||
}
|
||||
else if (speed <= 1.5)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Media item {MediaItemId} on channel {Channel} transcoded at {Speed}x (NOT throttled) which may not be fast enough to support playback",
|
||||
mediaItemId,
|
||||
channelNumber,
|
||||
speed);
|
||||
}
|
||||
}
|
||||
else if (speed < 0.99)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Media item {MediaItemId} on channel {Channel} transcoded at {Speed}x (throttled) which may not be fast enough to support playback",
|
||||
mediaItemId,
|
||||
channelNumber,
|
||||
speed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"speed=\s*([\d\.]+)x", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex FFmpegSpeed();
|
||||
}
|
||||
@@ -8,6 +8,7 @@ public static class FileSystemLayout
|
||||
public static readonly string AppDataFolder;
|
||||
|
||||
public static readonly string TranscodeFolder;
|
||||
public static readonly string TranscodeTroubleshootingChannel;
|
||||
public static readonly string TranscodeTroubleshootingFolder;
|
||||
|
||||
public static readonly string DataProtectionFolder;
|
||||
@@ -132,7 +133,8 @@ public static class FileSystemLayout
|
||||
}
|
||||
|
||||
TranscodeFolder = useCustomTranscodeFolder ? customTranscodeFolder : defaultTranscodeFolder;
|
||||
TranscodeTroubleshootingFolder = Path.Combine(TranscodeFolder, ".troubleshooting");
|
||||
TranscodeTroubleshootingChannel = ".troubleshooting";
|
||||
TranscodeTroubleshootingFolder = Path.Combine(TranscodeFolder, TranscodeTroubleshootingChannel);
|
||||
|
||||
DataProtectionFolder = Path.Combine(AppDataFolder, "data-protection");
|
||||
LogsFolder = Path.Combine(AppDataFolder, "logs");
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
namespace ErsatzTV.Core.Health.Checks;
|
||||
|
||||
public interface IErrorReportsHealthCheck : IHealthCheck
|
||||
{
|
||||
}
|
||||
@@ -56,9 +56,25 @@ public interface IFFmpegProcessService
|
||||
string vaapiDisplay,
|
||||
VaapiDriver vaapiDriver,
|
||||
string vaapiDevice,
|
||||
Option<int> qsvExtraHardwareFrames);
|
||||
Option<int> qsvExtraHardwareFrames,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<Command> ConcatChannel(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host);
|
||||
Task<Command> Slug(
|
||||
string ffmpegPath,
|
||||
Channel channel,
|
||||
DateTimeOffset now,
|
||||
TimeSpan duration,
|
||||
bool hlsRealtime,
|
||||
TimeSpan ptsOffset,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<Command> ConcatChannel(
|
||||
string ffmpegPath,
|
||||
bool saveReports,
|
||||
Channel channel,
|
||||
string scheme,
|
||||
string host,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<Command> WrapSegmenter(
|
||||
string ffmpegPath,
|
||||
@@ -69,7 +85,12 @@ public interface IFFmpegProcessService
|
||||
string accessToken,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<Command> ResizeImage(string ffmpegPath, string inputFile, string outputFile, int height);
|
||||
Task<Command> ResizeImage(
|
||||
string ffmpegPath,
|
||||
string inputFile,
|
||||
string outputFile,
|
||||
int height,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<Either<BaseError, string>> GenerateSongImage(
|
||||
string ffmpegPath,
|
||||
@@ -86,5 +107,10 @@ public interface IFFmpegProcessService
|
||||
int watermarkWidthPercent,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<Command> SeekTextSubtitle(string ffmpegPath, string inputFile, string codec, TimeSpan seek);
|
||||
Task<Command> SeekTextSubtitle(
|
||||
string ffmpegPath,
|
||||
string inputFile,
|
||||
string codec,
|
||||
TimeSpan seek,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -5,69 +5,69 @@ namespace ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
|
||||
public interface IJellyfinApiClient
|
||||
{
|
||||
Task<Either<BaseError, JellyfinServerInformation>> GetServerInformation(string address, string apiKey);
|
||||
Task<Either<BaseError, List<JellyfinLibrary>>> GetLibraries(string address, string apiKey);
|
||||
Task<Either<BaseError, JellyfinServerInformation>> GetServerInformation(string address, string authorizationHeader);
|
||||
Task<Either<BaseError, List<JellyfinLibrary>>> GetLibraries(string address, string authorizationHeader);
|
||||
|
||||
IAsyncEnumerable<Tuple<JellyfinMovie, int>> GetMovieLibraryItems(
|
||||
string address,
|
||||
string apiKey,
|
||||
string authorizationHeader,
|
||||
JellyfinLibrary library);
|
||||
|
||||
IAsyncEnumerable<Tuple<JellyfinShow, int>> GetShowLibraryItemsWithoutPeople(
|
||||
string address,
|
||||
string apiKey,
|
||||
string authorizationHeader,
|
||||
JellyfinLibrary library);
|
||||
|
||||
IAsyncEnumerable<Tuple<JellyfinSeason, int>> GetSeasonLibraryItems(
|
||||
string address,
|
||||
string apiKey,
|
||||
string authorizationHeader,
|
||||
JellyfinLibrary library,
|
||||
string showId);
|
||||
|
||||
IAsyncEnumerable<Tuple<JellyfinEpisode, int>> GetEpisodeLibraryItems(
|
||||
string address,
|
||||
string apiKey,
|
||||
string authorizationHeader,
|
||||
JellyfinLibrary library,
|
||||
string seasonId);
|
||||
|
||||
IAsyncEnumerable<Tuple<JellyfinEpisode, int>> GetEpisodeLibraryItemsWithoutPeople(
|
||||
string address,
|
||||
string apiKey,
|
||||
string authorizationHeader,
|
||||
JellyfinLibrary library,
|
||||
string seasonId);
|
||||
|
||||
IAsyncEnumerable<Tuple<JellyfinCollection, int>> GetCollectionLibraryItems(
|
||||
string address,
|
||||
string apiKey,
|
||||
string authorizationHeader,
|
||||
int mediaSourceId);
|
||||
|
||||
IAsyncEnumerable<Tuple<MediaItem, int>> GetCollectionItems(
|
||||
string address,
|
||||
string apiKey,
|
||||
string authorizationHeader,
|
||||
int mediaSourceId,
|
||||
string collectionId);
|
||||
|
||||
Task<Either<BaseError, MediaVersion>> GetPlaybackInfo(
|
||||
string address,
|
||||
string apiKey,
|
||||
string authorizationHeader,
|
||||
JellyfinLibrary library,
|
||||
string itemId);
|
||||
|
||||
Task<Either<BaseError, Option<JellyfinShow>>> GetSingleShow(
|
||||
string address,
|
||||
string apiKey,
|
||||
string authorizationHeader,
|
||||
JellyfinLibrary library,
|
||||
string showId);
|
||||
|
||||
Task<Either<BaseError, List<JellyfinShow>>> SearchShowsByTitle(
|
||||
string address,
|
||||
string apiKey,
|
||||
string authorizationHeader,
|
||||
JellyfinLibrary library,
|
||||
string showTitle);
|
||||
|
||||
Task<Either<BaseError, Option<JellyfinEpisode>>> GetSingleEpisode(
|
||||
string address,
|
||||
string apiKey,
|
||||
string authorizationHeader,
|
||||
JellyfinLibrary library,
|
||||
string seasonId,
|
||||
string episodeId);
|
||||
|
||||
@@ -2,5 +2,9 @@
|
||||
|
||||
public interface IJellyfinCollectionScanner
|
||||
{
|
||||
Task<Either<BaseError, Unit>> ScanCollections(string address, string apiKey, int mediaSourceId, bool deepScan);
|
||||
Task<Either<BaseError, Unit>> ScanCollections(
|
||||
string address,
|
||||
string authorizationHeader,
|
||||
int mediaSourceId,
|
||||
bool deepScan);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
|
||||
public interface IJellyfinMovieLibraryScanner
|
||||
{
|
||||
Task<Either<BaseError, Unit>> ScanLibrary(
|
||||
string address,
|
||||
string apiKey,
|
||||
JellyfinConnectionParameters connectionParameters,
|
||||
JellyfinLibrary library,
|
||||
bool deepScan,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
|
||||
public interface IJellyfinTelevisionLibraryScanner
|
||||
{
|
||||
Task<Either<BaseError, Unit>> ScanLibrary(
|
||||
string address,
|
||||
string apiKey,
|
||||
JellyfinConnectionParameters connectionParameters,
|
||||
JellyfinLibrary library,
|
||||
bool deepScan,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<Either<BaseError, Unit>> ScanSingleShow(
|
||||
string address,
|
||||
string apiKey,
|
||||
JellyfinConnectionParameters connectionParameters,
|
||||
JellyfinLibrary library,
|
||||
string showId,
|
||||
string showTitle,
|
||||
|
||||
@@ -14,4 +14,9 @@ public interface ILocalStatisticsProvider
|
||||
string ffmpegPath,
|
||||
MediaItem mediaItem,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<Option<int>> GetProfileCount(
|
||||
string ffmpegPath,
|
||||
MediaItem mediaItem,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace ErsatzTV.Core.Interfaces.Scheduling;
|
||||
|
||||
public interface IMediaCollectionEnumerator
|
||||
{
|
||||
string SchedulingContextName { get; }
|
||||
CollectionEnumeratorState State { get; }
|
||||
Option<MediaItem> Current { get; }
|
||||
Option<bool> CurrentIncludeInProgramGuide { get; }
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Search;
|
||||
@@ -38,7 +37,6 @@ public interface ISearchIndex : IDisposable
|
||||
Task<bool> RemoveItems(IEnumerable<int> ids);
|
||||
|
||||
Task<SearchResult> Search(
|
||||
IClient client,
|
||||
string query,
|
||||
string smartCollectionName,
|
||||
int skip,
|
||||
@@ -46,7 +44,6 @@ public interface ISearchIndex : IDisposable
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<SearchResult> Search(
|
||||
IClient client,
|
||||
string query,
|
||||
string smartCollectionName,
|
||||
int skip,
|
||||
|
||||
@@ -17,6 +17,9 @@ public static class ChannelIdentifier
|
||||
number /= 10;
|
||||
}
|
||||
|
||||
return $"C{channelNumber}.{id}.ersatztv.org";
|
||||
string instanceId = SystemEnvironment.InstanceId;
|
||||
return !string.IsNullOrWhiteSpace(instanceId)
|
||||
? $"C{channelNumber}.{id}.{instanceId}.ersatztv.org"
|
||||
: $"C{channelNumber}.{id}.ersatztv.org";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,4 +3,7 @@
|
||||
namespace ErsatzTV.Core.Jellyfin;
|
||||
|
||||
public record JellyfinConnectionParameters(string Address, string ApiKey, int MediaSourceId)
|
||||
: MediaServerConnectionParameters;
|
||||
: MediaServerConnectionParameters
|
||||
{
|
||||
public string AuthorizationHeader => $"MediaBrowser Token={ApiKey}";
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
|
||||
namespace ErsatzTV.Core.Metadata;
|
||||
|
||||
public partial class FallbackMetadataProvider(IClient client) : IFallbackMetadataProvider
|
||||
public partial class FallbackMetadataProvider : IFallbackMetadataProvider
|
||||
{
|
||||
private static readonly Regex SeasonPattern = SeasonNumber();
|
||||
|
||||
@@ -203,7 +202,7 @@ public partial class FallbackMetadataProvider(IClient client) : IFallbackMetadat
|
||||
[GeneratedRegex(@"s(?:eason)?\s?(\d+)(?![e\d])", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex SeasonNumber();
|
||||
|
||||
private List<EpisodeMetadata> GetEpisodeMetadata(string fileName, EpisodeMetadata baseMetadata)
|
||||
private static List<EpisodeMetadata> GetEpisodeMetadata(string fileName, EpisodeMetadata baseMetadata)
|
||||
{
|
||||
var result = new List<EpisodeMetadata> { baseMetadata };
|
||||
|
||||
@@ -260,15 +259,15 @@ public partial class FallbackMetadataProvider(IClient client) : IFallbackMetadat
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch (Exception)
|
||||
{
|
||||
client.Notify(ex);
|
||||
// do nothing
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private MovieMetadata GetMovieMetadata(string fileName, MovieMetadata metadata)
|
||||
private static MovieMetadata GetMovieMetadata(string fileName, MovieMetadata metadata)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -283,15 +282,15 @@ public partial class FallbackMetadataProvider(IClient client) : IFallbackMetadat
|
||||
metadata.DateUpdated = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch (Exception)
|
||||
{
|
||||
client.Notify(ex);
|
||||
// do nothing
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private Option<MusicVideoMetadata> GetMusicVideoMetadata(string fileName, MusicVideoMetadata metadata)
|
||||
private static Option<MusicVideoMetadata> GetMusicVideoMetadata(string fileName, MusicVideoMetadata metadata)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -310,14 +309,13 @@ public partial class FallbackMetadataProvider(IClient client) : IFallbackMetadat
|
||||
|
||||
return metadata;
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch (Exception)
|
||||
{
|
||||
client.Notify(ex);
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
private Option<OtherVideoMetadata> GetOtherVideoMetadata(string path, OtherVideoMetadata metadata)
|
||||
private static Option<OtherVideoMetadata> GetOtherVideoMetadata(string path, OtherVideoMetadata metadata)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -348,14 +346,13 @@ public partial class FallbackMetadataProvider(IClient client) : IFallbackMetadat
|
||||
|
||||
return metadata;
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch (Exception)
|
||||
{
|
||||
client.Notify(ex);
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
private Option<ImageMetadata> GetImageMetadata(string path, ImageMetadata metadata)
|
||||
private static Option<ImageMetadata> GetImageMetadata(string path, ImageMetadata metadata)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -386,14 +383,13 @@ public partial class FallbackMetadataProvider(IClient client) : IFallbackMetadat
|
||||
|
||||
return metadata;
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch (Exception)
|
||||
{
|
||||
client.Notify(ex);
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
private Option<RemoteStreamMetadata> GetRemoteStreamMetadata(string path, RemoteStreamMetadata metadata)
|
||||
private static Option<RemoteStreamMetadata> GetRemoteStreamMetadata(string path, RemoteStreamMetadata metadata)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -424,14 +420,13 @@ public partial class FallbackMetadataProvider(IClient client) : IFallbackMetadat
|
||||
|
||||
return metadata;
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch (Exception)
|
||||
{
|
||||
client.Notify(ex);
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
private Option<SongMetadata> GetSongMetadata(string path, SongMetadata metadata)
|
||||
private static Option<SongMetadata> GetSongMetadata(string path, SongMetadata metadata)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -462,14 +457,13 @@ public partial class FallbackMetadataProvider(IClient client) : IFallbackMetadat
|
||||
|
||||
return metadata;
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch (Exception)
|
||||
{
|
||||
client.Notify(ex);
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
private ShowMetadata GetTelevisionShowMetadata(string fileName, ShowMetadata metadata)
|
||||
private static ShowMetadata GetTelevisionShowMetadata(string fileName, ShowMetadata metadata)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -484,9 +478,9 @@ public partial class FallbackMetadataProvider(IClient client) : IFallbackMetadat
|
||||
metadata.DateUpdated = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch (Exception)
|
||||
{
|
||||
client.Notify(ex);
|
||||
// do nothing
|
||||
}
|
||||
|
||||
return metadata;
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.IO.Abstractions;
|
||||
using System.Security.Cryptography;
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Core.Metadata;
|
||||
|
||||
public class LocalFileSystem(IFileSystem fileSystem, IClient client, ILogger<LocalFileSystem> logger) : ILocalFileSystem
|
||||
public class LocalFileSystem(IFileSystem fileSystem, ILogger<LocalFileSystem> logger) : ILocalFileSystem
|
||||
{
|
||||
public Unit EnsureFolderExists(string folder)
|
||||
{
|
||||
@@ -50,10 +49,9 @@ public class LocalFileSystem(IFileSystem fileSystem, IClient client, ILogger<Loc
|
||||
{
|
||||
logger.LogWarning("Unauthorized access exception listing subdirectories of folder {Folder}", folder);
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch (Exception)
|
||||
{
|
||||
// do nothing
|
||||
client.Notify(ex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,10 +71,9 @@ public class LocalFileSystem(IFileSystem fileSystem, IClient client, ILogger<Loc
|
||||
{
|
||||
logger.LogWarning("Unauthorized access exception listing files in folder {Folder}", folder);
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch (Exception)
|
||||
{
|
||||
// do nothing
|
||||
client.Notify(ex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,10 +93,9 @@ public class LocalFileSystem(IFileSystem fileSystem, IClient client, ILogger<Loc
|
||||
{
|
||||
logger.LogWarning("Unauthorized access exception listing files in folder {Folder}", folder);
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch (Exception)
|
||||
{
|
||||
// do nothing
|
||||
client.Notify(ex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,10 +119,9 @@ public class LocalFileSystem(IFileSystem fileSystem, IClient client, ILogger<Loc
|
||||
{
|
||||
logger.LogWarning("Unauthorized access exception listing files in folder {Folder}", folder);
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch (Exception)
|
||||
{
|
||||
// do nothing
|
||||
client.Notify(ex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,7 +146,6 @@ public class LocalFileSystem(IFileSystem fileSystem, IClient client, ILogger<Loc
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
client.Notify(ex);
|
||||
return BaseError.New(ex.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Domain.Scheduling;
|
||||
@@ -25,6 +27,13 @@ public class BlockPlayoutBuilder(
|
||||
NullValueHandling = NullValueHandling.Ignore
|
||||
};
|
||||
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
protected virtual ILogger Logger => logger;
|
||||
|
||||
public virtual async Task<Either<BaseError, PlayoutBuildResult>> Build(
|
||||
@@ -262,7 +271,8 @@ public class BlockPlayoutBuilder(
|
||||
CollectionKey = JsonConvert.SerializeObject(collectionKey, JsonSettings),
|
||||
CollectionEtag = collectionEtags[collectionKey],
|
||||
PlayoutItemWatermarks = [],
|
||||
PlayoutItemGraphicsElements = []
|
||||
PlayoutItemGraphicsElements = [],
|
||||
SchedulingContext = GetSchedulingContext(blockItem, enumerator)
|
||||
};
|
||||
|
||||
foreach (BlockItemWatermark blockItemWatermark in blockItem.BlockItemWatermarks ?? [])
|
||||
@@ -448,4 +458,16 @@ public class BlockPlayoutBuilder(
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string GetSchedulingContext(BlockItem blockItem, IMediaCollectionEnumerator enumerator)
|
||||
{
|
||||
var context = new BlockSchedulingContext(
|
||||
blockItem.BlockId,
|
||||
blockItem.Id,
|
||||
enumerator.SchedulingContextName,
|
||||
enumerator.State.Seed,
|
||||
enumerator.State.Index);
|
||||
|
||||
return System.Text.Json.JsonSerializer.Serialize(context, Options);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,8 @@ public class BlockPlayoutShuffledMediaCollectionEnumerator : IMediaCollectionEnu
|
||||
}
|
||||
}
|
||||
|
||||
public string SchedulingContextName => "Block Shuffle";
|
||||
|
||||
public CollectionEnumeratorState State { get; }
|
||||
|
||||
public Option<MediaItem> Current => _shuffled.Any() ? _shuffled[State.Index % _mediaItemCount] : None;
|
||||
|
||||
8
ErsatzTV.Core/Scheduling/BlockSchedulingContext.cs
Normal file
8
ErsatzTV.Core/Scheduling/BlockSchedulingContext.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.Core.Scheduling;
|
||||
|
||||
public record BlockSchedulingContext(
|
||||
int BlockId,
|
||||
int BlockItemId,
|
||||
string Enumerator,
|
||||
int Seed,
|
||||
int Index);
|
||||
@@ -40,6 +40,8 @@ public sealed class ChronologicalMediaCollectionEnumerator : IMediaCollectionEnu
|
||||
// seed doesn't matter in chronological
|
||||
State.Index = state.Index;
|
||||
|
||||
public string SchedulingContextName => "Chronological";
|
||||
|
||||
public CollectionEnumeratorState State { get; }
|
||||
|
||||
public Option<MediaItem> Current => _sortedMediaItems.Count != 0 ? _sortedMediaItems[State.Index] : None;
|
||||
|
||||
10
ErsatzTV.Core/Scheduling/ClassicSchedulingContext.cs
Normal file
10
ErsatzTV.Core/Scheduling/ClassicSchedulingContext.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace ErsatzTV.Core.Scheduling;
|
||||
|
||||
public record ClassicSchedulingContext(
|
||||
string Scheduler,
|
||||
int ScheduleId,
|
||||
int ItemId,
|
||||
int? FillerPresetId,
|
||||
string Enumerator,
|
||||
int Seed,
|
||||
int Index);
|
||||
@@ -36,6 +36,8 @@ public class CustomOrderCollectionEnumerator : IMediaCollectionEnumerator
|
||||
// seed doesn't matter here
|
||||
State.Index = state.Index;
|
||||
|
||||
public string SchedulingContextName => "Custom Order";
|
||||
|
||||
public CollectionEnumeratorState State { get; }
|
||||
|
||||
public Option<MediaItem> Current => _sortedMediaItems.Count != 0 ? _sortedMediaItems[State.Index] : None;
|
||||
|
||||
@@ -35,6 +35,8 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
|
||||
// seed doesn't matter here
|
||||
State.Index = state.Index;
|
||||
|
||||
public string SchedulingContextName => "Playlist";
|
||||
|
||||
public CollectionEnumeratorState State { get; private set; }
|
||||
|
||||
public Option<MediaItem> Current => _sortedEnumerators.Count > 0
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
@@ -13,6 +15,14 @@ namespace ErsatzTV.Core.Scheduling;
|
||||
public abstract class PlayoutModeSchedulerBase<T>(ILogger logger) : IPlayoutModeScheduler<T>
|
||||
where T : ProgramScheduleItem
|
||||
{
|
||||
// ReSharper disable once StaticMemberInGenericType
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
private readonly Random _random = new();
|
||||
|
||||
protected ILogger Logger { get; } = logger;
|
||||
@@ -166,7 +176,8 @@ public abstract class PlayoutModeSchedulerBase<T>(ILogger logger) : IPlayoutMode
|
||||
FillerKind = FillerKind.Tail,
|
||||
GuideGroup = nextState.NextGuideGroup,
|
||||
DisableWatermarks = !scheduleItem.TailFiller.AllowWatermarks,
|
||||
ChapterTitle = ChapterTitleForMediaItem(mediaItem)
|
||||
ChapterTitle = ChapterTitleForMediaItem(mediaItem),
|
||||
SchedulingContext = GetSchedulingContext(scheduleItem, scheduleItem.TailFillerId, enumerator)
|
||||
};
|
||||
|
||||
newItems.Add(playoutItem);
|
||||
@@ -211,7 +222,8 @@ public abstract class PlayoutModeSchedulerBase<T>(ILogger logger) : IPlayoutMode
|
||||
OutPoint = TimeSpan.Zero,
|
||||
GuideGroup = nextState.NextGuideGroup,
|
||||
FillerKind = FillerKind.Fallback,
|
||||
DisableWatermarks = !scheduleItem.FallbackFiller.AllowWatermarks
|
||||
DisableWatermarks = !scheduleItem.FallbackFiller.AllowWatermarks,
|
||||
SchedulingContext = GetSchedulingContext(scheduleItem, scheduleItem.FallbackFillerId, enumerator)
|
||||
};
|
||||
|
||||
newItems.Add(playoutItem);
|
||||
@@ -407,10 +419,12 @@ public abstract class PlayoutModeSchedulerBase<T>(ILogger logger) : IPlayoutMode
|
||||
result.AddRange(
|
||||
AddCountFiller(
|
||||
playoutBuilderState,
|
||||
scheduleItem,
|
||||
e2,
|
||||
filler.Count.Value,
|
||||
scheduleItem.GuideMode == GuideMode.Filler ? FillerKind.GuideMode : FillerKind.PreRoll,
|
||||
filler.AllowWatermarks,
|
||||
filler.Id,
|
||||
cancellationToken));
|
||||
break;
|
||||
case FillerMode.RandomCount when filler.Count.HasValue:
|
||||
@@ -418,10 +432,12 @@ public abstract class PlayoutModeSchedulerBase<T>(ILogger logger) : IPlayoutMode
|
||||
result.AddRange(
|
||||
AddRandomCountFiller(
|
||||
playoutBuilderState,
|
||||
scheduleItem,
|
||||
e3,
|
||||
filler.Count.Value,
|
||||
scheduleItem.GuideMode == GuideMode.Filler ? FillerKind.GuideMode : FillerKind.PreRoll,
|
||||
filler.AllowWatermarks,
|
||||
filler.Id,
|
||||
cancellationToken));
|
||||
break;
|
||||
}
|
||||
@@ -479,12 +495,14 @@ public abstract class PlayoutModeSchedulerBase<T>(ILogger logger) : IPlayoutMode
|
||||
result.AddRange(
|
||||
AddCountFiller(
|
||||
playoutBuilderState,
|
||||
scheduleItem,
|
||||
e2,
|
||||
filler.Count.Value,
|
||||
scheduleItem.GuideMode == GuideMode.Filler
|
||||
? FillerKind.GuideMode
|
||||
: FillerKind.MidRoll,
|
||||
filler.AllowWatermarks,
|
||||
filler.Id,
|
||||
cancellationToken));
|
||||
}
|
||||
}
|
||||
@@ -500,12 +518,14 @@ public abstract class PlayoutModeSchedulerBase<T>(ILogger logger) : IPlayoutMode
|
||||
result.AddRange(
|
||||
AddRandomCountFiller(
|
||||
playoutBuilderState,
|
||||
scheduleItem,
|
||||
e3,
|
||||
filler.Count.Value,
|
||||
scheduleItem.GuideMode == GuideMode.Filler
|
||||
? FillerKind.GuideMode
|
||||
: FillerKind.MidRoll,
|
||||
filler.AllowWatermarks,
|
||||
filler.Id,
|
||||
cancellationToken));
|
||||
}
|
||||
}
|
||||
@@ -536,10 +556,12 @@ public abstract class PlayoutModeSchedulerBase<T>(ILogger logger) : IPlayoutMode
|
||||
result.AddRange(
|
||||
AddCountFiller(
|
||||
playoutBuilderState,
|
||||
scheduleItem,
|
||||
e2,
|
||||
filler.Count.Value,
|
||||
scheduleItem.GuideMode == GuideMode.Filler ? FillerKind.GuideMode : FillerKind.PostRoll,
|
||||
filler.AllowWatermarks,
|
||||
filler.Id,
|
||||
cancellationToken));
|
||||
break;
|
||||
case FillerMode.RandomCount when filler.Count.HasValue:
|
||||
@@ -547,10 +569,12 @@ public abstract class PlayoutModeSchedulerBase<T>(ILogger logger) : IPlayoutMode
|
||||
result.AddRange(
|
||||
AddRandomCountFiller(
|
||||
playoutBuilderState,
|
||||
scheduleItem,
|
||||
e3,
|
||||
filler.Count.Value,
|
||||
scheduleItem.GuideMode == GuideMode.Filler ? FillerKind.GuideMode : FillerKind.PostRoll,
|
||||
filler.AllowWatermarks,
|
||||
filler.Id,
|
||||
cancellationToken));
|
||||
break;
|
||||
}
|
||||
@@ -742,12 +766,14 @@ public abstract class PlayoutModeSchedulerBase<T>(ILogger logger) : IPlayoutMode
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<PlayoutItem> AddCountFiller(
|
||||
private List<PlayoutItem> AddCountFiller(
|
||||
PlayoutBuilderState playoutBuilderState,
|
||||
ProgramScheduleItem scheduleItem,
|
||||
IMediaCollectionEnumerator enumerator,
|
||||
int count,
|
||||
FillerKind fillerKind,
|
||||
bool allowWatermarks,
|
||||
int fillerPresetId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = new List<PlayoutItem>();
|
||||
@@ -770,7 +796,8 @@ public abstract class PlayoutModeSchedulerBase<T>(ILogger logger) : IPlayoutMode
|
||||
GuideGroup = playoutBuilderState.NextGuideGroup,
|
||||
FillerKind = fillerKind,
|
||||
DisableWatermarks = !allowWatermarks,
|
||||
ChapterTitle = ChapterTitleForMediaItem(mediaItem)
|
||||
ChapterTitle = ChapterTitleForMediaItem(mediaItem),
|
||||
SchedulingContext = GetSchedulingContext(scheduleItem, fillerPresetId, enumerator)
|
||||
};
|
||||
|
||||
result.Add(playoutItem);
|
||||
@@ -876,10 +903,12 @@ public abstract class PlayoutModeSchedulerBase<T>(ILogger logger) : IPlayoutMode
|
||||
|
||||
private List<PlayoutItem> AddRandomCountFiller(
|
||||
PlayoutBuilderState playoutBuilderState,
|
||||
ProgramScheduleItem scheduleItem,
|
||||
IMediaCollectionEnumerator enumerator,
|
||||
int count,
|
||||
FillerKind fillerKind,
|
||||
bool allowWatermarks,
|
||||
int fillerPresetId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = new List<PlayoutItem>();
|
||||
@@ -890,13 +919,34 @@ public abstract class PlayoutModeSchedulerBase<T>(ILogger logger) : IPlayoutMode
|
||||
{
|
||||
result = AddCountFiller(
|
||||
playoutBuilderState,
|
||||
scheduleItem,
|
||||
enumerator,
|
||||
randomCount,
|
||||
fillerKind,
|
||||
allowWatermarks,
|
||||
fillerPresetId,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
protected abstract string SchedulingContextName { get; }
|
||||
|
||||
protected string GetSchedulingContext(
|
||||
ProgramScheduleItem scheduleItem,
|
||||
int? fillerPresetId,
|
||||
IMediaCollectionEnumerator enumerator)
|
||||
{
|
||||
var context = new ClassicSchedulingContext(
|
||||
SchedulingContextName,
|
||||
scheduleItem.ProgramScheduleId,
|
||||
scheduleItem.Id,
|
||||
fillerPresetId,
|
||||
enumerator.SchedulingContextName,
|
||||
enumerator.State.Seed,
|
||||
enumerator.State.Index);
|
||||
|
||||
return JsonSerializer.Serialize(context, Options);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,7 +164,8 @@ public class PlayoutModeSchedulerDuration(ILogger logger)
|
||||
PreferredSubtitleLanguageCode = scheduleItem.PreferredSubtitleLanguageCode,
|
||||
SubtitleMode = scheduleItem.SubtitleMode,
|
||||
PlayoutItemWatermarks = [],
|
||||
PlayoutItemGraphicsElements = []
|
||||
PlayoutItemGraphicsElements = [],
|
||||
SchedulingContext = GetSchedulingContext(scheduleItem, null, contentEnumerator)
|
||||
};
|
||||
|
||||
foreach (ProgramScheduleItemWatermark programScheduleItemWatermark in scheduleItem
|
||||
@@ -351,4 +352,6 @@ public class PlayoutModeSchedulerDuration(ILogger logger)
|
||||
|
||||
return new PlayoutSchedulerResult(nextState, playoutItems, warnings);
|
||||
}
|
||||
|
||||
protected override string SchedulingContextName => "Duration";
|
||||
}
|
||||
|
||||
@@ -80,7 +80,8 @@ public class PlayoutModeSchedulerFlood(ILogger logger) : PlayoutModeSchedulerBas
|
||||
PreferredSubtitleLanguageCode = scheduleItem.PreferredSubtitleLanguageCode,
|
||||
SubtitleMode = scheduleItem.SubtitleMode,
|
||||
PlayoutItemWatermarks = [],
|
||||
PlayoutItemGraphicsElements = []
|
||||
PlayoutItemGraphicsElements = [],
|
||||
SchedulingContext = GetSchedulingContext(scheduleItem, null, contentEnumerator)
|
||||
};
|
||||
|
||||
foreach (ProgramScheduleItemWatermark programScheduleItemWatermark in scheduleItem
|
||||
@@ -205,4 +206,6 @@ public class PlayoutModeSchedulerFlood(ILogger logger) : PlayoutModeSchedulerBas
|
||||
|
||||
return new PlayoutSchedulerResult(nextState, playoutItems, warnings);
|
||||
}
|
||||
|
||||
protected override string SchedulingContextName => "Flood";
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user