Compare commits

...
82 Commits
Author SHA1 Message Date
timothyandClaude Opus 4.8 8a2ceeddee release: v26.3.1 — first fork build (infra rebuild of upstream 26.3.0)
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 2m43s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m56s
Re-trigger: Gitea dropped the run records for the prior push/tag of this
same tree (runner online, workflow active, but no ActionRun created).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 00:35:35 +02:00
timothyandClaude Opus 4.8 c7aad4689e docs: enshrine the fork versioning scheme
Document upstream's vYY.<release-seq>.<patch> scheme (year, sequential
release-in-year, patch) in docs/ci-cd.md + CLAUDE.md so we follow it going
forward: <release-seq> is NOT the calendar month (v25.2.0 shipped in June,
v26.3.0 in Feb), it's a per-year counter that resets each January. v26.3.1
= our infra-only rebuild of upstream 26.3.0; v26.4.0 reserved for the first
release with app changes. Also notes the [skip ci]-on-a-tagged-commit gotcha.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 00:29:13 +02:00
timothyandClaude Opus 4.8 c0c37d8987 docs: point CLAUDE.md deployment at the fork image/pipeline [skip ci]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 00:15:52 +02:00
timothy 45627ede82 ci: harden build pipeline per adversarial review
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 1m55s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 24s
- Add concurrency group so the single jazz runner can't run the
  push-main-then-push-tag release flow in parallel (shared :buildcache
  + smoke container would collide).
- Add pull_request trigger running the test job only (PRs had no gate);
  skip the build job on PRs.
- Only push images from main or a v* tag (workflow_dispatch from other
  refs now builds without publishing, instead of clobbering :latest/:prod).
- Replace the log-grep smoke check with a real HTTP readiness probe
  (docker exec python3 -> http://localhost:8409/), unique container name,
  and trap-based cleanup to avoid leaks on cancel.
- dotnet test now runs -c Release --no-build (was rebuilding in Debug).
- Directory.Build.props: WarningsAsErrors=NU1904 so critical NuGet
  advisories block in every project, not just ones with
  TreatWarningsAsErrors.
- Dockerfile copies global.json + .editorconfig too, so the image build
  matches CI's SDK pin and analyzer severities.
- Remove dead .github/dependabot.yml + FUNDING.yml (upstream-pointed).
- Rewrite docs/ci-cd.md to the implemented pipeline.

Refs #4, #3, #8.
2026-06-27 00:01:22 +02:00
timothy 8c9bd680a4 ci: fix smoke-test readiness marker for ErsatzTV's Serilog output
ErsatzTV logs via Serilog and never emits Kestrel's 'Now listening on'
line, so the smoke test grep never matched even though the app booted
fully in ~6s. Match ErsatzTV's actual startup logs instead ('… service
started', 'Located ffmpeg'). The image build itself was already passing.

Refs #4.
2026-06-26 23:42:29 +02:00
timothy 46e2fbf0f7 build: don't fail restore on low/moderate/high NuGet audit advisories
.NET 10's NuGet audit promotes vulnerable transitive packages to NU1901-1904
warnings during restore; the app projects set TreatWarningsAsErrors=true, so
restore failed on NCalcSync 5.11.0 (moderate) and SQLitePCLRaw 2.1.10 (high).

Demote NU1901/NU1902/NU1903 to warnings in Directory.Build.props (NU1904
critical still errors). Also COPY Directory.Build.props/.targets in the
Dockerfile before restore so the image build honors the same props as
local/CI builds (it previously copied only the .sln). Underlying deps tracked
in #8.

Refs #4, #8.
2026-06-26 22:22:48 +02:00
timothyandClaude Opus 4.8 9ad0c4a393 ci: add Gitea Actions build pipeline, retire upstream workflows
Adds .gitea/workflows/docker-build.yml: a test-gated, amd64-only Docker
build that pushes the fork's own image to the Gitea container registry
(192.168.1.95:3000). The .NET test job gates the image build; the build
job stamps INFO_VERSION from git, uses buildx registry layer caching, and
smoke-tests that the built image boots before finishing.

Tagging:
  - push to main -> :latest + :<short-sha> (test image; prod untouched)
  - push tag v*  -> :prod + :<version> + :<short-sha> (prod release)

Updates docker/Dockerfile to pull the FFmpeg base image from our Gitea
registry (192.168.1.95:3000/timothy/ersatztv-ffmpeg:7.1.1) instead of the
archived upstream ghcr.io image.

Removes the upstream .github/workflows/* — they target GHCR/DockerHub and
Azure/Apple signing secrets we don't have, call reusable workflows at dead
ersatztv/ersatztv@main paths, and were running as noise (incl. a daily
stale-issue cron) on the Gitea runner. Upstream is archived, so there are
no future merges to preserve them for.

Refs #4, #3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:17:48 +02:00
timothyandClaude Opus 4.8 bbfc9e720a docs: confirm root cause of M3U logo issue (#1), document URL-generation architecture
Live Host-header test against the running container proves tvg-logo follows
the request Host header — there is no hardcoded localhost in the M3U/XMLTV
path (the localhost in GenerateChannelLogoUrl only feeds the FFmpeg watermark
overlay). Record the confirmed cause, the operational vs. durable fixes, and
the key architectural fact for future work: ErsatzTV has no advertised/base-URL
setting, so every absolute URL derives from the request Host; ConfigElement is
key/value so adding a setting needs no EF migration. Code fix for #1 deferred
until CI/CD test/prod envs (#3, #4) exist.

Refs #1

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 22:02:19 +02:00
timothyandClaude Opus 4.6 e58bb9af21 Move CI/CD reference docs from memory to in-repo docs/
Build / Calculate version information (push) Successful in 12s
Build / build_and_upload (push) Failing after 0s
Build / build_images (push) Failing after 0s
Close stale issues / stale (push) Successful in 3s
Moves ci-cd.md (59 lines) from Claude memory into docs/ alongside
existing architecture docs. Slims MEMORY.md from 42 to 18 lines by
removing sections duplicated in CLAUDE.md (Tech Stack, Key Patterns,
Architecture Docs index).

Total memory load per session: 101 → 18 lines.

Fixes #7

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 20:03:31 +01:00
timothyandClaude Opus 4.6 aa6d8eae4c Add Task Completion Protocol to CLAUDE.md
Build / Calculate version information (push) Successful in 17s
Build / build_and_upload (push) Failing after 0s
Build / build_images (push) Failing after 0s
Close stale issues / stale (push) Successful in 13s
Replace informal implementer workflow with structured 7-step protocol
including mandatory root cause analysis for bug fixes. References /done
skill for automated enforcement.

Part of adversarial-reviewer #260.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 22:29:45 +01:00
timothyandClaude Opus 4.6 f1e97b94a7 Add architecture docs and fork maintenance strategy (#6)
Build / Calculate version information (push) Successful in 13s
Build / build_and_upload (push) Failing after 0s
Build / build_images (push) Failing after 0s
Close stale issues / stale (push) Successful in 15s
Document channel architecture, M3U/XMLTV integration with Jellyfin,
and fork maintenance strategy for the archived upstream. Also includes
CLAUDE.md updates for implementer workflow and project boundaries.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 22:42:07 +01:00
timothyandClaude Opus 4.6 5034941a79 Add Claude Code project setup
Build / Calculate version information (push) Successful in 22s
Build / build_and_upload (push) Failing after 0s
Build / build_images (push) Failing after 0s
Close stale issues / stale (push) Successful in 14s
- CLAUDE.md with architecture overview and development guide
- .mcp.json with docker, ssh, gitea, csharp-lsp, and nuget MCP servers
- Skills for ersatztv and jellyfin
- .gitignore: exclude .mcp/ (built MCP tools)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 23:50:41 +01:00
Jason DoveandGitHub 0d301df5e8 remove external dependencies (bugsnag, trakt) (#2840)
* remove bugsnag

* remove trakt client id (that will expire)
2026-02-26 10:43:48 -06:00
Jason Dove d26ae336cb prep for release v26.3.0 [no ci] 2026-02-24 15:27:51 -06:00
Jason DoveandGitHub 875069b927 fix stream seek value in graphics engine (#2838) 2026-02-23 14:54:28 -06:00
Jason DoveandGitHub fd86cb55f9 optimize qsv h264 stream startup (#2835) 2026-02-22 10:13:18 -06:00
Jason DoveandGitHub 0c30c47ba9 nvidia - decode 10-bit h264 in software (#2833)
* output progress/speed even when copying video

* nvidia - decode 10-bit h264 in software

* fixes

* fix tests
2026-02-20 23:00:15 -06:00
Jason DoveandGitHub 08cbf59527 lower gop size and keyframe interval (#2832)
* lower gop size and keyframe interval

* update changelog

* fix build using latest dotnet sdk

* fixes
2026-02-19 13:35:27 -06:00
a91de68a5c Add instance id support (#2828)
* Add instance id support

* actually use env variable for instance ID

* Default to ersatztv.org for instance id

* simplify

* fix ordering

* update changelog

---------

Co-authored-by: Jason Dove <1695733+jasongdove@users.noreply.github.com>
2026-02-18 09:09:44 -06:00
Jason DoveandGitHub 3e3bfbd5f5 use heuristic to work around some qsv av desync cases (#2829)
* check for multiple h264 profiles using qsv decoding

* fix build

* update changelog

* pass cancellation token
2026-02-16 12:37:40 -06:00
Jason Dove 31b07305ef remove more discord references [no ci] 2026-02-15 12:44:16 -06:00
Jason DoveandGitHub 49adcf7c37 replace discord links with new contact link (#2825) 2026-02-15 11:05:44 -06:00
Jason DoveandGitHub c0b8ff1a06 generate slug instead of probing and transcoding resource (#2824)
* generate slug instead of probing and using slug resource

* refactor

* more fixes
2026-02-15 09:46:07 -06:00
Jason DoveandGitHub c6d538e012 add channel slugs (#2823)
* add channel slugs

* safety
2026-02-14 19:57:35 -06:00
Jason DoveandGitHub 3dbde17f68 pin dotnet sdk in docker to 10.0.102 (#2822) 2026-02-12 08:42:54 -06:00
Jason DoveandGitHub 794d209941 use latest authorization method with jellyfin api (#2821)
* use latest authorization method with jellyfin api

* temp pin dotnet sdk version to 10.0.102

* fix parameter name
2026-02-12 08:29:47 -06:00
Jason DoveandGitHub 7b9197d48d fix trakt api calls with new client id (#2820) 2026-02-10 11:26:09 -06:00
Jason DoveandGitHub 2ad6547349 scheduler context improvements (#2819)
* improve classic scheduling context display

* add basic block scheduling context

* add scheduling context to classic filler

* improve parsing
2026-02-09 20:19:52 -06:00
Jason DoveandGitHub 4fa11b6943 add scheduling context to playout details table (#2817)
* add scheduling context to playout details table

* fix missing context copies
2026-02-05 13:45:05 -06:00
Jason DoveandGitHub 440d9f708e improve shuffle stability when reset (#2816) 2026-02-05 12:03:16 -06:00
4d469ec8fd Add Polish (pl) localization for ErsatzTV: Part II (#2815)
* Add Polish localization for MainLayout

* Add Polish language option to UI settings

* Add Polish to supported UI cultures

* Add Polish localization for channel UI

Added Polish translations for channel-related UI elements.

* Add Polish translation for 'Rows per Page' label

* Update Polish translation for rows per page label

---------

Co-authored-by: Jason Dove <1695733+jasongdove@users.noreply.github.com>
2026-02-05 09:38:02 -06:00
Jason DoveandGitHub a77a2d56ae prepare channels list for localization (#2814)
* prepare channels list for localization

* define supported ui cultures/languages in a single location

* fixes
2026-02-04 14:20:43 -06:00
240a329526 Add Polish (pl) localization to ErsatzTV (#2812)
* Add Polish localization for MainLayout

* Add Polish language option to UI settings

* Add Polish to supported UI cultures

---------

Co-authored-by: Jason Dove <1695733+jasongdove@users.noreply.github.com>
2026-02-04 08:30:31 -06:00
Jason DoveandGitHub 45e7d61676 update dependencies (#2813) 2026-02-04 07:39:47 -06:00
Jason DoveandGitHub 93811876e0 improve resource organization (#2810) 2026-02-03 14:57:45 -06:00
Jason DoveandGitHub 607d9b0662 add ui localization framework (#2809)
* move dark/light mode toggle to ui settings page

* separate current culture (formatting) and ui culture (language)

* add some more sample translations

* update changelog

* fix cancellation token
2026-02-03 13:52:52 -06:00
Jason DoveandGitHub f47134d2d0 log warnings when transcoding speed is potentially insufficient (#2808)
* refactor parsing ffmpeg progress/speed

* log warnings when transcoding speed is potentially insufficient

* dont log progress on hls direct; fix tests
2026-02-03 08:49:07 -06:00
Jason Dove ae13db981d fix secrets in release workflow 2026-02-02 14:52:27 -06:00
Jason Dove b7cc8499a3 prep for release v26.2.0 [no ci] 2026-02-02 14:47:44 -06:00
Jason DoveandGitHub 36147b9e9c fix indexing collections in elasticsearch (#2806)
* fix indexing collections in elasticsearch

* more safety
2026-01-29 18:23:07 -06:00
Jason DoveandGitHub bf8c821012 improve erasing playout items and history (#2805)
* improve erasing playout items and history

* fixes
2026-01-28 09:17:56 -06:00
Jason DoveandGitHub a0f5d8d5d5 detect more local movie artwork (#2804)
* expand test coverage

* support "backdrop" files as local movie fanart fallback
2026-01-27 16:35:28 -06:00
Jason DoveandGitHub f1072b70c7 add chapter title to filler expression (#2803)
* fix transcoding tests

* pass chapter title to filler expression

* update changelog
2026-01-27 09:40:38 -06:00
Jason DoveandGitHub e10b28bc0b add normalization options (#2802)
* add new fields to database

* update editor

* audio and video normalization settings appear to work

* implement optional color normalization

* fix transcoding tests

* update changelog
2026-01-26 23:43:56 -06:00
Jason DoveandGitHub cd2bb0f2e0 fix playout build failures due to playlist enumerator access (#2801) 2026-01-26 14:44:07 -06:00
Jason DoveandGitHub e80f687612 add marathon group by director (#2800) 2026-01-26 09:10:01 -06:00
Jason DoveandGitHub 317ca1967c fix building playouts when fill with group mode is used with graphics elements (#2799) 2026-01-25 15:29:18 -06:00
Jason DoveandGitHub b86f45844c add health check to verify ffmpeg capabilities (filters) (#2798)
* add health check to verify ffmpeg capabilities (filters)

* fix loudnorm
2026-01-25 12:28:09 -06:00
Jason DoveandGitHub 353f029452 fix null ref scanning other videos with nfo file (#2797)
* fix null ref scanning other videos with nfo file

* also fix movie null ref
2026-01-25 11:34:06 -06:00
Jason DoveandGitHub 1754e7d5fb add health check for empty classic schedules (#2796) 2026-01-23 15:47:48 -06:00
Jason DoveandGitHub f96be8f99f update plex episode metadata during scan (#2795) 2026-01-21 16:59:01 -06:00
Jason DoveandGitHub 08ceb53b2b make count an expression in classic schedules (#2794)
* make count an expression in classic schedules

* add tests
2026-01-20 09:50:45 -06:00
Jason DoveandGitHub 3d81f760ee fix z-index sorting in graphics engine (#2786) 2026-01-18 09:07:21 -06:00
Jason DoveandGitHub 4ce87feac1 log graphics element z index (#2785) 2026-01-17 08:15:43 -06:00
Jason DoveandGitHub f217ba185b sync jf and emby library name and type changes (#2784) 2026-01-17 06:14:45 -06:00
Jason DoveandGitHub e925bd6913 sync plex library name changes (#2783)
* sync plex library name changes

* feedback
2026-01-16 19:45:34 -06:00
Jason DoveandGitHub 3f4c9e063b don't delete channel watermarks that are still used (#2781)
* don't delete channel watermarks that are still used

* fix folder cleanup check
2026-01-16 14:24:03 -06:00
Jason DoveandGitHub 7f361d1ea9 update dependencies (#2780)
* update messaging

* update dependencies
2026-01-16 13:57:25 -06:00
Jason DoveandGitHub 35d24ffea6 cleanup artwork cache folder (#2779)
* cleanup artwork cache folder

* fixes

* ignore watermarks that no longer exist on the file system
2026-01-16 13:38:31 -06:00
Jason DoveandGitHub a2d023ee69 local scanner artwork cleanup (#2778)
* move plex artwork removal to its own repository

* clean up old local movie artwork

* clien up old music video/artist artwork

* clean up old remote stream artwork

* clean up old song artwork

* clean up old show artwork; properly update season artwork
2026-01-16 10:23:26 -06:00
Jason DoveandGitHub 36f44f14bb fix other video artwork in xmltv (#2777) 2026-01-15 22:42:16 -06:00
Jason DoveandGitHub ccb917d0df add ffmpeg profile pad mode (#2775)
* add ffmpeg profile pad mode

* update changelog
2026-01-15 09:39:45 -06:00
Jason DoveandGitHub 343a4619a6 downmix ac3 to stereo to match output layout (#2774) 2026-01-14 10:40:49 -06:00
Jason DoveandGitHub e167c9318c fix failing unit tests (#2772) 2026-01-14 06:47:34 -06:00
Jason DoveandGitHub de230f92db fix issue reading xmltv fragments (#2771)
* fix issue reading xmltv fragments

* cleanup
2026-01-13 22:30:31 -06:00
Jason DoveandGitHub 974020a98f optimize searching for shows, seasons and movies (#2768)
* load search logging level on startup

* optimize searching for shows, seasons and movies

* use season metadata directly
2026-01-12 19:42:49 -06:00
Jason DoveandGitHub da957c9377 restore roboto font (#2767) 2026-01-12 08:49:57 -06:00
Jason DoveandGitHub b72d150775 add day_of_week to channel stream selector content_condition (#2766) 2026-01-10 11:28:14 -06:00
Jason DoveandGitHub b0b7bd17b3 respect z_index on all graphics element types (#2765) 2026-01-09 10:26:59 -06:00
Jason Dove 1f2f04f3bd more fixes 2026-01-08 21:47:28 -06:00
Jason Dove 5bc90bb245 give id-token write permission 2026-01-08 20:47:30 -06:00
Jason Dove f73a32ec13 restore permissions 2026-01-08 20:36:09 -06:00
Jason Dove 748ed1cf71 properly define secrets 2026-01-08 20:26:24 -06:00
Jason Dove f2deaa6f7a properly pass secrets 2026-01-08 20:25:20 -06:00
Jason Dove 3698fa5b7d try again 2026-01-08 20:19:47 -06:00
Jason Dove dc92cb4ac3 use separate azure login step 2026-01-08 19:59:55 -06:00
Jason Dove 69410b1a9b try to fix signing 2026-01-08 19:39:21 -06:00
Jason DoveandGitHub 4aee03e066 use code signing on all windows executables (#2764) 2026-01-08 19:27:45 -06:00
Jason Dove e16d6c67f1 prep for release v26.1.1 [no ci] 2026-01-08 16:01:29 -06:00
Jason DoveandGitHub 5d8877975d fix macos build (#2763)
* fix macos build

* also update host
2026-01-08 11:51:16 -06:00
Jason DoveandGitHub 367305d960 include web resources locally, using libman (#2762) 2026-01-08 11:24:48 -06:00
Jason DoveandGitHub aa08ad5765 optimize check for orphaned artwork (#2760) 2026-01-07 16:46:17 -06:00
384 changed files with 96267 additions and 2753 deletions
+167
View 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
View 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`
+164
View File
@@ -0,0 +1,164 @@
name: Build ErsatzTV Image
# Builds the fork's own amd64 image and pushes it to the Gitea container registry.
# pull_request -> test job only (no image build/push)
# push to main -> :latest + :<short-sha> (test image; does NOT touch prod)
# push tag v* -> :prod + :<version> + :<short-sha> (prod release)
# workflow_dispatch -> manual run; only publishes when the ref is main or a v* tag
#
# Runner + registry provisioned in server-management#172. The Gitea registry is
# HTTP-only, so BuildKit needs the inline `http = true` config below (it does not
# inherit the host daemon's insecure-registries setting).
#
# `:latest` is intentionally the test/dev channel (per ersatztv#3); prod pins
# `:prod`, never `:latest` (enforced in the prod compose — server-management#481).
on:
workflow_dispatch:
pull_request:
push:
branches:
- main
tags:
- 'v*'
# Single runner on jazz: serialize all runs so the push-main-then-tag release
# flow can't collide on the shared :buildcache tag or the smoke container.
concurrency:
group: ersatztv-build
cancel-in-progress: false
env:
REGISTRY: 192.168.1.95:3000
IMAGE: 192.168.1.95:3000/timothy/ersatztv
jobs:
test:
name: Build & test (.NET)
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Restore
run: dotnet restore
- name: Strip Scanner project ref (matches Docker build)
run: sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
- name: Build
run: dotnet build --configuration Release --no-restore
- name: Test
run: dotnet test --configuration Release --no-build --blame-hang-timeout "2m" --verbosity normal
build:
name: Build & push image (amd64)
runs-on: ubuntu-latest
needs: test
if: github.event_name != 'pull_request'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Compute version and tags
id: meta
run: |
SHORT=$(git rev-parse --short HEAD)
if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
VERSION="${GITHUB_REF_NAME#v}"
INFO_VERSION="${VERSION}"
TAGS=("${IMAGE}:prod" "${IMAGE}:${VERSION}" "${IMAGE}:${SHORT}")
else
DESC=$(git describe --tags --abbrev=0 2>/dev/null || echo v0.0.0)
INFO_VERSION="${DESC#v}-${SHORT}"
TAGS=("${IMAGE}:latest" "${IMAGE}:${SHORT}")
fi
echo "info_version=${INFO_VERSION}" >> "$GITHUB_OUTPUT"
echo "short=${SHORT}" >> "$GITHUB_OUTPUT"
{
echo "tags<<__EOT__"
printf '%s\n' "${TAGS[@]}"
echo "__EOT__"
} >> "$GITHUB_OUTPUT"
echo "INFO_VERSION=${INFO_VERSION}"
printf 'tag: %s\n' "${TAGS[@]}"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
buildkitd-config-inline: |
[registry."192.168.1.95:3000"]
http = true
- name: Login to Gitea registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./docker/Dockerfile
platforms: linux/amd64
# only publish from main or a v* tag; other refs (e.g. branch dispatch) build only
push: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') }}
provenance: false
build-args: |
INFO_VERSION=${{ steps.meta.outputs.info_version }}
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=registry,ref=192.168.1.95:3000/timothy/ersatztv:buildcache
cache-to: type=registry,ref=192.168.1.95:3000/timothy/ersatztv:buildcache,mode=max,ignore-error=true
- name: Smoke test (HTTP readiness)
if: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') }}
run: |
IMG="${IMAGE}:${{ steps.meta.outputs.short }}"
NAME="etv-smoke-${{ github.run_id }}"
trap 'docker rm -f "$NAME" >/dev/null 2>&1 || true' EXIT
echo "Pulling ${IMG}"
docker pull "$IMG"
docker run -d --name "$NAME" --memory 2g \
-e ETV_CONFIG_FOLDER=/tmp/etv/config \
-e ETV_TRANSCODE_FOLDER=/tmp/etv/transcode \
"$IMG"
# probe ErsatzTV's web server from inside the container (image ships python3)
cat > probe.py <<'PY'
import urllib.request, urllib.error, sys
try:
urllib.request.urlopen("http://localhost:8409/", timeout=3)
except urllib.error.HTTPError:
pass # any HTTP status means the server is serving
except Exception:
sys.exit(1) # not listening yet
PY
ok=0
for _ in $(seq 1 60); do
if [ -z "$(docker ps -q --filter name="$NAME" --filter status=running)" ]; then
echo "Container exited early"; break
fi
if docker exec -i "$NAME" python3 - < probe.py >/dev/null 2>&1; then
ok=1; break
fi
sleep 2
done
echo "===== container logs (tail) ====="
docker logs "$NAME" 2>&1 | tail -n 40 || true
echo "================================="
if [ "$ok" != "1" ]; then
echo "Smoke test FAILED: ErsatzTV did not serve HTTP on :8409"
exit 1
fi
echo "Smoke test passed: ErsatzTV served HTTP on :8409"
-2
View File
@@ -1,2 +0,0 @@
github: jasongdove
custom: "https://www.paypal.me/jasongdove"
+3 -3
View File
@@ -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
+1 -1
View File
@@ -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
-26
View File
@@ -1,26 +0,0 @@
version: 2
updates:
- package-ecosystem: nuget
directory: "/"
schedule:
interval: daily
assignees:
- jasongdove
- package-ecosystem: docker
directory: "/docker"
schedule:
interval: daily
assignees:
- jasongdove
- package-ecosystem: docker
directory: "/docker/nvidia"
schedule:
interval: daily
assignees:
- jasongdove
- package-ecosystem: docker
directory: "/docker/vaapi"
schedule:
interval: daily
assignees:
- jasongdove
-306
View File
@@ -1,306 +0,0 @@
name: Build Artifacts
on:
workflow_call:
inputs:
release_tag:
description: 'Release tag'
required: true
type: string
release_version:
description: 'Release version number (e.g. v0.3.7-alpha)'
required: true
type: string
info_version:
description: 'Informational version number (e.g. 0.3.7-alpha)'
required: true
type: string
secrets:
apple_developer_certificate_p12_base64:
required: true
apple_developer_certificate_password:
required: true
ac_username:
required: true
ac_password:
required: true
gh_token:
required: true
jobs:
build_and_upload_mac:
name: Mac Build & Upload
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
- os: macos-14
kind: macOS
target: osx-x64
- os: macos-14
kind: macOS
target: osx-arm64
steps:
- name: Get the sources
uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: true
- name: Setup dotnet
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Clean
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
- name: Install dependencies
run: dotnet restore -r "${{ matrix.target}}"
- name: Import Code-Signing Certificates
uses: Apple-Actions/import-codesign-certs@v2
with:
p12-file-base64: ${{ secrets.apple_developer_certificate_p12_base64 }}
p12-password: ${{ secrets.apple_developer_certificate_password }}
- name: Calculate Release Name
shell: bash
run: |
release_name="ErsatzTV-${{ inputs.release_version }}-${{ matrix.target }}"
echo "RELEASE_NAME=${release_name}" >> $GITHUB_ENV
- name: Build
shell: bash
run: |
sed -i '' '/Scanner/d' ErsatzTV/ErsatzTV.csproj
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net10.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=false -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net10.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=false -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
- name: Bundle
shell: bash
run: |
brew install coreutils
plutil -replace CFBundleShortVersionString -string "${{ inputs.info_version }}" ErsatzTV-macOS/ErsatzTV-macOS/Info.plist
plutil -replace CFBundleVersion -string "${{ inputs.info_version }}" ErsatzTV-macOS/ErsatzTV-macOS/Info.plist
scripts/macOS/bundle.sh
- name: Sign
shell: bash
run: scripts/macOS/sign.sh
- name: Create DMG
shell: bash
run: |
brew install create-dmg
create-dmg \
--volname "ErsatzTV" \
--volicon "artwork/ErsatzTV.icns" \
--window-pos 200 120 \
--window-size 800 400 \
--icon-size 100 \
--icon "ErsatzTV.app" 200 190 \
--hide-extension "ErsatzTV.app" \
--app-drop-link 600 185 \
--skip-jenkins \
--no-internet-enable \
"ErsatzTV.dmg" \
"ErsatzTV.app/"
- name: Notarize
shell: bash
run: |
xcrun notarytool submit ErsatzTV.dmg --apple-id "${{ secrets.ac_username }}" --password "${{ secrets.ac_password }}" --team-id 32MB98Q32R --wait
xcrun stapler staple ErsatzTV.dmg
- name: Cleanup
shell: bash
run: |
mv ErsatzTV.dmg "${{ env.RELEASE_NAME }}.dmg"
rm -r publish
rm -r ErsatzTV.app
- name: Delete old release assets
uses: mknejp/delete-release-assets@v1
if: ${{ inputs.release_tag == 'develop' }}
with:
token: ${{ secrets.gh_token }}
tag: ${{ inputs.release_tag }}
fail-if-no-assets: false
assets: "*${{ matrix.target }}.dmg"
- name: Publish
uses: softprops/action-gh-release@v1
with:
prerelease: false
tag_name: ${{ inputs.release_tag }}
files: "${{ env.RELEASE_NAME }}.dmg"
env:
GITHUB_TOKEN: ${{ secrets.gh_token }}
build_and_upload_linux:
name: Build & Upload Linux
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
- os: ubuntu-latest
kind: linux
target: linux-x64
- os: ubuntu-latest
kind: linux
target: linux-musl-x64
- os: ubuntu-latest
kind: linux
target: linux-arm
- os: ubuntu-24.04-arm
kind: linux
target: linux-arm64
steps:
- name: Get the sources
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup dotnet
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Clean
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
- name: Install dependencies
run: dotnet restore -r "${{ matrix.target }}"
- name: Build
shell: bash
run: |
# Define some variables for things we need
release_name="ErsatzTV-${{ inputs.release_version }}-${{ matrix.target }}"
echo "RELEASE_NAME=${release_name}" >> $GITHUB_ENV
# Build everything
sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net10.0 --runtime "${{ matrix.target }}" -c Release -o "scanner" -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net10.0 --runtime "${{ matrix.target }}" -c Release -o "main" -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
mkdir "$release_name"
mv scanner/* "$release_name/"
mv main/* "$release_name/"
tar czvf "${release_name}.tar.gz" "$release_name"
# Delete output directory
rm -r "$release_name"
- name: Delete old release assets
uses: mknejp/delete-release-assets@v1
if: ${{ inputs.release_tag == 'develop' }}
with:
token: ${{ secrets.gh_token }}
tag: ${{ inputs.release_tag }}
fail-if-no-assets: false
assets: "*${{ matrix.target }}.tar.gz"
- name: Publish
uses: softprops/action-gh-release@v1
with:
prerelease: false
tag_name: ${{ inputs.release_tag }}
files: "${{ env.RELEASE_NAME }}.tar.gz"
env:
GITHUB_TOKEN: ${{ secrets.gh_token }}
build_dotnet_windows:
name: Build dotnet for Windows
runs-on: ubuntu-latest
steps:
- name: Get the sources
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup dotnet
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Clean
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
- name: Install dependencies
run: dotnet restore -r "win-x64"
- name: Build dotnet projects
shell: bash
run: |
sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net10.0 --runtime "win-x64" -c Release -o "scanner" -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-win-x64" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net10.0 --runtime "win-x64" -c Release -o "main" -p:RestoreEnablePackagePruning=true -p:InformationalVersion="${{ inputs.release_version }}-win-x64" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
- name: Upload .NET Artifact
uses: actions/upload-artifact@v4
with:
name: dotnet-windows-build
path: |
scanner/
main/
retention-days: 1
package_and_upload_windows:
name: Package & Upload Windows
runs-on: ubuntu-latest
needs: build_dotnet_windows
steps:
- name: Download dotnet artifacts
uses: actions/download-artifact@v4
with:
name: dotnet-windows-build
path: dotnet-build
- name: Download rust launcher
uses: suisei-cn/actions-download-file@v1.3.0
with:
url: "https://github.com/ErsatzTV/ErsatzTV-Windows/releases/download/v1.0.0/ErsatzTV-Windows.exe"
target: rust-build/
- name: Download ffmpeg
uses: suisei-cn/actions-download-file@v1.3.0
id: downloadffmpeg
with:
url: "https://github.com/ErsatzTV/ErsatzTV-ffmpeg/releases/download/7.1.1/ffmpeg-n7.1.1-56-gc2184b65d2-win64-gpl-7.1.zip"
target: ffmpeg/
- name: Package artifacts
shell: bash
run: |
release_name="ErsatzTV-${{ inputs.release_version }}-win-x64"
echo "RELEASE_NAME=${release_name}" >> $GITHUB_ENV
mkdir "$release_name"
mv dotnet-build/scanner/* "$release_name/"
mv dotnet-build/main/* "$release_name/"
# dotnet shouldn't copy the resources here, but it does
rm -rf "$release_name/Resources"
mv rust-build/ErsatzTV-Windows.exe "$release_name/ErsatzTV-Windows.exe"
7z e "ffmpeg/${{ steps.downloadffmpeg.outputs.filename }}" -o"$release_name" '*.exe' -r
rm -f "$release_name/ffplay.exe"
(cd "${release_name}" && zip -r "../${release_name}.zip" .)
- name: Delete old release assets
uses: mknejp/delete-release-assets@v1
if: ${{ inputs.release_tag == 'develop' }}
with:
token: ${{ secrets.gh_token }}
tag: ${{ inputs.release_tag }}
fail-if-no-assets: false
assets: "*win-x64.zip"
- name: Publish
uses: softprops/action-gh-release@v1
with:
prerelease: false
tag_name: ${{ inputs.release_tag }}
files: "${{ env.RELEASE_NAME }}.zip"
env:
GITHUB_TOKEN: ${{ secrets.gh_token }}
-58
View File
@@ -1,58 +0,0 @@
name: Build
on:
workflow_dispatch:
push:
branches:
- main
jobs:
calculate_version:
name: Calculate version information
runs-on: ubuntu-latest
steps:
- name: Get the sources
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Extract Docker Tag
shell: bash
run: |
tag=$(git describe --tags --abbrev=0)
tag2="${tag:1}"
short=$(git rev-parse --short HEAD)
final="${tag2}-${short}"
echo "GIT_TAG=${final}" >> $GITHUB_ENV
- name: Extract Artifacts Version
shell: bash
run: |
tag=$(git describe --tags --abbrev=0)
short=$(git rev-parse --short HEAD)
final="${tag}-${short}"
echo "ARTIFACTS_VERSION=${final}" >> $GITHUB_ENV
echo "INFO_VERSION=${tag:1}" >> $GITHUB_ENV
outputs:
git_tag: ${{ env.GIT_TAG }}
artifacts_version: ${{ env.ARTIFACTS_VERSION }}
info_version: ${{ env.INFO_VERSION }}
build_and_upload:
uses: ersatztv/ersatztv/.github/workflows/artifacts.yml@main
needs: calculate_version
with:
release_tag: develop
release_version: ${{ needs.calculate_version.outputs.artifacts_version }}
info_version: ${{ needs.calculate_version.outputs.info_version }}
secrets:
apple_developer_certificate_p12_base64: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_P12_BASE64 }}
apple_developer_certificate_password: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_PASSWORD }}
ac_username: ${{ secrets.AC_USERNAME }}
ac_password: ${{ secrets.AC_PASSWORD }}
gh_token: ${{ secrets.GITHUB_TOKEN }}
build_images:
uses: ersatztv/ersatztv/.github/workflows/docker.yml@main
needs: calculate_version
with:
base_version: develop
info_version: ${{ needs.calculate_version.outputs.git_tag }}
tag_version: ${{ github.sha }}
secrets:
docker_hub_username: ${{ secrets.DOCKER_HUB_USERNAME }}
docker_hub_access_token: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
-141
View File
@@ -1,141 +0,0 @@
name: Build & Publish to Docker Hub
on:
workflow_call:
inputs:
base_version:
description: 'Base version (latest or develop)'
required: true
type: string
info_version:
description: 'Informational version number (e.g. 0.3.7-alpha)'
required: true
type: string
tag_version:
description: 'Docker tag version (e.g. v0.3.7)'
required: true
type: string
secrets:
docker_hub_username:
required: true
docker_hub_access_token:
required: true
jobs:
build_images:
name: Build ${{ matrix.name }} image
runs-on: ${{ matrix.os }}
if: contains(github.event.head_commit.message, '[no build]') == false
strategy:
matrix:
include:
- name: amd64
os: ubuntu-latest
path: ''
suffix: '-amd64'
platform: 'linux/amd64'
- name: arm32v7
os: ubuntu-latest
path: 'arm32v7/'
suffix: '-arm'
platform: 'linux/arm/v7'
- name: arm64
os: ubuntu-24.04-arm
path: 'arm64/'
suffix: '-arm64'
platform: 'linux/arm64'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up QEMU
if: ${{ matrix.name == 'arm32v7' }}
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.docker_hub_username }}
password: ${{ secrets.docker_hub_access_token }}
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v5
with:
context: .
file: ./docker/${{ matrix.path }}Dockerfile
push: true
provenance: false
platforms: ${{ matrix.platform }}
build-args: |
INFO_VERSION=${{ inputs.info_version }}-docker${{ matrix.suffix }}
outputs: |
type=image,name=jasongdove/ersatztv,name-canonical=true,push-by-digest=true
type=image,name=ghcr.io/ersatztv/ersatztv,name-canonical=true,push-by-digest=true
- name: Save digest to artifact
run: echo ${{ steps.build.outputs.digest }} > digest.txt
- name: Upload digest artifact
uses: actions/upload-artifact@v4
with:
name: digest-${{ matrix.name }}
path: digest.txt
merge_manifests:
name: Merge Manifests
runs-on: ubuntu-latest
needs: build_images
steps:
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.docker_hub_username }}
password: ${{ secrets.docker_hub_access_token }}
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Download all digest artifacts
uses: actions/download-artifact@v4
with:
path: digests/
- name: Read digests from artifacts
id: digests
run: |
AMD64_HASH=$(cat digests/digest-amd64/digest.txt)
ARM32V7_HASH=$(cat digests/digest-arm32v7/digest.txt)
ARM64_HASH=$(cat digests/digest-arm64/digest.txt)
DOCKER_HUB_DIGESTS="jasongdove/ersatztv@${AMD64_HASH} jasongdove/ersatztv@${ARM64_HASH} jasongdove/ersatztv@${ARM32V7_HASH}"
GHCR_DIGESTS="ghcr.io/ersatztv/ersatztv@${AMD64_HASH} ghcr.io/ersatztv/ersatztv@${ARM64_HASH} ghcr.io/ersatztv/ersatztv@${ARM32V7_HASH}"
echo "docker_hub_digests=${DOCKER_HUB_DIGESTS}" >> $GITHUB_OUTPUT
echo "ghcr_digests=${GHCR_DIGESTS}" >> $GITHUB_OUTPUT
- name: Create and push manifests
run: |
docker manifest create jasongdove/ersatztv:${{ inputs.base_version }} ${{ steps.digests.outputs.docker_hub_digests }}
docker manifest push jasongdove/ersatztv:${{ inputs.base_version }}
docker manifest create jasongdove/ersatztv:${{ inputs.tag_version }} ${{ steps.digests.outputs.docker_hub_digests }}
docker manifest push jasongdove/ersatztv:${{ inputs.tag_version }}
docker manifest create ghcr.io/ersatztv/ersatztv:${{ inputs.base_version }} ${{ steps.digests.outputs.ghcr_digests }}
docker manifest push ghcr.io/ersatztv/ersatztv:${{ inputs.base_version }}
docker manifest create ghcr.io/ersatztv/ersatztv:${{ inputs.tag_version }} ${{ steps.digests.outputs.ghcr_digests }}
docker manifest push ghcr.io/ersatztv/ersatztv:${{ inputs.tag_version }}
-27
View File
@@ -1,27 +0,0 @@
name: 'Close stale issues'
on:
schedule:
- cron: '30 1 * * *'
workflow_dispatch:
jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v9
with:
ascending: true
days-before-stale: 120
days-before-pr-stale: -1
days-before-close: 21
days-before-pr-close: -1
operations-per-run: 500
exempt-issue-labels: 'regression,security,roadmap,future,feature,enhancement,confirmed'
stale-issue-label: 'stale'
stale-issue-message: |-
This issue has gone 120 days without an update and will be closed within 21 days if there is no new activity. To prevent this issue from being closed, please confirm the issue has not already been fixed by providing updated examples or logs.
If you have any questions you can use one of several ways to [contact us](https://ersatztv.org).
close-issue-message: |-
This issue was closed due to inactivity.
-123
View File
@@ -1,123 +0,0 @@
name: Pull Request
on:
pull_request:
jobs:
build_and_analyze:
runs-on: ubuntu-latest
steps:
- name: Get the sources
uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: true
- name: Setup dotnet
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Clean
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
- name: Install dependencies
run: dotnet restore
- name: Prep project file
run: sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
- name: Build
run: dotnet build --configuration Release --no-restore /p:EnableThreadingAnalyzers=true
build_and_test_windows:
runs-on: windows-latest
steps:
- name: Get the sources
uses: actions/checkout@v4
- name: Setup dotnet
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Clean
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
- name: Install dependencies
run: dotnet restore
- name: Prep project file
run: sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
- name: Build
run: dotnet build --configuration Release --no-restore
- name: Test
run: dotnet test --blame-hang-timeout "2m" --no-restore --verbosity normal
- name: Build Windows
run: |
cd ErsatzTV-Windows
cargo build --release --all-features
build_and_test_linux:
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
- os: ubuntu-latest
target: linux-x64
- os: ubuntu-latest
target: linux-musl-x64
- os: ubuntu-latest
target: linux-arm
- os: ubuntu-24.04-arm
target: linux-arm64
steps:
- name: Get the sources
uses: actions/checkout@v4
- name: Setup dotnet
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Clean
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
- name: Install dependencies
run: dotnet restore -p:RestoreEnablePackagePruning=true -r "${{ matrix.target }}"
- name: Prep project file
run: sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
- name: Build
run: dotnet build ErsatzTV/ErsatzTV.csproj --runtime "${{ matrix.target }}" --configuration Release --no-restore && dotnet build --configuration Release --no-restore
- name: Test
run: dotnet test --blame-hang-timeout "2m" --no-restore --verbosity normal
build_and_test_mac:
runs-on: macos-14
steps:
- name: Get the sources
uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: true
- name: Setup dotnet
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Clean
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
- name: Install dependencies
run: dotnet restore
- name: Prep project file
run: sed -i '' '/Scanner/d' ErsatzTV/ErsatzTV.csproj
- name: Build
run: dotnet build --configuration Release --no-restore
- name: Test
run: dotnet test --blame-hang-timeout "2m" --no-restore --verbosity normal
-53
View File
@@ -1,53 +0,0 @@
name: Release
on:
release:
types: [ published ]
jobs:
calculate_version:
name: Calculate version information
runs-on: ubuntu-latest
steps:
- name: Get the sources
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Extract Docker Tag
shell: bash
run: |
tag=$(git describe --tags --abbrev=0)
echo "GIT_TAG=${tag:1}" >> $GITHUB_ENV
echo "DOCKER_TAG=${tag}" >> $GITHUB_ENV
- name: Extract Artifacts Version
shell: bash
run: |
tag=$(git describe --tags --abbrev=0)
echo "ARTIFACTS_VERSION=${tag}" >> $GITHUB_ENV
echo "INFO_VERSION=${tag:1}" >> $GITHUB_ENV
outputs:
git_tag: ${{ env.GIT_TAG }}
docker_tag: ${{ env.DOCKER_TAG }}
artifacts_version: ${{ env.ARTIFACTS_VERSION }}
info_version: ${{ env.INFO_VERSION }}
build_and_upload:
uses: ersatztv/ersatztv/.github/workflows/artifacts.yml@main
needs: calculate_version
with:
release_tag: ${{ needs.calculate_version.outputs.artifacts_version }}
release_version: ${{ needs.calculate_version.outputs.artifacts_version }}
info_version: ${{ needs.calculate_version.outputs.info_version }}
secrets:
apple_developer_certificate_p12_base64: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_P12_BASE64 }}
apple_developer_certificate_password: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_PASSWORD }}
ac_username: ${{ secrets.AC_USERNAME }}
ac_password: ${{ secrets.AC_PASSWORD }}
gh_token: ${{ secrets.GITHUB_TOKEN }}
build_images:
uses: ersatztv/ersatztv/.github/workflows/docker.yml@main
needs: calculate_version
with:
base_version: latest
info_version: ${{ needs.calculate_version.outputs.git_tag }}
tag_version: ${{ needs.calculate_version.outputs.docker_tag }}
secrets:
docker_hub_username: ${{ secrets.DOCKER_HUB_USERNAME }}
docker_hub_access_token: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
+3
View File
@@ -3,6 +3,9 @@
project.lock.json
.DS_Store
*.pyc
# Claude Code
.mcp/
nupkg/
# Visual Studio Code
+56
View 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"
}
}
}
}
+129 -8
View File
@@ -4,6 +4,124 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
### Changed
- Remove BugSnag error reporting integration
- Remove developer's personal Trakt API key
- Users who want to continue to use Trakt must create an API app and set the `Client ID` as the environment variable `TRAKT__CLIENTID`
### Fixed
- Support adding trakt lists using `app.trakt.tv` domain (instead of just `trakt.tv`)
## [26.3.0] - 2026-02-24
### Added
- Add log warnings when actual transcoding speed is potentially insufficient to support smooth playback
- Log messages will include media item id, channel number and transcoding speed
- Add UI language setting to **Settings** > **UI**
- A small number of translations have been added for `Português (Brasil)` and `Polski`
- Translation contributions are always welcome!
- Add `Troubleshoot` button to playout details table to show info that may be helpful in determining the source of a playout item
- Classic schedule info includes schedule, schedule item, scheduler, filler, playback order, random seed, collection index
- Block schedule info includes block, block item, playback order, random seed, collection index
- E.g. items with the same random seed are part of the same shuffle
- Add channel setting `Slug Seconds`
- This controls how many (optional) seconds of black video and silent audio to insert between *every* playout item
- This will drift playback from the wall clock as slugs are not scheduled in the playout, but are inserted dynamically during playback
- If this feature turns out to be popular, methods to correct the drift may be investigated
- Add `ETV_INSTANCE_ID` environment variable to disambiguate EPG data from multiple ErsatzTV instances
- When set, the value will be used in channel identifiers before the final `.ersatztv.org`
- Show warning message when selecting audio format `aac (latm)` for general streaming use when it is only intended for DVB-C
### Changed
- Move dark/light mode toggle to **Settings** > **UI**
- Use latest (non-deprecated) authorization method with Jellyfin API
- Replace direct Discord links with new contact page https://ersatztv.org/contact which also includes other options like Matrix
- Lower GOP size and keyframe interval from four seconds to two seconds in accordance with HLS2 draft spec recommendations
### Fixed
- Improve stability of playback orders `Shuffle` and `Shuffle in Order` over time
- Fix Trakt list sync
- Fix some cases of QSV audio/video desync when *not* seeking by using software decode
- This only applies to content that *might* be problematic (using a heuristic)
- NVIDIA: force software decode of 10-bit h264 content since hardware decode is unsupported by ffmpeg until version 8
- Graphics engine: fix stream seek value used throughout graphics engine
- This should fix loading EPG data when used with chapters/mid-roll
- This should also fix graphics element visibility when using start_seconds on content with chapters/mid-roll
- This bug was caused by stream seek including the playout item in-point (the chapter start time)
- Stream seek should only be non-zero when first joining a channel (i.e. in the middle of a playout item or chapter)
## [26.2.0] - 2026-02-02
### Added
- Channel stream selector: add zero-based culture-specific `day_of_week` to `content_condition`, for example:
- en-US can match sunday using `day_of_week = 0`
- fr-FR can match sunday using `day_of_week = 6`
- As a complete example, to match Saturday from 9pm (inclusive) to 11pm (exclusive), based on content start time
- `content_condition: day_of_week = 6 and (time_of_day_seconds >= 75600 and time_of_day_seconds < 82800)`
- Add `Pad Mode` to ffmpeg profile. Options are:
- `Hardware If Possible` - default/existing behavior when hardware acceleration is properly configured
- `Software` - force software padding
- This can be used to work around buggy GPU driver behavior where padding is green instead of black
- This is most often seen with VAAPI acceleration (radeonsi or i965 drivers)
- Add API endpoint to clean artwork cache folder (on demand)
- POST `/api/maintenance/clean_artwork`
- Add health check to warn about unsupported empty (classic) schedules
- Add health check to warn about incompatible ffmpeg due to missing filters
- This is directly applicable to homebrew `ffmpeg` on MacOS, which is no longer compatible with ErsatzTV
- `ffmpeg@7` or `ffmpeg-full` should be used instead
- Add `Marathon Group By` option `Director`
- This groups the *first* director on Movies, Episodes, Music Videos and Other Videos
- This is supported in classic schedules and sequential schedules
- Add FFmpeg Profile options:
- `Normalize Audio` (default: true) - normalizes audio streams, or stream copies when disabled
- `Normalize Video` (default: true) - normalizes video streams, or stream copies when disabled
- `Normalize Colors` (default: true) - normalizes color parameters when enabled
- Disabling any of these options may have a significant performance benefit *at the expense of stream stability*
- Add chapter `title` to filler expression
- This can be used to include or exclude chapters with specific (case-insensitive) titles
- E.g. `title == 'here'`, `title != 'not here'`, `title like '%here%'`
- Local movie libraries: load fanart from `backdrop` files (created by Jellyfin)
### Changed
- Disable automatic artwork database cleanup
- This will be re-enabled at some point in the future (after more testing)
- For now, the API should be used to clean as needed
- Classic Schedules: make multiple `count` an expression
- The following parameters can be used:
- `count`: the total number of items in the collection
- `random`: a random number between zero and (count - 1)
- For example:
- `count / 2` will play half of the items in the collection
- `random % 4 + 1` will play between 1 and 4 items
- `2` (similar to before this change) will play exactly two items
### Fixed
- Use code signing on all Windows executables (`ErsatzTV-Windows.exe`, `ErsatzTV.exe`, `ErsatzTV.Scanner.exe`)
- Graphics engine:
- Respect `z_index` (draw order) on all graphics element types
- Fix bug with `z_index` sorting
- Restore default UI font that was erroneously removed in v26.1.1
- Classic schedules: fix building playouts when `Fill With Group Mode` schedule items also have graphics elements
- Use configured searching log level on startup, instead of the default log level of `Information`
- MySql: fix searching for shows and seasons in schedule items editor
- Fix 500 errors when serving XMLTV due to concurrent file reads and writes
- Fix playback of AC3 audio when targeting stereo output and input layout changes mid-stream
- Use other video artwork in XMLTV template
- Properly update (add or remove) artwork for all local media libraries when files have changed
- Sync Plex library name changes
- Sync Plex episode title, plot, year, date added, release date, episode number changes
- Sync Jellyfin and Emby library name and type changes
- Library type (movies, shows) can only be changed when synchronization is *disabled* for the library in ETV
- Fix some sequential and scripted playout build failures when using playlists or marathons
- Fix erasing playout items and history so all related data is also erased
- This includes rerun history, unscheduled gaps, build status
- Fix indexing collections when using Elasticsearch backend
## [26.1.1] - 2026-01-08
### Fixed
- Use code signing on Windows launcher (`ErsatzTV-Windows.exe`) to avoid antivirus false positive
### Changed
- Optimize database check for orphaned artwork
- Include web resources (CSS, JS) locally instead of relying on CDNs
## [26.1.0] - 2026-01-06
### Added
@@ -718,13 +836,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- `random` will start at a random point in the content
- `2` (similar to before this change) will skip the first two items in the content
- YAML playout: make `count` an expression
- The following parameters can be used:
- `count`: the total number of items in the content
- `random`: a random number between zero and (count - 1)
- For example:
- `count / 2` will play half of the items in the content
- `random % 4 + 1` will play between 1 and 4 items
- `2` (similar to before this change) will play exactly two items
- The following parameters can be used:
- `count`: the total number of items in the content
- `random`: a random number between zero and (count - 1)
- For example:
- `count / 2` will play half of the items in the content
- `random % 4 + 1` will play between 1 and 4 items
- `2` (similar to before this change) will play exactly two items
- YAML playout: add `disable_watermarks` property to all content instructions
- This property defaults to `false` (meaning watermarks are allowed by default)
- Setting to `true` will prevent watermarks from ever appearing over the content
@@ -3081,7 +3199,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Initial release to facilitate testing outside of Docker.
[Unreleased]: https://github.com/ErsatzTV/ErsatzTV/compare/v26.1.0...HEAD
[Unreleased]: https://github.com/ErsatzTV/ErsatzTV/compare/v26.3.0...HEAD
[26.3.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v26.2.0...v26.3.0
[26.2.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v26.1.1...v26.2.0
[26.1.1]: https://github.com/ErsatzTV/ErsatzTV/compare/v26.1.0...v26.1.1
[26.1.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.9.0...v26.1.0
[25.9.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.8.0...v25.9.0
[25.8.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.7.1...v25.8.0
+92
View File
@@ -0,0 +1,92 @@
# ErsatzTV Fork
Custom IPTV channel server for Jellyfin. Forked from [ErsatzTV/ErsatzTV](https://github.com/ErsatzTV/ErsatzTV) after upstream archival (Feb 2026, v26.3.0). Our fork lives on [Gitea](http://192.168.1.95:3000/timothy/ersatztv).
## Architecture
- **Language**: C# / .NET 10, Blazor Server UI (MudBlazor)
- **Pattern**: CQRS via MediatR — queries/commands in `ErsatzTV.Application/`
- **Database**: EF Core (SQLite default, MySQL optional) — context in `ErsatzTV.Infrastructure/Data/TvContext.cs`
- **Media**: FFmpeg via CliWrap, SkiaSharp for logo generation
- **Functional C#**: Language Ext (Option, Either monads throughout)
### Project Layout
| Project | Role |
|---------|------|
| `ErsatzTV/` | ASP.NET Core host, Blazor pages, API controllers, DI setup |
| `ErsatzTV.Application/` | MediatR handlers (business logic) |
| `ErsatzTV.Core/` | Domain entities, interfaces, no infrastructure deps |
| `ErsatzTV.Infrastructure/` | EF Core repos, data access |
| `ErsatzTV.Infrastructure.Sqlite/` | SQLite-specific implementations |
| `ErsatzTV.FFmpeg/` | FFmpeg process wrapper |
| `ErsatzTV.Scanner/` | Media library scanning |
### Key Files
- **M3U generation**: `ErsatzTV.Core/Iptv/ChannelPlaylist.cs``ToM3U()`
- **XMLTV generation**: `ErsatzTV.Application/Channels/Queries/GetChannelGuideHandler.cs`
- **IPTV controller**: `ErsatzTV/Controllers/IptvController.cs``/iptv/*` routes
- **Logo generation**: `ErsatzTV.Core/Images/ChannelLogoGenerator.cs`
- **Channel entities**: `ErsatzTV.Core/Domain/Channel.cs`
- **DB context**: `ErsatzTV.Infrastructure/Data/TvContext.cs`
## Deployment
- **Docker host**: jazz (192.168.1.99), container `ersatztv`, port 8409
- **Config volume**: `~/downloadswarm/ersatztv/` on jazz → `/config` in container
- **SQLite DB**: `/config/ersatztv.sqlite3` (WAL mode, root-owned)
- **Images** (our fork, built by `.gitea/workflows/docker-build.yml``192.168.1.95:3000/timothy/ersatztv`): push to `main``:latest` + `:<sha>` (test image); push `v*` tag → `:prod` + `:<version>` + `:<sha>`. Prod container still runs upstream `ghcr.io/ersatztv/ersatztv:latest` pending cutover (server-management#481). Pipeline details: `docs/ci-cd.md`.
## Development
```bash
# Build
dotnet build ErsatzTV.sln
# Run locally (needs FFmpeg in PATH)
dotnet run --project ErsatzTV
# Docker build
docker build -f docker/Dockerfile -t ersatztv:dev .
```
## Conventions
- 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)
- **Versioning**: release tags are `vYY.<release-seq>.<patch>` (year · sequential release-within-year · patch) — inherited from upstream, **not** year.month. `v26.3.1` = our infra rebuild of upstream 26.3.0 (no app changes); `v26.4.0` is reserved for the first release with app changes. Never `[skip ci]` a commit you'll tag (it suppresses the release build). Full policy: `docs/ci-cd.md` → Versioning & releases.
- Backlog tracked via [Gitea Issues](http://192.168.1.95:3000/timothy/ersatztv/issues)
## Task Completion Protocol
Every task that closes a Gitea issue MUST complete ALL of these before it is considered done. Use `/done <issue>` to run through this automatically.
1. **Root cause** (bug fixes / incidents only): Document WHY the problem existed, not just what was changed. If root cause is unknown, say so explicitly and open a follow-up investigation issue. Fixing symptoms without understanding causes creates recurring problems.
2. **Comment on issues** as you work — what you found, what approach you're taking, any deviations from the suggested fix.
3. **Push changes**: `git push` all commits before closing. Use `fixes #N` in commit messages to auto-close where appropriate.
4. **Close comment**: Add a structured closing comment on the issue covering: what was done, root cause (if applicable), files changed, anything deferred, follow-up issues created, and which docs were updated.
5. **Close the issue** via API or `fixes #N` commit. Leave open with a comment only if partially addressed.
6. **Update docs**: If the change affects operational behavior, update the relevant Obsidian docs (`~/homelab-docs/`), MEMORY.md, or CLAUDE.md inline — not as a follow-up.
7. **Reply to reviewer** (if from adversarial review): Summary of done/deferred/questions. This triggers the next review cycle.
## Project Boundaries
**ersatztv OWNS**: ErsatzTV fork code (C#/.NET), channel/collection/schedule management, M3U/XMLTV generation, the ErsatzTV skill in server-management.
**ersatztv does NOT own**:
- Docker compose configs → server-management (`~/downloadswarm/stacks/ersatztv/`)
- NFS mounts, Ansible, DNS, networking → server-management
- Content sourcing (yt-dlp downloads, Sonarr/Radarr libraries) → media-management (planned)
- Jellyfin skill → server-management (symlinked)
**For infrastructure changes** (Docker, NFS, ports, Authelia): open an issue in `timothy/server-management`.
**For content/media sourcing questions** (what goes into channels, yt-dlp pipelines): open an issue in `timothy/media-management` once it exists; for now, `timothy/server-management`.
**For plan/audit reviews**: open `~/adversarial-reviewer` before significant architecture changes.
**Full cross-project rules**: `~/homelab-docs/Operations/Project Boundaries.md` (https://docs.tblindustries.be).
**ErsatzTV docs**: `~/homelab-docs/Docker/ErsatzTV.md` + project-local `docs/` (fork strategy, channels, M3U/XMLTV).
+10
View File
@@ -3,5 +3,15 @@
<InformationalVersion>develop</InformationalVersion>
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
<AllowMissingPrunePackageData>true</AllowMissingPrunePackageData>
<!-- NuGet audit (on by default in .NET 10) reports vulnerable transitive
packages as NU1901-1904 warnings. Several projects set
TreatWarningsAsErrors=true, which would otherwise fail `dotnet restore`
on advisories we can't immediately fix. Demote low/moderate/high audit
advisories to warnings (still printed in build logs); NU1904 (critical)
stays an error so criticals still block. Track fixes separately.
WarningsAsErrors promotes NU1904 in EVERY project (even those without
TreatWarningsAsErrors), so "criticals block" actually holds repo-wide. -->
<WarningsNotAsErrors>$(WarningsNotAsErrors);NU1901;NU1902;NU1903</WarningsNotAsErrors>
<WarningsAsErrors>$(WarningsAsErrors);NU1904</WarningsAsErrors>
</PropertyGroup>
</Project>
@@ -11,6 +11,7 @@ public record ChannelViewModel(
string Group,
string Categories,
int FFmpegProfileId,
double? SlugSeconds,
ArtworkContentTypeModel Logo,
ChannelStreamSelectorMode StreamSelectorMode,
string StreamSelector,
@@ -10,6 +10,7 @@ public record CreateChannel(
string Group,
string Categories,
int FFmpegProfileId,
double? SlugSeconds,
ArtworkContentTypeModel Logo,
ChannelStreamSelectorMode StreamSelectorMode,
string StreamSelector,
@@ -80,6 +80,7 @@ public class CreateChannelHandler(
Group = request.Group,
Categories = request.Categories,
FFmpegProfileId = ffmpegProfileId,
SlugSeconds = request.SlugSeconds,
PlayoutSource = request.PlayoutSource,
PlayoutMode = request.PlayoutMode,
MirrorSourceChannelId = request.MirrorSourceChannelId,
@@ -883,6 +883,8 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
metadata.Genres ??= [];
metadata.Guids ??= [];
string artworkPath = GetPrioritizedArtworkPath(metadata);
var data = new
{
ProgrammeStart = start,
@@ -897,6 +899,8 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
OtherVideoPlot = metadata.Plot,
OtherVideoHasYear = metadata.Year.HasValue,
OtherVideoYear = metadata.Year,
OtherVideoHasArtwork = !string.IsNullOrWhiteSpace(artworkPath),
OtherVideoArtworkUrl = artworkPath,
OtherVideoGenres = metadata.Genres.Map(g => g.Name).OrderBy(n => n),
OtherVideoHasContentRating = !string.IsNullOrWhiteSpace(metadata.ContentRating),
OtherVideoContentRating = metadata.ContentRating
@@ -11,6 +11,7 @@ public record UpdateChannel(
string Group,
string Categories,
int FFmpegProfileId,
double? SlugSeconds,
ArtworkContentTypeModel Logo,
ChannelStreamSelectorMode StreamSelectorMode,
string StreamSelector,
@@ -52,6 +52,7 @@ public class UpdateChannelHandler(
c.Group = update.Group;
c.Categories = update.Categories;
c.FFmpegProfileId = update.FFmpegProfileId;
c.SlugSeconds = update.SlugSeconds;
c.StreamSelectorMode = update.StreamSelectorMode;
c.StreamSelector = update.StreamSelector;
c.PreferredAudioLanguageCode = update.PreferredAudioLanguageCode;
+1
View File
@@ -14,6 +14,7 @@ internal static class Mapper
channel.Group,
channel.Categories,
channel.FFmpegProfileId,
channel.SlugSeconds,
GetLogo(channel),
channel.StreamSelectorMode,
channel.StreamSelector,
@@ -11,30 +11,18 @@ using Microsoft.IO;
namespace ErsatzTV.Application.Channels;
public partial class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, Either<BaseError, ChannelGuide>>
public partial class GetChannelGuideHandler(
IDbContextFactory<TvContext> dbContextFactory,
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
IFileSystem fileSystem,
ILocalFileSystem localFileSystem)
: IRequestHandler<GetChannelGuide, Either<BaseError, ChannelGuide>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ILocalFileSystem _localFileSystem;
private readonly RecyclableMemoryStreamManager _recyclableMemoryStreamManager;
private readonly IFileSystem _fileSystem;
public GetChannelGuideHandler(
IDbContextFactory<TvContext> dbContextFactory,
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
IFileSystem fileSystem,
ILocalFileSystem localFileSystem)
{
_dbContextFactory = dbContextFactory;
_recyclableMemoryStreamManager = recyclableMemoryStreamManager;
_fileSystem = fileSystem;
_localFileSystem = localFileSystem;
}
public async Task<Either<BaseError, ChannelGuide>> Handle(
GetChannelGuide request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
var hiddenChannelNumbers = dbContext.Channels
.Where(c => c.ShowInEpg == false)
.Select(c => c.Number)
@@ -42,13 +30,13 @@ public partial class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, E
.Select(n => $"{n}.xml")
.ToImmutableHashSet();
string channelsFile = Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, "channels.xml");
if (!_fileSystem.File.Exists(channelsFile))
string channelsFile = fileSystem.Path.Combine(FileSystemLayout.ChannelGuideCacheFolder, "channels.xml");
if (!fileSystem.File.Exists(channelsFile))
{
return BaseError.New($"Required file {channelsFile} is missing");
}
long mtime = File.GetLastWriteTime(channelsFile).Ticks;
long mtime = fileSystem.File.GetLastWriteTime(channelsFile).Ticks;
var accessTokenUri = $"?v={mtime}";
if (!string.IsNullOrWhiteSpace(request.AccessToken))
@@ -56,7 +44,7 @@ public partial class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, E
accessTokenUri += $"&amp;access_token={request.AccessToken}";
}
string channelsFragment = await File.ReadAllTextAsync(channelsFile, Encoding.UTF8, cancellationToken);
string channelsFragment = await ReadAllTextShared(channelsFile, cancellationToken);
// TODO: is regex faster?
channelsFragment = channelsFragment
@@ -65,30 +53,52 @@ public partial class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, E
var channelDataFragments = new Dictionary<string, string>();
foreach (string fileName in _localFileSystem.ListFiles(FileSystemLayout.ChannelGuideCacheFolder))
foreach (string fileName in localFileSystem.ListFiles(FileSystemLayout.ChannelGuideCacheFolder))
{
if (fileName.Contains("channels"))
{
continue;
}
if (hiddenChannelNumbers.Contains(Path.GetFileName(fileName)))
if (hiddenChannelNumbers.Contains(fileSystem.Path.GetFileName(fileName)))
{
continue;
}
string channelDataFragment = await File.ReadAllTextAsync(fileName, Encoding.UTF8, cancellationToken);
try
{
string channelDataFragment = await ReadAllTextShared(fileName, cancellationToken);
channelDataFragment = channelDataFragment
.Replace("{RequestBase}", $"{request.Scheme}://{request.Host}{request.BaseUrl}")
.Replace("{AccessTokenUri}", accessTokenUri);
channelDataFragment = channelDataFragment
.Replace("{RequestBase}", $"{request.Scheme}://{request.Host}{request.BaseUrl}")
.Replace("{AccessTokenUri}", accessTokenUri);
channelDataFragment = EtvTagRegex().Replace(channelDataFragment, string.Empty);
channelDataFragment = EtvTagRegex().Replace(channelDataFragment, string.Empty);
channelDataFragments.Add(Path.GetFileNameWithoutExtension(fileName), channelDataFragment);
channelDataFragments.Add(fileSystem.Path.GetFileNameWithoutExtension(fileName), channelDataFragment);
}
catch (FileNotFoundException)
{
// ignore this channel fragment
}
catch (IOException)
{
// ignore this channel fragment
}
}
return new ChannelGuide(_recyclableMemoryStreamManager, channelsFragment, channelDataFragments);
return new ChannelGuide(recyclableMemoryStreamManager, channelsFragment, channelDataFragments);
}
private async Task<string> ReadAllTextShared(string fileName, CancellationToken cancellationToken)
{
await using var stream = fileSystem.FileStream.New(
fileName,
FileMode.Open,
FileAccess.Read,
FileShare.ReadWrite);
using var reader = new StreamReader(stream, Encoding.UTF8, leaveOpen: true);
return await reader.ReadToEndAsync(cancellationToken);
}
[GeneratedRegex(@"<etv:[^>]+?>.*?<\/etv:[^>]+?>|<etv:[^>]+?\/>", RegexOptions.Singleline)]
@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Channels;
public record GetSlugSecondsByChannelNumber(string ChannelNumber) : IRequest<Option<double>>;
@@ -0,0 +1,17 @@
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Channels;
public class GetSlugSecondsByChannelNumberHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetSlugSecondsByChannelNumber, Option<double>>
{
public async Task<Option<double>> Handle(GetSlugSecondsByChannelNumber request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.Channels
.AsNoTracking()
.SingleOrDefaultAsync(c => c.Number == request.ChannelNumber, cancellationToken)
.Map(c => Optional(c?.SlugSeconds));
}
}
@@ -0,0 +1,5 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Configuration;
public record UpdateUiSettings(UiSettingsViewModel UiSettings) : IRequest<Either<BaseError, Unit>>;
@@ -0,0 +1,28 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
namespace ErsatzTV.Application.Configuration;
public class UpdateUiSettingsHandler(IConfigElementRepository configElementRepository)
: IRequestHandler<UpdateUiSettings, Either<BaseError, Unit>>
{
public async Task<Either<BaseError, Unit>> Handle(
UpdateUiSettings request,
CancellationToken cancellationToken)
{
return await ApplyUpdate(request.UiSettings, cancellationToken);
}
private async Task<Unit> ApplyUpdate(UiSettingsViewModel uiSettings, CancellationToken cancellationToken)
{
await configElementRepository.Upsert(
ConfigElementKey.PagesIsDarkMode,
uiSettings.IsDarkMode,
cancellationToken);
await configElementRepository.Upsert(ConfigElementKey.PagesLanguage, uiSettings.Language, cancellationToken);
return Unit.Default;
}
}
@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Configuration;
public record GetUiSettings : IRequest<UiSettingsViewModel>;
@@ -0,0 +1,25 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
namespace ErsatzTV.Application.Configuration;
public class GetUiSettingsHandler(IConfigElementRepository configElementRepository)
: IRequestHandler<GetUiSettings, UiSettingsViewModel>
{
public async Task<UiSettingsViewModel> Handle(GetUiSettings request, CancellationToken cancellationToken)
{
Option<bool> pagesIsDarkMode = await configElementRepository.GetValue<bool>(
ConfigElementKey.PagesIsDarkMode,
cancellationToken);
Option<string> pagesLanguage = await configElementRepository.GetValue<string>(
ConfigElementKey.PagesLanguage,
cancellationToken);
return new UiSettingsViewModel
{
IsDarkMode = await pagesIsDarkMode.IfNoneAsync(true),
Language = await pagesLanguage.IfNoneAsync("en")
};
}
}
@@ -0,0 +1,8 @@
namespace ErsatzTV.Application.Configuration;
public class UiSettingsViewModel
{
public bool IsDarkMode { get; set; }
public string Language { get; set; }
}
@@ -2,7 +2,7 @@
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<NoWarn>VSTHRD200</NoWarn>
<NoWarn>VSTHRD200,CA1873</NoWarn>
<ImplicitUsings>enable</ImplicitUsings>
<AnalysisLevel>latest-Recommended</AnalysisLevel>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
@@ -10,12 +10,11 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Bugsnag" Version="4.1.0" />
<PackageReference Include="CliWrap" Version="3.10.0" />
<PackageReference Include="Humanizer.Core" Version="3.0.1" />
<PackageReference Include="MediatR" Version="[12.5.0]" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.2" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.2" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="Serilog.Formatting.Compact.Reader" Version="4.0.0" />
<PackageReference Include="WebMarkupMin.Core" Version="2.20.1" />
@@ -26,7 +26,7 @@ public class RefreshFFmpegCapabilitiesHandler(
foreach (string ffmpegPath in maybeFFmpegPath)
{
_ = await hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath);
_ = await hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath, cancellationToken);
Option<string> maybeFFprobePath = await dbContext.ConfigElements
.GetValue<string>(ConfigElementKey.FFprobePath, cancellationToken)
@@ -7,6 +7,8 @@ namespace ErsatzTV.Application.FFmpegProfiles;
public record CreateFFmpegProfile(
string Name,
int ThreadCount,
bool NormalizeAudio,
bool NormalizeVideo,
HardwareAccelerationKind HardwareAcceleration,
string VaapiDisplay,
VaapiDriver VaapiDriver,
@@ -14,6 +16,7 @@ public record CreateFFmpegProfile(
int? QsvExtraHardwareFrames,
int ResolutionId,
ScalingBehavior ScalingBehavior,
FilterMode PadMode,
FFmpegProfileVideoFormat VideoFormat,
string VideoProfile,
string VideoPreset,
@@ -30,4 +33,5 @@ public record CreateFFmpegProfile(
int AudioChannels,
int AudioSampleRate,
bool NormalizeFramerate,
bool NormalizeColors,
bool DeinterlaceVideo) : IRequest<Either<BaseError, CreateFFmpegProfileResult>>;
@@ -44,42 +44,63 @@ public class CreateFFmpegProfileHandler :
CancellationToken cancellationToken) =>
(ValidateName(request), ValidateThreadCount(request),
await ResolutionMustExist(dbContext, request, cancellationToken))
.Apply((name, threadCount, resolutionId) => new FFmpegProfile
.Apply((name, threadCount, resolutionId) =>
{
Name = name,
ThreadCount = threadCount,
HardwareAcceleration = request.HardwareAcceleration,
VaapiDriver = request.VaapiDriver,
VaapiDevice = request.VaapiDevice,
QsvExtraHardwareFrames = request.QsvExtraHardwareFrames,
ResolutionId = resolutionId,
ScalingBehavior = request.ScalingBehavior,
VideoFormat = request.VideoFormat,
VideoProfile = request.VideoProfile,
VideoPreset = request.VideoPreset,
AllowBFrames = request.AllowBFrames,
var hwAccel = request.NormalizeVideo
? request.HardwareAcceleration
: HardwareAccelerationKind.None;
// mpeg2video only supports 8-bit content
BitDepth = request.VideoFormat is FFmpegProfileVideoFormat.Mpeg2Video
? FFmpegProfileBitDepth.EightBit
: request.BitDepth,
return new FFmpegProfile
{
Name = name,
ThreadCount = threadCount,
VideoBitrate = request.VideoBitrate,
VideoBufferSize = request.VideoBufferSize,
TonemapAlgorithm = request.TonemapAlgorithm,
AudioFormat = request.AudioFormat,
AudioBitrate = request.AudioBitrate,
AudioBufferSize = request.AudioBufferSize,
NormalizeAudio = request.NormalizeAudio,
NormalizeVideo = request.NormalizeVideo,
NormalizeLoudnessMode = request.NormalizeLoudnessMode,
TargetLoudness = request.NormalizeLoudnessMode is NormalizeLoudnessMode.LoudNorm
? request.TargetLoudness
: null,
HardwareAcceleration = hwAccel,
VaapiDriver = request.VaapiDriver,
VaapiDevice = request.VaapiDevice,
QsvExtraHardwareFrames = request.QsvExtraHardwareFrames,
ResolutionId = resolutionId,
ScalingBehavior = request.ScalingBehavior,
AudioChannels = request.AudioChannels,
AudioSampleRate = request.AudioSampleRate,
NormalizeFramerate = request.NormalizeFramerate,
DeinterlaceVideo = request.DeinterlaceVideo
// only allow customization with VAAPI accel
PadMode = hwAccel switch
{
HardwareAccelerationKind.None => FilterMode.Software,
HardwareAccelerationKind.Vaapi => request.PadMode,
_ => FilterMode.HardwareIfPossible
},
VideoFormat = request.NormalizeVideo ? request.VideoFormat : FFmpegProfileVideoFormat.Copy,
VideoProfile = request.VideoProfile,
VideoPreset = request.VideoPreset,
AllowBFrames = request.AllowBFrames,
// mpeg2video only supports 8-bit content
BitDepth = request.VideoFormat is FFmpegProfileVideoFormat.Mpeg2Video
? FFmpegProfileBitDepth.EightBit
: request.BitDepth,
VideoBitrate = request.VideoBitrate,
VideoBufferSize = request.VideoBufferSize,
TonemapAlgorithm = request.TonemapAlgorithm,
AudioFormat = request.NormalizeAudio ? request.AudioFormat : FFmpegProfileAudioFormat.Copy,
AudioBitrate = request.AudioBitrate,
AudioBufferSize = request.AudioBufferSize,
NormalizeLoudnessMode = request.NormalizeLoudnessMode,
TargetLoudness = request.NormalizeLoudnessMode is NormalizeLoudnessMode.LoudNorm
? request.TargetLoudness
: null,
AudioChannels = request.AudioChannels,
AudioSampleRate = request.AudioSampleRate,
NormalizeFramerate = request.NormalizeFramerate,
NormalizeColors = request.NormalizeColors,
DeinterlaceVideo = request.DeinterlaceVideo
};
});
private static Validation<BaseError, string> ValidateName(CreateFFmpegProfile createFFmpegProfile) =>
@@ -8,6 +8,8 @@ public record UpdateFFmpegProfile(
int FFmpegProfileId,
string Name,
int ThreadCount,
bool NormalizeAudio,
bool NormalizeVideo,
HardwareAccelerationKind HardwareAcceleration,
string VaapiDisplay,
VaapiDriver VaapiDriver,
@@ -15,6 +17,7 @@ public record UpdateFFmpegProfile(
int? QsvExtraHardwareFrames,
int ResolutionId,
ScalingBehavior ScalingBehavior,
FilterMode PadMode,
FFmpegProfileVideoFormat VideoFormat,
string VideoProfile,
string VideoPreset,
@@ -31,4 +34,5 @@ public record UpdateFFmpegProfile(
int AudioChannels,
int AudioSampleRate,
bool NormalizeFramerate,
bool NormalizeColors,
bool DeinterlaceVideo) : IRequest<Either<BaseError, UpdateFFmpegProfileResult>>;
@@ -27,16 +27,23 @@ public class UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFa
UpdateFFmpegProfile update,
CancellationToken cancellationToken)
{
var hwAccel = update.NormalizeVideo
? update.HardwareAcceleration
: HardwareAccelerationKind.None;
p.Name = update.Name;
p.ThreadCount = update.ThreadCount;
p.HardwareAcceleration = update.HardwareAcceleration;
p.NormalizeAudio = update.NormalizeAudio;
p.NormalizeVideo = update.NormalizeVideo;
p.HardwareAcceleration = hwAccel;
p.VaapiDisplay = update.VaapiDisplay;
p.VaapiDriver = update.VaapiDriver;
p.VaapiDevice = update.VaapiDevice;
p.QsvExtraHardwareFrames = update.QsvExtraHardwareFrames;
p.ResolutionId = update.ResolutionId;
p.ScalingBehavior = update.ScalingBehavior;
p.VideoFormat = update.VideoFormat;
p.PadMode = update.PadMode;
p.VideoFormat = update.NormalizeVideo ? update.VideoFormat : FFmpegProfileVideoFormat.Copy;
p.VideoProfile = update.VideoProfile;
p.VideoPreset = update.VideoPreset;
p.AllowBFrames = update.AllowBFrames;
@@ -53,10 +60,20 @@ public class UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFa
p.VideoFormat = FFmpegProfileVideoFormat.Hevc;
}
// only allow customization with VAAPI accel
if (p.HardwareAcceleration is HardwareAccelerationKind.None)
{
p.PadMode = FilterMode.Software;
}
else if (p.HardwareAcceleration is not HardwareAccelerationKind.Vaapi)
{
p.PadMode = FilterMode.HardwareIfPossible;
}
p.VideoBitrate = update.VideoBitrate;
p.VideoBufferSize = update.VideoBufferSize;
p.TonemapAlgorithm = update.TonemapAlgorithm;
p.AudioFormat = update.AudioFormat;
p.AudioFormat = update.NormalizeAudio ? update.AudioFormat : FFmpegProfileAudioFormat.Copy;
p.AudioBitrate = update.AudioBitrate;
p.AudioBufferSize = update.AudioBufferSize;
@@ -68,6 +85,7 @@ public class UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFa
p.AudioChannels = update.AudioChannels;
p.AudioSampleRate = update.AudioSampleRate;
p.NormalizeFramerate = update.NormalizeFramerate;
p.NormalizeColors = update.NormalizeColors;
p.DeinterlaceVideo = update.DeinterlaceVideo;
// don't save invalid preset
@@ -8,6 +8,8 @@ public record FFmpegProfileViewModel(
int Id,
string Name,
int ThreadCount,
bool NormalizeAudio,
bool NormalizeVideo,
HardwareAccelerationKind HardwareAcceleration,
string VaapiDisplay,
VaapiDriver VaapiDriver,
@@ -15,6 +17,7 @@ public record FFmpegProfileViewModel(
int? QsvExtraHardwareFrames,
ResolutionViewModel Resolution,
ScalingBehavior ScalingBehavior,
FilterMode PadMode,
FFmpegProfileVideoFormat VideoFormat,
string VideoProfile,
string VideoPreset,
@@ -31,4 +34,5 @@ public record FFmpegProfileViewModel(
int AudioChannels,
int AudioSampleRate,
bool NormalizeFramerate,
bool NormalizeColors,
bool DeinterlaceVideo);
@@ -10,6 +10,8 @@ internal static class Mapper
profile.Id,
profile.Name,
profile.ThreadCount,
profile.NormalizeAudio,
profile.NormalizeVideo,
profile.HardwareAcceleration,
profile.VaapiDisplay ?? "drm",
profile.VaapiDriver,
@@ -17,6 +19,7 @@ internal static class Mapper
profile.QsvExtraHardwareFrames,
Resolutions.Mapper.ProjectToViewModel(profile.Resolution),
profile.ScalingBehavior,
profile.PadMode,
profile.VideoFormat,
profile.VideoProfile,
profile.VideoPreset ?? string.Empty,
@@ -33,6 +36,7 @@ internal static class Mapper
profile.AudioChannels,
profile.AudioSampleRate,
profile.NormalizeFramerate,
profile.NormalizeColors,
profile.DeinterlaceVideo == true);
internal static FFmpegProfileResponseModel ProjectToResponseModel(FFmpegProfile ffmpegProfile) =>
@@ -31,15 +31,18 @@ public class
Validation<BaseError, string> validation = await Validate(dbContext, cancellationToken);
return await validation.Match(
GetHardwareAccelerationKinds,
ffmpegPath => GetHardwareAccelerationKinds(ffmpegPath, cancellationToken),
_ => Task.FromResult(new List<HardwareAccelerationKind> { HardwareAccelerationKind.None }));
}
private async Task<List<HardwareAccelerationKind>> GetHardwareAccelerationKinds(string ffmpegPath)
private async Task<List<HardwareAccelerationKind>> GetHardwareAccelerationKinds(
string ffmpegPath,
CancellationToken cancellationToken)
{
var result = new List<HardwareAccelerationKind> { HardwareAccelerationKind.None };
IFFmpegCapabilities ffmpegCapabilities = await _hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath);
IFFmpegCapabilities ffmpegCapabilities =
await _hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath, cancellationToken);
if (ffmpegCapabilities.HasHardwareAcceleration(HardwareAccelerationMode.Nvenc))
{
@@ -74,7 +74,8 @@ public class
ffmpegPath,
originalPath,
withExtension,
request.MaxHeight.Value);
request.MaxHeight.Value,
cancellationToken);
CommandResult resize = await process.ExecuteAsync(cancellationToken);
@@ -36,8 +36,10 @@ public class SaveJellyfinSecretsHandler : IRequestHandler<SaveJellyfinSecrets, E
private async Task<Validation<BaseError, Parameters>> Validate(SaveJellyfinSecrets request)
{
var connectionParameters = new JellyfinConnectionParameters(request.Secrets.Address, request.Secrets.ApiKey, 0);
Either<BaseError, JellyfinServerInformation> maybeServerInformation = await _jellyfinApiClient
.GetServerInformation(request.Secrets.Address, request.Secrets.ApiKey);
.GetServerInformation(connectionParameters.Address, connectionParameters.AuthorizationHeader);
return maybeServerInformation.Match(
info => Validation<BaseError, Parameters>.Success(new Parameters(request.Secrets, info)),
@@ -38,7 +38,7 @@ public class
.MapT(p => SynchronizeLibraries(p, cancellationToken))
.Bind(v => v.ToEitherAsync());
private Task<Validation<BaseError, ConnectionParameters>> Validate(SynchronizeJellyfinLibraries request) =>
private Task<Validation<BaseError, ConnectionAndSource>> Validate(SynchronizeJellyfinLibraries request) =>
MediaSourceMustExist(request)
.BindT(MediaSourceMustHaveActiveConnection)
.BindT(MediaSourceMustHaveApiKey);
@@ -48,43 +48,48 @@ public class
_mediaSourceRepository.GetJellyfin(request.JellyfinMediaSourceId)
.Map(o => o.ToValidation<BaseError>("Jellyfin media source does not exist."));
private Validation<BaseError, ConnectionParameters> MediaSourceMustHaveActiveConnection(
private Validation<BaseError, ConnectionAndSource> MediaSourceMustHaveActiveConnection(
JellyfinMediaSource jellyfinMediaSource)
{
Option<JellyfinConnection> maybeConnection = jellyfinMediaSource.Connections.HeadOrNone();
return maybeConnection.Map(connection => new ConnectionParameters(jellyfinMediaSource, connection))
return maybeConnection.Map(connection => new ConnectionAndSource(
new JellyfinConnectionParameters(connection.Address, string.Empty, connection.JellyfinMediaSourceId),
jellyfinMediaSource))
.ToValidation<BaseError>("Jellyfin media source requires an active connection");
}
private async Task<Validation<BaseError, ConnectionParameters>> MediaSourceMustHaveApiKey(
ConnectionParameters connectionParameters)
private async Task<Validation<BaseError, ConnectionAndSource>> MediaSourceMustHaveApiKey(
ConnectionAndSource connectionAndSource)
{
JellyfinSecrets secrets = await _jellyfinSecretStore.ReadSecrets();
return Optional(secrets.Address == connectionParameters.ActiveConnection.Address)
return Optional(secrets.Address == connectionAndSource.ConnectionParameters.Address)
.Where(match => match)
.Map(_ => connectionParameters with { ApiKey = secrets.ApiKey })
.Map(_ => connectionAndSource with
{
ConnectionParameters = connectionAndSource.ConnectionParameters with { ApiKey = secrets.ApiKey }
})
.ToValidation<BaseError>("Jellyfin media source requires an api key");
}
private async Task<Unit> SynchronizeLibraries(
ConnectionParameters connectionParameters,
ConnectionAndSource connectionAndSource,
CancellationToken cancellationToken)
{
Either<BaseError, List<JellyfinLibrary>> maybeLibraries = await _jellyfinApiClient.GetLibraries(
connectionParameters.ActiveConnection.Address,
connectionParameters.ApiKey);
connectionAndSource.ConnectionParameters.Address,
connectionAndSource.ConnectionParameters.AuthorizationHeader);
foreach (BaseError error in maybeLibraries.LeftToSeq())
{
_logger.LogWarning(
"Unable to synchronize libraries from jellyfin server {JellyfinServer}: {Error}",
connectionParameters.JellyfinMediaSource.ServerName,
connectionAndSource.MediaSource.ServerName,
error.Value);
}
foreach (List<JellyfinLibrary> libraries in maybeLibraries.RightToSeq())
{
var existing = connectionParameters.JellyfinMediaSource.Libraries
var existing = connectionAndSource.MediaSource.Libraries
.OfType<JellyfinLibrary>()
.ToList();
var toAdd = libraries.Filter(library => existing.All(l => l.ItemId != library.ItemId)).ToList();
@@ -92,7 +97,7 @@ public class
var toUpdate = libraries
.Filter(l => toAdd.All(a => a.ItemId != l.ItemId) && toRemove.All(r => r.ItemId != l.ItemId)).ToList();
List<int> ids = await _mediaSourceRepository.UpdateLibraries(
connectionParameters.JellyfinMediaSource.Id,
connectionAndSource.MediaSource.Id,
toAdd,
toRemove,
toUpdate,
@@ -107,10 +112,7 @@ public class
return Unit.Default;
}
private sealed record ConnectionParameters(
JellyfinMediaSource JellyfinMediaSource,
JellyfinConnection ActiveConnection)
{
public string ApiKey { get; set; }
}
private sealed record ConnectionAndSource(
JellyfinConnectionParameters ConnectionParameters,
JellyfinMediaSource MediaSource);
}
@@ -1,18 +1,174 @@
using ErsatzTV.Core;
using System.Globalization;
using System.IO.Abstractions;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Images;
using Humanizer;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Maintenance;
public class DeleteOrphanedArtworkHandler : IRequestHandler<DeleteOrphanedArtwork, Either<BaseError, Unit>>
public class DeleteOrphanedArtworkHandler(
IDbContextFactory<TvContext> dbContextFactory,
IArtworkRepository artworkRepository,
IFileSystem fileSystem,
ILogger<DeleteOrphanedArtworkHandler> logger)
: IRequestHandler<DeleteOrphanedArtwork, Either<BaseError, Unit>>
{
private readonly IArtworkRepository _artworkRepository;
public async Task<Either<BaseError, Unit>> Handle(
DeleteOrphanedArtwork request,
CancellationToken cancellationToken)
{
try
{
await CleanUpDatabase();
await CleanUpFileSystem(cancellationToken);
public DeleteOrphanedArtworkHandler(IArtworkRepository artworkRepository) =>
_artworkRepository = artworkRepository;
return Unit.Default;
}
catch (Exception e)
{
return BaseError.New(e.Message);
}
}
public Task<Either<BaseError, Unit>>
Handle(DeleteOrphanedArtwork request, CancellationToken cancellationToken) =>
_artworkRepository.GetOrphanedArtwork()
.Bind(_artworkRepository.Delete)
.Map(_ => Right<BaseError, Unit>(Unit.Default));
private async Task CleanUpDatabase()
{
List<int> ids = await artworkRepository.GetOrphanedArtworkIds();
if (ids.Count > 0)
{
await artworkRepository.Delete(ids);
}
}
private async Task CleanUpFileSystem(CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
System.Collections.Generic.HashSet<string> validFiles = [];
List<string> watermarks = await dbContext.ChannelWatermarks
.TagWithCallSite()
.AsNoTracking()
.Select(c => c.Image)
.ToListAsync(cancellationToken);
foreach (string watermark in watermarks.Where(w => !string.IsNullOrWhiteSpace(w)))
{
validFiles.Add(watermark);
}
var lastId = 0;
while (true)
{
List<MinimalArtwork> result = await dbContext.Artwork
.TagWithCallSite()
.AsNoTracking()
.Where(a => a.Id > lastId)
.OrderBy(a => a.Id)
.Take(1000)
.Select(a => new MinimalArtwork(a.Id, a.Path, a.BlurHash43, a.BlurHash54, a.BlurHash64))
.ToListAsync(cancellationToken);
if (result.Count == 0)
{
break;
}
foreach (MinimalArtwork artwork in result)
{
if (!string.IsNullOrWhiteSpace(artwork.Path) && !artwork.Path.Contains('/'))
{
validFiles.Add(artwork.Path);
}
if (!string.IsNullOrWhiteSpace(artwork.BlurHash43))
{
validFiles.Add(ImageCache.GetBlurHashFileName(artwork.BlurHash43));
}
if (!string.IsNullOrWhiteSpace(artwork.BlurHash54))
{
validFiles.Add(ImageCache.GetBlurHashFileName(artwork.BlurHash54));
}
if (!string.IsNullOrWhiteSpace(artwork.BlurHash64))
{
validFiles.Add(ImageCache.GetBlurHashFileName(artwork.BlurHash64));
}
}
lastId = result.Last().Id;
}
logger.LogDebug("Loaded {Count} artwork hashes (valid file names)", validFiles.Count);
var deleted = 0;
long bytes = 0;
foreach (string file in fileSystem.Directory.EnumerateFiles(
FileSystemLayout.ArtworkCacheFolder,
"*.*",
SearchOption.AllDirectories))
{
string fileName = fileSystem.Path.GetFileName(file);
if (!validFiles.Contains(fileName))
{
try
{
bytes += fileSystem.FileInfo.New(file).Length;
fileSystem.File.Delete(file);
deleted++;
}
catch (Exception ex)
{
logger.LogWarning(ex, "Could not delete artwork file {File}", file);
}
}
}
logger.LogDebug(
"Deleted {Count} unused artwork cache files totaling {Size}",
deleted,
bytes.Bytes().Humanize(CultureInfo.CurrentCulture));
DeleteEmptySubfolders(FileSystemLayout.ArtworkCacheFolder);
}
private void DeleteEmptySubfolders(string path)
{
if (!fileSystem.Directory.Exists(path))
{
return;
}
foreach (string sub in fileSystem.Directory.GetDirectories(path))
{
DeleteEmptySubfolders(sub);
}
if (!fileSystem.Directory.EnumerateFileSystemEntries(path).Any())
{
try
{
// don't delete artwork cache folder or its direct children
if (path != FileSystemLayout.ArtworkCacheFolder)
{
var parent = fileSystem.Directory.GetParent(path);
if (parent?.FullName != FileSystemLayout.ArtworkCacheFolder)
{
fileSystem.Directory.Delete(path);
}
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Could not delete empty cache folder {Folder}", path);
}
}
}
private sealed record MinimalArtwork(int Id, string Path, string BlurHash43, string BlurHash54, string BlurHash64);
}
@@ -1,5 +1,4 @@
using Bugsnag;
using ErsatzTV.Core;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
@@ -8,16 +7,13 @@ namespace ErsatzTV.Application.Maintenance;
public class EmptyTrashHandler : IRequestHandler<EmptyTrash, Either<BaseError, Unit>>
{
private readonly IClient _client;
private readonly IMediaItemRepository _mediaItemRepository;
private readonly ISearchIndex _searchIndex;
public EmptyTrashHandler(
IClient client,
IMediaItemRepository mediaItemRepository,
ISearchIndex searchIndex)
{
_client = client;
_mediaItemRepository = mediaItemRepository;
_searchIndex = searchIndex;
}
@@ -27,7 +23,6 @@ public class EmptyTrashHandler : IRequestHandler<EmptyTrash, Either<BaseError, U
CancellationToken cancellationToken)
{
SearchResult result = await _searchIndex.Search(
_client,
"state:FileNotFound",
string.Empty,
0,
@@ -110,10 +110,10 @@ public partial class AddTraktListHandler : TraktCommandBase, IRequestHandler<Add
return maybeList.Map(_ => Unit.Default);
}
[GeneratedRegex(@"https:\/\/trakt\.tv\/users\/([\w\-_]+)\/(?:lists\/)?([\w\-_]+)")]
[GeneratedRegex(@"https:\/\/(?:app\.)?trakt\.tv\/users\/([\w\-_]+)\/(?:lists\/)?([\w\-_]+)")]
private static partial Regex UriTraktListRegex();
[GeneratedRegex(@"https:\/\/trakt\.tv\/lists\/([\w\-_]+)\/([\w\-_]+)")]
[GeneratedRegex(@"https:\/\/(?:app\.)?trakt\.tv\/lists\/([\w\-_]+)\/([\w\-_]+)")]
private static partial Regex UriTraktListRegex2();
[GeneratedRegex(@"([\w\-_]+)\/(?:lists\/)?([\w\-_]+)")]
@@ -1,5 +1,4 @@
using System.Threading.Channels;
using Bugsnag;
using EFCore.BulkExtensions;
using ErsatzTV.Application.Channels;
using ErsatzTV.Application.Subtitles;
@@ -22,7 +21,6 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
{
private readonly IBlockPlayoutBuilder _blockPlayoutBuilder;
private readonly IBlockPlayoutFillerBuilder _blockPlayoutFillerBuilder;
private readonly IClient _client;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IEntityLocker _entityLocker;
private readonly IExternalJsonPlayoutBuilder _externalJsonPlayoutBuilder;
@@ -35,7 +33,6 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
private readonly IScriptedPlayoutBuilder _scriptedPlayoutBuilder;
public BuildPlayoutHandler(
IClient client,
IDbContextFactory<TvContext> dbContextFactory,
IPlayoutBuilder playoutBuilder,
IBlockPlayoutBuilder blockPlayoutBuilder,
@@ -49,7 +46,6 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
ChannelWriter<IBackgroundServiceRequest> workerChannel,
ILogger<BuildPlayoutHandler> logger)
{
_client = client;
_dbContextFactory = dbContextFactory;
_playoutBuilder = playoutBuilder;
_blockPlayoutBuilder = blockPlayoutBuilder;
@@ -348,7 +344,6 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
newBuildStatus.Success = false;
newBuildStatus.Message = $"Timeout building playout for channel {channelName}";
_client.Notify(ex);
return BaseError.New(
$"Timeout building playout for channel {channelName}; this may be a bug!");
}
@@ -359,7 +354,6 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
newBuildStatus.Success = false;
newBuildStatus.Message = $"Unexpected error building playout for channel {channelName}: {ex}";
_client.Notify(ex);
return BaseError.New(
$"Unexpected error building playout for channel {channelName}: {ex.Message}");
}
+1
View File
@@ -23,6 +23,7 @@ internal static class Mapper
playoutItem.StartOffset,
playoutItem.FinishOffset,
playoutItem.GetDisplayDuration(),
playoutItem.SchedulingContext,
Some(playoutItem.FillerKind));
internal static PlayoutAlternateScheduleViewModel ProjectToViewModel(
@@ -2,4 +2,10 @@ using ErsatzTV.Core.Domain.Filler;
namespace ErsatzTV.Application.Playouts;
public record PlayoutItemViewModel(string Title, DateTimeOffset Start, DateTimeOffset Finish, string Duration, Option<FillerKind> FillerKind);
public record PlayoutItemViewModel(
string Title,
DateTimeOffset Start,
DateTimeOffset Finish,
string Duration,
string SchedulingContext,
Option<FillerKind> FillerKind);
@@ -112,7 +112,8 @@ public class GetFuturePlayoutItemsByIdHandler(IDbContextFactory<TvContext> dbCon
gap.FinishOffset,
TimeSpan.FromSeconds(Math.Round(gapDuration.TotalSeconds)).ToString(
gapDuration.TotalHours >= 1 ? @"h\:mm\:ss" : @"mm\:ss",
CultureInfo.CurrentUICulture.DateTimeFormat),
CultureInfo.CurrentCulture.DateTimeFormat),
string.Empty,
None
);
}).ToList();
@@ -26,7 +26,7 @@ public record AddProgramScheduleItem(
int? MarathonBatchSize,
FillWithGroupMode FillWithGroupMode,
MultipleMode MultipleMode,
int? MultipleCount,
string MultipleCount,
TimeSpan? PlayoutDuration,
TailMode TailMode,
int? DiscardToFillAttempts,
@@ -24,7 +24,7 @@ public interface IProgramScheduleItemRequest
int? MarathonBatchSize { get; }
FillWithGroupMode FillWithGroupMode { get; }
MultipleMode MultipleMode { get; }
int? MultipleCount { get; }
string MultipleCount { get; }
TimeSpan? PlayoutDuration { get; }
TailMode TailMode { get; }
int? DiscardToFillAttempts { get; }
@@ -79,10 +79,10 @@ public abstract class ProgramScheduleItemCommandBase
"[MultipleMode] cannot be [PlaylistItemSize] when collection is not a playlist");
}
if (item.MultipleMode is MultipleMode.Count && item.MultipleCount.GetValueOrDefault() < 1)
if (item.MultipleMode is MultipleMode.Count && string.IsNullOrWhiteSpace(item.MultipleCount))
{
return BaseError.New(
"[MultipleCount] must be greater than 0 for playout mode 'multiple / count'");
"[MultipleCount] must be valid for playout mode 'multiple / count'");
}
break;
@@ -298,7 +298,7 @@ public abstract class ProgramScheduleItemCommandBase
MarathonBatchSize = item.MarathonBatchSize,
FillWithGroupMode = item.FillWithGroupMode,
MultipleMode = item.MultipleMode,
Count = item.MultipleMode is MultipleMode.Count ? item.MultipleCount.GetValueOrDefault() : 0,
Count = item.MultipleMode is MultipleMode.Count ? item.MultipleCount ?? "0" : "0",
CustomTitle = item.CustomTitle,
GuideMode = item.GuideMode,
PreRollFillerId = item.PreRollFillerId,
@@ -26,7 +26,7 @@ public record ReplaceProgramScheduleItem(
int? MarathonBatchSize,
FillWithGroupMode FillWithGroupMode,
MultipleMode MultipleMode,
int? MultipleCount,
string MultipleCount,
TimeSpan? PlayoutDuration,
TailMode TailMode,
int? DiscardToFillAttempts,
@@ -32,7 +32,7 @@ public record ProgramScheduleItemMultipleViewModel : ProgramScheduleItemViewMode
int? marathonBatchSize,
FillWithGroupMode fillWithGroupMode,
MultipleMode multipleMode,
int count,
string count,
string customTitle,
GuideMode guideMode,
FillerPresetViewModel preRollFiller,
@@ -87,5 +87,5 @@ public record ProgramScheduleItemMultipleViewModel : ProgramScheduleItemViewMode
public MultipleMode MultipleMode { get; set; }
public int Count { get; }
public string Count { get; }
}
@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.ProgramSchedules;
public record ProcessSchedulingContext(string SerializedContext) : IRequest<Option<string>>;
@@ -0,0 +1,218 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using JsonSerializer = System.Text.Json.JsonSerializer;
namespace ErsatzTV.Application.ProgramSchedules;
public class ProcessSchedulingContextHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<ProcessSchedulingContext, Option<string>>
{
private static readonly JsonSerializerOptions Options = new()
{
Converters = { new JsonStringEnumConverter() },
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = true
};
public async Task<Option<string>> Handle(ProcessSchedulingContext request, CancellationToken cancellationToken)
{
try
{
using JsonDocument doc = JsonDocument.Parse(request.SerializedContext);
if (doc.RootElement.TryGetProperty(nameof(ClassicSchedulingContext.ScheduleId), out _))
{
var classicContext = JsonSerializer.Deserialize<ClassicSchedulingContext>(request.SerializedContext, Options);
if (classicContext is not null && classicContext.ScheduleId > 0)
{
return await GetClassicDetails(classicContext, cancellationToken);
}
}
else if (doc.RootElement.TryGetProperty(nameof(BlockSchedulingContext.BlockId), out _))
{
var blockContext = JsonSerializer.Deserialize<BlockSchedulingContext>(request.SerializedContext, Options);
if (blockContext is not null && blockContext.BlockId > 0)
{
return await GetBlockDetails(blockContext, cancellationToken);
}
}
}
catch (JsonException)
{
// not a valid json string, or not a context we can process
}
return request.SerializedContext;
}
private async Task<Option<string>> GetClassicDetails(
ClassicSchedulingContext classicContext,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
string scheduleName = await dbContext.ProgramSchedules
.Where(s => s.Id == classicContext.ScheduleId)
.Select(s => s.Name)
.SingleOrDefaultAsync(cancellationToken);
var scheduleItem = await dbContext.ProgramScheduleItems
.AsNoTracking()
.AsSplitQuery()
.Include(si => si.Collection)
.Include(si => si.MultiCollection)
.Include(si => si.Playlist)
.Include(si => si.RerunCollection)
.Include(si => si.SmartCollection)
.SingleOrDefaultAsync(si => si.Id == classicContext.ItemId, cancellationToken);
var name = "Classic";
ClassicContextFiller filler = null;
if (classicContext.FillerPresetId is > 0)
{
name = "Classic - Filler";
var fillerPreset = await dbContext.FillerPresets
.AsNoTracking()
.AsSplitQuery()
.Include(p => p.Collection)
.Include(p => p.MultiCollection)
.Include(p => p.Playlist)
.Include(p => p.SmartCollection)
.SingleOrDefaultAsync(p => p.Id == classicContext.FillerPresetId, cancellationToken);
if (fillerPreset is not null)
{
string collectionName = fillerPreset.CollectionType switch
{
CollectionType.Collection => fillerPreset.Collection.Name,
CollectionType.MultiCollection => fillerPreset.MultiCollection.Name,
CollectionType.Playlist => fillerPreset.Playlist.Name,
CollectionType.SmartCollection => fillerPreset.SmartCollection.Name,
_ => null
};
filler = new ClassicContextFiller(
fillerPreset.Id,
fillerPreset.Name,
fillerPreset.FillerKind,
fillerPreset.FillerMode,
fillerPreset.CollectionType,
collectionName);
}
}
ClassicContextScheduleItem item;
if (scheduleItem is not null)
{
string collectionName = scheduleItem.CollectionType switch
{
CollectionType.Collection => scheduleItem.Collection.Name,
CollectionType.MultiCollection => scheduleItem.MultiCollection.Name,
CollectionType.Playlist => scheduleItem.Playlist.Name,
CollectionType.RerunRerun or CollectionType.RerunFirstRun => scheduleItem.RerunCollection.Name,
CollectionType.SmartCollection => scheduleItem.SmartCollection.Name,
_ => null
};
item = new ClassicContextScheduleItem(scheduleItem.Id, scheduleItem.CollectionType, collectionName);
}
else
{
item = new ClassicContextScheduleItem(classicContext.ItemId, null, null);
}
var context = new ClassicContext(
new ContextScheduler(name, classicContext.Scheduler),
new ClassicContextSchedule(classicContext.ScheduleId, scheduleName ?? string.Empty),
item,
filler,
new ContextEnumerator(classicContext.Enumerator, classicContext.Seed, classicContext.Index));
return JsonSerializer.Serialize(context, Options);
}
private async Task<Option<string>> GetBlockDetails(BlockSchedulingContext blockContext, CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
var block = await dbContext.Blocks
.AsNoTracking()
.Include(b => b.BlockGroup)
.SingleOrDefaultAsync(s => s.Id == blockContext.BlockId, cancellationToken);
var blockItem = await dbContext.BlockItems
.AsNoTracking()
.AsSplitQuery()
.Include(si => si.Collection)
.Include(si => si.MultiCollection)
.Include(si => si.SmartCollection)
.SingleOrDefaultAsync(s => s.Id == blockContext.BlockItemId, cancellationToken);
BlockContextBlockItem item;
if (blockItem is not null)
{
string collectionName = blockItem.CollectionType switch
{
CollectionType.Collection => blockItem.Collection.Name,
CollectionType.MultiCollection => blockItem.MultiCollection.Name,
CollectionType.SmartCollection => blockItem.SmartCollection.Name,
_ => null
};
item = new BlockContextBlockItem(blockItem.Id, blockItem.CollectionType, collectionName);
}
else
{
item = new BlockContextBlockItem(blockContext.BlockItemId, null, null);
}
var context = new BlockContext(
new ContextScheduler("Block", null),
new BlockContextBlock(
blockContext.BlockId,
block?.BlockGroup?.Name ?? string.Empty,
block?.Name ?? string.Empty),
item,
new ContextEnumerator(blockContext.Enumerator, blockContext.Seed, blockContext.Index));
return JsonSerializer.Serialize(context, Options);
}
private sealed record ClassicContext(
ContextScheduler Scheduler,
ClassicContextSchedule Schedule,
ClassicContextScheduleItem ScheduleItem,
ClassicContextFiller Filler,
ContextEnumerator Enumerator);
private sealed record ContextScheduler(string Type, string Mode);
private sealed record ClassicContextSchedule(int Id, string Name);
private sealed record ClassicContextScheduleItem(int Id, CollectionType? CollectionType, string CollectionName);
private sealed record ClassicContextFiller(
int Id,
string Name,
FillerKind Kind,
FillerMode Mode,
CollectionType CollectionType,
string CollectionName);
private sealed record ContextEnumerator(string Name, int Seed, int Index);
private sealed record BlockContext(
ContextScheduler Scheduler,
BlockContextBlock Block,
BlockContextBlockItem BlockItem,
ContextEnumerator Enumerator);
private sealed record BlockContextBlock(int Id, string Group, string Name);
private sealed record BlockContextBlockItem(int Id, CollectionType? CollectionType, string CollectionName);
}
@@ -21,27 +21,43 @@ public class ErasePlayoutHistoryHandler(IDbContextFactory<TvContext> dbContextFa
foreach (Playout playout in maybePlayout)
{
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
int nextSeed = new Random().Next();
playout.Seed = nextSeed;
// this deletes the owned PlayoutAnchor
playout.Anchor = null;
playout.OnDemandCheckpoint = null;
await dbContext.SaveChangesAsync(cancellationToken);
await dbContext.Database.ExecuteSqlAsync(
$"DELETE FROM PlayoutItem WHERE PlayoutId = {playout.Id}",
cancellationToken);
await dbContext.PlayoutItems
.Where(pi => pi.PlayoutId == playout.Id)
.ExecuteDeleteAsync(cancellationToken);
await dbContext.Database.ExecuteSqlAsync(
$"DELETE FROM PlayoutHistory WHERE PlayoutId = {playout.Id}",
cancellationToken);
await dbContext.PlayoutHistory
.Where(ph => ph.PlayoutId == playout.Id)
.ExecuteDeleteAsync(cancellationToken);
await dbContext.Database.ExecuteSqlAsync(
$"DELETE FROM PlayoutAnchor WHERE PlayoutId = {playout.Id}",
cancellationToken);
await dbContext.PlayoutProgramScheduleItemAnchors
.Where(a => a.PlayoutId == playout.Id)
.ExecuteDeleteAsync(cancellationToken);
await dbContext.Database.ExecuteSqlAsync(
$"DELETE FROM PlayoutProgramScheduleAnchor WHERE PlayoutId = {playout.Id}",
cancellationToken);
await dbContext.RerunHistory
.Where(rh => rh.PlayoutId == playout.Id)
.ExecuteDeleteAsync(cancellationToken);
await dbContext.PlayoutGaps
.Where(pg => pg.PlayoutId == playout.Id)
.ExecuteDeleteAsync(cancellationToken);
await dbContext.PlayoutBuildStatus
.Where(pb => pb.PlayoutId == playout.Id)
.ExecuteDeleteAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
}
}
}
@@ -1,7 +1,5 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Scheduling;
@@ -14,39 +12,49 @@ public class ErasePlayoutItemsHandler(IDbContextFactory<TvContext> dbContextFact
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<Playout> maybePlayout = await dbContext.Playouts
.Include(p => p.Items)
.Include(p => p.PlayoutHistory)
.TagWithCallSite()
.AsNoTracking()
.Filter(p => p.ScheduleKind == PlayoutScheduleKind.Block ||
p.ScheduleKind == PlayoutScheduleKind.Sequential ||
p.ScheduleKind == PlayoutScheduleKind.Scripted)
.SelectOneAsync(p => p.Id, p => p.Id == request.PlayoutId, cancellationToken);
.SingleOrDefaultAsync(p => p.Id == request.PlayoutId, cancellationToken);
foreach (Playout playout in maybePlayout)
{
// find the earliest item that finishes after "now"
Option<PlayoutItem> maybeFirstItem = playout.Items
.Filter(i => i.FinishOffset > DateTimeOffset.Now)
.OrderBy(i => i.StartOffset)
.HeadOrNone();
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
// delete all history starting with that item
// importantly, do NOT delete earlier history
foreach (PlayoutItem item in maybeFirstItem)
// find the earliest item that finishes after "now"
Option<PlayoutItem> maybeFirstItem = await dbContext.PlayoutItems
.TagWithCallSite()
.AsNoTracking()
.Where(pi => pi.PlayoutId == playout.Id)
.Where(pi => pi.Finish > DateTime.UtcNow)
.OrderBy(i => i.Start)
.FirstOrDefaultAsync(cancellationToken);
foreach (PlayoutItem firstItem in maybeFirstItem)
{
var toRemove = playout.PlayoutHistory.Filter(h => h.When >= item.Start).ToList();
foreach (PlayoutHistory history in toRemove)
{
playout.PlayoutHistory.Remove(history);
}
// delete all history starting with that item
// importantly, do NOT delete earlier history
await dbContext.PlayoutHistory
.Where(ph => ph.PlayoutId == playout.Id)
.Where(ph => ph.When >= firstItem.Start)
.ExecuteDeleteAsync(cancellationToken);
}
// save history changes
await dbContext.SaveChangesAsync(cancellationToken);
await dbContext.PlayoutItems
.Where(pi => pi.PlayoutId == playout.Id)
.ExecuteDeleteAsync(cancellationToken);
// delete all playout items
await dbContext.Database.ExecuteSqlAsync(
$"DELETE FROM PlayoutItem WHERE PlayoutId = {playout.Id}",
cancellationToken);
await dbContext.PlayoutGaps
.Where(pg => pg.PlayoutId == playout.Id)
.ExecuteDeleteAsync(cancellationToken);
await dbContext.PlayoutBuildStatus
.Where(pb => pb.PlayoutId == playout.Id)
.ExecuteDeleteAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
}
}
}
@@ -1,10 +1,9 @@
using Bugsnag;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Search;
namespace ErsatzTV.Application.Search;
public class QuerySearchIndexAllItemsHandler(IClient client, ISearchIndex searchIndex)
public class QuerySearchIndexAllItemsHandler(ISearchIndex searchIndex)
: IRequestHandler<QuerySearchIndexAllItems, SearchResultAllItemsViewModel>
{
public async Task<SearchResultAllItemsViewModel> Handle(
@@ -23,7 +22,7 @@ public class QuerySearchIndexAllItemsHandler(IClient client, ISearchIndex search
await GetIds(LuceneSearchIndex.RemoteStreamType, request.Query, cancellationToken));
private async Task<List<int>> GetIds(string type, string query, CancellationToken cancellationToken) =>
(await searchIndex.Search(client, $"type:{type} AND ({query})", string.Empty, 0, 0, cancellationToken)).Items
(await searchIndex.Search($"type:{type} AND ({query})", string.Empty, 0, 0, cancellationToken)).Items
.Map(i => i.Id)
.ToList();
}
@@ -1,5 +1,4 @@
using Bugsnag;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
using ErsatzTV.Infrastructure.Data;
@@ -9,7 +8,6 @@ using static ErsatzTV.Application.MediaCards.Mapper;
namespace ErsatzTV.Application.Search;
public class QuerySearchIndexArtistsHandler(
IClient client,
ISearchIndex searchIndex,
IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<QuerySearchIndexArtists, ArtistCardResultsViewModel>
@@ -19,7 +17,6 @@ public class QuerySearchIndexArtistsHandler(
CancellationToken cancellationToken)
{
SearchResult searchResult = await searchIndex.Search(
client,
request.Query,
string.Empty,
(request.PageNumber - 1) * request.PageSize,
@@ -1,5 +1,4 @@
using Bugsnag;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Emby;
@@ -17,7 +16,6 @@ namespace ErsatzTV.Application.Search;
public class
QuerySearchIndexEpisodesHandler(
IClient client,
ISearchIndex searchIndex,
IPlexPathReplacementService plexPathReplacementService,
IJellyfinPathReplacementService jellyfinPathReplacementService,
@@ -31,7 +29,6 @@ public class
CancellationToken cancellationToken)
{
SearchResult searchResult = await searchIndex.Search(
client,
request.Query,
string.Empty,
(request.PageNumber - 1) * request.PageSize,
@@ -1,5 +1,4 @@
using Bugsnag;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
using ErsatzTV.Infrastructure.Data;
@@ -9,7 +8,6 @@ using static ErsatzTV.Application.MediaCards.Mapper;
namespace ErsatzTV.Application.Search;
public class QuerySearchIndexImagesHandler(
IClient client,
ISearchIndex searchIndex,
IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<QuerySearchIndexImages, ImageCardResultsViewModel>
@@ -19,7 +17,6 @@ public class QuerySearchIndexImagesHandler(
CancellationToken cancellationToken)
{
SearchResult searchResult = await searchIndex.Search(
client,
request.Query,
string.Empty,
(request.PageNumber - 1) * request.PageSize,
@@ -1,5 +1,4 @@
using Bugsnag;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
@@ -10,7 +9,6 @@ using static ErsatzTV.Application.MediaCards.Mapper;
namespace ErsatzTV.Application.Search;
public class QuerySearchIndexMoviesHandler(
IClient client,
ISearchIndex searchIndex,
IDbContextFactory<TvContext> dbContextFactory)
: QuerySearchIndexHandlerBase, IRequestHandler<QuerySearchIndexMovies, MovieCardResultsViewModel>
@@ -20,7 +18,6 @@ public class QuerySearchIndexMoviesHandler(
CancellationToken cancellationToken)
{
SearchResult searchResult = await searchIndex.Search(
client,
request.Query,
string.Empty,
(request.PageNumber - 1) * request.PageSize,
@@ -1,5 +1,4 @@
using Bugsnag;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Emby;
@@ -15,7 +14,6 @@ namespace ErsatzTV.Application.Search;
public class
QuerySearchIndexMusicVideosHandler(
IClient client,
ISearchIndex searchIndex,
IPlexPathReplacementService plexPathReplacementService,
IJellyfinPathReplacementService jellyfinPathReplacementService,
@@ -28,7 +26,6 @@ public class
CancellationToken cancellationToken)
{
SearchResult searchResult = await searchIndex.Search(
client,
request.Query,
string.Empty,
(request.PageNumber - 1) * request.PageSize,
@@ -1,5 +1,4 @@
using Bugsnag;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
using ErsatzTV.Infrastructure.Data;
@@ -10,7 +9,6 @@ namespace ErsatzTV.Application.Search;
public class
QuerySearchIndexOtherVideosHandler(
IClient client,
ISearchIndex searchIndex,
IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<QuerySearchIndexOtherVideos, OtherVideoCardResultsViewModel>
@@ -20,7 +18,6 @@ public class
CancellationToken cancellationToken)
{
SearchResult searchResult = await searchIndex.Search(
client,
request.Query,
string.Empty,
(request.PageNumber - 1) * request.PageSize,
@@ -1,5 +1,4 @@
using Bugsnag;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
using ErsatzTV.Infrastructure.Data;
@@ -9,7 +8,6 @@ using static ErsatzTV.Application.MediaCards.Mapper;
namespace ErsatzTV.Application.Search;
public class QuerySearchIndexRemoteStreamsHandler(
IClient client,
ISearchIndex searchIndex,
IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<QuerySearchIndexRemoteStreams, RemoteStreamCardResultsViewModel>
@@ -19,7 +17,6 @@ public class QuerySearchIndexRemoteStreamsHandler(
CancellationToken cancellationToken)
{
SearchResult searchResult = await searchIndex.Search(
client,
request.Query,
string.Empty,
(request.PageNumber - 1) * request.PageSize,
@@ -1,5 +1,4 @@
using Bugsnag;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
@@ -11,7 +10,6 @@ namespace ErsatzTV.Application.Search;
public class
QuerySearchIndexSeasonsHandler(
IClient client,
ISearchIndex searchIndex,
IDbContextFactory<TvContext> dbContextFactory)
: QuerySearchIndexHandlerBase, IRequestHandler<QuerySearchIndexSeasons, TelevisionSeasonCardResultsViewModel>
@@ -21,7 +19,6 @@ public class
CancellationToken cancellationToken)
{
SearchResult searchResult = await searchIndex.Search(
client,
request.Query,
string.Empty,
(request.PageNumber - 1) * request.PageSize,
@@ -1,5 +1,4 @@
using Bugsnag;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
@@ -11,7 +10,6 @@ namespace ErsatzTV.Application.Search;
public class
QuerySearchIndexShowsHandler(
IClient client,
ISearchIndex searchIndex,
IDbContextFactory<TvContext> dbContextFactory)
: QuerySearchIndexHandlerBase, IRequestHandler<QuerySearchIndexShows, TelevisionShowCardResultsViewModel>
@@ -21,7 +19,6 @@ public class
CancellationToken cancellationToken)
{
SearchResult searchResult = await searchIndex.Search(
client,
request.Query,
string.Empty,
(request.PageNumber - 1) * request.PageSize,
@@ -1,5 +1,4 @@
using Bugsnag;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
using ErsatzTV.Infrastructure.Data;
@@ -8,7 +7,7 @@ using static ErsatzTV.Application.MediaCards.Mapper;
namespace ErsatzTV.Application.Search;
public class QuerySearchIndexSongsHandler(IClient client, ISearchIndex searchIndex, IDbContextFactory<TvContext> dbContextFactory)
public class QuerySearchIndexSongsHandler(ISearchIndex searchIndex, IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<QuerySearchIndexSongs, SongCardResultsViewModel>
{
public async Task<SongCardResultsViewModel> Handle(
@@ -16,7 +15,6 @@ public class QuerySearchIndexSongsHandler(IClient client, ISearchIndex searchInd
CancellationToken cancellationToken)
{
SearchResult searchResult = await searchIndex.Search(
client,
request.Query,
string.Empty,
(request.PageNumber - 1) * request.PageSize,
@@ -1,23 +1,30 @@
using System.Collections.Immutable;
using System.Globalization;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Search;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Search;
public class SearchMoviesHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<SearchMovies, List<NamedMediaItemViewModel>>
public class SearchMoviesHandler(
ISearchIndex searchIndex,
IDbContextFactory<TvContext> dbContextFactory)
: SearchUsingSearchIndexHandler(searchIndex), IRequestHandler<SearchMovies, List<NamedMediaItemViewModel>>
{
public async Task<List<NamedMediaItemViewModel>> Handle(SearchMovies request, CancellationToken cancellationToken)
{
ImmutableHashSet<int> ids = await Search(LuceneSearchIndex.MovieType, request.Query, cancellationToken);
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.MovieMetadata
.TagWithCallSite()
.AsNoTracking()
.Where(m => EF.Functions.Like(m.Title + " " + m.Year, $"%{request.Query}%"))
.OrderBy(m => m.Title)
.ThenBy(m => m.Year)
.Take(10)
.Where(mm => ids.Contains(mm.MovieId))
.OrderBy(mm => mm.Title)
.ThenBy(mm => mm.Year)
.ToListAsync(cancellationToken)
.Map(list => list.Map(ToNamedMediaItem).ToList());
}
@@ -1,30 +1,43 @@
using System.Collections.Immutable;
using System.Globalization;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Search;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Search;
public class SearchTelevisionSeasonsHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<SearchTelevisionSeasons, List<NamedMediaItemViewModel>>
public class SearchTelevisionSeasonsHandler(
ISearchIndex searchIndex,
IDbContextFactory<TvContext> dbContextFactory)
: SearchUsingSearchIndexHandler(searchIndex),
IRequestHandler<SearchTelevisionSeasons, List<NamedMediaItemViewModel>>
{
public async Task<List<NamedMediaItemViewModel>> Handle(
SearchTelevisionSeasons request,
CancellationToken cancellationToken)
{
ImmutableHashSet<int> ids = await Search(LuceneSearchIndex.SeasonType, request.Query, cancellationToken);
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await (from season in dbContext.Set<Season>()
join seasonMetadata in dbContext.Set<SeasonMetadata>()
on season.Id equals seasonMetadata.SeasonId
join showMetadata in dbContext.Set<ShowMetadata>()
on season.ShowId equals showMetadata.ShowId
where EF.Functions.Like(showMetadata.Title + " " + seasonMetadata.Title, $"%{request.Query}%")
orderby showMetadata.Title, season.SeasonNumber
select new TelevisionSeason(season.Id, showMetadata.Title, showMetadata.Year, season.SeasonNumber))
.Take(20)
return await dbContext.SeasonMetadata
.TagWithCallSite()
.AsNoTracking()
.Include(s => s.Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.Where(sm => ids.Contains(sm.SeasonId))
.ToListAsync(cancellationToken)
.Map(list => list.Map(ToNamedMediaItem).ToList());
.Map(list => list.Map(sm => new TelevisionSeason(
sm.SeasonId,
sm.Season.Show.ShowMetadata.HeadOrNone().Match(s => s.Title, string.Empty),
sm.Year,
sm.Season.SeasonNumber))
.OrderBy(s => s.Title)
.ThenBy(s => s.SeasonNumber)
.Map(ToNamedMediaItem)
.ToList());
}
private static NamedMediaItemViewModel ToNamedMediaItem(TelevisionSeason season) =>
@@ -1,25 +1,33 @@
using System.Collections.Immutable;
using System.Globalization;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Search;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Search;
public class SearchTelevisionShowsHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<SearchTelevisionShows, List<NamedMediaItemViewModel>>
public class SearchTelevisionShowsHandler(
ISearchIndex searchIndex,
IDbContextFactory<TvContext> dbContextFactory)
: SearchUsingSearchIndexHandler(searchIndex),
IRequestHandler<SearchTelevisionShows, List<NamedMediaItemViewModel>>
{
public async Task<List<NamedMediaItemViewModel>> Handle(
SearchTelevisionShows request,
CancellationToken cancellationToken)
{
ImmutableHashSet<int> ids = await Search(LuceneSearchIndex.ShowType, request.Query, cancellationToken);
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.ShowMetadata
.TagWithCallSite()
.AsNoTracking()
.Where(s => EF.Functions.Like(s.Title + " " + s.Year, $"%{request.Query}%"))
.OrderBy(s => s.Title)
.ThenBy(s => s.Year)
.Take(10)
.Where(sm => ids.Contains(sm.ShowId))
.OrderBy(sm => sm.Title)
.ThenBy(sm => sm.Year)
.ToListAsync(cancellationToken)
.Map(list => list.Map(ToNamedMediaItem).ToList());
}
@@ -0,0 +1,23 @@
using System.Collections.Immutable;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Search;
namespace ErsatzTV.Application.Search;
public abstract class SearchUsingSearchIndexHandler(ISearchIndex searchIndex)
{
private const int PageSize = 10;
protected async Task<ImmutableHashSet<int>> Search(string type, string query, CancellationToken cancellationToken)
{
var searchResult = await searchIndex.Search(
$"type:{type} AND *{query.Replace(" ", @"\ ")}*",
string.Empty,
0,
PageSize,
[LuceneSearchIndex.TitleAndYearSearchField],
cancellationToken);
return searchResult.Items.Select(i => i.Id).ToImmutableHashSet();
}
}
@@ -1,6 +1,5 @@
using System.IO.Abstractions;
using System.Threading.Channels;
using Bugsnag;
using ErsatzTV.Application.Channels;
using ErsatzTV.Application.Graphics;
using ErsatzTV.Application.Maintenance;
@@ -22,7 +21,6 @@ namespace ErsatzTV.Application.Streaming;
public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Either<BaseError, Unit>>
{
private readonly IClient _client;
private readonly IFileSystem _fileSystem;
private readonly IConfigElementRepository _configElementRepository;
private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
@@ -42,7 +40,6 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
IHlsInitSegmentCache hlsInitSegmentCache,
IServiceScopeFactory serviceScopeFactory,
IMediator mediator,
IClient client,
IFileSystem fileSystem,
ILocalFileSystem localFileSystem,
ILogger<StartFFmpegSessionHandler> logger,
@@ -57,7 +54,6 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
_hlsInitSegmentCache = hlsInitSegmentCache;
_serviceScopeFactory = serviceScopeFactory;
_mediator = mediator;
_client = client;
_fileSystem = fileSystem;
_localFileSystem = localFileSystem;
_logger = logger;
@@ -129,7 +125,6 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
_ => new HlsSessionWorker(
_serviceScopeFactory,
_graphicsEngine,
_client,
OutputFormatKind.Hls,
_hlsPlaylistFilter,
_hlsInitSegmentCache,
@@ -6,5 +6,7 @@ public enum HlsSessionState
ZeroAndWorkAhead,
SeekAndRealtime,
ZeroAndRealtime,
SlugAndWorkAhead,
SlugAndRealtime,
PlayoutUpdated
}
@@ -5,9 +5,9 @@ using System.IO.Abstractions;
using System.IO.Pipelines;
using System.Text;
using System.Timers;
using Bugsnag;
using CliWrap;
using CliWrap.Buffered;
using ErsatzTV.Application.Channels;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
@@ -27,7 +27,6 @@ namespace ErsatzTV.Application.Streaming;
public class HlsSessionWorker : IHlsSessionWorker
{
private static int _workAheadCount;
private readonly IClient _client;
private readonly OutputFormatKind _outputFormatKind;
private readonly IHlsInitSegmentCache _hlsInitSegmentCache;
private readonly Dictionary<long, int> _discontinuityMap = [];
@@ -54,11 +53,11 @@ public class HlsSessionWorker : IHlsSessionWorker
private Timer _timer;
private DateTimeOffset _transcodedUntil;
private string _workingDirectory;
private Option<double> _slugSeconds;
public HlsSessionWorker(
IServiceScopeFactory serviceScopeFactory,
IGraphicsEngine graphicsEngine,
IClient client,
OutputFormatKind outputFormatKind,
IHlsPlaylistFilter hlsPlaylistFilter,
IHlsInitSegmentCache hlsInitSegmentCache,
@@ -71,7 +70,6 @@ public class HlsSessionWorker : IHlsSessionWorker
_serviceScope = serviceScopeFactory.CreateScope();
_mediator = _serviceScope.ServiceProvider.GetRequiredService<IMediator>();
_graphicsEngine = graphicsEngine;
_client = client;
_outputFormatKind = outputFormatKind;
_hlsInitSegmentCache = hlsInitSegmentCache;
_hlsPlaylistFilter = hlsPlaylistFilter;
@@ -215,6 +213,10 @@ public class HlsSessionWorker : IHlsSessionWorker
new GetPlayoutIdByChannelNumber(_channelNumber),
cancellationToken);
_slugSeconds = await _mediator.Send(
new GetSlugSecondsByChannelNumber(_channelNumber),
cancellationToken);
// time shift on-demand playout if needed
foreach (int playoutId in maybePlayoutId)
{
@@ -307,9 +309,6 @@ public class HlsSessionWorker : IHlsSessionWorker
var sw = Stopwatch.StartNew();
try
{
DateTimeOffset start = DateTimeOffset.Now;
DateTimeOffset finish = start.AddSeconds(8);
string playlistFileName = Path.Combine(_workingDirectory, "live.m3u8");
_logger.LogDebug("Waiting for playlist to exist");
@@ -320,6 +319,10 @@ public class HlsSessionWorker : IHlsSessionWorker
_logger.LogDebug("Playlist exists");
// start the segment-wait deadline only after the playlist file appears,
// so slow pipeline setup (e.g. h264 profile probing) doesn't consume the budget
DateTimeOffset finish = DateTimeOffset.Now.AddSeconds(8);
var segmentCount = 0;
int lastSegmentCount = -1;
while (DateTimeOffset.Now < finish && segmentCount < initialSegmentCount)
@@ -382,6 +385,14 @@ public class HlsSessionWorker : IHlsSessionWorker
// after seeking and NOT completing the item, seek again, transcode method will accelerate if needed
HlsSessionState.SeekAndWorkAhead when !isComplete => HlsSessionState.SeekAndRealtime,
// switch back to normal item after slug
HlsSessionState.SlugAndWorkAhead => HlsSessionState.ZeroAndWorkAhead,
HlsSessionState.SlugAndRealtime => HlsSessionState.ZeroAndRealtime,
// after completing the item, insert a slug
HlsSessionState.ZeroAndWorkAhead or HlsSessionState.SeekAndWorkAhead when isComplete && _slugSeconds.IsSome => HlsSessionState.SlugAndWorkAhead,
HlsSessionState.ZeroAndRealtime or HlsSessionState.SeekAndRealtime when isComplete && _slugSeconds.IsSome => HlsSessionState.SlugAndRealtime,
// after seeking and completing the item, start at zero
HlsSessionState.SeekAndWorkAhead => HlsSessionState.ZeroAndWorkAhead,
@@ -441,6 +452,7 @@ public class HlsSessionWorker : IHlsSessionWorker
{
HlsSessionState.SeekAndWorkAhead => HlsSessionState.SeekAndRealtime,
HlsSessionState.ZeroAndWorkAhead => HlsSessionState.ZeroAndRealtime,
HlsSessionState.SlugAndWorkAhead => HlsSessionState.SlugAndRealtime,
_ => _state
};
@@ -456,19 +468,32 @@ public class HlsSessionWorker : IHlsSessionWorker
_logger.LogDebug("HLS session state: {State}", _state);
DateTimeOffset now = wasSeekAndWorkAhead ? DateTimeOffset.Now : _transcodedUntil;
bool startAtZero = _state is HlsSessionState.ZeroAndWorkAhead or HlsSessionState.ZeroAndRealtime;
bool startAtZero = _state is HlsSessionState.ZeroAndWorkAhead or HlsSessionState.ZeroAndRealtime
or HlsSessionState.SlugAndWorkAhead or HlsSessionState.SlugAndRealtime;
var request = new GetPlayoutItemProcessByChannelNumber(
_channelNumber,
StreamingMode.HttpLiveStreamingSegmenter,
now,
startAtZero,
realtime,
_channelStart,
ptsOffset,
_targetFramerate,
IsTroubleshooting: false,
Option<int>.None);
bool isSlug = _state is HlsSessionState.SlugAndWorkAhead or HlsSessionState.SlugAndRealtime;
FFmpegProcessRequest request = isSlug
? new GetSlugProcessByChannelNumber(
_channelNumber,
StreamingMode.HttpLiveStreamingSegmenter,
now,
realtime,
_channelStart,
ptsOffset,
_targetFramerate,
_slugSeconds)
: new GetPlayoutItemProcessByChannelNumber(
_channelNumber,
StreamingMode.HttpLiveStreamingSegmenter,
now,
startAtZero,
realtime,
_channelStart,
ptsOffset,
_targetFramerate,
IsTroubleshooting: false,
Option<int>.None);
// _logger.LogInformation("Request {@Request}", request);
@@ -528,9 +553,12 @@ public class HlsSessionWorker : IHlsSessionWorker
linkedCts.Token);
}
var progressParser = new FFmpegProgress();
CommandResult commandResult = await processWithPipe
.WithWorkingDirectory(_workingDirectory)
.WithStandardErrorPipe(PipeTarget.ToStringBuilder(stdErrBuffer))
.WithStandardOutputPipe(PipeTarget.ToDelegate(progressParser.ParseLine))
.WithValidation(CommandResultValidation.None)
.ExecuteAsync(linkedCts.Token);
@@ -538,12 +566,20 @@ public class HlsSessionWorker : IHlsSessionWorker
{
_logger.LogDebug("HLS process has completed for channel {Channel}", _channelNumber);
_logger.LogDebug(
"Transcoded until: {Until} - Buffer: {Buffer} seconds",
"Transcoded until: {Until} - Buffer: {Buffer} seconds - Speed {Speed}",
processModel.Until,
processModel.Until.Subtract(DateTimeOffset.Now).TotalSeconds);
processModel.Until.Subtract(DateTimeOffset.Now).TotalSeconds,
progressParser.Speed);
_transcodedUntil = processModel.Until;
_state = NextState(_state, processModel);
_hasWrittenSegments = true;
progressParser.LogSpeed(
processModel.MediaItemId,
processModel.IsWorkingAhead,
_channelNumber,
_logger);
return true;
}
else
@@ -620,15 +656,6 @@ public class HlsSessionWorker : IHlsSessionWorker
{
_logger.LogError(ex, "Error transcoding channel {Channel} - {Message}", _channelNumber, ex.Message);
try
{
_client.Notify(ex);
}
catch (Exception)
{
// do nothing
}
return false;
}
finally
@@ -36,7 +36,8 @@ public class GetConcatProcessByChannelNumberHandler : FFmpegProcessHandler<GetCo
saveReports,
channel,
request.Scheme,
request.Host);
request.Host,
cancellationToken);
return new PlayoutItemProcessModel(
process,
@@ -32,7 +32,8 @@ public class GetErrorProcessHandler(
channel.FFmpegProfile.VaapiDisplay,
channel.FFmpegProfile.VaapiDriver,
channel.FFmpegProfile.VaapiDevice,
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames));
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames),
cancellationToken);
return new PlayoutItemProcessModel(
process,
@@ -1,6 +1,5 @@
using System.IO.Abstractions;
using System.Text;
using Bugsnag;
using CliWrap;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
@@ -13,7 +12,6 @@ using Newtonsoft.Json;
namespace ErsatzTV.Application.Streaming;
public class GetLastPtsTimeHandler(
IClient client,
IFileSystem fileSystem,
ITempFilePool tempFilePool,
IConfigElementRepository configElementRepository,
@@ -181,9 +179,9 @@ public class GetLastPtsTimeHandler(
logger.LogWarning("Transcode folder is in bad state; troubleshooting info saved to {File}", file);
}
catch (Exception ex)
catch (Exception)
{
client.Notify(ex);
// do nothing
}
}
@@ -321,7 +321,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
if (request.IsTroubleshooting)
{
channel.Number = ".troubleshooting";
channel.Number = FileSystemLayout.TranscodeTroubleshootingChannel;
}
if (_isDebugNoSync)
@@ -337,7 +337,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
channel.FFmpegProfile.VaapiDisplay,
channel.FFmpegProfile.VaapiDriver,
channel.FFmpegProfile.VaapiDevice,
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames));
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames),
cancellationToken);
return new PlayoutItemProcessModel(
doesNotExistProcess,
@@ -498,7 +499,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
Option<TimeSpan> maybeDuration = maybeNextStart.Map(s => s - now);
// limit working ahead on errors to 1 minute
if (!request.HlsRealtime && maybeDuration.IfNone(TimeSpan.FromMinutes(2)) > TimeSpan.FromMinutes(1))
if (!request.HlsRealtime && await maybeDuration.IfNoneAsync(TimeSpan.FromMinutes(2)) > TimeSpan.FromMinutes(1))
{
maybeNextStart = now.AddMinutes(1);
maybeDuration = TimeSpan.FromMinutes(1);
@@ -508,7 +509,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
if (request.IsTroubleshooting)
{
channel.Number = ".troubleshooting";
channel.Number = FileSystemLayout.TranscodeTroubleshootingChannel;
maybeDuration = TimeSpan.FromSeconds(30);
finish = now + TimeSpan.FromSeconds(30);
@@ -534,7 +535,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
channel.FFmpegProfile.VaapiDisplay,
channel.FFmpegProfile.VaapiDriver,
channel.FFmpegProfile.VaapiDevice,
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames));
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames),
cancellationToken);
return new PlayoutItemProcessModel(
offlineProcess,
@@ -558,7 +560,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
channel.FFmpegProfile.VaapiDisplay,
channel.FFmpegProfile.VaapiDriver,
channel.FFmpegProfile.VaapiDevice,
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames));
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames),
cancellationToken);
return new PlayoutItemProcessModel(
doesNotExistProcess,
@@ -582,7 +585,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
channel.FFmpegProfile.VaapiDisplay,
channel.FFmpegProfile.VaapiDriver,
channel.FFmpegProfile.VaapiDevice,
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames));
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames),
cancellationToken);
return new PlayoutItemProcessModel(
errorProcess,
@@ -766,7 +770,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
.ToListAsync(cancellationToken);
// always play min(duration to next item, version.Duration)
TimeSpan duration = maybeDuration.IfNone(version.Duration);
TimeSpan duration = await maybeDuration.IfNoneAsync(version.Duration);
if (version.Duration < duration)
{
duration = version.Duration;
@@ -21,19 +21,21 @@ public class GetSeekTextSubtitleProcessHandler(
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, string> validation = await Validate(dbContext, cancellationToken);
return await validation.Match(
ffmpegPath => GetProcess(request, ffmpegPath),
ffmpegPath => GetProcess(request, ffmpegPath, cancellationToken),
error => Task.FromResult<Either<BaseError, SeekTextSubtitleProcess>>(error.Join()));
}
private async Task<Either<BaseError, SeekTextSubtitleProcess>> GetProcess(
GetSeekTextSubtitleProcess request,
string ffmpegPath)
string ffmpegPath,
CancellationToken cancellationToken)
{
Command process = await ffmpegProcessService.SeekTextSubtitle(
ffmpegPath,
request.PathAndCodec.Path,
request.PathAndCodec.Codec,
request.Seek);
request.Seek,
cancellationToken);
return new SeekTextSubtitleProcess(process);
}
@@ -0,0 +1,22 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.FFmpeg;
namespace ErsatzTV.Application.Streaming;
public record GetSlugProcessByChannelNumber(
string ChannelNumber,
StreamingMode Mode,
DateTimeOffset Now,
bool HlsRealtime,
DateTimeOffset ChannelStart,
TimeSpan PtsOffset,
Option<FrameRate> TargetFramerate,
Option<double> SlugSeconds) : FFmpegProcessRequest(
ChannelNumber,
Mode,
Now,
StartAtZero: true,
HlsRealtime,
ChannelStart,
PtsOffset,
FFmpegProfileId: Option<int>.None);
@@ -0,0 +1,53 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Streaming;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Streaming;
public class GetSlugProcessByChannelNumberHandler(
IDbContextFactory<TvContext> dbContextFactory,
IFFmpegProcessService ffmpegProcessService)
: FFmpegProcessHandler<GetSlugProcessByChannelNumber>(dbContextFactory)
{
protected override async Task<Either<BaseError, PlayoutItemProcessModel>> GetProcess(
TvContext dbContext,
GetSlugProcessByChannelNumber request,
Channel channel,
string ffmpegPath,
string ffprobePath,
CancellationToken cancellationToken)
{
var duration = TimeSpan.FromSeconds(await request.SlugSeconds.IfNoneAsync(0));
if (duration <= TimeSpan.Zero)
{
return BaseError.New("Slug seconds must be non-zero");
}
DateTimeOffset finish = request.Now.Add(duration);
var playoutItemResult = await ffmpegProcessService.Slug(
ffmpegPath,
channel,
request.Now,
duration,
request.HlsRealtime,
request.PtsOffset,
cancellationToken);
var result = new PlayoutItemProcessModel(
playoutItemResult,
Option<GraphicsEngineContext>.None,
duration,
finish,
isComplete: true,
request.Now.ToUnixTimeSeconds(),
Option<int>.None,
Optional(channel.PlayoutOffset),
!request.HlsRealtime);
return Right<BaseError, PlayoutItemProcessModel>(result);
}
}
@@ -185,7 +185,7 @@ public class PrepareTroubleshootingPlaybackHandler(
{
Artwork = [],
Name = "ETV",
Number = ".troubleshooting",
Number = FileSystemLayout.TranscodeTroubleshootingChannel,
FFmpegProfile = ffmpegProfile,
StreamingMode = request.StreamingMode,
StreamSelectorMode = ChannelStreamSelectorMode.Troubleshooting,
@@ -2,10 +2,10 @@ using System.IO.Pipelines;
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
using CliWrap;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Interfaces.Streaming;
using ErsatzTV.Core.Interfaces.Troubleshooting;
@@ -17,7 +17,7 @@ using Serilog.Events;
namespace ErsatzTV.Application.Troubleshooting;
public partial class StartTroubleshootingPlaybackHandler(
public class StartTroubleshootingPlaybackHandler(
ITroubleshootingNotifier notifier,
IMediator mediator,
IEntityLocker entityLocker,
@@ -138,14 +138,23 @@ public partial class StartTroubleshootingPlaybackHandler(
linkedCts.Token);
}
var progressParser = new FFmpegProgress();
CommandResult commandResult = await processWithPipe
.WithWorkingDirectory(FileSystemLayout.TranscodeTroubleshootingFolder)
.WithStandardErrorPipe(PipeTarget.Null)
.WithStandardOutputPipe(PipeTarget.ToDelegate(progressParser.ParseLine))
.WithValidation(CommandResultValidation.None)
.ExecuteAsync(linkedCts.Token);
logger.LogDebug("Troubleshooting playback completed with exit code {ExitCode}", commandResult.ExitCode);
progressParser.LogSpeed(
request.MediaItemInfo.Map(i => i.Id),
true,
FileSystemLayout.TranscodeTroubleshootingChannel,
logger);
try
{
IEnumerable<string> logs = logService.Sink.GetLogs(request.SessionId);
@@ -160,26 +169,11 @@ public partial class StartTroubleshootingPlaybackHandler(
// do nothing
}
Option<double> maybeSpeed = Option<double>.None;
Option<string> maybeFile = Directory.GetFiles(FileSystemLayout.TranscodeTroubleshootingFolder, "ffmpeg*.log").HeadOrNone();
foreach (string file in maybeFile)
{
await foreach (string line in File.ReadLinesAsync(file, linkedCts.Token))
{
Match match = FFmpegSpeed().Match(line);
if (match.Success && double.TryParse(match.Groups[1].Value, out double speed))
{
maybeSpeed = speed;
break;
}
}
}
await mediator.Publish(
new PlaybackTroubleshootingCompletedNotification(
commandResult.ExitCode,
Option<Exception>.None,
maybeSpeed),
progressParser.Speed),
linkedCts.Token);
if (commandResult.ExitCode != 0)
@@ -210,7 +204,4 @@ public partial class StartTroubleshootingPlaybackHandler(
loggingLevelSwitches.StreamingLevelSwitch.MinimumLevel = currentStreamingLevel;
}
}
[GeneratedRegex(@"speed=\s*([\d\.]+)x", RegexOptions.IgnoreCase)]
private static partial Regex FFmpegSpeed();
}
@@ -161,7 +161,8 @@ public class GetTroubleshootingInfoHandler : IRequestHandler<GetTroubleshootingI
videoToolboxCapabilities.AppendLine();
}
var ffmpegCapabilities = await _hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath.Value);
var ffmpegCapabilities =
await _hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath.Value, cancellationToken);
aviSynthDemuxer = ffmpegCapabilities.HasDemuxFormat(FFmpegKnownFormat.AviSynth);
aviSynthInstalled = _hardwareCapabilitiesFactory.IsAviSynthInstalled();
}
@@ -2,28 +2,27 @@
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<NoWarn>VSTHRD200</NoWarn>
<NoWarn>VSTHRD200,CA1873</NoWarn>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Bugsnag" Version="4.1.0" />
<PackageReference Include="CliWrap" Version="3.10.0" />
<PackageReference Include="LanguageExt.Core" Version="4.4.9" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.2" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.2" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.2" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.2" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="10.0.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="NUnit" Version="4.4.0" />
<PackageReference Include="NUnit3TestAdapter" Version="6.0.1" />
<PackageReference Include="NUnit3TestAdapter" Version="6.1.0" />
<PackageReference Include="Serilog" Version="4.3.0" />
<PackageReference Include="Serilog.Extensions.Logging" Version="10.0.0" />
<PackageReference Include="Serilog.Sinks.Debug" Version="3.0.0" />
<PackageReference Include="Shouldly" Version="4.3.0" />
<PackageReference Include="Testably.Abstractions.Testing" Version="5.0.1" />
<PackageReference Include="Testably.Abstractions.Testing" Version="5.1.0" />
</ItemGroup>
<ItemGroup>
@@ -7,6 +7,7 @@ using NUnit.Framework;
using Serilog;
using Shouldly;
using Testably.Abstractions.Testing;
using TimeZoneConverter;
namespace ErsatzTV.Core.Tests.FFmpeg;
@@ -569,6 +570,289 @@ public class CustomStreamSelectorTests
}
}
[Test]
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Content_Condition_Fail()
{
const string YAML =
"""
---
items:
- audio_language: ["ja"]
subtitle_language: ["eng"]
content_condition: "day_of_week = 1"
- audio_language: ["eng"]
disable_subtitles: true
""";
var fileSystem = new MockFileSystem();
fileSystem.Initialize()
.WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
var tz = TZConvert.GetTimeZoneInfo("America/Chicago");
var start = new DateTime(2026, 1, 11, 0, 0, 0, DateTimeKind.Unspecified); // sunday
var dto = new DateTimeOffset(start, tz.GetUtcOffset(start));
StreamSelectorResult result = await streamSelector.SelectStreams(_channel, dto, _audioVersion, _subtitles);
result.AudioStream.IsSome.ShouldBeTrue();
foreach (MediaStream audioStream in result.AudioStream)
{
audioStream.Index.ShouldBe(1);
audioStream.Language.ShouldBe("eng");
}
result.Subtitle.IsSome.ShouldBeFalse();
}
[Test]
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Content_Condition_Match()
{
const string YAML =
"""
---
items:
- audio_language: ["ja"]
subtitle_language: ["eng"]
content_condition: "day_of_week = 0"
- audio_language: ["eng"]
disable_subtitles: true
""";
var fileSystem = new MockFileSystem();
fileSystem.Initialize()
.WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
var tz = TZConvert.GetTimeZoneInfo("America/Chicago");
var start = new DateTime(2026, 1, 11, 0, 0, 0, DateTimeKind.Unspecified); // sunday
var dto = new DateTimeOffset(start, tz.GetUtcOffset(start));
StreamSelectorResult result = await streamSelector.SelectStreams(_channel, dto, _audioVersion, _subtitles);
result.AudioStream.IsSome.ShouldBeTrue();
foreach (MediaStream audioStream in result.AudioStream)
{
audioStream.Index.ShouldBe(0);
audioStream.Language.ShouldBe("ja");
}
result.Subtitle.IsSome.ShouldBeTrue();
foreach (Subtitle subtitle in result.Subtitle)
{
subtitle.Id.ShouldBe(2);
subtitle.Language.ShouldBe("eng");
}
}
[Test]
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Fail_Before()
{
// saturday from 9pm-11pm
const string YAML =
"""
---
items:
- audio_language: ["ja"]
subtitle_language: ["eng"]
content_condition: "day_of_week = 6 and (time_of_day_seconds >= 75600 and time_of_day_seconds < 82800)"
- audio_language: ["eng"]
disable_subtitles: true
""";
var fileSystem = new MockFileSystem();
fileSystem.Initialize()
.WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
var tz = TZConvert.GetTimeZoneInfo("America/Chicago");
var start = new DateTime(2026, 1, 10, 20, 59, 59, DateTimeKind.Unspecified); // saturday at 8:59:59pm
var dto = new DateTimeOffset(start, tz.GetUtcOffset(start));
StreamSelectorResult result = await streamSelector.SelectStreams(_channel, dto, _audioVersion, _subtitles);
result.AudioStream.IsSome.ShouldBeTrue();
foreach (MediaStream audioStream in result.AudioStream)
{
audioStream.Index.ShouldBe(1);
audioStream.Language.ShouldBe("eng");
}
result.Subtitle.IsSome.ShouldBeFalse();
}
[Test]
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Fail_After()
{
// saturday from 9pm-11pm
const string YAML =
"""
---
items:
- audio_language: ["ja"]
subtitle_language: ["eng"]
content_condition: "day_of_week = 6 and (time_of_day_seconds >= 75600 and time_of_day_seconds < 82800)"
- audio_language: ["eng"]
disable_subtitles: true
""";
var fileSystem = new MockFileSystem();
fileSystem.Initialize()
.WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
var tz = TZConvert.GetTimeZoneInfo("America/Chicago");
var start = new DateTime(2026, 1, 10, 23, 0, 0, DateTimeKind.Unspecified); // saturday at 11:00pm
var dto = new DateTimeOffset(start, tz.GetUtcOffset(start));
StreamSelectorResult result = await streamSelector.SelectStreams(_channel, dto, _audioVersion, _subtitles);
result.AudioStream.IsSome.ShouldBeTrue();
foreach (MediaStream audioStream in result.AudioStream)
{
audioStream.Index.ShouldBe(1);
audioStream.Language.ShouldBe("eng");
}
result.Subtitle.IsSome.ShouldBeFalse();
}
[Test]
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Fail_Wrong_Day()
{
// saturday from 9pm-11pm
const string YAML =
"""
---
items:
- audio_language: ["ja"]
subtitle_language: ["eng"]
content_condition: "day_of_week = 6 and (time_of_day_seconds >= 75600 and time_of_day_seconds < 82800)"
- audio_language: ["eng"]
disable_subtitles: true
""";
var fileSystem = new MockFileSystem();
fileSystem.Initialize()
.WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
var tz = TZConvert.GetTimeZoneInfo("America/Chicago");
var start = new DateTime(2026, 1, 11, 22, 0, 0, DateTimeKind.Unspecified); // sunday at 10:00pm
var dto = new DateTimeOffset(start, tz.GetUtcOffset(start));
StreamSelectorResult result = await streamSelector.SelectStreams(_channel, dto, _audioVersion, _subtitles);
result.AudioStream.IsSome.ShouldBeTrue();
foreach (MediaStream audioStream in result.AudioStream)
{
audioStream.Index.ShouldBe(1);
audioStream.Language.ShouldBe("eng");
}
result.Subtitle.IsSome.ShouldBeFalse();
}
[Test]
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Match()
{
// saturday from 9pm-11pm
const string YAML =
"""
---
items:
- audio_language: ["ja"]
subtitle_language: ["eng"]
content_condition: "day_of_week = 6 and (time_of_day_seconds >= 75600 and time_of_day_seconds < 82800)"
- audio_language: ["eng"]
disable_subtitles: true
""";
var fileSystem = new MockFileSystem();
fileSystem.Initialize()
.WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
var tz = TZConvert.GetTimeZoneInfo("America/Chicago");
var start = new DateTime(2026, 1, 10, 22, 0, 0, DateTimeKind.Unspecified); // saturday at 10:00pm
var dto = new DateTimeOffset(start, tz.GetUtcOffset(start));
StreamSelectorResult result = await streamSelector.SelectStreams(_channel, dto, _audioVersion, _subtitles);
result.AudioStream.IsSome.ShouldBeTrue();
foreach (MediaStream audioStream in result.AudioStream)
{
audioStream.Index.ShouldBe(0);
audioStream.Language.ShouldBe("ja");
}
result.Subtitle.IsSome.ShouldBeTrue();
foreach (Subtitle subtitle in result.Subtitle)
{
subtitle.Id.ShouldBe(2);
subtitle.Language.ShouldBe("eng");
}
}
[Test]
[SetCulture("fr-FR")]
public async Task Should_Select_English_Audio_No_Subtitles_Day_Of_Week_Time_Of_Day_Content_Condition_Match_France()
{
// saturday from 9pm-11pm
const string YAML =
"""
---
items:
- audio_language: ["ja"]
subtitle_language: ["eng"]
content_condition: "day_of_week = 5 and (time_of_day_seconds >= 75600 and time_of_day_seconds < 82800)"
- audio_language: ["eng"]
disable_subtitles: true
""";
var fileSystem = new MockFileSystem();
fileSystem.Initialize()
.WithFile(TestFileName).Which(f => f.HasStringContent(YAML));
var streamSelector = new CustomStreamSelector(fileSystem, _logger);
var tz = TZConvert.GetTimeZoneInfo("America/Chicago");
var start = new DateTime(2026, 1, 10, 22, 0, 0, DateTimeKind.Unspecified); // saturday at 10:00pm
var dto = new DateTimeOffset(start, tz.GetUtcOffset(start));
StreamSelectorResult result = await streamSelector.SelectStreams(_channel, dto, _audioVersion, _subtitles);
result.AudioStream.IsSome.ShouldBeTrue();
foreach (MediaStream audioStream in result.AudioStream)
{
audioStream.Index.ShouldBe(0);
audioStream.Language.ShouldBe("ja");
}
result.Subtitle.IsSome.ShouldBeTrue();
foreach (Subtitle subtitle in result.Subtitle)
{
subtitle.Id.ShouldBe(2);
subtitle.Language.ShouldBe("eng");
}
}
[Test]
public async Task Should_Ignore_Blocked_Audio_Title()
{
@@ -8,6 +8,7 @@ using NSubstitute;
using NUnit.Framework;
using Serilog;
using Shouldly;
using Testably.Abstractions.Testing;
namespace ErsatzTV.Core.Tests.FFmpeg;
@@ -65,8 +66,18 @@ public class WatermarkSelectorTests
var loggerFactory = new LoggerFactory().AddSerilog(Log.Logger);
// watermarks should always exist; effectively ignoring filesystem checks for now
var mockFileSystem = new MockFileSystem();
mockFileSystem.Initialize()
.WithFile("/tmp/watermark");
var fakeImageCache = Substitute.For<IImageCache>();
fakeImageCache.GetPathForImage(Arg.Any<string>(), Arg.Is(ArtworkKind.Watermark), Arg.Any<Option<int>>())
.Returns(_ => "/tmp/watermark");
WatermarkSelector = new WatermarkSelector(
Substitute.For<IImageCache>(),
mockFileSystem,
fakeImageCache,
new DecoSelector(loggerFactory.CreateLogger<DecoSelector>()),
loggerFactory.CreateLogger<WatermarkSelector>());
@@ -76,6 +76,8 @@ public class FakeMediaCollectionRepository : IMediaCollectionRepository
public Task<bool> IsCustomPlaybackOrder(int collectionId) => false.AsTask();
public Task<Option<string>> GetNameFromKey(CollectionKey emptyCollection, CancellationToken cancellationToken) => Option<string>.None.AsTask();
public List<CollectionWithItems> GroupIntoFakeCollections(List<MediaItem> items, string fakeKey = null) =>
throw new NotSupportedException();
public List<CollectionWithItems> GroupIntoFakeCollections(List<MediaItem> items, string fakeKey = null)
{
return [new CollectionWithItems(1, 0, fakeKey, items, true, PlaybackOrder.Shuffle, false)];
}
}
@@ -1,7 +1,5 @@
using Bugsnag;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Metadata;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
@@ -11,7 +9,7 @@ namespace ErsatzTV.Core.Tests.Metadata;
public class FallbackMetadataProviderTests
{
[SetUp]
public void SetUp() => _fallbackMetadataProvider = new FallbackMetadataProvider(Substitute.For<IClient>());
public void SetUp() => _fallbackMetadataProvider = new FallbackMetadataProvider();
private FallbackMetadataProvider _fallbackMetadataProvider;
@@ -666,7 +666,7 @@ public class ContinuePlayoutTests : PlayoutBuilderTestBase
Collection = collectionOne,
CollectionId = collectionOne.Id,
StartTime = null,
Count = 3,
Count = "3",
PlaybackOrder = PlaybackOrder.Chronological
},
new ProgramScheduleItemMultiple
@@ -676,7 +676,7 @@ public class ContinuePlayoutTests : PlayoutBuilderTestBase
Collection = collectionTwo,
CollectionId = collectionTwo.Id,
StartTime = null,
Count = 3,
Count = "3",
PlaybackOrder = PlaybackOrder.Chronological
}
};
@@ -15,6 +15,103 @@ namespace ErsatzTV.Core.Tests.Scheduling.ClassicScheduling;
[TestFixture]
public class NewPlayoutTests : PlayoutBuilderTestBase
{
[Test]
public async Task FillWithGroupMode_Should_Not_Fail()
{
var collection = new Collection
{
Id = 1,
Name = "Multiple Items",
MediaItems =
[
TestMovie(1, TimeSpan.FromHours(1), new DateTime(2020, 1, 1)),
TestMovie(2, TimeSpan.FromHours(1), new DateTime(2020, 2, 1))
]
};
var fakeRepository = new FakeMediaCollectionRepository(
Map((collection.Id, collection.MediaItems.ToList())));
var items = new List<ProgramScheduleItem>
{
new ProgramScheduleItemMultiple
{
Id = 1,
Index = 1,
//Collection = collection,
CollectionId = collection.Id,
StartTime = null,
PlaybackOrder = PlaybackOrder.Chronological,
MultipleMode = MultipleMode.Count,
Count = "2",
FillWithGroupMode = FillWithGroupMode.FillWithShuffledGroups,
ProgramScheduleItemGraphicsElements = []
}
};
// having a graphics element reference the schedule item triggers the bug
items[0].ProgramScheduleItemGraphicsElements.Add(new ProgramScheduleItemGraphicsElement
{
GraphicsElementId = 1,
ProgramScheduleItem = items[0],
ProgramScheduleItemId = items[0].Id
});
var playout = new Playout
{
ProgramSchedule = new ProgramSchedule
{
Items = items
},
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
ProgramScheduleAnchors = [],
Items = [],
ProgramScheduleAlternates = [],
FillGroupIndices = []
};
var referenceData =
new PlayoutReferenceData(
playout.Channel,
Option<Deco>.None,
[],
[],
playout.ProgramSchedule,
[],
[],
TimeSpan.Zero);
IConfigElementRepository configRepo = Substitute.For<IConfigElementRepository>();
var televisionRepo = new FakeTelevisionRepository();
IArtistRepository artistRepo = Substitute.For<IArtistRepository>();
IMultiEpisodeShuffleCollectionEnumeratorFactory factory =
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
IRerunHelper rerunHelper = Substitute.For<IRerunHelper>();
var builder = new PlayoutBuilder(
configRepo,
fakeRepository,
televisionRepo,
artistRepo,
factory,
new MockFileSystem(),
rerunHelper,
Logger);
DateTimeOffset start = HoursAfterMidnight(0);
DateTimeOffset finish = start + TimeSpan.FromHours(6);
Either<BaseError, PlayoutBuildResult> buildResult = await builder.Build(
playout,
referenceData,
PlayoutBuildResult.Empty,
PlayoutBuildMode.Reset,
start,
finish,
CancellationToken);
buildResult.IsRight.ShouldBeTrue();
}
[Test]
public async Task OnlyZeroDurationItem_Should_Abort()
{
@@ -852,7 +949,7 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
Collection = fixedCollection,
CollectionId = fixedCollection.Id,
StartTime = TimeSpan.FromHours(3),
Count = 2,
Count = "2",
PlaybackOrder = PlaybackOrder.Chronological
}
};
@@ -981,7 +1078,7 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
Collection = fixedCollection,
CollectionId = fixedCollection.Id,
StartTime = TimeSpan.FromHours(3),
Count = 2,
Count = "2",
PlaybackOrder = PlaybackOrder.Chronological
}
};
@@ -1361,7 +1458,7 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
Collection = multipleCollection,
CollectionId = multipleCollection.Id,
StartTime = null,
Count = 2,
Count = "2",
PlaybackOrder = PlaybackOrder.Chronological
},
new ProgramScheduleItemDuration
@@ -1494,7 +1591,7 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
Collection = collectionOne,
CollectionId = collectionOne.Id,
StartTime = null,
Count = 0,
Count = "0",
MultipleMode = MultipleMode.CollectionSize,
PlaybackOrder = PlaybackOrder.Chronological
},
@@ -1505,7 +1602,7 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
Collection = collectionTwo,
CollectionId = collectionTwo.Id,
StartTime = null,
Count = 0,
Count = "0",
MultipleMode = MultipleMode.CollectionSize,
PlaybackOrder = PlaybackOrder.Chronological
}
@@ -1777,7 +1874,7 @@ public class NewPlayoutTests : PlayoutBuilderTestBase
Collection = collectionOne,
CollectionId = collectionOne.Id,
StartTime = null,
Count = 1,
Count = "1",
PlaybackOrder = PlaybackOrder.Chronological,
PostRollFiller = new FillerPreset
{
@@ -0,0 +1,34 @@
using ErsatzTV.Core.Interfaces.Scheduling;
using ErsatzTV.Core.Scheduling;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Core.Tests.Scheduling;
[TestFixture]
public class CountExpressionTests
{
[Test]
[TestCase("2", 2)]
[TestCase("count", 10)]
[TestCase("count / 2", 5)]
[TestCase("count * 2", 20)]
[TestCase("count + 1", 11)]
[TestCase("count - 1", 9)]
[TestCase("random % 4 + 1", 3)]
[TestCase("invalid", 0)]
[TestCase("count / 0", 0)]
public void Should_Evaluate_Expression(string expression, int expected)
{
var enumerator = Substitute.For<IMediaCollectionEnumerator>();
enumerator.Count.Returns(10);
var random = Substitute.For<Random>();
random.Next().Returns(2);
int result = CountExpression.Evaluate(expression, enumerator, random, CancellationToken.None);
result.ShouldBe(expected);
}
}
@@ -75,4 +75,106 @@ public class FillerExpressionTests
result[1].EndTime.ShouldBe(TimeSpan.FromMinutes(20));
result[2].EndTime.ShouldBe(TimeSpan.FromMinutes(30));
}
[Test]
public void Match_Case_Insensitive_Titles_Expression()
{
// 30 min content
var playoutItem = new PlayoutItem { Start = DateTimeOffset.Now.UtcDateTime };
playoutItem.Finish = playoutItem.Start + TimeSpan.FromMinutes(30);
// chapters every 5 min
var chapters = new List<MediaChapter>
{
new() { ChapterId = 1, StartTime = TimeSpan.Zero, EndTime = TimeSpan.FromMinutes(5), Title = "Not Here" },
new() { ChapterId = 2, StartTime = TimeSpan.FromMinutes(5), EndTime = TimeSpan.FromMinutes(10), Title = "Here" },
new() { ChapterId = 3, StartTime = TimeSpan.FromMinutes(10), EndTime = TimeSpan.FromMinutes(15), Title = "Not Here" },
new() { ChapterId = 4, StartTime = TimeSpan.FromMinutes(15), EndTime = TimeSpan.FromMinutes(20), Title = "Here" },
new() { ChapterId = 5, StartTime = TimeSpan.FromMinutes(20), EndTime = TimeSpan.FromMinutes(25), Title = "Not Here" },
new() { ChapterId = 6, StartTime = TimeSpan.FromMinutes(25), EndTime = TimeSpan.FromMinutes(30), Title = "Here" }
};
var fillerPreset = new FillerPreset
{
FillerKind = FillerKind.MidRoll,
Expression =
"title == 'here'"
};
List<MediaChapter> result = FillerExpression.FilterChapters(fillerPreset.Expression, chapters, playoutItem);
result.Count.ShouldBe(3);
result[0].EndTime.ShouldBe(TimeSpan.FromMinutes(10));
result[1].EndTime.ShouldBe(TimeSpan.FromMinutes(20));
result[2].EndTime.ShouldBe(TimeSpan.FromMinutes(30));
}
[Test]
public void Exclude_Case_Insensitive_Titles_Expression()
{
// 30 min content
var playoutItem = new PlayoutItem { Start = DateTimeOffset.Now.UtcDateTime };
playoutItem.Finish = playoutItem.Start + TimeSpan.FromMinutes(30);
// chapters every 5 min
var chapters = new List<MediaChapter>
{
new() { ChapterId = 1, StartTime = TimeSpan.Zero, EndTime = TimeSpan.FromMinutes(5), Title = "Not Here" },
new() { ChapterId = 2, StartTime = TimeSpan.FromMinutes(5), EndTime = TimeSpan.FromMinutes(10), Title = "Here" },
new() { ChapterId = 3, StartTime = TimeSpan.FromMinutes(10), EndTime = TimeSpan.FromMinutes(15), Title = "Not Here" },
new() { ChapterId = 4, StartTime = TimeSpan.FromMinutes(15), EndTime = TimeSpan.FromMinutes(20), Title = "Here" },
new() { ChapterId = 5, StartTime = TimeSpan.FromMinutes(20), EndTime = TimeSpan.FromMinutes(25), Title = "Not Here" },
new() { ChapterId = 6, StartTime = TimeSpan.FromMinutes(25), EndTime = TimeSpan.FromMinutes(30), Title = "Here" }
};
var fillerPreset = new FillerPreset
{
FillerKind = FillerKind.MidRoll,
Expression =
"title != 'not here'"
};
List<MediaChapter> result = FillerExpression.FilterChapters(fillerPreset.Expression, chapters, playoutItem);
result.Count.ShouldBe(3);
result[0].EndTime.ShouldBe(TimeSpan.FromMinutes(10));
result[1].EndTime.ShouldBe(TimeSpan.FromMinutes(20));
result[2].EndTime.ShouldBe(TimeSpan.FromMinutes(30));
}
[Test]
public void Include_Partial_Case_Insensitive_Titles_Expression()
{
// 30 min content
var playoutItem = new PlayoutItem { Start = DateTimeOffset.Now.UtcDateTime };
playoutItem.Finish = playoutItem.Start + TimeSpan.FromMinutes(30);
// chapters every 5 min
var chapters = new List<MediaChapter>
{
new() { ChapterId = 1, StartTime = TimeSpan.Zero, EndTime = TimeSpan.FromMinutes(5), Title = "Not Here" },
new() { ChapterId = 2, StartTime = TimeSpan.FromMinutes(5), EndTime = TimeSpan.FromMinutes(10), Title = "Here" },
new() { ChapterId = 3, StartTime = TimeSpan.FromMinutes(10), EndTime = TimeSpan.FromMinutes(15), Title = "Not Here" },
new() { ChapterId = 4, StartTime = TimeSpan.FromMinutes(15), EndTime = TimeSpan.FromMinutes(20), Title = "Here" },
new() { ChapterId = 5, StartTime = TimeSpan.FromMinutes(20), EndTime = TimeSpan.FromMinutes(25), Title = "Not Here" },
new() { ChapterId = 6, StartTime = TimeSpan.FromMinutes(25), EndTime = TimeSpan.FromMinutes(30), Title = "Here" }
};
var fillerPreset = new FillerPreset
{
FillerKind = FillerKind.MidRoll,
Expression =
"title like \"%here%\""
};
List<MediaChapter> result = FillerExpression.FilterChapters(fillerPreset.Expression, chapters, playoutItem);
result.Count.ShouldBe(6);
result[0].EndTime.ShouldBe(TimeSpan.FromMinutes(5));
result[1].EndTime.ShouldBe(TimeSpan.FromMinutes(10));
result[2].EndTime.ShouldBe(TimeSpan.FromMinutes(15));
result[3].EndTime.ShouldBe(TimeSpan.FromMinutes(20));
result[4].EndTime.ShouldBe(TimeSpan.FromMinutes(25));
result[5].EndTime.ShouldBe(TimeSpan.FromMinutes(30));
}
}
@@ -796,7 +796,10 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
ProgramScheduleItem scheduleItem,
ProgramScheduleItem nextScheduleItem,
DateTimeOffset hardStop,
Random random,
CancellationToken cancellationToken) =>
throw new NotSupportedException();
protected override string SchedulingContextName => "Test";
}
}
@@ -13,9 +13,14 @@ namespace ErsatzTV.Core.Tests.Scheduling;
public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
{
[SetUp]
public void SetUp() => _cancellationToken = new CancellationTokenSource(TimeSpan.FromSeconds(10)).Token;
public void SetUp()
{
_cancellationToken = new CancellationTokenSource(TimeSpan.FromSeconds(10)).Token;
_random = new Random();
}
private CancellationToken _cancellationToken;
private Random _random;
private readonly ILogger<PlayoutModeSchedulerDuration> _logger;
public PlayoutModeSchedulerDurationTests()
@@ -66,6 +71,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
scheduleItem,
NextScheduleItem,
HardStop(scheduleItemsEnumerator),
_random,
_cancellationToken);
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
@@ -139,6 +145,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
scheduleItem,
NextScheduleItem,
HardStop(scheduleItemsEnumerator),
_random,
_cancellationToken);
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
@@ -211,6 +218,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
scheduleItem,
NextScheduleItem,
HardStop(scheduleItemsEnumerator),
_random,
_cancellationToken);
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.Add(new TimeSpan(2, 45, 0)));
@@ -280,6 +288,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
scheduleItem,
NextScheduleItem,
HardStop(scheduleItemsEnumerator),
_random,
_cancellationToken);
// duration block should end after exact duration, with gap
@@ -363,6 +372,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
scheduleItem,
NextScheduleItem,
HardStop(scheduleItemsEnumerator),
_random,
_cancellationToken);
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
@@ -450,6 +460,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
scheduleItem,
NextScheduleItem,
HardStop(scheduleItemsEnumerator),
_random,
_cancellationToken);
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
@@ -549,6 +560,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
scheduleItem,
NextScheduleItem,
HardStop(scheduleItemsEnumerator),
_random,
_cancellationToken);
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
@@ -665,6 +677,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
scheduleItem,
NextScheduleItem,
HardStop(scheduleItemsEnumerator),
_random,
_cancellationToken);
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddHours(3));
@@ -816,6 +829,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
scheduleItem,
NextScheduleItem,
HardStop(scheduleItemsEnumerator),
_random,
_cancellationToken);
playoutBuilderState.CurrentTime.ShouldBe(startState.CurrentTime.AddMinutes(30));
@@ -880,6 +894,7 @@ public class PlayoutModeSchedulerDurationTests : SchedulerTestBase
scheduleItem,
NextScheduleItem,
HardStop(scheduleItemsEnumerator),
_random,
_cancellationToken);
playoutItems.ShouldBeEmpty();

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