Compare commits

...
37 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
252 changed files with 32466 additions and 1929 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
-336
View File
@@ -1,336 +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
azure_client_id:
required: true
azure_tenant_id:
required: true
azure_subscription_id:
required: true
permissions:
id-token: write
contents: write
jobs:
build_and_upload_mac:
name: Mac Build & Upload
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: windows-latest
needs: build_dotnet_windows
steps:
- name: Download dotnet artifacts
uses: actions/download-artifact@v4
with:
name: dotnet-windows-build
path: dotnet-build
- name: Azure login
uses: azure/login@v2
with:
client-id: ${{ secrets.azure_client_id }}
tenant-id: ${{ secrets.azure_tenant_id }}
subscription-id: ${{ secrets.azure_subscription_id }}
enable-AzPSSession: true
- name: Sign dotnet artifacts
uses: azure/trusted-signing-action@v0
with:
endpoint: https://eus.codesigning.azure.net/
trusted-signing-account-name: ArtifactSigning
certificate-profile-name: ErsatzTV
files-folder: ${{ github.workspace }}/dotnet-build
files-folder-recurse: true
files-folder-filter: ErsatzTV.exe,ErsatzTV.Scanner.exe
file-digest: SHA256
timestamp-rfc3161: http://timestamp.acs.microsoft.com
timestamp-digest: SHA256
- name: Download rust launcher
uses: suisei-cn/actions-download-file@v1.3.0
with:
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}" && 7z a "../${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 }}
-62
View File
@@ -1,62 +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 }}
azure_client_id: ${{ secrets.AZURE_CLIENT_ID }}
azure_tenant_id: ${{ secrets.AZURE_TENANT_ID }}
azure_subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
build_images:
uses: ersatztv/ersatztv/.github/workflows/docker.yml@main
needs: calculate_version
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.
-118
View File
@@ -1,118 +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
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
-56
View File
@@ -1,56 +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 }}
azure_client_id: ${{ secrets.AZURE_CLIENT_ID }}
azure_tenant_id: ${{ secrets.AZURE_TENANT_ID }}
azure_subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
build_images:
uses: ersatztv/ersatztv/.github/workflows/docker.yml@main
needs: calculate_version
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"
}
}
}
}
+46 -1
View File
@@ -4,6 +4,50 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
### Changed
- Remove BugSnag error reporting integration
- Remove developer's personal Trakt API key
- Users who want to continue to use Trakt must create an API app and set the `Client ID` as the environment variable `TRAKT__CLIENTID`
### Fixed
- Support adding trakt lists using `app.trakt.tv` domain (instead of just `trakt.tv`)
## [26.3.0] - 2026-02-24
### Added
- Add log warnings when actual transcoding speed is potentially insufficient to support smooth playback
- Log messages will include media item id, channel number and transcoding speed
- Add UI language setting to **Settings** > **UI**
- A small number of translations have been added for `Português (Brasil)` and `Polski`
- Translation contributions are always welcome!
- Add `Troubleshoot` button to playout details table to show info that may be helpful in determining the source of a playout item
- Classic schedule info includes schedule, schedule item, scheduler, filler, playback order, random seed, collection index
- Block schedule info includes block, block item, playback order, random seed, collection index
- E.g. items with the same random seed are part of the same shuffle
- Add channel setting `Slug Seconds`
- This controls how many (optional) seconds of black video and silent audio to insert between *every* playout item
- This will drift playback from the wall clock as slugs are not scheduled in the playout, but are inserted dynamically during playback
- If this feature turns out to be popular, methods to correct the drift may be investigated
- Add `ETV_INSTANCE_ID` environment variable to disambiguate EPG data from multiple ErsatzTV instances
- When set, the value will be used in channel identifiers before the final `.ersatztv.org`
- Show warning message when selecting audio format `aac (latm)` for general streaming use when it is only intended for DVB-C
### Changed
- Move dark/light mode toggle to **Settings** > **UI**
- Use latest (non-deprecated) authorization method with Jellyfin API
- Replace direct Discord links with new contact page https://ersatztv.org/contact which also includes other options like Matrix
- Lower GOP size and keyframe interval from four seconds to two seconds in accordance with HLS2 draft spec recommendations
### Fixed
- Improve stability of playback orders `Shuffle` and `Shuffle in Order` over time
- Fix Trakt list sync
- Fix some cases of QSV audio/video desync when *not* seeking by using software decode
- This only applies to content that *might* be problematic (using a heuristic)
- NVIDIA: force software decode of 10-bit h264 content since hardware decode is unsupported by ffmpeg until version 8
- Graphics engine: fix stream seek value used throughout graphics engine
- This should fix loading EPG data when used with chapters/mid-roll
- This should also fix graphics element visibility when using start_seconds on content with chapters/mid-roll
- This bug was caused by stream seek including the playout item in-point (the chapter start time)
- Stream seek should only be non-zero when first joining a channel (i.e. in the middle of a playout item or chapter)
## [26.2.0] - 2026-02-02
### Added
@@ -3155,7 +3199,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Initial release to facilitate testing outside of Docker.
[Unreleased]: https://github.com/ErsatzTV/ErsatzTV/compare/v26.2.0...HEAD
[Unreleased]: https://github.com/ErsatzTV/ErsatzTV/compare/v26.3.0...HEAD
[26.3.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v26.2.0...v26.3.0
[26.2.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v26.1.1...v26.2.0
[26.1.1]: https://github.com/ErsatzTV/ErsatzTV/compare/v26.1.0...v26.1.1
[26.1.0]: https://github.com/ErsatzTV/ErsatzTV/compare/v25.9.0...v26.1.0
+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,
@@ -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,
@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Channels;
public record GetSlugSecondsByChannelNumber(string ChannelNumber) : IRequest<Option<double>>;
@@ -0,0 +1,17 @@
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Channels;
public class GetSlugSecondsByChannelNumberHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetSlugSecondsByChannelNumber, Option<double>>
{
public async Task<Option<double>> Handle(GetSlugSecondsByChannelNumber request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.Channels
.AsNoTracking()
.SingleOrDefaultAsync(c => c.Number == request.ChannelNumber, cancellationToken)
.Map(c => Optional(c?.SlugSeconds));
}
}
@@ -0,0 +1,5 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Configuration;
public record UpdateUiSettings(UiSettingsViewModel UiSettings) : IRequest<Either<BaseError, Unit>>;
@@ -0,0 +1,28 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
namespace ErsatzTV.Application.Configuration;
public class UpdateUiSettingsHandler(IConfigElementRepository configElementRepository)
: IRequestHandler<UpdateUiSettings, Either<BaseError, Unit>>
{
public async Task<Either<BaseError, Unit>> Handle(
UpdateUiSettings request,
CancellationToken cancellationToken)
{
return await ApplyUpdate(request.UiSettings, cancellationToken);
}
private async Task<Unit> ApplyUpdate(UiSettingsViewModel uiSettings, CancellationToken cancellationToken)
{
await configElementRepository.Upsert(
ConfigElementKey.PagesIsDarkMode,
uiSettings.IsDarkMode,
cancellationToken);
await configElementRepository.Upsert(ConfigElementKey.PagesLanguage, uiSettings.Language, cancellationToken);
return Unit.Default;
}
}
@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Configuration;
public record GetUiSettings : IRequest<UiSettingsViewModel>;
@@ -0,0 +1,25 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
namespace ErsatzTV.Application.Configuration;
public class GetUiSettingsHandler(IConfigElementRepository configElementRepository)
: IRequestHandler<GetUiSettings, UiSettingsViewModel>
{
public async Task<UiSettingsViewModel> Handle(GetUiSettings request, CancellationToken cancellationToken)
{
Option<bool> pagesIsDarkMode = await configElementRepository.GetValue<bool>(
ConfigElementKey.PagesIsDarkMode,
cancellationToken);
Option<string> pagesLanguage = await configElementRepository.GetValue<string>(
ConfigElementKey.PagesLanguage,
cancellationToken);
return new UiSettingsViewModel
{
IsDarkMode = await pagesIsDarkMode.IfNoneAsync(true),
Language = await pagesLanguage.IfNoneAsync("en")
};
}
}
@@ -0,0 +1,8 @@
namespace ErsatzTV.Application.Configuration;
public class UiSettingsViewModel
{
public bool IsDarkMode { get; set; }
public string Language { get; set; }
}
@@ -2,7 +2,7 @@
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<NoWarn>VSTHRD200</NoWarn>
<NoWarn>VSTHRD200,CA1873</NoWarn>
<ImplicitUsings>enable</ImplicitUsings>
<AnalysisLevel>latest-Recommended</AnalysisLevel>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
@@ -10,7 +10,6 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Bugsnag" Version="4.1.0" />
<PackageReference Include="CliWrap" Version="3.10.0" />
<PackageReference Include="Humanizer.Core" Version="3.0.1" />
<PackageReference Include="MediatR" Version="[12.5.0]" />
@@ -26,7 +26,7 @@ public class RefreshFFmpegCapabilitiesHandler(
foreach (string ffmpegPath in maybeFFmpegPath)
{
_ = await hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath);
_ = await hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath, cancellationToken);
Option<string> maybeFFprobePath = await dbContext.ConfigElements
.GetValue<string>(ConfigElementKey.FFprobePath, cancellationToken)
@@ -31,15 +31,18 @@ public class
Validation<BaseError, string> validation = await Validate(dbContext, cancellationToken);
return await validation.Match(
GetHardwareAccelerationKinds,
ffmpegPath => GetHardwareAccelerationKinds(ffmpegPath, cancellationToken),
_ => Task.FromResult(new List<HardwareAccelerationKind> { HardwareAccelerationKind.None }));
}
private async Task<List<HardwareAccelerationKind>> GetHardwareAccelerationKinds(string ffmpegPath)
private async Task<List<HardwareAccelerationKind>> GetHardwareAccelerationKinds(
string ffmpegPath,
CancellationToken cancellationToken)
{
var result = new List<HardwareAccelerationKind> { HardwareAccelerationKind.None };
IFFmpegCapabilities ffmpegCapabilities = await _hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath);
IFFmpegCapabilities ffmpegCapabilities =
await _hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath, cancellationToken);
if (ffmpegCapabilities.HasHardwareAcceleration(HardwareAccelerationMode.Nvenc))
{
@@ -74,7 +74,8 @@ public class
ffmpegPath,
originalPath,
withExtension,
request.MaxHeight.Value);
request.MaxHeight.Value,
cancellationToken);
CommandResult resize = await process.ExecuteAsync(cancellationToken);
@@ -36,8 +36,10 @@ public class SaveJellyfinSecretsHandler : IRequestHandler<SaveJellyfinSecrets, E
private async Task<Validation<BaseError, Parameters>> Validate(SaveJellyfinSecrets request)
{
var connectionParameters = new JellyfinConnectionParameters(request.Secrets.Address, request.Secrets.ApiKey, 0);
Either<BaseError, JellyfinServerInformation> maybeServerInformation = await _jellyfinApiClient
.GetServerInformation(request.Secrets.Address, request.Secrets.ApiKey);
.GetServerInformation(connectionParameters.Address, connectionParameters.AuthorizationHeader);
return maybeServerInformation.Match(
info => Validation<BaseError, Parameters>.Success(new Parameters(request.Secrets, info)),
@@ -38,7 +38,7 @@ public class
.MapT(p => SynchronizeLibraries(p, cancellationToken))
.Bind(v => v.ToEitherAsync());
private Task<Validation<BaseError, ConnectionParameters>> Validate(SynchronizeJellyfinLibraries request) =>
private Task<Validation<BaseError, ConnectionAndSource>> Validate(SynchronizeJellyfinLibraries request) =>
MediaSourceMustExist(request)
.BindT(MediaSourceMustHaveActiveConnection)
.BindT(MediaSourceMustHaveApiKey);
@@ -48,43 +48,48 @@ public class
_mediaSourceRepository.GetJellyfin(request.JellyfinMediaSourceId)
.Map(o => o.ToValidation<BaseError>("Jellyfin media source does not exist."));
private Validation<BaseError, ConnectionParameters> MediaSourceMustHaveActiveConnection(
private Validation<BaseError, ConnectionAndSource> MediaSourceMustHaveActiveConnection(
JellyfinMediaSource jellyfinMediaSource)
{
Option<JellyfinConnection> maybeConnection = jellyfinMediaSource.Connections.HeadOrNone();
return maybeConnection.Map(connection => new ConnectionParameters(jellyfinMediaSource, connection))
return maybeConnection.Map(connection => new ConnectionAndSource(
new JellyfinConnectionParameters(connection.Address, string.Empty, connection.JellyfinMediaSourceId),
jellyfinMediaSource))
.ToValidation<BaseError>("Jellyfin media source requires an active connection");
}
private async Task<Validation<BaseError, ConnectionParameters>> MediaSourceMustHaveApiKey(
ConnectionParameters connectionParameters)
private async Task<Validation<BaseError, ConnectionAndSource>> MediaSourceMustHaveApiKey(
ConnectionAndSource connectionAndSource)
{
JellyfinSecrets secrets = await _jellyfinSecretStore.ReadSecrets();
return Optional(secrets.Address == connectionParameters.ActiveConnection.Address)
return Optional(secrets.Address == connectionAndSource.ConnectionParameters.Address)
.Where(match => match)
.Map(_ => connectionParameters with { ApiKey = secrets.ApiKey })
.Map(_ => connectionAndSource with
{
ConnectionParameters = connectionAndSource.ConnectionParameters with { ApiKey = secrets.ApiKey }
})
.ToValidation<BaseError>("Jellyfin media source requires an api key");
}
private async Task<Unit> SynchronizeLibraries(
ConnectionParameters connectionParameters,
ConnectionAndSource connectionAndSource,
CancellationToken cancellationToken)
{
Either<BaseError, List<JellyfinLibrary>> maybeLibraries = await _jellyfinApiClient.GetLibraries(
connectionParameters.ActiveConnection.Address,
connectionParameters.ApiKey);
connectionAndSource.ConnectionParameters.Address,
connectionAndSource.ConnectionParameters.AuthorizationHeader);
foreach (BaseError error in maybeLibraries.LeftToSeq())
{
_logger.LogWarning(
"Unable to synchronize libraries from jellyfin server {JellyfinServer}: {Error}",
connectionParameters.JellyfinMediaSource.ServerName,
connectionAndSource.MediaSource.ServerName,
error.Value);
}
foreach (List<JellyfinLibrary> libraries in maybeLibraries.RightToSeq())
{
var existing = connectionParameters.JellyfinMediaSource.Libraries
var existing = connectionAndSource.MediaSource.Libraries
.OfType<JellyfinLibrary>()
.ToList();
var toAdd = libraries.Filter(library => existing.All(l => l.ItemId != library.ItemId)).ToList();
@@ -92,7 +97,7 @@ public class
var toUpdate = libraries
.Filter(l => toAdd.All(a => a.ItemId != l.ItemId) && toRemove.All(r => r.ItemId != l.ItemId)).ToList();
List<int> ids = await _mediaSourceRepository.UpdateLibraries(
connectionParameters.JellyfinMediaSource.Id,
connectionAndSource.MediaSource.Id,
toAdd,
toRemove,
toUpdate,
@@ -107,10 +112,7 @@ public class
return Unit.Default;
}
private sealed record ConnectionParameters(
JellyfinMediaSource JellyfinMediaSource,
JellyfinConnection ActiveConnection)
{
public string ApiKey { get; set; }
}
private sealed record ConnectionAndSource(
JellyfinConnectionParameters ConnectionParameters,
JellyfinMediaSource MediaSource);
}
@@ -1,5 +1,4 @@
using Bugsnag;
using ErsatzTV.Core;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
@@ -8,16 +7,13 @@ namespace ErsatzTV.Application.Maintenance;
public class EmptyTrashHandler : IRequestHandler<EmptyTrash, Either<BaseError, Unit>>
{
private readonly IClient _client;
private readonly IMediaItemRepository _mediaItemRepository;
private readonly ISearchIndex _searchIndex;
public EmptyTrashHandler(
IClient client,
IMediaItemRepository mediaItemRepository,
ISearchIndex searchIndex)
{
_client = client;
_mediaItemRepository = mediaItemRepository;
_searchIndex = searchIndex;
}
@@ -27,7 +23,6 @@ public class EmptyTrashHandler : IRequestHandler<EmptyTrash, Either<BaseError, U
CancellationToken cancellationToken)
{
SearchResult result = await _searchIndex.Search(
_client,
"state:FileNotFound",
string.Empty,
0,
@@ -110,10 +110,10 @@ public partial class AddTraktListHandler : TraktCommandBase, IRequestHandler<Add
return maybeList.Map(_ => Unit.Default);
}
[GeneratedRegex(@"https:\/\/trakt\.tv\/users\/([\w\-_]+)\/(?:lists\/)?([\w\-_]+)")]
[GeneratedRegex(@"https:\/\/(?:app\.)?trakt\.tv\/users\/([\w\-_]+)\/(?:lists\/)?([\w\-_]+)")]
private static partial Regex UriTraktListRegex();
[GeneratedRegex(@"https:\/\/trakt\.tv\/lists\/([\w\-_]+)\/([\w\-_]+)")]
[GeneratedRegex(@"https:\/\/(?:app\.)?trakt\.tv\/lists\/([\w\-_]+)\/([\w\-_]+)")]
private static partial Regex UriTraktListRegex2();
[GeneratedRegex(@"([\w\-_]+)\/(?:lists\/)?([\w\-_]+)")]
@@ -1,5 +1,4 @@
using System.Threading.Channels;
using Bugsnag;
using EFCore.BulkExtensions;
using ErsatzTV.Application.Channels;
using ErsatzTV.Application.Subtitles;
@@ -22,7 +21,6 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
{
private readonly IBlockPlayoutBuilder _blockPlayoutBuilder;
private readonly IBlockPlayoutFillerBuilder _blockPlayoutFillerBuilder;
private readonly IClient _client;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IEntityLocker _entityLocker;
private readonly IExternalJsonPlayoutBuilder _externalJsonPlayoutBuilder;
@@ -35,7 +33,6 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
private readonly IScriptedPlayoutBuilder _scriptedPlayoutBuilder;
public BuildPlayoutHandler(
IClient client,
IDbContextFactory<TvContext> dbContextFactory,
IPlayoutBuilder playoutBuilder,
IBlockPlayoutBuilder blockPlayoutBuilder,
@@ -49,7 +46,6 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
ChannelWriter<IBackgroundServiceRequest> workerChannel,
ILogger<BuildPlayoutHandler> logger)
{
_client = client;
_dbContextFactory = dbContextFactory;
_playoutBuilder = playoutBuilder;
_blockPlayoutBuilder = blockPlayoutBuilder;
@@ -348,7 +344,6 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
newBuildStatus.Success = false;
newBuildStatus.Message = $"Timeout building playout for channel {channelName}";
_client.Notify(ex);
return BaseError.New(
$"Timeout building playout for channel {channelName}; this may be a bug!");
}
@@ -359,7 +354,6 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
newBuildStatus.Success = false;
newBuildStatus.Message = $"Unexpected error building playout for channel {channelName}: {ex}";
_client.Notify(ex);
return BaseError.New(
$"Unexpected error building playout for channel {channelName}: {ex.Message}");
}
+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();
@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.ProgramSchedules;
public record ProcessSchedulingContext(string SerializedContext) : IRequest<Option<string>>;
@@ -0,0 +1,218 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using JsonSerializer = System.Text.Json.JsonSerializer;
namespace ErsatzTV.Application.ProgramSchedules;
public class ProcessSchedulingContextHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<ProcessSchedulingContext, Option<string>>
{
private static readonly JsonSerializerOptions Options = new()
{
Converters = { new JsonStringEnumConverter() },
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = true
};
public async Task<Option<string>> Handle(ProcessSchedulingContext request, CancellationToken cancellationToken)
{
try
{
using JsonDocument doc = JsonDocument.Parse(request.SerializedContext);
if (doc.RootElement.TryGetProperty(nameof(ClassicSchedulingContext.ScheduleId), out _))
{
var classicContext = JsonSerializer.Deserialize<ClassicSchedulingContext>(request.SerializedContext, Options);
if (classicContext is not null && classicContext.ScheduleId > 0)
{
return await GetClassicDetails(classicContext, cancellationToken);
}
}
else if (doc.RootElement.TryGetProperty(nameof(BlockSchedulingContext.BlockId), out _))
{
var blockContext = JsonSerializer.Deserialize<BlockSchedulingContext>(request.SerializedContext, Options);
if (blockContext is not null && blockContext.BlockId > 0)
{
return await GetBlockDetails(blockContext, cancellationToken);
}
}
}
catch (JsonException)
{
// not a valid json string, or not a context we can process
}
return request.SerializedContext;
}
private async Task<Option<string>> GetClassicDetails(
ClassicSchedulingContext classicContext,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
string scheduleName = await dbContext.ProgramSchedules
.Where(s => s.Id == classicContext.ScheduleId)
.Select(s => s.Name)
.SingleOrDefaultAsync(cancellationToken);
var scheduleItem = await dbContext.ProgramScheduleItems
.AsNoTracking()
.AsSplitQuery()
.Include(si => si.Collection)
.Include(si => si.MultiCollection)
.Include(si => si.Playlist)
.Include(si => si.RerunCollection)
.Include(si => si.SmartCollection)
.SingleOrDefaultAsync(si => si.Id == classicContext.ItemId, cancellationToken);
var name = "Classic";
ClassicContextFiller filler = null;
if (classicContext.FillerPresetId is > 0)
{
name = "Classic - Filler";
var fillerPreset = await dbContext.FillerPresets
.AsNoTracking()
.AsSplitQuery()
.Include(p => p.Collection)
.Include(p => p.MultiCollection)
.Include(p => p.Playlist)
.Include(p => p.SmartCollection)
.SingleOrDefaultAsync(p => p.Id == classicContext.FillerPresetId, cancellationToken);
if (fillerPreset is not null)
{
string collectionName = fillerPreset.CollectionType switch
{
CollectionType.Collection => fillerPreset.Collection.Name,
CollectionType.MultiCollection => fillerPreset.MultiCollection.Name,
CollectionType.Playlist => fillerPreset.Playlist.Name,
CollectionType.SmartCollection => fillerPreset.SmartCollection.Name,
_ => null
};
filler = new ClassicContextFiller(
fillerPreset.Id,
fillerPreset.Name,
fillerPreset.FillerKind,
fillerPreset.FillerMode,
fillerPreset.CollectionType,
collectionName);
}
}
ClassicContextScheduleItem item;
if (scheduleItem is not null)
{
string collectionName = scheduleItem.CollectionType switch
{
CollectionType.Collection => scheduleItem.Collection.Name,
CollectionType.MultiCollection => scheduleItem.MultiCollection.Name,
CollectionType.Playlist => scheduleItem.Playlist.Name,
CollectionType.RerunRerun or CollectionType.RerunFirstRun => scheduleItem.RerunCollection.Name,
CollectionType.SmartCollection => scheduleItem.SmartCollection.Name,
_ => null
};
item = new ClassicContextScheduleItem(scheduleItem.Id, scheduleItem.CollectionType, collectionName);
}
else
{
item = new ClassicContextScheduleItem(classicContext.ItemId, null, null);
}
var context = new ClassicContext(
new ContextScheduler(name, classicContext.Scheduler),
new ClassicContextSchedule(classicContext.ScheduleId, scheduleName ?? string.Empty),
item,
filler,
new ContextEnumerator(classicContext.Enumerator, classicContext.Seed, classicContext.Index));
return JsonSerializer.Serialize(context, Options);
}
private async Task<Option<string>> GetBlockDetails(BlockSchedulingContext blockContext, CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
var block = await dbContext.Blocks
.AsNoTracking()
.Include(b => b.BlockGroup)
.SingleOrDefaultAsync(s => s.Id == blockContext.BlockId, cancellationToken);
var blockItem = await dbContext.BlockItems
.AsNoTracking()
.AsSplitQuery()
.Include(si => si.Collection)
.Include(si => si.MultiCollection)
.Include(si => si.SmartCollection)
.SingleOrDefaultAsync(s => s.Id == blockContext.BlockItemId, cancellationToken);
BlockContextBlockItem item;
if (blockItem is not null)
{
string collectionName = blockItem.CollectionType switch
{
CollectionType.Collection => blockItem.Collection.Name,
CollectionType.MultiCollection => blockItem.MultiCollection.Name,
CollectionType.SmartCollection => blockItem.SmartCollection.Name,
_ => null
};
item = new BlockContextBlockItem(blockItem.Id, blockItem.CollectionType, collectionName);
}
else
{
item = new BlockContextBlockItem(blockContext.BlockItemId, null, null);
}
var context = new BlockContext(
new ContextScheduler("Block", null),
new BlockContextBlock(
blockContext.BlockId,
block?.BlockGroup?.Name ?? string.Empty,
block?.Name ?? string.Empty),
item,
new ContextEnumerator(blockContext.Enumerator, blockContext.Seed, blockContext.Index));
return JsonSerializer.Serialize(context, Options);
}
private sealed record ClassicContext(
ContextScheduler Scheduler,
ClassicContextSchedule Schedule,
ClassicContextScheduleItem ScheduleItem,
ClassicContextFiller Filler,
ContextEnumerator Enumerator);
private sealed record ContextScheduler(string Type, string Mode);
private sealed record ClassicContextSchedule(int Id, string Name);
private sealed record ClassicContextScheduleItem(int Id, CollectionType? CollectionType, string CollectionName);
private sealed record ClassicContextFiller(
int Id,
string Name,
FillerKind Kind,
FillerMode Mode,
CollectionType CollectionType,
string CollectionName);
private sealed record ContextEnumerator(string Name, int Seed, int Index);
private sealed record BlockContext(
ContextScheduler Scheduler,
BlockContextBlock Block,
BlockContextBlockItem BlockItem,
ContextEnumerator Enumerator);
private sealed record BlockContextBlock(int Id, string Group, string Name);
private sealed record BlockContextBlockItem(int Id, CollectionType? CollectionType, string CollectionName);
}
@@ -1,10 +1,9 @@
using Bugsnag;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Search;
namespace ErsatzTV.Application.Search;
public class QuerySearchIndexAllItemsHandler(IClient client, ISearchIndex searchIndex)
public class QuerySearchIndexAllItemsHandler(ISearchIndex searchIndex)
: IRequestHandler<QuerySearchIndexAllItems, SearchResultAllItemsViewModel>
{
public async Task<SearchResultAllItemsViewModel> Handle(
@@ -23,7 +22,7 @@ public class QuerySearchIndexAllItemsHandler(IClient client, ISearchIndex search
await GetIds(LuceneSearchIndex.RemoteStreamType, request.Query, cancellationToken));
private async Task<List<int>> GetIds(string type, string query, CancellationToken cancellationToken) =>
(await searchIndex.Search(client, $"type:{type} AND ({query})", string.Empty, 0, 0, cancellationToken)).Items
(await searchIndex.Search($"type:{type} AND ({query})", string.Empty, 0, 0, cancellationToken)).Items
.Map(i => i.Id)
.ToList();
}
@@ -1,5 +1,4 @@
using Bugsnag;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
using ErsatzTV.Infrastructure.Data;
@@ -9,7 +8,6 @@ using static ErsatzTV.Application.MediaCards.Mapper;
namespace ErsatzTV.Application.Search;
public class QuerySearchIndexArtistsHandler(
IClient client,
ISearchIndex searchIndex,
IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<QuerySearchIndexArtists, ArtistCardResultsViewModel>
@@ -19,7 +17,6 @@ public class QuerySearchIndexArtistsHandler(
CancellationToken cancellationToken)
{
SearchResult searchResult = await searchIndex.Search(
client,
request.Query,
string.Empty,
(request.PageNumber - 1) * request.PageSize,
@@ -1,5 +1,4 @@
using Bugsnag;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Emby;
@@ -17,7 +16,6 @@ namespace ErsatzTV.Application.Search;
public class
QuerySearchIndexEpisodesHandler(
IClient client,
ISearchIndex searchIndex,
IPlexPathReplacementService plexPathReplacementService,
IJellyfinPathReplacementService jellyfinPathReplacementService,
@@ -31,7 +29,6 @@ public class
CancellationToken cancellationToken)
{
SearchResult searchResult = await searchIndex.Search(
client,
request.Query,
string.Empty,
(request.PageNumber - 1) * request.PageSize,
@@ -1,5 +1,4 @@
using Bugsnag;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
using ErsatzTV.Infrastructure.Data;
@@ -9,7 +8,6 @@ using static ErsatzTV.Application.MediaCards.Mapper;
namespace ErsatzTV.Application.Search;
public class QuerySearchIndexImagesHandler(
IClient client,
ISearchIndex searchIndex,
IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<QuerySearchIndexImages, ImageCardResultsViewModel>
@@ -19,7 +17,6 @@ public class QuerySearchIndexImagesHandler(
CancellationToken cancellationToken)
{
SearchResult searchResult = await searchIndex.Search(
client,
request.Query,
string.Empty,
(request.PageNumber - 1) * request.PageSize,
@@ -1,5 +1,4 @@
using Bugsnag;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
@@ -10,7 +9,6 @@ using static ErsatzTV.Application.MediaCards.Mapper;
namespace ErsatzTV.Application.Search;
public class QuerySearchIndexMoviesHandler(
IClient client,
ISearchIndex searchIndex,
IDbContextFactory<TvContext> dbContextFactory)
: QuerySearchIndexHandlerBase, IRequestHandler<QuerySearchIndexMovies, MovieCardResultsViewModel>
@@ -20,7 +18,6 @@ public class QuerySearchIndexMoviesHandler(
CancellationToken cancellationToken)
{
SearchResult searchResult = await searchIndex.Search(
client,
request.Query,
string.Empty,
(request.PageNumber - 1) * request.PageSize,
@@ -1,5 +1,4 @@
using Bugsnag;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Emby;
@@ -15,7 +14,6 @@ namespace ErsatzTV.Application.Search;
public class
QuerySearchIndexMusicVideosHandler(
IClient client,
ISearchIndex searchIndex,
IPlexPathReplacementService plexPathReplacementService,
IJellyfinPathReplacementService jellyfinPathReplacementService,
@@ -28,7 +26,6 @@ public class
CancellationToken cancellationToken)
{
SearchResult searchResult = await searchIndex.Search(
client,
request.Query,
string.Empty,
(request.PageNumber - 1) * request.PageSize,
@@ -1,5 +1,4 @@
using Bugsnag;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
using ErsatzTV.Infrastructure.Data;
@@ -10,7 +9,6 @@ namespace ErsatzTV.Application.Search;
public class
QuerySearchIndexOtherVideosHandler(
IClient client,
ISearchIndex searchIndex,
IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<QuerySearchIndexOtherVideos, OtherVideoCardResultsViewModel>
@@ -20,7 +18,6 @@ public class
CancellationToken cancellationToken)
{
SearchResult searchResult = await searchIndex.Search(
client,
request.Query,
string.Empty,
(request.PageNumber - 1) * request.PageSize,
@@ -1,5 +1,4 @@
using Bugsnag;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
using ErsatzTV.Infrastructure.Data;
@@ -9,7 +8,6 @@ using static ErsatzTV.Application.MediaCards.Mapper;
namespace ErsatzTV.Application.Search;
public class QuerySearchIndexRemoteStreamsHandler(
IClient client,
ISearchIndex searchIndex,
IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<QuerySearchIndexRemoteStreams, RemoteStreamCardResultsViewModel>
@@ -19,7 +17,6 @@ public class QuerySearchIndexRemoteStreamsHandler(
CancellationToken cancellationToken)
{
SearchResult searchResult = await searchIndex.Search(
client,
request.Query,
string.Empty,
(request.PageNumber - 1) * request.PageSize,
@@ -1,5 +1,4 @@
using Bugsnag;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
@@ -11,7 +10,6 @@ namespace ErsatzTV.Application.Search;
public class
QuerySearchIndexSeasonsHandler(
IClient client,
ISearchIndex searchIndex,
IDbContextFactory<TvContext> dbContextFactory)
: QuerySearchIndexHandlerBase, IRequestHandler<QuerySearchIndexSeasons, TelevisionSeasonCardResultsViewModel>
@@ -21,7 +19,6 @@ public class
CancellationToken cancellationToken)
{
SearchResult searchResult = await searchIndex.Search(
client,
request.Query,
string.Empty,
(request.PageNumber - 1) * request.PageSize,
@@ -1,5 +1,4 @@
using Bugsnag;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
@@ -11,7 +10,6 @@ namespace ErsatzTV.Application.Search;
public class
QuerySearchIndexShowsHandler(
IClient client,
ISearchIndex searchIndex,
IDbContextFactory<TvContext> dbContextFactory)
: QuerySearchIndexHandlerBase, IRequestHandler<QuerySearchIndexShows, TelevisionShowCardResultsViewModel>
@@ -21,7 +19,6 @@ public class
CancellationToken cancellationToken)
{
SearchResult searchResult = await searchIndex.Search(
client,
request.Query,
string.Empty,
(request.PageNumber - 1) * request.PageSize,
@@ -1,5 +1,4 @@
using Bugsnag;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
using ErsatzTV.Infrastructure.Data;
@@ -8,7 +7,7 @@ using static ErsatzTV.Application.MediaCards.Mapper;
namespace ErsatzTV.Application.Search;
public class QuerySearchIndexSongsHandler(IClient client, ISearchIndex searchIndex, IDbContextFactory<TvContext> dbContextFactory)
public class QuerySearchIndexSongsHandler(ISearchIndex searchIndex, IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<QuerySearchIndexSongs, SongCardResultsViewModel>
{
public async Task<SongCardResultsViewModel> Handle(
@@ -16,7 +15,6 @@ public class QuerySearchIndexSongsHandler(IClient client, ISearchIndex searchInd
CancellationToken cancellationToken)
{
SearchResult searchResult = await searchIndex.Search(
client,
request.Query,
string.Empty,
(request.PageNumber - 1) * request.PageSize,
@@ -1,6 +1,5 @@
using System.Collections.Immutable;
using System.Globalization;
using Bugsnag;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Search;
@@ -11,10 +10,9 @@ using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Search;
public class SearchMoviesHandler(
IClient client,
ISearchIndex searchIndex,
IDbContextFactory<TvContext> dbContextFactory)
: SearchUsingSearchIndexHandler(client, searchIndex), IRequestHandler<SearchMovies, List<NamedMediaItemViewModel>>
: SearchUsingSearchIndexHandler(searchIndex), IRequestHandler<SearchMovies, List<NamedMediaItemViewModel>>
{
public async Task<List<NamedMediaItemViewModel>> Handle(SearchMovies request, CancellationToken cancellationToken)
{
@@ -1,6 +1,5 @@
using System.Collections.Immutable;
using System.Globalization;
using Bugsnag;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Data;
@@ -10,10 +9,9 @@ using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Search;
public class SearchTelevisionSeasonsHandler(
IClient client,
ISearchIndex searchIndex,
IDbContextFactory<TvContext> dbContextFactory)
: SearchUsingSearchIndexHandler(client, searchIndex),
: SearchUsingSearchIndexHandler(searchIndex),
IRequestHandler<SearchTelevisionSeasons, List<NamedMediaItemViewModel>>
{
public async Task<List<NamedMediaItemViewModel>> Handle(
@@ -1,6 +1,5 @@
using System.Collections.Immutable;
using System.Globalization;
using Bugsnag;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Search;
@@ -11,10 +10,9 @@ using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Search;
public class SearchTelevisionShowsHandler(
IClient client,
ISearchIndex searchIndex,
IDbContextFactory<TvContext> dbContextFactory)
: SearchUsingSearchIndexHandler(client, searchIndex),
: SearchUsingSearchIndexHandler(searchIndex),
IRequestHandler<SearchTelevisionShows, List<NamedMediaItemViewModel>>
{
public async Task<List<NamedMediaItemViewModel>> Handle(
@@ -1,18 +1,16 @@
using System.Collections.Immutable;
using Bugsnag;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Infrastructure.Search;
namespace ErsatzTV.Application.Search;
public abstract class SearchUsingSearchIndexHandler(IClient client, ISearchIndex searchIndex)
public abstract class SearchUsingSearchIndexHandler(ISearchIndex searchIndex)
{
private const int PageSize = 10;
protected async Task<ImmutableHashSet<int>> Search(string type, string query, CancellationToken cancellationToken)
{
var searchResult = await searchIndex.Search(
client,
$"type:{type} AND *{query.Replace(" ", @"\ ")}*",
string.Empty,
0,
@@ -1,6 +1,5 @@
using System.IO.Abstractions;
using System.Threading.Channels;
using Bugsnag;
using ErsatzTV.Application.Channels;
using ErsatzTV.Application.Graphics;
using ErsatzTV.Application.Maintenance;
@@ -22,7 +21,6 @@ namespace ErsatzTV.Application.Streaming;
public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Either<BaseError, Unit>>
{
private readonly IClient _client;
private readonly IFileSystem _fileSystem;
private readonly IConfigElementRepository _configElementRepository;
private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
@@ -42,7 +40,6 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
IHlsInitSegmentCache hlsInitSegmentCache,
IServiceScopeFactory serviceScopeFactory,
IMediator mediator,
IClient client,
IFileSystem fileSystem,
ILocalFileSystem localFileSystem,
ILogger<StartFFmpegSessionHandler> logger,
@@ -57,7 +54,6 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
_hlsInitSegmentCache = hlsInitSegmentCache;
_serviceScopeFactory = serviceScopeFactory;
_mediator = mediator;
_client = client;
_fileSystem = fileSystem;
_localFileSystem = localFileSystem;
_logger = logger;
@@ -129,7 +125,6 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
_ => new HlsSessionWorker(
_serviceScopeFactory,
_graphicsEngine,
_client,
OutputFormatKind.Hls,
_hlsPlaylistFilter,
_hlsInitSegmentCache,
@@ -6,5 +6,7 @@ public enum HlsSessionState
ZeroAndWorkAhead,
SeekAndRealtime,
ZeroAndRealtime,
SlugAndWorkAhead,
SlugAndRealtime,
PlayoutUpdated
}
@@ -5,9 +5,9 @@ using System.IO.Abstractions;
using System.IO.Pipelines;
using System.Text;
using System.Timers;
using Bugsnag;
using CliWrap;
using CliWrap.Buffered;
using ErsatzTV.Application.Channels;
using ErsatzTV.Application.Playouts;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
@@ -27,7 +27,6 @@ namespace ErsatzTV.Application.Streaming;
public class HlsSessionWorker : IHlsSessionWorker
{
private static int _workAheadCount;
private readonly IClient _client;
private readonly OutputFormatKind _outputFormatKind;
private readonly IHlsInitSegmentCache _hlsInitSegmentCache;
private readonly Dictionary<long, int> _discontinuityMap = [];
@@ -54,11 +53,11 @@ public class HlsSessionWorker : IHlsSessionWorker
private Timer _timer;
private DateTimeOffset _transcodedUntil;
private string _workingDirectory;
private Option<double> _slugSeconds;
public HlsSessionWorker(
IServiceScopeFactory serviceScopeFactory,
IGraphicsEngine graphicsEngine,
IClient client,
OutputFormatKind outputFormatKind,
IHlsPlaylistFilter hlsPlaylistFilter,
IHlsInitSegmentCache hlsInitSegmentCache,
@@ -71,7 +70,6 @@ public class HlsSessionWorker : IHlsSessionWorker
_serviceScope = serviceScopeFactory.CreateScope();
_mediator = _serviceScope.ServiceProvider.GetRequiredService<IMediator>();
_graphicsEngine = graphicsEngine;
_client = client;
_outputFormatKind = outputFormatKind;
_hlsInitSegmentCache = hlsInitSegmentCache;
_hlsPlaylistFilter = hlsPlaylistFilter;
@@ -215,6 +213,10 @@ public class HlsSessionWorker : IHlsSessionWorker
new GetPlayoutIdByChannelNumber(_channelNumber),
cancellationToken);
_slugSeconds = await _mediator.Send(
new GetSlugSecondsByChannelNumber(_channelNumber),
cancellationToken);
// time shift on-demand playout if needed
foreach (int playoutId in maybePlayoutId)
{
@@ -307,9 +309,6 @@ public class HlsSessionWorker : IHlsSessionWorker
var sw = Stopwatch.StartNew();
try
{
DateTimeOffset start = DateTimeOffset.Now;
DateTimeOffset finish = start.AddSeconds(8);
string playlistFileName = Path.Combine(_workingDirectory, "live.m3u8");
_logger.LogDebug("Waiting for playlist to exist");
@@ -320,6 +319,10 @@ public class HlsSessionWorker : IHlsSessionWorker
_logger.LogDebug("Playlist exists");
// start the segment-wait deadline only after the playlist file appears,
// so slow pipeline setup (e.g. h264 profile probing) doesn't consume the budget
DateTimeOffset finish = DateTimeOffset.Now.AddSeconds(8);
var segmentCount = 0;
int lastSegmentCount = -1;
while (DateTimeOffset.Now < finish && segmentCount < initialSegmentCount)
@@ -382,6 +385,14 @@ public class HlsSessionWorker : IHlsSessionWorker
// after seeking and NOT completing the item, seek again, transcode method will accelerate if needed
HlsSessionState.SeekAndWorkAhead when !isComplete => HlsSessionState.SeekAndRealtime,
// switch back to normal item after slug
HlsSessionState.SlugAndWorkAhead => HlsSessionState.ZeroAndWorkAhead,
HlsSessionState.SlugAndRealtime => HlsSessionState.ZeroAndRealtime,
// after completing the item, insert a slug
HlsSessionState.ZeroAndWorkAhead or HlsSessionState.SeekAndWorkAhead when isComplete && _slugSeconds.IsSome => HlsSessionState.SlugAndWorkAhead,
HlsSessionState.ZeroAndRealtime or HlsSessionState.SeekAndRealtime when isComplete && _slugSeconds.IsSome => HlsSessionState.SlugAndRealtime,
// after seeking and completing the item, start at zero
HlsSessionState.SeekAndWorkAhead => HlsSessionState.ZeroAndWorkAhead,
@@ -441,6 +452,7 @@ public class HlsSessionWorker : IHlsSessionWorker
{
HlsSessionState.SeekAndWorkAhead => HlsSessionState.SeekAndRealtime,
HlsSessionState.ZeroAndWorkAhead => HlsSessionState.ZeroAndRealtime,
HlsSessionState.SlugAndWorkAhead => HlsSessionState.SlugAndRealtime,
_ => _state
};
@@ -456,19 +468,32 @@ public class HlsSessionWorker : IHlsSessionWorker
_logger.LogDebug("HLS session state: {State}", _state);
DateTimeOffset now = wasSeekAndWorkAhead ? DateTimeOffset.Now : _transcodedUntil;
bool startAtZero = _state is HlsSessionState.ZeroAndWorkAhead or HlsSessionState.ZeroAndRealtime;
bool startAtZero = _state is HlsSessionState.ZeroAndWorkAhead or HlsSessionState.ZeroAndRealtime
or HlsSessionState.SlugAndWorkAhead or HlsSessionState.SlugAndRealtime;
var request = new GetPlayoutItemProcessByChannelNumber(
_channelNumber,
StreamingMode.HttpLiveStreamingSegmenter,
now,
startAtZero,
realtime,
_channelStart,
ptsOffset,
_targetFramerate,
IsTroubleshooting: false,
Option<int>.None);
bool isSlug = _state is HlsSessionState.SlugAndWorkAhead or HlsSessionState.SlugAndRealtime;
FFmpegProcessRequest request = isSlug
? new GetSlugProcessByChannelNumber(
_channelNumber,
StreamingMode.HttpLiveStreamingSegmenter,
now,
realtime,
_channelStart,
ptsOffset,
_targetFramerate,
_slugSeconds)
: new GetPlayoutItemProcessByChannelNumber(
_channelNumber,
StreamingMode.HttpLiveStreamingSegmenter,
now,
startAtZero,
realtime,
_channelStart,
ptsOffset,
_targetFramerate,
IsTroubleshooting: false,
Option<int>.None);
// _logger.LogInformation("Request {@Request}", request);
@@ -528,9 +553,12 @@ public class HlsSessionWorker : IHlsSessionWorker
linkedCts.Token);
}
var progressParser = new FFmpegProgress();
CommandResult commandResult = await processWithPipe
.WithWorkingDirectory(_workingDirectory)
.WithStandardErrorPipe(PipeTarget.ToStringBuilder(stdErrBuffer))
.WithStandardOutputPipe(PipeTarget.ToDelegate(progressParser.ParseLine))
.WithValidation(CommandResultValidation.None)
.ExecuteAsync(linkedCts.Token);
@@ -538,12 +566,20 @@ public class HlsSessionWorker : IHlsSessionWorker
{
_logger.LogDebug("HLS process has completed for channel {Channel}", _channelNumber);
_logger.LogDebug(
"Transcoded until: {Until} - Buffer: {Buffer} seconds",
"Transcoded until: {Until} - Buffer: {Buffer} seconds - Speed {Speed}",
processModel.Until,
processModel.Until.Subtract(DateTimeOffset.Now).TotalSeconds);
processModel.Until.Subtract(DateTimeOffset.Now).TotalSeconds,
progressParser.Speed);
_transcodedUntil = processModel.Until;
_state = NextState(_state, processModel);
_hasWrittenSegments = true;
progressParser.LogSpeed(
processModel.MediaItemId,
processModel.IsWorkingAhead,
_channelNumber,
_logger);
return true;
}
else
@@ -620,15 +656,6 @@ public class HlsSessionWorker : IHlsSessionWorker
{
_logger.LogError(ex, "Error transcoding channel {Channel} - {Message}", _channelNumber, ex.Message);
try
{
_client.Notify(ex);
}
catch (Exception)
{
// do nothing
}
return false;
}
finally
@@ -36,7 +36,8 @@ public class GetConcatProcessByChannelNumberHandler : FFmpegProcessHandler<GetCo
saveReports,
channel,
request.Scheme,
request.Host);
request.Host,
cancellationToken);
return new PlayoutItemProcessModel(
process,
@@ -32,7 +32,8 @@ public class GetErrorProcessHandler(
channel.FFmpegProfile.VaapiDisplay,
channel.FFmpegProfile.VaapiDriver,
channel.FFmpegProfile.VaapiDevice,
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames));
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames),
cancellationToken);
return new PlayoutItemProcessModel(
process,
@@ -1,6 +1,5 @@
using System.IO.Abstractions;
using System.Text;
using Bugsnag;
using CliWrap;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
@@ -13,7 +12,6 @@ using Newtonsoft.Json;
namespace ErsatzTV.Application.Streaming;
public class GetLastPtsTimeHandler(
IClient client,
IFileSystem fileSystem,
ITempFilePool tempFilePool,
IConfigElementRepository configElementRepository,
@@ -181,9 +179,9 @@ public class GetLastPtsTimeHandler(
logger.LogWarning("Transcode folder is in bad state; troubleshooting info saved to {File}", file);
}
catch (Exception ex)
catch (Exception)
{
client.Notify(ex);
// do nothing
}
}
@@ -321,7 +321,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
if (request.IsTroubleshooting)
{
channel.Number = ".troubleshooting";
channel.Number = FileSystemLayout.TranscodeTroubleshootingChannel;
}
if (_isDebugNoSync)
@@ -337,7 +337,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
channel.FFmpegProfile.VaapiDisplay,
channel.FFmpegProfile.VaapiDriver,
channel.FFmpegProfile.VaapiDevice,
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames));
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames),
cancellationToken);
return new PlayoutItemProcessModel(
doesNotExistProcess,
@@ -498,7 +499,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
Option<TimeSpan> maybeDuration = maybeNextStart.Map(s => s - now);
// limit working ahead on errors to 1 minute
if (!request.HlsRealtime && maybeDuration.IfNone(TimeSpan.FromMinutes(2)) > TimeSpan.FromMinutes(1))
if (!request.HlsRealtime && await maybeDuration.IfNoneAsync(TimeSpan.FromMinutes(2)) > TimeSpan.FromMinutes(1))
{
maybeNextStart = now.AddMinutes(1);
maybeDuration = TimeSpan.FromMinutes(1);
@@ -508,7 +509,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
if (request.IsTroubleshooting)
{
channel.Number = ".troubleshooting";
channel.Number = FileSystemLayout.TranscodeTroubleshootingChannel;
maybeDuration = TimeSpan.FromSeconds(30);
finish = now + TimeSpan.FromSeconds(30);
@@ -534,7 +535,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
channel.FFmpegProfile.VaapiDisplay,
channel.FFmpegProfile.VaapiDriver,
channel.FFmpegProfile.VaapiDevice,
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames));
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames),
cancellationToken);
return new PlayoutItemProcessModel(
offlineProcess,
@@ -558,7 +560,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
channel.FFmpegProfile.VaapiDisplay,
channel.FFmpegProfile.VaapiDriver,
channel.FFmpegProfile.VaapiDevice,
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames));
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames),
cancellationToken);
return new PlayoutItemProcessModel(
doesNotExistProcess,
@@ -582,7 +585,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
channel.FFmpegProfile.VaapiDisplay,
channel.FFmpegProfile.VaapiDriver,
channel.FFmpegProfile.VaapiDevice,
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames));
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames),
cancellationToken);
return new PlayoutItemProcessModel(
errorProcess,
@@ -766,7 +770,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
.ToListAsync(cancellationToken);
// always play min(duration to next item, version.Duration)
TimeSpan duration = maybeDuration.IfNone(version.Duration);
TimeSpan duration = await maybeDuration.IfNoneAsync(version.Duration);
if (version.Duration < duration)
{
duration = version.Duration;
@@ -21,19 +21,21 @@ public class GetSeekTextSubtitleProcessHandler(
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, string> validation = await Validate(dbContext, cancellationToken);
return await validation.Match(
ffmpegPath => GetProcess(request, ffmpegPath),
ffmpegPath => GetProcess(request, ffmpegPath, cancellationToken),
error => Task.FromResult<Either<BaseError, SeekTextSubtitleProcess>>(error.Join()));
}
private async Task<Either<BaseError, SeekTextSubtitleProcess>> GetProcess(
GetSeekTextSubtitleProcess request,
string ffmpegPath)
string ffmpegPath,
CancellationToken cancellationToken)
{
Command process = await ffmpegProcessService.SeekTextSubtitle(
ffmpegPath,
request.PathAndCodec.Path,
request.PathAndCodec.Codec,
request.Seek);
request.Seek,
cancellationToken);
return new SeekTextSubtitleProcess(process);
}
@@ -0,0 +1,22 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.FFmpeg;
namespace ErsatzTV.Application.Streaming;
public record GetSlugProcessByChannelNumber(
string ChannelNumber,
StreamingMode Mode,
DateTimeOffset Now,
bool HlsRealtime,
DateTimeOffset ChannelStart,
TimeSpan PtsOffset,
Option<FrameRate> TargetFramerate,
Option<double> SlugSeconds) : FFmpegProcessRequest(
ChannelNumber,
Mode,
Now,
StartAtZero: true,
HlsRealtime,
ChannelStart,
PtsOffset,
FFmpegProfileId: Option<int>.None);
@@ -0,0 +1,53 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Streaming;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Streaming;
public class GetSlugProcessByChannelNumberHandler(
IDbContextFactory<TvContext> dbContextFactory,
IFFmpegProcessService ffmpegProcessService)
: FFmpegProcessHandler<GetSlugProcessByChannelNumber>(dbContextFactory)
{
protected override async Task<Either<BaseError, PlayoutItemProcessModel>> GetProcess(
TvContext dbContext,
GetSlugProcessByChannelNumber request,
Channel channel,
string ffmpegPath,
string ffprobePath,
CancellationToken cancellationToken)
{
var duration = TimeSpan.FromSeconds(await request.SlugSeconds.IfNoneAsync(0));
if (duration <= TimeSpan.Zero)
{
return BaseError.New("Slug seconds must be non-zero");
}
DateTimeOffset finish = request.Now.Add(duration);
var playoutItemResult = await ffmpegProcessService.Slug(
ffmpegPath,
channel,
request.Now,
duration,
request.HlsRealtime,
request.PtsOffset,
cancellationToken);
var result = new PlayoutItemProcessModel(
playoutItemResult,
Option<GraphicsEngineContext>.None,
duration,
finish,
isComplete: true,
request.Now.ToUnixTimeSeconds(),
Option<int>.None,
Optional(channel.PlayoutOffset),
!request.HlsRealtime);
return Right<BaseError, PlayoutItemProcessModel>(result);
}
}
@@ -185,7 +185,7 @@ public class PrepareTroubleshootingPlaybackHandler(
{
Artwork = [],
Name = "ETV",
Number = ".troubleshooting",
Number = FileSystemLayout.TranscodeTroubleshootingChannel,
FFmpegProfile = ffmpegProfile,
StreamingMode = request.StreamingMode,
StreamSelectorMode = ChannelStreamSelectorMode.Troubleshooting,
@@ -2,10 +2,10 @@ using System.IO.Pipelines;
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
using CliWrap;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Interfaces.Streaming;
using ErsatzTV.Core.Interfaces.Troubleshooting;
@@ -17,7 +17,7 @@ using Serilog.Events;
namespace ErsatzTV.Application.Troubleshooting;
public partial class StartTroubleshootingPlaybackHandler(
public class StartTroubleshootingPlaybackHandler(
ITroubleshootingNotifier notifier,
IMediator mediator,
IEntityLocker entityLocker,
@@ -138,14 +138,23 @@ public partial class StartTroubleshootingPlaybackHandler(
linkedCts.Token);
}
var progressParser = new FFmpegProgress();
CommandResult commandResult = await processWithPipe
.WithWorkingDirectory(FileSystemLayout.TranscodeTroubleshootingFolder)
.WithStandardErrorPipe(PipeTarget.Null)
.WithStandardOutputPipe(PipeTarget.ToDelegate(progressParser.ParseLine))
.WithValidation(CommandResultValidation.None)
.ExecuteAsync(linkedCts.Token);
logger.LogDebug("Troubleshooting playback completed with exit code {ExitCode}", commandResult.ExitCode);
progressParser.LogSpeed(
request.MediaItemInfo.Map(i => i.Id),
true,
FileSystemLayout.TranscodeTroubleshootingChannel,
logger);
try
{
IEnumerable<string> logs = logService.Sink.GetLogs(request.SessionId);
@@ -160,26 +169,11 @@ public partial class StartTroubleshootingPlaybackHandler(
// do nothing
}
Option<double> maybeSpeed = Option<double>.None;
Option<string> maybeFile = Directory.GetFiles(FileSystemLayout.TranscodeTroubleshootingFolder, "ffmpeg*.log").HeadOrNone();
foreach (string file in maybeFile)
{
await foreach (string line in File.ReadLinesAsync(file, linkedCts.Token))
{
Match match = FFmpegSpeed().Match(line);
if (match.Success && double.TryParse(match.Groups[1].Value, out double speed))
{
maybeSpeed = speed;
break;
}
}
}
await mediator.Publish(
new PlaybackTroubleshootingCompletedNotification(
commandResult.ExitCode,
Option<Exception>.None,
maybeSpeed),
progressParser.Speed),
linkedCts.Token);
if (commandResult.ExitCode != 0)
@@ -210,7 +204,4 @@ public partial class StartTroubleshootingPlaybackHandler(
loggingLevelSwitches.StreamingLevelSwitch.MinimumLevel = currentStreamingLevel;
}
}
[GeneratedRegex(@"speed=\s*([\d\.]+)x", RegexOptions.IgnoreCase)]
private static partial Regex FFmpegSpeed();
}
@@ -161,7 +161,8 @@ public class GetTroubleshootingInfoHandler : IRequestHandler<GetTroubleshootingI
videoToolboxCapabilities.AppendLine();
}
var ffmpegCapabilities = await _hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath.Value);
var ffmpegCapabilities =
await _hardwareCapabilitiesFactory.GetFFmpegCapabilities(ffmpegPath.Value, cancellationToken);
aviSynthDemuxer = ffmpegCapabilities.HasDemuxFormat(FFmpegKnownFormat.AviSynth);
aviSynthInstalled = _hardwareCapabilitiesFactory.IsAviSynthInstalled();
}
@@ -2,12 +2,11 @@
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<NoWarn>VSTHRD200</NoWarn>
<NoWarn>VSTHRD200,CA1873</NoWarn>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Bugsnag" Version="4.1.0" />
<PackageReference Include="CliWrap" Version="3.10.0" />
<PackageReference Include="LanguageExt.Core" Version="4.4.9" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.2" />
@@ -23,7 +22,7 @@
<PackageReference Include="Serilog.Extensions.Logging" Version="10.0.0" />
<PackageReference Include="Serilog.Sinks.Debug" Version="3.0.0" />
<PackageReference Include="Shouldly" Version="4.3.0" />
<PackageReference Include="Testably.Abstractions.Testing" Version="5.0.2" />
<PackageReference Include="Testably.Abstractions.Testing" Version="5.1.0" />
</ItemGroup>
<ItemGroup>
@@ -1,7 +1,5 @@
using Bugsnag;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Metadata;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
@@ -11,7 +9,7 @@ namespace ErsatzTV.Core.Tests.Metadata;
public class FallbackMetadataProviderTests
{
[SetUp]
public void SetUp() => _fallbackMetadataProvider = new FallbackMetadataProvider(Substitute.For<IClient>());
public void SetUp() => _fallbackMetadataProvider = new FallbackMetadataProvider();
private FallbackMetadataProvider _fallbackMetadataProvider;
@@ -799,5 +799,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
Random random,
CancellationToken cancellationToken) =>
throw new NotSupportedException();
protected override string SchedulingContextName => "Test";
}
}
@@ -1,4 +1,3 @@
using Bugsnag;
using Dapper;
using Destructurama;
using ErsatzTV.Core.Domain;
@@ -93,8 +92,6 @@ public class ScheduleIntegrationTests
services.AddSingleton<ISearchIndex, LuceneSearchIndex>();
services.AddSingleton<ILanguageCodeCache, LanguageCodeCache>();
services.AddSingleton(_ => Substitute.For<IClient>());
ServiceProvider provider = services.BuildServiceProvider();
IDbContextFactory<TvContext> factory = provider.GetRequiredService<IDbContextFactory<TvContext>>();
@@ -110,7 +107,6 @@ public class ScheduleIntegrationTests
await searchIndex.Initialize(
new LocalFileSystem(
new MockFileSystem(),
provider.GetRequiredService<IClient>(),
provider.GetRequiredService<ILogger<LocalFileSystem>>()),
provider.GetRequiredService<IConfigElementRepository>(),
_cancellationToken);
@@ -123,7 +119,7 @@ public class ScheduleIntegrationTests
var builder = new PlayoutBuilder(
new ConfigElementRepository(factory),
new MediaCollectionRepository(Substitute.For<IClient>(), searchIndex, factory),
new MediaCollectionRepository(searchIndex, factory),
new TelevisionRepository(factory, provider.GetRequiredService<ILogger<TelevisionRepository>>()),
new ArtistRepository(factory),
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(),
@@ -319,7 +315,7 @@ public class ScheduleIntegrationTests
var builder = new PlayoutBuilder(
new ConfigElementRepository(factory),
new MediaCollectionRepository(Substitute.For<IClient>(), Substitute.For<ISearchIndex>(), factory),
new MediaCollectionRepository(Substitute.For<ISearchIndex>(), factory),
new TelevisionRepository(factory, provider.GetRequiredService<ILogger<TelevisionRepository>>()),
new ArtistRepository(factory),
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(),
@@ -0,0 +1,132 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
using LanguageExt.UnsafeValueAccess;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Core.Tests.Scheduling;
[TestFixture]
public class ShuffleInOrderCollectionEnumeratorTests
{
[Test]
public void Should_Not_Repeat_Items_Until_Cycle_Complete()
{
var collections = new List<CollectionWithItems>
{
new(
0,
0,
"1",
Enumerable.Range(1, 10).Select(i => new Movie { Id = i, MovieMetadata = [] }).Cast<MediaItem>().ToList(),
true,
PlaybackOrder.ShuffleInOrder,
false),
new(
0,
0,
"2",
Enumerable.Range(11, 20).Select(i => new Movie { Id = i, MovieMetadata = [] }).Cast<MediaItem>().ToList(),
true,
PlaybackOrder.ShuffleInOrder,
false)
};
var state = new CollectionEnumeratorState { Seed = 1234, Index = 0 };
var enumerator = new ShuffleInOrderCollectionEnumerator(collections, state, false, CancellationToken.None);
var seenIds = new System.Collections.Generic.HashSet<int>();
for (int i = 0; i < 20; i++)
{
enumerator.Current.IsSome.ShouldBeTrue();
int id = enumerator.Current.ValueUnsafe().Id;
seenIds.ShouldNotContain(id, $"at index {i}");
seenIds.Add(id);
enumerator.MoveNext(Option<DateTimeOffset>.None);
}
seenIds.Count.ShouldBe(20);
}
[Test]
public void Should_Handle_Single_Collection()
{
var collections = new List<CollectionWithItems>
{
new(
0,
0,
"1",
Enumerable.Range(1, 10).Select(i => new Movie { Id = i, MovieMetadata = [] }).Cast<MediaItem>().ToList(),
true,
PlaybackOrder.ShuffleInOrder,
false)
};
var state = new CollectionEnumeratorState { Seed = 1234, Index = 0 };
var enumerator = new ShuffleInOrderCollectionEnumerator(collections, state, false, CancellationToken.None);
var seenIds = new List<int>();
for (int i = 0; i < 10; i++)
{
seenIds.Add(enumerator.Current.ValueUnsafe().Id);
enumerator.MoveNext(Option<DateTimeOffset>.None);
}
seenIds.Count.ShouldBe(10);
seenIds.ShouldBeInOrder(SortDirection.Ascending);
}
[Test]
public void Should_Reshuffle_After_Cycle()
{
var collections = new List<CollectionWithItems>
{
new(
0,
0,
"1",
Enumerable.Range(1, 10).Select(i => new Movie { Id = i, MovieMetadata = [] }).Cast<MediaItem>().ToList(),
true,
PlaybackOrder.ShuffleInOrder,
false)
};
var state = new CollectionEnumeratorState { Seed = 1234, Index = 0 };
var enumerator = new ShuffleInOrderCollectionEnumerator(collections, state, false, CancellationToken.None);
for (int i = 0; i < 10; i++)
{
enumerator.MoveNext(Option<DateTimeOffset>.None);
}
enumerator.State.Index.ShouldBe(0);
// Should have a new seed
enumerator.State.Seed.ShouldNotBe(1234);
}
[Test]
public void ResetState_Should_Update_Seed()
{
var collections = new List<CollectionWithItems>
{
new(
0,
0,
"1",
Enumerable.Range(1, 10).Select(i => new Movie { Id = i, MovieMetadata = [] }).Cast<MediaItem>().ToList(),
true,
PlaybackOrder.ShuffleInOrder,
false)
};
var state = new CollectionEnumeratorState { Seed = 1234, Index = 0 };
var enumerator = new ShuffleInOrderCollectionEnumerator(collections, state, false, CancellationToken.None);
var newState = new CollectionEnumeratorState { Seed = 5678, Index = 5 };
enumerator.ResetState(newState);
enumerator.State.Seed.ShouldBe(5678);
enumerator.State.Index.ShouldBe(5);
}
}
+1
View File
@@ -17,6 +17,7 @@ public class Channel
public string Categories { get; set; }
public int FFmpegProfileId { get; set; }
public FFmpegProfile FFmpegProfile { get; set; }
public double? SlugSeconds { get; set; }
public int? WatermarkId { get; set; }
public ChannelWatermark Watermark { get; set; }
public int? FallbackFillerId { get; set; }
+1
View File
@@ -32,6 +32,7 @@ public class ConfigElementKey
public static ConfigElementKey HDHRTunerCount => new("hdhr.tuner_count");
public static ConfigElementKey HDHRUUID => new("hdhr.uuid");
public static ConfigElementKey PagesIsDarkMode => new("pages.is_dark_mode");
public static ConfigElementKey PagesLanguage => new("pages.language");
public static ConfigElementKey ChannelsPageSize => new("pages.channels.page_size");
public static ConfigElementKey ChannelsShowDisabled => new("pages.channels.show_disabled");
public static ConfigElementKey CollectionsPageSize => new("pages.collections.page_size");
+7 -2
View File
@@ -37,6 +37,9 @@ public class PlayoutItem
public DateTimeOffset StartOffset => new DateTimeOffset(Start, TimeSpan.Zero).ToLocalTime();
public DateTimeOffset FinishOffset => new DateTimeOffset(Finish, TimeSpan.Zero).ToLocalTime();
// for troubleshooting
public string SchedulingContext { get; set; }
public DateTimeOffset? GuideFinishOffset => GuideFinish.HasValue
? new DateTimeOffset(GuideFinish.Value, TimeSpan.Zero).ToLocalTime()
: null;
@@ -81,7 +84,8 @@ public class PlayoutItem
CollectionKey = CollectionKey,
CollectionEtag = CollectionEtag,
PlayoutItemWatermarks = watermarksCopy,
PlayoutItemGraphicsElements = graphicsElementsCopy
PlayoutItemGraphicsElements = graphicsElementsCopy,
SchedulingContext = SchedulingContext
};
}
@@ -127,7 +131,8 @@ public class PlayoutItem
CollectionKey = CollectionKey,
CollectionEtag = CollectionEtag,
PlayoutItemWatermarks = watermarksCopy,
PlayoutItemGraphicsElements = graphicsElementsCopy
PlayoutItemGraphicsElements = graphicsElementsCopy,
SchedulingContext = SchedulingContext
};
}
+1 -2
View File
@@ -2,7 +2,7 @@
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<NoWarn>VSTHRD200</NoWarn>
<NoWarn>VSTHRD200,CA1873</NoWarn>
<ImplicitUsings>enable</ImplicitUsings>
<AnalysisLevel>latest-Recommended</AnalysisLevel>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
@@ -10,7 +10,6 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Bugsnag" Version="4.1.0" />
<PackageReference Include="Destructurama.Attributed" Version="5.2.0" />
<PackageReference Include="Flurl" Version="4.0.0" />
<PackageReference Include="Humanizer.Core" Version="3.0.1" />
@@ -233,6 +233,16 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
scanKind = await ProbeScanKind(ffmpegPath, videoVersion.MediaItem, cancellationToken);
}
HardwareAccelerationMode hwAccel = GetHardwareAccelerationMode(playbackSettings);
// QSV may have sync issues with h264 files that have multiple profiles
// check and flag here so software decoding can be used if needed
bool hasMultipleProfiles = false;
if (hwAccel is HardwareAccelerationMode.Qsv && videoStream.Codec is VideoFormat.H264)
{
hasMultipleProfiles = await ProbeHasMultipleProfiles(ffmpegPath, videoVersion.MediaItem, cancellationToken);
}
var ffmpegVideoStream = new VideoStream(
videoStream.Index,
videoStream.Codec,
@@ -248,7 +258,10 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
videoVersion.MediaVersion.DisplayAspectRatio,
new FrameRate(videoVersion.MediaVersion.RFrameRate),
videoPath != audioPath, // still image when paths are different
scanKind);
scanKind)
{
HasMultipleProfiles = hasMultipleProfiles
};
var videoInputFile = new VideoInputFile(
videoPath,
@@ -405,8 +418,6 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
graphicsElementContexts.AddRange(watermarks.Map(wm => new WatermarkElementContext(wm)));
}
HardwareAccelerationMode hwAccel = GetHardwareAccelerationMode(playbackSettings);
string videoFormat = GetVideoFormat(playbackSettings);
Option<string> maybeVideoProfile = GetVideoProfile(videoFormat, channel.FFmpegProfile.VideoProfile);
Option<string> maybeVideoPreset = GetVideoPreset(
@@ -538,7 +549,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
frameRate,
channelStartTime,
start,
await playbackSettings.StreamSeek.IfNoneAsync(TimeSpan.Zero),
now > start ? now - start : TimeSpan.Zero,
finish - now,
originalContentDuration);
@@ -576,7 +587,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
videoVersion.MediaVersion is BackgroundImageMediaVersion { IsSongWithProgress: true },
false,
GetTonemapAlgorithm(playbackSettings),
channel.Number == ".troubleshooting");
channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel);
_logger.LogDebug("FFmpeg desired state {FrameState}", desiredState);
@@ -593,7 +604,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
VaapiDeviceName(hwAccel, vaapiDevice),
await customReportsFolder.IfNoneAsync(FileSystemLayout.FFmpegReportsFolder),
FileSystemLayout.FontsCacheFolder,
ffmpegPath);
ffmpegPath,
cancellationToken);
FFmpegPipeline pipeline = pipelineBuilder.Build(ffmpegState, desiredState);
@@ -652,6 +664,19 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
return result;
}
private async Task<bool> ProbeHasMultipleProfiles(
string ffmpegPath,
MediaItem mediaItem,
CancellationToken cancellationToken)
{
_logger.LogDebug("Will probe for h264 profile count");
Option<int> profileCount =
await _localStatisticsProvider.GetProfileCount(ffmpegPath, mediaItem, cancellationToken);
return await profileCount.IfNoneAsync(1) > 1;
}
public async Task<Command> ForError(
string ffmpegPath,
Channel channel,
@@ -663,9 +688,10 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
string vaapiDisplay,
VaapiDriver vaapiDriver,
string vaapiDevice,
Option<int> qsvExtraHardwareFrames)
Option<int> qsvExtraHardwareFrames,
CancellationToken cancellationToken)
{
FFmpegPlaybackSettings playbackSettings = FFmpegPlaybackSettingsCalculator.CalculateErrorSettings(
FFmpegPlaybackSettings playbackSettings = FFmpegPlaybackSettingsCalculator.CalculateGeneratedImageSettings(
channel.StreamingMode,
channel.FFmpegProfile,
hlsRealtime);
@@ -726,57 +752,6 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
playbackSettings.NormalizeColors,
playbackSettings.Deinterlace);
OutputFormatKind outputFormat = OutputFormatKind.MpegTs;
switch (channel.StreamingMode)
{
case StreamingMode.HttpLiveStreamingSegmenter:
outputFormat = OutputFormatKind.Hls;
break;
}
Option<string> hlsPlaylistPath = outputFormat is OutputFormatKind.Hls or OutputFormatKind.HlsMp4
? Path.Combine(FileSystemLayout.TranscodeFolder, channel.Number, "live.m3u8")
: Option<string>.None;
long nowSeconds = now.ToUnixTimeSeconds();
Option<string> hlsSegmentTemplate = outputFormat switch
{
OutputFormatKind.Hls => Path.Combine(FileSystemLayout.TranscodeFolder, channel.Number, "live%06d.ts"),
OutputFormatKind.HlsMp4 => Path.Combine(
FileSystemLayout.TranscodeFolder,
channel.Number,
$"live_{nowSeconds}_%06d.m4s"),
_ => Option<string>.None
};
Option<string> hlsInitTemplate = outputFormat switch
{
OutputFormatKind.HlsMp4 => $"{nowSeconds}_init.mp4",
_ => Option<string>.None
};
Option<string> hlsSegmentOptions = Option<string>.None;
if (outputFormat is OutputFormatKind.Hls)
{
string options = string.Empty;
if (ptsOffset == TimeSpan.Zero)
{
options += "+initial_discontinuity";
}
if (audioFormat == AudioFormat.AacLatm)
{
options += "+latm";
}
if (!string.IsNullOrWhiteSpace(options))
{
hlsSegmentOptions = $"mpegts_flags={options}";
}
}
string videoPath = _localFileSystem.GetCustomOrDefaultFile(
FileSystemLayout.ResourcesCacheFolder,
"background.png");
@@ -802,8 +777,10 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
HardwareAccelerationMode hwAccel = GetHardwareAccelerationMode(playbackSettings);
_logger.LogDebug("HW accel mode: {HwAccel}", hwAccel);
var hlsOptions = GetHlsOptions(channel, now, ptsOffset, audioFormat);
var ffmpegState = new FFmpegState(
channel.Number == ".troubleshooting",
channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel,
HardwareAccelerationMode.None, // no hw accel decode since errors loop
hwAccel,
VaapiDriverName(hwAccel, vaapiDriver),
@@ -816,18 +793,18 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
None,
None,
None,
outputFormat,
hlsPlaylistPath,
hlsSegmentTemplate,
hlsInitTemplate,
hlsSegmentOptions,
hlsOptions.OutputFormat,
hlsOptions.HlsPlaylistPath,
hlsOptions.HlsSegmentTemplate,
hlsOptions.HlsInitTemplate,
hlsOptions.HlsSegmentOptions,
ptsOffset,
Option<int>.None,
qsvExtraHardwareFrames,
false,
false,
GetTonemapAlgorithm(playbackSettings),
channel.Number == ".troubleshooting");
channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel);
var ffmpegSubtitleStream = new ErsatzTV.FFmpeg.MediaStream(0, "ass", StreamKind.Video);
@@ -851,11 +828,144 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
VaapiDisplayName(hwAccel, vaapiDisplay),
VaapiDriverName(hwAccel, vaapiDriver),
VaapiDeviceName(hwAccel, vaapiDevice),
channel.Number == ".troubleshooting"
channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel
? FileSystemLayout.TranscodeTroubleshootingFolder
: FileSystemLayout.FFmpegReportsFolder,
FileSystemLayout.FontsCacheFolder,
ffmpegPath);
ffmpegPath,
cancellationToken);
FFmpegPipeline pipeline = pipelineBuilder.Build(ffmpegState, desiredState);
return GetCommand(ffmpegPath, videoInputFile, audioInputFile, None, None, None, pipeline);
}
public async Task<Command> Slug(
string ffmpegPath,
Channel channel,
DateTimeOffset now,
TimeSpan duration,
bool hlsRealtime,
TimeSpan ptsOffset,
CancellationToken cancellationToken)
{
FFmpegPlaybackSettings playbackSettings = FFmpegPlaybackSettingsCalculator.CalculateGeneratedImageSettings(
channel.StreamingMode,
channel.FFmpegProfile,
hlsRealtime);
Resolution desiredResolution = channel.FFmpegProfile.Resolution;
string audioFormat = playbackSettings.AudioFormat switch
{
FFmpegProfileAudioFormat.Ac3 => AudioFormat.Ac3,
FFmpegProfileAudioFormat.AacLatm => AudioFormat.AacLatm,
_ => AudioFormat.Aac
};
var audioState = new AudioState(
audioFormat,
playbackSettings.AudioChannels,
playbackSettings.AudioBitrate,
playbackSettings.AudioBufferSize,
playbackSettings.AudioSampleRate,
false,
AudioFilter.None,
playbackSettings.TargetLoudness);
string videoFormat = GetVideoFormat(playbackSettings);
var desiredState = new FrameState(
playbackSettings.RealtimeOutput,
InfiniteLoop: false,
videoFormat,
GetVideoProfile(videoFormat, channel.FFmpegProfile.VideoProfile),
VideoPreset.Unset,
channel.FFmpegProfile.AllowBFrames,
new PixelFormatYuv420P(),
new FrameSize(desiredResolution.Width, desiredResolution.Height),
new FrameSize(desiredResolution.Width, desiredResolution.Height),
Option<FrameSize>.None,
channel.FFmpegProfile.PadMode is FilterMode.HardwareIfPossible
? FFmpegFilterMode.HardwareIfPossible
: FFmpegFilterMode.Software,
IsAnamorphic: false,
Option<FrameRate>.None,
playbackSettings.VideoBitrate,
playbackSettings.VideoBufferSize,
playbackSettings.VideoTrackTimeScale,
playbackSettings.NormalizeColors,
playbackSettings.Deinterlace);
var frameRate = await playbackSettings.FrameRate.IfNoneAsync(new FrameRate("24"));
var ffmpegVideoStream = new VideoStream(
0,
VideoFormat.GeneratedImage,
string.Empty,
new PixelFormatUnknown(), // leave this unknown so we convert to desired yuv420p
ColorParams.Default,
desiredState.PaddedSize,
MaybeSampleAspectRatio: "1:1",
DisplayAspectRatio: string.Empty,
frameRate,
true,
ScanKind.Progressive);
var videoInputFile = new LavfiInputFile(
$"color=c=black:s={desiredState.PaddedSize.Width}x{desiredState.PaddedSize.Height}:r={frameRate.FrameRateString}:d={duration.TotalSeconds}",
ffmpegVideoStream);
HardwareAccelerationMode hwAccel = GetHardwareAccelerationMode(playbackSettings);
var hlsOptions = GetHlsOptions(channel, now, ptsOffset, audioFormat);
var ffmpegState = new FFmpegState(
channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel,
HardwareAccelerationMode.None, // no hw accel decode since errors loop
hwAccel,
VaapiDriverName(hwAccel, channel.FFmpegProfile.VaapiDriver),
VaapiDeviceName(hwAccel, channel.FFmpegProfile.VaapiDevice),
playbackSettings.StreamSeek,
duration,
channel.StreamingMode != StreamingMode.HttpLiveStreamingDirect,
"ErsatzTV",
channel.Name,
None,
None,
None,
hlsOptions.OutputFormat,
hlsOptions.HlsPlaylistPath,
hlsOptions.HlsSegmentTemplate,
hlsOptions.HlsInitTemplate,
hlsOptions.HlsSegmentOptions,
ptsOffset,
Option<int>.None,
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames),
false,
false,
GetTonemapAlgorithm(playbackSettings),
channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel);
var audioInputFile = new NullAudioInputFile(audioState);
IPipelineBuilder pipelineBuilder = await _pipelineBuilderFactory.GetBuilder(
hwAccel,
videoInputFile,
audioInputFile,
Option<WatermarkInputFile>.None,
Option<SubtitleInputFile>.None,
Option<ConcatInputFile>.None,
Option<GraphicsEngineInput>.None,
VaapiDisplayName(hwAccel, channel.FFmpegProfile.VaapiDisplay),
VaapiDriverName(hwAccel, channel.FFmpegProfile.VaapiDriver),
VaapiDeviceName(hwAccel, channel.FFmpegProfile.VaapiDevice),
channel.Number == FileSystemLayout.TranscodeTroubleshootingChannel
? FileSystemLayout.TranscodeTroubleshootingFolder
: FileSystemLayout.FFmpegReportsFolder,
FileSystemLayout.FontsCacheFolder,
ffmpegPath,
cancellationToken);
FFmpegPipeline pipeline = pipelineBuilder.Build(ffmpegState, desiredState);
@@ -867,7 +977,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
bool saveReports,
Channel channel,
string scheme,
string host)
string host,
CancellationToken cancellationToken)
{
var resolution = new FrameSize(channel.FFmpegProfile.Resolution.Width, channel.FFmpegProfile.Resolution.Height);
@@ -888,7 +999,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
Option<string>.None,
FileSystemLayout.FFmpegReportsFolder,
FileSystemLayout.FontsCacheFolder,
ffmpegPath);
ffmpegPath,
cancellationToken);
FFmpegPipeline pipeline = pipelineBuilder.Concat(
concatInputFile,
@@ -958,7 +1070,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
Option<string>.None,
FileSystemLayout.FFmpegReportsFolder,
FileSystemLayout.FontsCacheFolder,
ffmpegPath);
ffmpegPath,
cancellationToken);
FFmpegPipeline pipeline = pipelineBuilder.WrapSegmenter(
concatInputFile,
@@ -967,7 +1080,12 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
return GetCommand(ffmpegPath, None, None, None, concatInputFile, None, pipeline);
}
public async Task<Command> ResizeImage(string ffmpegPath, string inputFile, string outputFile, int height)
public async Task<Command> ResizeImage(
string ffmpegPath,
string inputFile,
string outputFile,
int height,
CancellationToken cancellationToken)
{
var videoInputFile = new VideoInputFile(
inputFile,
@@ -1000,7 +1118,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
Option<string>.None,
FileSystemLayout.FFmpegReportsFolder,
FileSystemLayout.FontsCacheFolder,
ffmpegPath);
ffmpegPath,
cancellationToken);
FFmpegPipeline pipeline = pipelineBuilder.Resize(outputFile, new FrameSize(-1, height));
@@ -1036,7 +1155,12 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
watermarkWidthPercent,
cancellationToken);
public async Task<Command> SeekTextSubtitle(string ffmpegPath, string inputFile, string codec, TimeSpan seek)
public async Task<Command> SeekTextSubtitle(
string ffmpegPath,
string inputFile,
string codec,
TimeSpan seek,
CancellationToken cancellationToken)
{
var videoInputFile = new VideoInputFile(
inputFile,
@@ -1069,7 +1193,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
Option<string>.None,
FileSystemLayout.FFmpegReportsFolder,
FileSystemLayout.FontsCacheFolder,
ffmpegPath);
ffmpegPath,
cancellationToken);
FFmpegPipeline pipeline = pipelineBuilder.Seek(inputFile, codec, seek);
@@ -1260,4 +1385,67 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
return false;
}
private static HlsOptions GetHlsOptions(Channel channel, DateTimeOffset now, TimeSpan ptsOffset, string audioFormat)
{
OutputFormatKind outputFormat = OutputFormatKind.MpegTs;
switch (channel.StreamingMode)
{
case StreamingMode.HttpLiveStreamingSegmenter:
outputFormat = OutputFormatKind.Hls;
break;
}
Option<string> hlsPlaylistPath = outputFormat is OutputFormatKind.Hls or OutputFormatKind.HlsMp4
? Path.Combine(FileSystemLayout.TranscodeFolder, channel.Number, "live.m3u8")
: Option<string>.None;
long nowSeconds = now.ToUnixTimeSeconds();
Option<string> hlsSegmentTemplate = outputFormat switch
{
OutputFormatKind.Hls => Path.Combine(FileSystemLayout.TranscodeFolder, channel.Number, "live%06d.ts"),
OutputFormatKind.HlsMp4 => Path.Combine(
FileSystemLayout.TranscodeFolder,
channel.Number,
$"live_{nowSeconds}_%06d.m4s"),
_ => Option<string>.None
};
Option<string> hlsInitTemplate = outputFormat switch
{
OutputFormatKind.HlsMp4 => $"{nowSeconds}_init.mp4",
_ => Option<string>.None
};
Option<string> hlsSegmentOptions = Option<string>.None;
if (outputFormat is OutputFormatKind.Hls)
{
string options = string.Empty;
if (ptsOffset == TimeSpan.Zero)
{
options += "+initial_discontinuity";
}
if (audioFormat == AudioFormat.AacLatm)
{
options += "+latm";
}
if (!string.IsNullOrWhiteSpace(options))
{
hlsSegmentOptions = $"mpegts_flags={options}";
}
}
return new HlsOptions(outputFormat, hlsPlaylistPath, hlsSegmentTemplate, hlsInitTemplate, hlsSegmentOptions);
}
private sealed record HlsOptions(
OutputFormatKind OutputFormat,
Option<string> HlsPlaylistPath,
Option<string> HlsSegmentTemplate,
Option<string> HlsInitTemplate,
Option<string> HlsSegmentOptions);
}
@@ -181,7 +181,7 @@ public static class FFmpegPlaybackSettingsCalculator
return result;
}
public static FFmpegPlaybackSettings CalculateErrorSettings(
public static FFmpegPlaybackSettings CalculateGeneratedImageSettings(
StreamingMode streamingMode,
FFmpegProfile ffmpegProfile,
bool hlsRealtime) =>
+1 -6
View File
@@ -1,5 +1,4 @@
using System.Diagnostics;
using Bugsnag;
using CliWrap;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
@@ -12,7 +11,6 @@ namespace ErsatzTV.Core.FFmpeg;
public class FFmpegProcessService
{
private readonly IClient _client;
private readonly IFFmpegStreamSelector _ffmpegStreamSelector;
private readonly ILogger<FFmpegProcessService> _logger;
private readonly ITempFilePool _tempFilePool;
@@ -20,12 +18,10 @@ public class FFmpegProcessService
public FFmpegProcessService(
IFFmpegStreamSelector ffmpegStreamSelector,
ITempFilePool tempFilePool,
IClient client,
ILogger<FFmpegProcessService> logger)
{
_ffmpegStreamSelector = ffmpegStreamSelector;
_tempFilePool = tempFilePool;
_client = client;
_logger = logger;
}
@@ -71,7 +67,7 @@ public class FFmpegProcessService
}
FFmpegPlaybackSettings playbackSettings =
FFmpegPlaybackSettingsCalculator.CalculateErrorSettings(
FFmpegPlaybackSettingsCalculator.CalculateGeneratedImageSettings(
StreamingMode.TransportStream,
channel.FFmpegProfile,
false);
@@ -131,7 +127,6 @@ public class FFmpegProcessService
catch (Exception ex)
{
_logger.LogWarning(ex, "Error generating song image");
_client.Notify(ex);
return Left(BaseError.New(ex.Message));
}
}
+55
View File
@@ -0,0 +1,55 @@
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Core.FFmpeg;
public partial class FFmpegProgress
{
public Option<double> Speed { get; private set; } = Option<double>.None;
public void ParseLine(string line)
{
Match match = FFmpegSpeed().Match(line);
if (match.Success && double.TryParse(match.Groups[1].Value, out double speed))
{
Speed = speed;
}
}
public void LogSpeed(Option<int> mediaItemId, bool isWorkingAhead, string channelNumber, ILogger logger)
{
foreach (double speed in Speed)
{
if (isWorkingAhead)
{
if (speed < 1.0)
{
logger.LogCritical(
"Media item {MediaItemId} on channel {Channel} transcoded at {Speed}x (NOT throttled) which is NOT fast enough to support playback",
mediaItemId,
channelNumber,
speed);
}
else if (speed <= 1.5)
{
logger.LogWarning(
"Media item {MediaItemId} on channel {Channel} transcoded at {Speed}x (NOT throttled) which may not be fast enough to support playback",
mediaItemId,
channelNumber,
speed);
}
}
else if (speed < 0.99)
{
logger.LogWarning(
"Media item {MediaItemId} on channel {Channel} transcoded at {Speed}x (throttled) which may not be fast enough to support playback",
mediaItemId,
channelNumber,
speed);
}
}
}
[GeneratedRegex(@"speed=\s*([\d\.]+)x", RegexOptions.IgnoreCase)]
private static partial Regex FFmpegSpeed();
}
+3 -1
View File
@@ -8,6 +8,7 @@ public static class FileSystemLayout
public static readonly string AppDataFolder;
public static readonly string TranscodeFolder;
public static readonly string TranscodeTroubleshootingChannel;
public static readonly string TranscodeTroubleshootingFolder;
public static readonly string DataProtectionFolder;
@@ -132,7 +133,8 @@ public static class FileSystemLayout
}
TranscodeFolder = useCustomTranscodeFolder ? customTranscodeFolder : defaultTranscodeFolder;
TranscodeTroubleshootingFolder = Path.Combine(TranscodeFolder, ".troubleshooting");
TranscodeTroubleshootingChannel = ".troubleshooting";
TranscodeTroubleshootingFolder = Path.Combine(TranscodeFolder, TranscodeTroubleshootingChannel);
DataProtectionFolder = Path.Combine(AppDataFolder, "data-protection");
LogsFolder = Path.Combine(AppDataFolder, "logs");
@@ -1,5 +0,0 @@
namespace ErsatzTV.Core.Health.Checks;
public interface IErrorReportsHealthCheck : IHealthCheck
{
}
@@ -56,9 +56,25 @@ public interface IFFmpegProcessService
string vaapiDisplay,
VaapiDriver vaapiDriver,
string vaapiDevice,
Option<int> qsvExtraHardwareFrames);
Option<int> qsvExtraHardwareFrames,
CancellationToken cancellationToken);
Task<Command> ConcatChannel(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host);
Task<Command> Slug(
string ffmpegPath,
Channel channel,
DateTimeOffset now,
TimeSpan duration,
bool hlsRealtime,
TimeSpan ptsOffset,
CancellationToken cancellationToken);
Task<Command> ConcatChannel(
string ffmpegPath,
bool saveReports,
Channel channel,
string scheme,
string host,
CancellationToken cancellationToken);
Task<Command> WrapSegmenter(
string ffmpegPath,
@@ -69,7 +85,12 @@ public interface IFFmpegProcessService
string accessToken,
CancellationToken cancellationToken);
Task<Command> ResizeImage(string ffmpegPath, string inputFile, string outputFile, int height);
Task<Command> ResizeImage(
string ffmpegPath,
string inputFile,
string outputFile,
int height,
CancellationToken cancellationToken);
Task<Either<BaseError, string>> GenerateSongImage(
string ffmpegPath,
@@ -86,5 +107,10 @@ public interface IFFmpegProcessService
int watermarkWidthPercent,
CancellationToken cancellationToken);
Task<Command> SeekTextSubtitle(string ffmpegPath, string inputFile, string codec, TimeSpan seek);
Task<Command> SeekTextSubtitle(
string ffmpegPath,
string inputFile,
string codec,
TimeSpan seek,
CancellationToken cancellationToken);
}
@@ -5,69 +5,69 @@ namespace ErsatzTV.Core.Interfaces.Jellyfin;
public interface IJellyfinApiClient
{
Task<Either<BaseError, JellyfinServerInformation>> GetServerInformation(string address, string apiKey);
Task<Either<BaseError, List<JellyfinLibrary>>> GetLibraries(string address, string apiKey);
Task<Either<BaseError, JellyfinServerInformation>> GetServerInformation(string address, string authorizationHeader);
Task<Either<BaseError, List<JellyfinLibrary>>> GetLibraries(string address, string authorizationHeader);
IAsyncEnumerable<Tuple<JellyfinMovie, int>> GetMovieLibraryItems(
string address,
string apiKey,
string authorizationHeader,
JellyfinLibrary library);
IAsyncEnumerable<Tuple<JellyfinShow, int>> GetShowLibraryItemsWithoutPeople(
string address,
string apiKey,
string authorizationHeader,
JellyfinLibrary library);
IAsyncEnumerable<Tuple<JellyfinSeason, int>> GetSeasonLibraryItems(
string address,
string apiKey,
string authorizationHeader,
JellyfinLibrary library,
string showId);
IAsyncEnumerable<Tuple<JellyfinEpisode, int>> GetEpisodeLibraryItems(
string address,
string apiKey,
string authorizationHeader,
JellyfinLibrary library,
string seasonId);
IAsyncEnumerable<Tuple<JellyfinEpisode, int>> GetEpisodeLibraryItemsWithoutPeople(
string address,
string apiKey,
string authorizationHeader,
JellyfinLibrary library,
string seasonId);
IAsyncEnumerable<Tuple<JellyfinCollection, int>> GetCollectionLibraryItems(
string address,
string apiKey,
string authorizationHeader,
int mediaSourceId);
IAsyncEnumerable<Tuple<MediaItem, int>> GetCollectionItems(
string address,
string apiKey,
string authorizationHeader,
int mediaSourceId,
string collectionId);
Task<Either<BaseError, MediaVersion>> GetPlaybackInfo(
string address,
string apiKey,
string authorizationHeader,
JellyfinLibrary library,
string itemId);
Task<Either<BaseError, Option<JellyfinShow>>> GetSingleShow(
string address,
string apiKey,
string authorizationHeader,
JellyfinLibrary library,
string showId);
Task<Either<BaseError, List<JellyfinShow>>> SearchShowsByTitle(
string address,
string apiKey,
string authorizationHeader,
JellyfinLibrary library,
string showTitle);
Task<Either<BaseError, Option<JellyfinEpisode>>> GetSingleEpisode(
string address,
string apiKey,
string authorizationHeader,
JellyfinLibrary library,
string seasonId,
string episodeId);
@@ -2,5 +2,9 @@
public interface IJellyfinCollectionScanner
{
Task<Either<BaseError, Unit>> ScanCollections(string address, string apiKey, int mediaSourceId, bool deepScan);
Task<Either<BaseError, Unit>> ScanCollections(
string address,
string authorizationHeader,
int mediaSourceId,
bool deepScan);
}
@@ -1,12 +1,12 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Jellyfin;
namespace ErsatzTV.Core.Interfaces.Jellyfin;
public interface IJellyfinMovieLibraryScanner
{
Task<Either<BaseError, Unit>> ScanLibrary(
string address,
string apiKey,
JellyfinConnectionParameters connectionParameters,
JellyfinLibrary library,
bool deepScan,
CancellationToken cancellationToken);
@@ -1,19 +1,18 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Jellyfin;
namespace ErsatzTV.Core.Interfaces.Jellyfin;
public interface IJellyfinTelevisionLibraryScanner
{
Task<Either<BaseError, Unit>> ScanLibrary(
string address,
string apiKey,
JellyfinConnectionParameters connectionParameters,
JellyfinLibrary library,
bool deepScan,
CancellationToken cancellationToken);
Task<Either<BaseError, Unit>> ScanSingleShow(
string address,
string apiKey,
JellyfinConnectionParameters connectionParameters,
JellyfinLibrary library,
string showId,
string showTitle,
@@ -14,4 +14,9 @@ public interface ILocalStatisticsProvider
string ffmpegPath,
MediaItem mediaItem,
CancellationToken cancellationToken);
Task<Option<int>> GetProfileCount(
string ffmpegPath,
MediaItem mediaItem,
CancellationToken cancellationToken);
}
@@ -4,6 +4,7 @@ namespace ErsatzTV.Core.Interfaces.Scheduling;
public interface IMediaCollectionEnumerator
{
string SchedulingContextName { get; }
CollectionEnumeratorState State { get; }
Option<MediaItem> Current { get; }
Option<bool> CurrentIncludeInProgramGuide { get; }
@@ -1,5 +1,4 @@
using Bugsnag;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Search;
@@ -38,7 +37,6 @@ public interface ISearchIndex : IDisposable
Task<bool> RemoveItems(IEnumerable<int> ids);
Task<SearchResult> Search(
IClient client,
string query,
string smartCollectionName,
int skip,
@@ -46,7 +44,6 @@ public interface ISearchIndex : IDisposable
CancellationToken cancellationToken);
Task<SearchResult> Search(
IClient client,
string query,
string smartCollectionName,
int skip,
+4 -1
View File
@@ -17,6 +17,9 @@ public static class ChannelIdentifier
number /= 10;
}
return $"C{channelNumber}.{id}.ersatztv.org";
string instanceId = SystemEnvironment.InstanceId;
return !string.IsNullOrWhiteSpace(instanceId)
? $"C{channelNumber}.{id}.{instanceId}.ersatztv.org"
: $"C{channelNumber}.{id}.ersatztv.org";
}
}
@@ -3,4 +3,7 @@
namespace ErsatzTV.Core.Jellyfin;
public record JellyfinConnectionParameters(string Address, string ApiKey, int MediaSourceId)
: MediaServerConnectionParameters;
: MediaServerConnectionParameters
{
public string AuthorizationHeader => $"MediaBrowser Token={ApiKey}";
}
@@ -1,12 +1,11 @@
using System.Globalization;
using System.Text.RegularExpressions;
using Bugsnag;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
namespace ErsatzTV.Core.Metadata;
public partial class FallbackMetadataProvider(IClient client) : IFallbackMetadataProvider
public partial class FallbackMetadataProvider : IFallbackMetadataProvider
{
private static readonly Regex SeasonPattern = SeasonNumber();
@@ -203,7 +202,7 @@ public partial class FallbackMetadataProvider(IClient client) : IFallbackMetadat
[GeneratedRegex(@"s(?:eason)?\s?(\d+)(?![e\d])", RegexOptions.IgnoreCase)]
private static partial Regex SeasonNumber();
private List<EpisodeMetadata> GetEpisodeMetadata(string fileName, EpisodeMetadata baseMetadata)
private static List<EpisodeMetadata> GetEpisodeMetadata(string fileName, EpisodeMetadata baseMetadata)
{
var result = new List<EpisodeMetadata> { baseMetadata };
@@ -260,15 +259,15 @@ public partial class FallbackMetadataProvider(IClient client) : IFallbackMetadat
}
}
}
catch (Exception ex)
catch (Exception)
{
client.Notify(ex);
// do nothing
}
return result;
}
private MovieMetadata GetMovieMetadata(string fileName, MovieMetadata metadata)
private static MovieMetadata GetMovieMetadata(string fileName, MovieMetadata metadata)
{
try
{
@@ -283,15 +282,15 @@ public partial class FallbackMetadataProvider(IClient client) : IFallbackMetadat
metadata.DateUpdated = DateTime.UtcNow;
}
}
catch (Exception ex)
catch (Exception)
{
client.Notify(ex);
// do nothing
}
return metadata;
}
private Option<MusicVideoMetadata> GetMusicVideoMetadata(string fileName, MusicVideoMetadata metadata)
private static Option<MusicVideoMetadata> GetMusicVideoMetadata(string fileName, MusicVideoMetadata metadata)
{
try
{
@@ -310,14 +309,13 @@ public partial class FallbackMetadataProvider(IClient client) : IFallbackMetadat
return metadata;
}
catch (Exception ex)
catch (Exception)
{
client.Notify(ex);
return None;
}
}
private Option<OtherVideoMetadata> GetOtherVideoMetadata(string path, OtherVideoMetadata metadata)
private static Option<OtherVideoMetadata> GetOtherVideoMetadata(string path, OtherVideoMetadata metadata)
{
try
{
@@ -348,14 +346,13 @@ public partial class FallbackMetadataProvider(IClient client) : IFallbackMetadat
return metadata;
}
catch (Exception ex)
catch (Exception)
{
client.Notify(ex);
return None;
}
}
private Option<ImageMetadata> GetImageMetadata(string path, ImageMetadata metadata)
private static Option<ImageMetadata> GetImageMetadata(string path, ImageMetadata metadata)
{
try
{
@@ -386,14 +383,13 @@ public partial class FallbackMetadataProvider(IClient client) : IFallbackMetadat
return metadata;
}
catch (Exception ex)
catch (Exception)
{
client.Notify(ex);
return None;
}
}
private Option<RemoteStreamMetadata> GetRemoteStreamMetadata(string path, RemoteStreamMetadata metadata)
private static Option<RemoteStreamMetadata> GetRemoteStreamMetadata(string path, RemoteStreamMetadata metadata)
{
try
{
@@ -424,14 +420,13 @@ public partial class FallbackMetadataProvider(IClient client) : IFallbackMetadat
return metadata;
}
catch (Exception ex)
catch (Exception)
{
client.Notify(ex);
return None;
}
}
private Option<SongMetadata> GetSongMetadata(string path, SongMetadata metadata)
private static Option<SongMetadata> GetSongMetadata(string path, SongMetadata metadata)
{
try
{
@@ -462,14 +457,13 @@ public partial class FallbackMetadataProvider(IClient client) : IFallbackMetadat
return metadata;
}
catch (Exception ex)
catch (Exception)
{
client.Notify(ex);
return None;
}
}
private ShowMetadata GetTelevisionShowMetadata(string fileName, ShowMetadata metadata)
private static ShowMetadata GetTelevisionShowMetadata(string fileName, ShowMetadata metadata)
{
try
{
@@ -484,9 +478,9 @@ public partial class FallbackMetadataProvider(IClient client) : IFallbackMetadat
metadata.DateUpdated = DateTime.UtcNow;
}
}
catch (Exception ex)
catch (Exception)
{
client.Notify(ex);
// do nothing
}
return metadata;
+5 -11
View File
@@ -1,13 +1,12 @@
using System.Diagnostics.CodeAnalysis;
using System.IO.Abstractions;
using System.Security.Cryptography;
using Bugsnag;
using ErsatzTV.Core.Interfaces.Metadata;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Core.Metadata;
public class LocalFileSystem(IFileSystem fileSystem, IClient client, ILogger<LocalFileSystem> logger) : ILocalFileSystem
public class LocalFileSystem(IFileSystem fileSystem, ILogger<LocalFileSystem> logger) : ILocalFileSystem
{
public Unit EnsureFolderExists(string folder)
{
@@ -50,10 +49,9 @@ public class LocalFileSystem(IFileSystem fileSystem, IClient client, ILogger<Loc
{
logger.LogWarning("Unauthorized access exception listing subdirectories of folder {Folder}", folder);
}
catch (Exception ex)
catch (Exception)
{
// do nothing
client.Notify(ex);
}
}
@@ -73,10 +71,9 @@ public class LocalFileSystem(IFileSystem fileSystem, IClient client, ILogger<Loc
{
logger.LogWarning("Unauthorized access exception listing files in folder {Folder}", folder);
}
catch (Exception ex)
catch (Exception)
{
// do nothing
client.Notify(ex);
}
}
@@ -96,10 +93,9 @@ public class LocalFileSystem(IFileSystem fileSystem, IClient client, ILogger<Loc
{
logger.LogWarning("Unauthorized access exception listing files in folder {Folder}", folder);
}
catch (Exception ex)
catch (Exception)
{
// do nothing
client.Notify(ex);
}
}
@@ -123,10 +119,9 @@ public class LocalFileSystem(IFileSystem fileSystem, IClient client, ILogger<Loc
{
logger.LogWarning("Unauthorized access exception listing files in folder {Folder}", folder);
}
catch (Exception ex)
catch (Exception)
{
// do nothing
client.Notify(ex);
}
}
@@ -151,7 +146,6 @@ public class LocalFileSystem(IFileSystem fileSystem, IClient client, ILogger<Loc
}
catch (Exception ex)
{
client.Notify(ex);
return BaseError.New(ex.ToString());
}
}

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