Compare commits

..
Author SHA1 Message Date
Jason Dove 97725ac92d add small hdr test file 2025-06-14 10:04:03 -05:00
Jason DoveandGitHub 6b90da8982 add pad_vaapi filter (#2036) 2025-06-14 13:36:24 +00:00
Jason DoveandGitHub 1184dc565c use ffmpeg 7.1.1 for base, arm, arm64 docker (#2035)
* use ffmpeg 7.1.1 for base, arm, arm64 docker

* keep newline
2025-06-14 11:16:30 +00:00
Jason DoveandGitHub d82ccf8fb5 use hardware-accelerated tonemapping with qsv (#2034)
* add tonemap for qsv

* update changelog
2025-06-14 01:19:35 +00:00
Jason DoveandGitHub 4d83cc705f use ffmpeg 7.1.1 for vaapi docker (#2033) 2025-06-14 00:34:17 +00:00
Jason DoveandGitHub f80addacba use the clip algorithm for software tonemapping (#2032) 2025-06-13 20:12:31 +00:00
Jason DoveandGitHub 18c2a816dc use ffmpeg 7.1.1 on windows (#2031) 2025-06-13 13:36:19 +00:00
Jason DoveandGitHub 4f085c1950 fix detecting nvidia capabilities on blackwell gpus (#2030) 2025-06-13 03:14:22 +00:00
Jason DoveandGitHub dad0662fa6 use libplacebo to tonemap with nvidia/vulkan (#2029) 2025-06-12 20:49:06 +00:00
Jason DoveandGitHub dfdfa6f349 use hardware-accelerated tonemapping with vaapi (#2028)
* add tonemap_vaapi filter

* let vaapi pipeline handle hdr content

* use tonemap_opencl with vaapi

* update changelog
2025-06-12 16:13:43 +00:00
Jason DoveandGitHub 5fe3e97b31 add software tonemap filter to support hdr content (#2027) 2025-06-12 14:47:59 +00:00
Jason DoveandGitHub da1cfab5f4 remove jellyfin admin user id requirement (#2025) 2025-06-09 13:33:47 +00:00
Jason DoveandGitHub 6d152e4b4a use more accurate BANDWIDTH value in multi-variant playlist (#2023) 2025-06-07 20:45:07 +00:00
Jason DoveandGitHub 2ca722523b improvements to plex connection management (#2020) 2025-05-31 18:44:55 +00:00
Jason DoveandGitHub d9a3496bf5 add fixed start time behavior option to schedules and schedule items (#2017) 2025-05-30 09:30:09 +00:00
Jason DoveandGitHub c8f5b51d93 use cache busting to avoid ui errors after upgrading mudblazor (#2016) 2025-05-30 00:22:49 +00:00
Jason DoveandGitHub 956734ce39 globalization fixes (#2014)
* fix crashes caused by decimal separator

* improvements to playout reset ui

* remove code quality workflow
2025-05-26 16:32:25 +00:00
Jason DoveandGitHub e44a391f00 fix navigation when using ETV_BASE_URL (#2013) 2025-05-25 15:29:36 +00:00
Jason DoveandGitHub b43b66dd35 start to make ui minimally responsive (#2004) 2025-04-28 21:39:48 +00:00
Jason DoveandGitHub 140a663da4 update dependencies (#2003)
* update dependencies

* more dependency updates
2025-04-28 17:20:45 +00:00
Jason Dove 876d79c11c update changelog [no ci] 2025-04-22 15:34:28 -05:00
Jason DoveandGitHub 57a4480c3f try to push docker images to ghcr.io (#2000) 2025-04-22 20:24:14 +00:00
Jason DoveandGitHub 8f1b57eb88 another attempt at fixing separate ports behind reverse proxy (#1994) 2025-04-14 18:53:25 +00:00
Jason DoveandGitHub 70472ac84e add public port env vars; allow streaming through ui port (#1993) 2025-04-14 18:21:05 +00:00
Jason Dove 6aab8f53b8 fix changelog [no ci] 2025-04-14 11:57:01 -05:00
Jason DoveandGitHub b30b458574 allow ui and streaming to run on different ports (#1992)
* allow ui and streaming to run on different ports

* revert global.json change
2025-04-14 16:48:19 +00:00
Jason DoveandGitHub 6fe6382485 add remote ip and user agent to http request logging (#1990) 2025-04-07 18:57:08 +00:00
Jason DoveandGitHub 100ae0adda add api endpoint to empty trash (#1988) 2025-04-06 14:26:40 +00:00
Jason DoveandGitHub f0d5200843 bump image sharp lib to fix build (#1986) 2025-04-04 02:48:12 +00:00
Jason DoveandGitHub af36218cc2 add linux-musl-x64 artifact (#1985) 2025-04-04 02:45:09 +00:00
Jason DoveandGitHub eca62e8bec fix error synchronizing collections from plex server that has zero collections (#1964) 2025-01-15 21:42:22 -06:00
Jason Dove 03d4ab8c72 no longer mark releases as pre-release [no ci] 2025-01-10 12:26:53 -06:00
Jason Dove 57b590cd0f prep for release v25.1.0 [no ci] 2025-01-10 12:12:39 -06:00
Jason DoveandGitHub f0a5d89f73 fix song progress with 4:3 resolutions (#1961) 2024-12-27 16:09:58 -06:00
Jason DoveandGitHub 17a77694a0 add tag_full field to search index (#1956) 2024-12-09 13:55:47 -06:00
Jason DoveandGitHub 838c2a1661 fix default song background for 4:3 resolutions (#1951)
* fix default song background for 4:3 resolutions

* use 16:9 progress overlay for now
2024-11-28 08:56:38 -06:00
Jason DoveandGitHub 375a306edc fix vaapi capabilities detection when display is not drm (#1949)
* fix vainfo device syntax

* update logging

* don't pass vaapi_device when display is not drm

* fix hw accel

* proper accel fix

* Revert "proper accel fix"

This reverts commit 5dbadb1f79.

* Revert "fix hw accel"

This reverts commit b6b132d7b9.

* Revert "don't pass vaapi_device when display is not drm"

This reverts commit 0459210718.

* still show vaapi device
2024-11-25 14:27:21 -06:00
Jason DoveandGitHub 759052c725 add vaapi display option to ffmpeg profile (#1948)
* add vaapi display option

* fix vaapi capabilities cache key

* update logging
2024-11-25 13:14:21 -06:00
Jason DoveandGitHub dc112f0c7d add default song album art; adjust default blurhashes (#1946)
* fix song progress on white backgrounds

* remove yellow from default song backgrounds

* add default album art for generated song images

* update changelog
2024-11-20 22:27:09 -06:00
Jason DoveandGitHub c8ec87b01f add optional progress bar to generated song videos (#1945)
* optionally include progress bar in generated song video

* update progress bar size/location

* move everything up 10% when song progress is enabled

* add watermark border to song progress bar

* always show accurate progress bar

* lower progress bar to 90% alpha

* update changelog
2024-11-20 15:57:00 -06:00
Jason DoveandGitHub fbb7a661fb fix guide group generation for duration yaml instruction (#1943) 2024-11-19 06:26:19 -06:00
Jason DoveandGitHub 37ceac5651 add advance option to epg_group yaml playout instruction (#1942) 2024-11-18 22:07:21 -06:00
Jason DoveandGitHub 0953e258a5 add rewind_on_reset option wait_until yaml playout instruction (#1941) 2024-11-18 19:31:51 -06:00
Jason DoveandGitHub fdbd8a07b6 update macos runner to macos-13; macos-12 is no longer supported (#1940) 2024-11-18 09:59:37 -06:00
Jason DoveandGitHub 5465c45a51 fix plex collection sync (#1939) 2024-11-18 09:41:52 -06:00
Jason DoveandGitHub a0bef3568b fix selecting audio stream with preferred title (#1937)
* update dependencies to fix build errors

* fix selecting audio stream with preferred title
2024-11-16 19:28:30 -06:00
Jason DoveandGitHub f75bb25a1a fix building playouts with certain playlist changes (#1935)
* fix building playouts with certain playlist changes

* update changelog
2024-11-13 14:38:07 -06:00
Jason DoveandGitHub e4ff825ae8 fix displaying playout item durations that are greater than 24 hours (#1932)
* fix displaying playout item durations that are greater than 24 hours

* upgrade refit to make build happy

* upgrade all dependencies
2024-11-08 10:11:57 -06:00
Jason DoveandGitHub 6c4f63ad91 fix scaling behavior crop (#1928) 2024-10-26 08:38:44 -05:00
Jason DoveandGitHub c0a14ba81c add button to reset all playouts (#1927) 2024-10-24 11:23:47 -05:00
Jason DoveandGitHub c063720169 use new channel identifiers in M3U and XMLTV to disambiguate in Plex (#1920) 2024-10-16 09:37:33 -05:00
Jason DoveandGitHub 5d0f40978d fix deleting local libraries with mysql (#1919) 2024-10-16 08:46:45 -05:00
Jason DoveandGitHub 063f463951 update dependencies (#1918) 2024-10-16 08:40:03 -05:00
Jason DoveandGitHub e7817b1460 fix deco default filler hardware acceleration (#1916)
* fix hw accel for deco default filler

* use custom filler kind for deco default filler
2024-10-11 14:33:30 -05:00
Jason DoveandGitHub 7d6faee27b properly destroy channel preview (#1910) 2024-10-04 12:29:40 -05:00
Jason DoveandGitHub 7bba422880 fix emptying trash (#1905) 2024-09-25 10:27:34 -05:00
Jason DoveandGitHub db6ae27384 fix mysql database cleaner (#1902) 2024-09-20 08:42:52 -05:00
282 changed files with 48900 additions and 1229 deletions
+8 -5
View File
@@ -33,10 +33,10 @@ jobs:
strategy:
matrix:
include:
- os: macos-12
- os: macos-13
kind: macOS
target: osx-x64
- os: macos-12
- os: macos-13
kind: macOS
target: osx-arm64
steps:
@@ -130,7 +130,7 @@ jobs:
- name: Publish
uses: softprops/action-gh-release@v1
with:
prerelease: true
prerelease: false
tag_name: ${{ inputs.release_tag }}
files: |
${{ env.RELEASE_NAME }}.dmg
@@ -146,6 +146,9 @@ jobs:
- 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
@@ -175,7 +178,7 @@ jobs:
id: downloadffmpeg
name: Download ffmpeg
with:
url: "https://github.com/ErsatzTV/ErsatzTV-ffmpeg/releases/download/6.1-working-cuvid/ffmpeg-6.1-working-cuvid.7z"
url: "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2025-06-12-14-05/ffmpeg-n7.1.1-22-g0f1fe3d153-win64-gpl-7.1.zip"
target: ffmpeg/
- name: Build
@@ -230,7 +233,7 @@ jobs:
- name: Publish
uses: softprops/action-gh-release@v1
with:
prerelease: true
prerelease: false
tag_name: ${{ inputs.release_tag }}
files: |
${{ env.RELEASE_NAME }}.zip
+2 -2
View File
@@ -19,14 +19,14 @@ jobs:
tag=$(git describe --tags --abbrev=0)
tag2="${tag:1}"
short=$(git rev-parse --short HEAD)
final="${tag2/beta/$short}"
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/beta/$short}"
final="${tag}-${short}"
echo "ARTIFACTS_VERSION=${final}" >> $GITHUB_ENV
echo "INFO_VERSION=${tag:1}" >> $GITHUB_ENV
outputs:
-23
View File
@@ -1,23 +0,0 @@
name: Qodana
on:
workflow_dispatch:
push:
branches:
- main
jobs:
qodana:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
checks: write
steps:
- uses: actions/checkout@v3
with:
ref: ${{ github.event.pull_request.head.sha }} # to check out the actual pull request commit, not the merge commit
fetch-depth: 0 # a full history is required for pull request analysis
- name: 'Qodana Scan'
uses: JetBrains/qodana-action@v2024.1
env:
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
+13
View File
@@ -67,6 +67,13 @@ jobs:
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
uses: docker/build-push-action@v5
with:
@@ -79,6 +86,8 @@ jobs:
tags: |
jasongdove/ersatztv:${{ inputs.base_version }}${{ matrix.suffix }}
jasongdove/ersatztv:${{ inputs.tag_version }}${{ matrix.suffix }}
ghcr.io/ersatztv/ersatztv:${{ inputs.base_version }}${{ matrix.suffix }}
ghcr.io/ersatztv/ersatztv:${{ inputs.tag_version }}${{ matrix.suffix }}
if: ${{ matrix.name != 'arm64' && matrix.name != 'arm32v7' }}
- name: Build and push
@@ -94,6 +103,8 @@ jobs:
tags: |
jasongdove/ersatztv:${{ inputs.base_version }}${{ matrix.suffix }}
jasongdove/ersatztv:${{ inputs.tag_version }}${{ matrix.suffix }}
ghcr.io/ersatztv/ersatztv:${{ inputs.base_version }}${{ matrix.suffix }}
ghcr.io/ersatztv/ersatztv:${{ inputs.tag_version }}${{ matrix.suffix }}
if: ${{ matrix.name == 'arm64' }}
- name: Build and push
@@ -109,4 +120,6 @@ jobs:
tags: |
jasongdove/ersatztv:${{ inputs.base_version }}${{ matrix.suffix }}
jasongdove/ersatztv:${{ inputs.tag_version }}${{ matrix.suffix }}
ghcr.io/ersatztv/ersatztv:${{ inputs.base_version }}${{ matrix.suffix }}
ghcr.io/ersatztv/ersatztv:${{ inputs.tag_version }}${{ matrix.suffix }}
if: ${{ matrix.name == 'arm32v7' }}
+1 -1
View File
@@ -54,7 +54,7 @@ jobs:
- name: Test
run: dotnet test --blame-hang-timeout "2m" --no-restore --verbosity normal
build_and_test_mac:
runs-on: macos-12
runs-on: macos-13
steps:
- name: Get the sources
uses: actions/checkout@v4
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
run: |
tag=$(git describe --tags --abbrev=0)
echo "GIT_TAG=${tag:1}" >> $GITHUB_ENV
echo "DOCKER_TAG=${tag/-beta/}" >> $GITHUB_ENV
echo "DOCKER_TAG=${tag}" >> $GITHUB_ENV
- name: Extract Artifacts Version
shell: bash
run: |
+88
View File
@@ -4,6 +4,94 @@ 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]
### Added
- Add `linux-musl-x64` artifact for users running Alpine x64
- Add API endpoint to empty trash (POST to `/api/maintenance/empty_trash`)
- e.g. `curl -XPOST -d '' http://localhost:8409/api/maintenance/empty_trash`
- Add remote IP and user agent to HTTP request logging
- Add environment variables to allow ETV to run UI and streaming on separate ports
- `ETV_STREAMING_PORT`: port used for streaming requests, defaults to 8409
- `ETV_UI_PORT`: port used for admin UI, defaults to 8409
- Publish docker images to ghcr.io (`ghcr.io/ersatztv/ersatztv`)
- Add new option `Fixed Start Time Behavior` to Schedules and Schedule Items
- Schedules can set a default behavior for all items
- Schedule items can override this default behavior
- Possible values are:
- `Strict`: Always wait for the exact start time, even if that means waiting (adding unscheduled time) until the next day
- `Flexible`: Start scheduling immediately (do not wait) if waiting (adding unscheduled time) would go into the next day
- As an example, if the current scheduling time is 6:02 AM and the next schedule item has a fixed start time of 6:00 AM
- `Strict` will add nearly 24h (23:58) of unscheduled time so that it can start exactly at 6:00 AM the next day
- `Flexible` will NOT add unscheduled time, and will schedule its item at 6:02 AM (which may also affect the scheduling of later items)
- Add basic HDR transcoding support
- VAAPI may use hardware-accelerated tone mapping (when opencl accel is also available)
- NVIDIA may use hardware-accelerated tone mapping (when vulkan accel and libplacebo filter are also available)
- QSV may use hardware-accelerated tone mapping (when hardware decoding is used)
- In all other cases, HDR content will use a software pipeline and the clip algorithm
- Use hardware-accelerated padding with VAAPI
### Changed
- Start to make UI minimally responsive (functional on smaller screens)
- Change how ETV determines which address to use for Plex connections
- The active Plex connection (address) will only be cached for 30 seconds
- When the connection is no longer cached, a ping will be sent to the last used address for Plex (the last address that had a successful ping)
- If the ping is successful, the address will be cached for another 30 seconds
- If the ping is not successful, all addresses will be checked again, and the first address to return a successful ping will be cached for 30 seconds
- Remove requirement to have Jellyfin admin user; user id is no longer required on requests to latest Jellyfin server
- Upgrade bundled ffmpeg on Windows from 6.1 to 7.1.1
- Upgrade VAAPI docker image Ubuntu base from 22 to 24; bundled ffmpeg from 6.1 to 7.1.1
- Upgrade base, arm, arm64 docker images bundled ffmpeg from 6.1 to 7.1.1
### Fixed
- Fix error message about synchronizing Plex collections from a Plex server that has zero collections
- Fix navigation after form submission when using `ETV_BASE_URL` environment variable
- Fix UI crashes when channel numbers contain a period `.` in locales that have a different decimal separator (e.g. `,`)
- Fix playout detail table to only reload once when resetting a playout
- Fix date formatting in playout detail table on reload (will now respect browser's `Accept-Language` header)
- Use cache busting to avoid UI errors after upgrading the MudBlazor library
- Fix multi-variant playlist to report more accurate `BANDWIDTH` value based on ffmpeg profile
- Fix detecting NVIDIA capabilities on Blackwell GPUs
## [25.1.0] - 2025-01-10
### Added
- Add `Reset All Playouts` button to top of playouts page
- Add `rewind_on_reset` option to `wait_until` YAML playout instruction
- This option allows YAML playouts to start in the past
- Add `advance` option to `epg_group` YAML playout instruction
- When set to `false`, this option will lock the guide group without starting a new guide group
- This can be helpful for "post roll" items that should be part of the previous item's guide group
- Add `Song Video Mode` to channel settings
- `Default` - existing behavior
- `With Progress` - show animated progress bar at bottom of generated video
- Thanks to @JeckDev for the idea and the artwork
- Add fallback album art image for songs that have no album art
- Add `Vaapi Display` option to FFmpeg Profile
- Possible values will be install-specific and sourced from `vainfo`
- `drm` was the previous default value, and should be used in most cases
- Test all `Vaapi Display` values in `Troubleshooting` > `VAAPI Capabilities`
- Add `tag_full` field to search index
- This field contains the same values as the existing `tag` field, but it is not analyzed or tokenized
### Changed
- **BREAKING CHANGE**: Change channel identifiers used in XMLTV to work around bad behavior in Plex
### Fixed
- Fix startup error with MySql backend caused by database cleaner
- Fix emptying trash with ElasticSearch backend
- Fix double loading of trash UI elements, and fix reloading of all UI elements after emptying trash
- Fix destroying channel preview player when preview dialog is closed
- This bug made it difficult to "stop" a channel after previewing it
- Fix bug where deco default filler would never use hardware acceleration
- Fix deleting local libraries with MySql backend
- Fix `Scaling Behavior` `Crop` when content is smaller than FFmpeg Profile resolution
- Now, content will properly scale beyond the desired resolution before cropping
- Fix displaying playout item durations that are greater than 24 hours
- Fix building playouts when playlist has been changed to have fewer items
- Fix selecting audio stream with preferred title
- Fix synchronizing Plex collections
- If this breaks collection sync for you, you will need to update your Plex server
- Fix guide group generation for `duration` YAML instructions
- Fix default song background when targeting 4:3 resolutions
- Previously the background was always 16:9 and was padded, now it will fill 4:3
## [0.8.8-beta] - 2024-09-19
### Added
@@ -1,6 +1,6 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Artworks;
public record GetArtwork(int Id) : IRequest<Either<BaseError, Artwork>>;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Artworks;
public record GetArtwork(int Id) : IRequest<Either<BaseError, Artwork>>;
@@ -21,7 +21,8 @@ public record ChannelViewModel(
string PreferredSubtitleLanguageCode,
ChannelSubtitleMode SubtitleMode,
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
string MusicVideoCreditsTemplate)
string MusicVideoCreditsTemplate,
ChannelSongVideoMode SongVideoMode)
{
public string WebEncodedName => WebUtility.UrlEncode(Name);
}
@@ -19,4 +19,5 @@ public record CreateChannel(
string PreferredSubtitleLanguageCode,
ChannelSubtitleMode SubtitleMode,
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
string MusicVideoCreditsTemplate) : IRequest<Either<BaseError, CreateChannelResult>>;
string MusicVideoCreditsTemplate,
ChannelSongVideoMode SongVideoMode) : IRequest<Either<BaseError, CreateChannelResult>>;
@@ -76,7 +76,8 @@ public class CreateChannelHandler(
PreferredSubtitleLanguageCode = request.PreferredSubtitleLanguageCode,
SubtitleMode = request.SubtitleMode,
MusicVideoCreditsMode = request.MusicVideoCreditsMode,
MusicVideoCreditsTemplate = request.MusicVideoCreditsTemplate
MusicVideoCreditsTemplate = request.MusicVideoCreditsTemplate,
SongVideoMode = request.SongVideoMode
};
foreach (int id in watermarkId)
@@ -7,6 +7,7 @@ using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Iptv;
using ErsatzTV.Core.Jellyfin;
using ErsatzTV.Core.Streaming;
using ErsatzTV.Infrastructure.Data;
@@ -286,7 +287,7 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
int finishIndex = j;
while (finishIndex + 1 < sorted.Count && (sorted[finishIndex + 1].GuideGroup == startItem.GuideGroup
|| sorted[finishIndex + 1].FillerKind is FillerKind.GuideMode
or FillerKind.Tail or FillerKind.Fallback))
or FillerKind.Tail or FillerKind.Fallback or FillerKind.DecoDefault))
{
finishIndex++;
}
@@ -514,6 +515,8 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
{
ProgrammeStart = start,
ProgrammeStop = stop,
ChannelId = ChannelIdentifier.FromNumber(request.ChannelNumber),
ChannelIdLegacy = ChannelIdentifier.LegacyFromNumber(request.ChannelNumber),
request.ChannelNumber,
HasCustomTitle = hasCustomTitle,
displayItem.CustomTitle,
@@ -569,6 +572,8 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
{
ProgrammeStart = start,
ProgrammeStop = stop,
ChannelId = ChannelIdentifier.FromNumber(request.ChannelNumber),
ChannelIdLegacy = ChannelIdentifier.LegacyFromNumber(request.ChannelNumber),
request.ChannelNumber,
HasCustomTitle = hasCustomTitle,
displayItem.CustomTitle,
@@ -629,6 +634,8 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
{
ProgrammeStart = start,
ProgrammeStop = stop,
ChannelId = ChannelIdentifier.FromNumber(request.ChannelNumber),
ChannelIdLegacy = ChannelIdentifier.LegacyFromNumber(request.ChannelNumber),
request.ChannelNumber,
HasCustomTitle = hasCustomTitle,
displayItem.CustomTitle,
@@ -686,6 +693,8 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
{
ProgrammeStart = start,
ProgrammeStop = stop,
ChannelId = ChannelIdentifier.FromNumber(request.ChannelNumber),
ChannelIdLegacy = ChannelIdentifier.LegacyFromNumber(request.ChannelNumber),
request.ChannelNumber,
HasCustomTitle = hasCustomTitle,
displayItem.CustomTitle,
@@ -738,6 +747,8 @@ public class RefreshChannelDataHandler : IRequestHandler<RefreshChannelData>
{
ProgrammeStart = start,
ProgrammeStop = stop,
ChannelId = ChannelIdentifier.FromNumber(request.ChannelNumber),
ChannelIdLegacy = ChannelIdentifier.LegacyFromNumber(request.ChannelNumber),
request.ChannelNumber,
HasCustomTitle = hasCustomTitle,
displayItem.CustomTitle,
@@ -4,6 +4,7 @@ using System.Xml;
using Dapper;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Iptv;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
@@ -78,6 +79,8 @@ public class RefreshChannelListHandler : IRequestHandler<RefreshChannelList>
{
var data = new
{
ChannelId = ChannelIdentifier.FromNumber(channel.Number),
ChannelIdLegacy = ChannelIdentifier.LegacyFromNumber(channel.Number),
ChannelNumber = channel.Number,
ChannelName = channel.Name,
ChannelCategories = GetCategories(channel.Categories),
@@ -20,4 +20,5 @@ public record UpdateChannel(
string PreferredSubtitleLanguageCode,
ChannelSubtitleMode SubtitleMode,
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
string MusicVideoCreditsTemplate) : IRequest<Either<BaseError, ChannelViewModel>>;
string MusicVideoCreditsTemplate,
ChannelSongVideoMode SongVideoMode) : IRequest<Either<BaseError, ChannelViewModel>>;
@@ -41,6 +41,7 @@ public class UpdateChannelHandler(
c.SubtitleMode = update.SubtitleMode;
c.MusicVideoCreditsMode = update.MusicVideoCreditsMode;
c.MusicVideoCreditsTemplate = update.MusicVideoCreditsTemplate;
c.SongVideoMode = update.SongVideoMode;
c.Artwork ??= new List<Artwork>();
if (!string.IsNullOrWhiteSpace(update.Logo))
+5 -1
View File
@@ -24,7 +24,8 @@ internal static class Mapper
channel.PreferredSubtitleLanguageCode,
channel.SubtitleMode,
channel.MusicVideoCreditsMode,
channel.MusicVideoCreditsTemplate);
channel.MusicVideoCreditsTemplate,
channel.SongVideoMode);
internal static ChannelResponseModel ProjectToResponseModel(Channel channel) =>
new(
@@ -38,6 +39,9 @@ internal static class Mapper
internal static ResolutionViewModel ProjectToViewModel(Resolution resolution) =>
new(resolution.Height, resolution.Width);
internal static ResolutionAndBitrateViewModel ProjectToViewModel(Resolution resolution, int bitrate) =>
new(resolution.Height, resolution.Width, bitrate);
private static string GetLogo(Channel channel) =>
Optional(channel.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Logo))
.Match(a => a.Path, string.Empty);
@@ -1,3 +0,0 @@
namespace ErsatzTV.Application.Channels;
public record GetChannelResolution(string ChannelNumber) : IRequest<Option<ResolutionViewModel>>;
@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Channels;
public record GetChannelResolutionAndBitrate(string ChannelNumber) : IRequest<Option<ResolutionAndBitrateViewModel>>;
@@ -5,11 +5,11 @@ using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Channels;
public class GetChannelResolutionHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetChannelResolution, Option<ResolutionViewModel>>
public class GetChannelResolutionAndBitrateHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetChannelResolutionAndBitrate, Option<ResolutionAndBitrateViewModel>>
{
public async Task<Option<ResolutionViewModel>> Handle(
GetChannelResolution request,
public async Task<Option<ResolutionAndBitrateViewModel>> Handle(
GetChannelResolutionAndBitrate request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
@@ -20,6 +20,8 @@ public class GetChannelResolutionHandler(IDbContextFactory<TvContext> dbContextF
.ThenInclude(ff => ff.Resolution)
.SelectOneAsync(c => c.Number, c => c.Number == request.ChannelNumber);
return maybeChannel.Map(c => Mapper.ProjectToViewModel(c.FFmpegProfile.Resolution));
return maybeChannel.Map(c => Mapper.ProjectToViewModel(
c.FFmpegProfile.Resolution,
(int)((c.FFmpegProfile.VideoBitrate * 1000 + c.FFmpegProfile.AudioBitrate * 1000) * 1.2)));
}
}
@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Channels;
public record ResolutionAndBitrateViewModel(int Height, int Width, int Bitrate);
@@ -26,7 +26,6 @@ public class SynchronizeEmbyMediaSourcesHandler : IRequestHandler<SynchronizeEmb
List<EmbyMediaSource> mediaSources = await _mediaSourceRepository.GetAllEmby();
foreach (EmbyMediaSource mediaSource in mediaSources)
{
// await _channel.WriteAsync(new SynchronizeEmbyAdminUserId(mediaSource.Id), cancellationToken);
await _scannerWorkerChannel.WriteAsync(new SynchronizeEmbyLibraries(mediaSource.Id), cancellationToken);
}
@@ -9,13 +9,13 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Bugsnag" Version="3.1.0" />
<PackageReference Include="CliWrap" Version="3.6.6" />
<PackageReference Include="Bugsnag" Version="4.0.0" />
<PackageReference Include="CliWrap" Version="3.8.2" />
<PackageReference Include="Humanizer.Core" Version="2.14.1" />
<PackageReference Include="MediatR" Version="12.4.1" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.11.20">
<PackageReference Include="MediatR" Version="12.5.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.4" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.1" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.13.61">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
@@ -8,6 +8,7 @@ public record CreateFFmpegProfile(
string Name,
int ThreadCount,
HardwareAccelerationKind HardwareAcceleration,
string VaapiDisplay,
VaapiDriver VaapiDriver,
string VaapiDevice,
int? QsvExtraHardwareFrames,
@@ -9,6 +9,7 @@ public record UpdateFFmpegProfile(
string Name,
int ThreadCount,
HardwareAccelerationKind HardwareAcceleration,
string VaapiDisplay,
VaapiDriver VaapiDriver,
string VaapiDevice,
int? QsvExtraHardwareFrames,
@@ -36,6 +36,7 @@ public class
p.Name = update.Name;
p.ThreadCount = update.ThreadCount;
p.HardwareAcceleration = update.HardwareAcceleration;
p.VaapiDisplay = update.VaapiDisplay;
p.VaapiDriver = update.VaapiDriver;
p.VaapiDevice = update.VaapiDevice;
p.QsvExtraHardwareFrames = update.QsvExtraHardwareFrames;
@@ -9,6 +9,7 @@ public record FFmpegProfileViewModel(
string Name,
int ThreadCount,
HardwareAccelerationKind HardwareAcceleration,
string VaapiDisplay,
VaapiDriver VaapiDriver,
string VaapiDevice,
int? QsvExtraHardwareFrames,
@@ -11,6 +11,7 @@ internal static class Mapper
profile.Name,
profile.ThreadCount,
profile.HardwareAcceleration,
profile.VaapiDisplay ?? "drm",
profile.VaapiDriver,
profile.VaapiDevice,
profile.QsvExtraHardwareFrames,
@@ -46,6 +47,7 @@ internal static class Mapper
ffmpegProfile.Name,
ffmpegProfile.ThreadCount,
(int)ffmpegProfile.HardwareAcceleration,
ffmpegProfile.VaapiDisplay,
(int)ffmpegProfile.VaapiDriver,
ffmpegProfile.VaapiDevice,
ffmpegProfile.ResolutionId,
@@ -1,6 +0,0 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Jellyfin;
public record SynchronizeJellyfinAdminUserId(int JellyfinMediaSourceId) : IRequest<Either<BaseError, Unit>>,
IScannerBackgroundServiceRequest;
@@ -1,107 +0,0 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Jellyfin;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Jellyfin;
public class
SynchronizeJellyfinAdminUserIdHandler : IRequestHandler<SynchronizeJellyfinAdminUserId,
Either<BaseError, Unit>>
{
private readonly IJellyfinApiClient _jellyfinApiClient;
private readonly IJellyfinSecretStore _jellyfinSecretStore;
private readonly ILogger<SynchronizeJellyfinAdminUserIdHandler> _logger;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IMemoryCache _memoryCache;
public SynchronizeJellyfinAdminUserIdHandler(
IMemoryCache memoryCache,
IMediaSourceRepository mediaSourceRepository,
IJellyfinSecretStore jellyfinSecretStore,
IJellyfinApiClient jellyfinApiClient,
ILogger<SynchronizeJellyfinAdminUserIdHandler> logger)
{
_memoryCache = memoryCache;
_mediaSourceRepository = mediaSourceRepository;
_jellyfinSecretStore = jellyfinSecretStore;
_jellyfinApiClient = jellyfinApiClient;
_logger = logger;
}
public Task<Either<BaseError, Unit>> Handle(
SynchronizeJellyfinAdminUserId request,
CancellationToken cancellationToken) =>
Validate(request)
.Map(v => v.ToEither<ConnectionParameters>())
.BindT(PerformSync);
private async Task<Either<BaseError, Unit>> PerformSync(ConnectionParameters parameters)
{
if (_memoryCache.TryGetValue($"jellyfin_admin_user_id.{parameters.JellyfinMediaSource.Id}", out string _))
{
return Unit.Default;
}
Either<BaseError, string> maybeUserId = await _jellyfinApiClient.GetAdminUserId(
parameters.ActiveConnection.Address,
parameters.ApiKey);
return await maybeUserId.Match(
userId =>
{
// _logger.LogDebug("Jellyfin admin user id is {UserId}", userId);
_memoryCache.Set($"jellyfin_admin_user_id.{parameters.JellyfinMediaSource.Id}", userId);
return Task.FromResult<Either<BaseError, Unit>>(Unit.Default);
},
async error =>
{
// clear api key if unable to sync with jellyfin
if (error.Value.Contains("Unauthorized"))
{
await _jellyfinSecretStore.SaveSecrets(
new JellyfinSecrets { Address = parameters.ActiveConnection.Address, ApiKey = null });
}
return Left<BaseError, Unit>(error);
});
}
private Task<Validation<BaseError, ConnectionParameters>> Validate(SynchronizeJellyfinAdminUserId request) =>
MediaSourceMustExist(request)
.BindT(MediaSourceMustHaveActiveConnection)
.BindT(MediaSourceMustHaveApiKey);
private Task<Validation<BaseError, JellyfinMediaSource>> MediaSourceMustExist(
SynchronizeJellyfinAdminUserId request) =>
_mediaSourceRepository.GetJellyfin(request.JellyfinMediaSourceId)
.Map(o => o.ToValidation<BaseError>("Jellyfin media source does not exist."));
private Validation<BaseError, ConnectionParameters> MediaSourceMustHaveActiveConnection(
JellyfinMediaSource jellyfinMediaSource)
{
Option<JellyfinConnection> maybeConnection = jellyfinMediaSource.Connections.HeadOrNone();
return maybeConnection.Map(connection => new ConnectionParameters(jellyfinMediaSource, connection))
.ToValidation<BaseError>("Jellyfin media source requires an active connection");
}
private async Task<Validation<BaseError, ConnectionParameters>> MediaSourceMustHaveApiKey(
ConnectionParameters connectionParameters)
{
JellyfinSecrets secrets = await _jellyfinSecretStore.ReadSecrets();
return Optional(secrets.Address == connectionParameters.ActiveConnection.Address)
.Where(match => match)
.Map(_ => connectionParameters with { ApiKey = secrets.ApiKey })
.ToValidation<BaseError>("Jellyfin media source requires an api key");
}
private sealed record ConnectionParameters(
JellyfinMediaSource JellyfinMediaSource,
JellyfinConnection ActiveConnection)
{
public string ApiKey { get; set; }
}
}
@@ -26,9 +26,6 @@ public class SynchronizeJellyfinMediaSourcesHandler : IRequestHandler<Synchroniz
List<JellyfinMediaSource> mediaSources = await _mediaSourceRepository.GetAllJellyfin();
foreach (JellyfinMediaSource mediaSource in mediaSources)
{
await _scannerWorkerChannel.WriteAsync(
new SynchronizeJellyfinAdminUserId(mediaSource.Id),
cancellationToken);
await _scannerWorkerChannel.WriteAsync(new SynchronizeJellyfinLibraries(mediaSource.Id), cancellationToken);
}
@@ -47,9 +47,12 @@ public class DeleteLocalLibraryHandler : LocalLibraryHandlerBase,
"""
DELETE FROM MediaItem WHERE Id IN
(
SELECT MI.Id FROM MediaItem MI
INNER JOIN LibraryPath LP ON MI.LibraryPathId = LP.Id
WHERE LP.LibraryId = @LibraryId
SELECT Id FROM
(
SELECT MI.Id FROM MediaItem MI
INNER JOIN LibraryPath LP ON MI.LibraryPathId = LP.Id
WHERE LP.LibraryId = @LibraryId
) AS MI
)
""",
new { LibraryId = localLibrary.Id });
@@ -3,7 +3,6 @@ using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
using ErsatzTV.Infrastructure.Search;
namespace ErsatzTV.Application.Maintenance;
@@ -27,33 +26,16 @@ public class EmptyTrashHandler : IRequestHandler<EmptyTrash, Either<BaseError, U
EmptyTrash request,
CancellationToken cancellationToken)
{
string[] types =
{
LuceneSearchIndex.MovieType,
LuceneSearchIndex.ShowType,
LuceneSearchIndex.SeasonType,
LuceneSearchIndex.EpisodeType,
LuceneSearchIndex.MusicVideoType,
LuceneSearchIndex.OtherVideoType,
LuceneSearchIndex.SongType,
LuceneSearchIndex.ArtistType
};
SearchResult result = await _searchIndex.Search(_client, "state:FileNotFound", 0, 10_000);
var ids = result.Items.Map(i => i.Id).ToList();
var ids = new List<int>();
foreach (string type in types)
// ElasticSearch remove items may fail, so do that first
if (await _searchIndex.RemoveItems(ids))
{
SearchResult result = await _searchIndex.Search(_client, $"type:{type} AND (state:FileNotFound)", 0, 0);
ids.AddRange(result.Items.Map(i => i.Id));
}
Either<BaseError, Unit> deleteResult = await _mediaItemRepository.DeleteItems(ids);
if (deleteResult.IsRight)
{
await _searchIndex.RemoveItems(ids);
_searchIndex.Commit();
return await _mediaItemRepository.DeleteItems(ids);
}
return deleteResult;
return BaseError.New("Failed to empty trash");
}
}
@@ -69,7 +69,7 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
{
try
{
_entityLocker.LockPlayout(playout.Id);
await _entityLocker.LockPlayout(playout.Id);
switch (playout.ProgramSchedulePlayoutType)
{
@@ -135,7 +135,7 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
}
finally
{
_entityLocker.UnlockPlayout(playout.Id);
await _entityLocker.UnlockPlayout(playout.Id);
}
return Unit.Default;
@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Playouts;
public record ResetAllPlayouts : IRequest;
@@ -0,0 +1,40 @@
using System.Threading.Channels;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Playouts;
public class ResetAllPlayoutsHandler(
IEntityLocker locker,
ChannelWriter<IBackgroundServiceRequest> channel,
IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<ResetAllPlayouts>
{
public async Task Handle(ResetAllPlayouts request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
foreach (Playout playout in await dbContext.Playouts.ToListAsync(cancellationToken))
{
switch (playout.ProgramSchedulePlayoutType)
{
case ProgramSchedulePlayoutType.Flood:
case ProgramSchedulePlayoutType.Block:
case ProgramSchedulePlayoutType.Yaml:
if (!locker.IsPlayoutLocked(playout.Id))
{
await channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Reset), cancellationToken);
}
break;
case ProgramSchedulePlayoutType.ExternalJson:
case ProgramSchedulePlayoutType.None:
default:
// external json cannot be reset
continue;
}
}
}
}
+1 -7
View File
@@ -10,7 +10,7 @@ internal static class Mapper
GetDisplayTitle(playoutItem),
playoutItem.StartOffset,
playoutItem.FinishOffset,
GetDisplayDuration(playoutItem.FinishOffset - playoutItem.StartOffset));
playoutItem.GetDisplayDuration());
internal static PlayoutAlternateScheduleViewModel ProjectToViewModel(
ProgramScheduleAlternate programScheduleAlternate) =>
@@ -81,10 +81,4 @@ internal static class Mapper
return string.Empty;
}
}
internal static string GetDisplayDuration(TimeSpan duration) =>
string.Format(
CultureInfo.InvariantCulture,
duration.TotalHours >= 1 ? @"{0:h\:mm\:ss}" : @"{0:mm\:ss}",
duration);
}
@@ -10,7 +10,7 @@ using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Plex;
public class SynchronizePlexMediaSourcesHandler : IRequestHandler<SynchronizePlexMediaSources,
public class SynchronizePlexMediaSourcesHandler : PlexBaseConnectionHandler, IRequestHandler<SynchronizePlexMediaSources,
Either<BaseError, List<PlexMediaSource>>>
{
private const string LocalhostUri = "http://localhost:32400";
@@ -31,6 +31,7 @@ public class SynchronizePlexMediaSourcesHandler : IRequestHandler<SynchronizePle
ChannelWriter<IScannerBackgroundServiceRequest> channel,
IEntityLocker entityLocker,
ILogger<SynchronizePlexMediaSourcesHandler> logger)
: base(plexServerApiClient, mediaSourceRepository, logger)
{
_mediaSourceRepository = mediaSourceRepository;
_plexTvApiClient = plexTvApiClient;
@@ -102,63 +103,35 @@ public class SynchronizePlexMediaSourcesHandler : IRequestHandler<SynchronizePle
var toRemove = existing.Connections
.Filter(connection => server.Connections.All(c => c.Uri != connection.Uri)).ToList();
await _mediaSourceRepository.Update(existing, toAdd, toRemove);
await FindConnectionToActivate(existing);
Option<PlexServerAuthToken> maybeToken = await _plexSecretStore.GetServerAuthToken(server.ClientIdentifier);
if (maybeToken.IsNone)
{
_logger.LogError(
"Unable to activate Plex connection for server {Server} without auth token",
server.ServerName);
}
foreach (PlexServerAuthToken token in maybeToken)
{
await FindConnectionToActivate(existing, token);
}
}
if (maybeExisting.IsNone)
{
await _mediaSourceRepository.Add(server);
await FindConnectionToActivate(server);
}
}
private async Task FindConnectionToActivate(PlexMediaSource server)
{
var prioritized = server.Connections
.OrderByDescending(pc => pc.Uri == LocalhostUri)
.ThenByDescending(pc => pc.IsActive)
.ToList();
foreach (PlexConnection connection in server.Connections)
{
connection.IsActive = false;
}
Option<PlexServerAuthToken> maybeToken = await _plexSecretStore.GetServerAuthToken(server.ClientIdentifier);
foreach (PlexServerAuthToken token in maybeToken)
{
foreach (PlexConnection connection in prioritized)
Option<PlexServerAuthToken> maybeToken = await _plexSecretStore.GetServerAuthToken(server.ClientIdentifier);
if (maybeToken.IsNone)
{
try
{
_logger.LogDebug("Attempting to locate to Plex at {Uri}", connection.Uri);
if (await _plexServerApiClient.Ping(connection, token))
{
_logger.LogInformation("Located Plex at {Uri}", connection.Uri);
connection.IsActive = true;
break;
}
}
catch
{
// do nothing
}
_logger.LogError(
"Unable to activate Plex connection for server {Server} without auth token",
server.ServerName);
}
foreach (PlexServerAuthToken token in maybeToken)
{
await FindConnectionToActivate(server, token);
}
}
if (maybeToken.IsNone)
{
_logger.LogError(
"Unable to activate Plex connection for server {Server} without auth token",
server.ServerName);
}
if (server.Connections.All(c => !c.IsActive))
{
_logger.LogError("Unable to locate Plex");
server.Connections.Head().IsActive = true;
}
await _mediaSourceRepository.Update(server, new List<PlexConnection>(), new List<PlexConnection>());
}
}
@@ -20,14 +20,15 @@ public class TryCompletePlexPinFlowHandler : IRequestHandler<TryCompletePlexPinF
public async Task<Either<BaseError, bool>>
Handle(TryCompletePlexPinFlow request, CancellationToken cancellationToken)
{
var cts = new CancellationTokenSource(TimeSpan.FromMinutes(2));
CancellationToken token = cts.Token;
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(2));
using var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, cancellationToken);
CancellationToken token = linkedTokenSource.Token;
while (!token.IsCancellationRequested)
{
bool result = await _plexTvApiClient.TryCompletePinFlow(request.AuthPin);
if (result)
{
await _channel.WriteAsync(new SynchronizePlexMediaSources(), cancellationToken);
await _channel.WriteAsync(new SynchronizePlexMediaSources(), token);
return true;
}
@@ -0,0 +1,93 @@
using System.Collections.Concurrent;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Plex;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Plex;
public abstract class PlexBaseConnectionHandler(
IPlexServerApiClient plexServerApiClient,
IMediaSourceRepository mediaSourceRepository,
ILogger logger)
{
protected async Task<Option<PlexConnection>> FindConnectionToActivate(PlexMediaSource server, PlexServerAuthToken token)
{
Option<PlexConnection> result = Option<PlexConnection>.None;
foreach (PlexConnection connection in server.Connections)
{
connection.IsActive = false;
}
using var cts = new CancellationTokenSource();
ConcurrentDictionary<PlexConnection, TimeSpan> successfulTimes = new();
var tasks = server.Connections
.Map(connection => PingPlexConnection(connection, token, successfulTimes, cts.Token))
.ToList();
while (tasks.Count > 0)
{
Task completed = await Task.WhenAny(tasks);
if (completed.IsCompletedSuccessfully)
{
if (!successfulTimes.IsEmpty)
{
await cts.CancelAsync();
break;
}
}
tasks.Remove(completed);
}
Option<PlexConnection> maybeBest = successfulTimes.OrderByDescending(kv => kv.Value).Select(kvp => kvp.Key).HeadOrNone();
foreach (PlexConnection connection in maybeBest)
{
connection.IsActive = true;
}
if (server.Connections.All(c => !c.IsActive))
{
logger.LogError("Unable to locate Plex");
server.Connections.Head().IsActive = true;
}
await mediaSourceRepository.Update(server, [], []);
return result;
}
private async Task PingPlexConnection(PlexConnection connection, PlexServerAuthToken token, ConcurrentDictionary<PlexConnection, TimeSpan> successfulTimes, CancellationToken cancellationToken)
{
try
{
logger.LogDebug("Attempting to locate to Plex at {Uri}", connection.Uri);
var sw = new System.Diagnostics.Stopwatch();
sw.Start();
bool pingResult = await plexServerApiClient.Ping(connection, token, cancellationToken);
sw.Stop();
if (pingResult)
{
logger.LogInformation(
"Located Plex at {Uri} in {Milliseconds} ms",
connection.Uri,
sw.ElapsedMilliseconds);
successfulTimes.TryAdd(connection, sw.Elapsed);
}
else
{
logger.LogDebug(
"Unable to locate Plex at {Uri} after {Milliseconds} ms",
connection.Uri,
sw.ElapsedMilliseconds);
}
}
catch
{
// do nothing
}
}
}
@@ -4,24 +4,32 @@ using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Plex;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Plex;
public class GetPlexConnectionParametersHandler : IRequestHandler<GetPlexConnectionParameters,
public class GetPlexConnectionParametersHandler : PlexBaseConnectionHandler, IRequestHandler<GetPlexConnectionParameters,
Either<BaseError, PlexConnectionParametersViewModel>>
{
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IMemoryCache _memoryCache;
private readonly IPlexServerApiClient _plexServerApiClient;
private readonly IPlexSecretStore _plexSecretStore;
private readonly ILogger<GetPlexConnectionParametersHandler> _logger;
public GetPlexConnectionParametersHandler(
IMemoryCache memoryCache,
IPlexServerApiClient plexServerApiClient,
IMediaSourceRepository mediaSourceRepository,
IPlexSecretStore plexSecretStore)
IPlexSecretStore plexSecretStore,
ILogger<GetPlexConnectionParametersHandler> logger)
: base(plexServerApiClient, mediaSourceRepository, logger)
{
_memoryCache = memoryCache;
_plexServerApiClient = plexServerApiClient;
_mediaSourceRepository = mediaSourceRepository;
_plexSecretStore = plexSecretStore;
_logger = logger;
}
public async Task<Either<BaseError, PlexConnectionParametersViewModel>> Handle(
@@ -33,55 +41,43 @@ public class GetPlexConnectionParametersHandler : IRequestHandler<GetPlexConnect
return parameters;
}
Either<BaseError, PlexConnectionParametersViewModel> maybeParameters =
await Validate(request)
.MapT(
cp => new PlexConnectionParametersViewModel(
new Uri(cp.ActiveConnection.Uri),
cp.PlexServerAuthToken.AuthToken))
.Map(v => v.ToEither<PlexConnectionParametersViewModel>());
return maybeParameters.Match(
p =>
Option<PlexMediaSource> maybeMediaSource = await _mediaSourceRepository.GetPlex(request.PlexMediaSourceId);
foreach (PlexMediaSource mediaSource in maybeMediaSource)
{
Option<PlexServerAuthToken> maybeToken =
await _plexSecretStore.GetServerAuthToken(mediaSource.ClientIdentifier);
foreach (PlexServerAuthToken token in maybeToken)
{
_memoryCache.Set(request, p, TimeSpan.FromHours(1));
return maybeParameters;
},
error => error);
}
// try to keep the same connection
Option<PlexConnection> maybeActiveConnection = mediaSource.Connections.Filter(c => c.IsActive).HeadOrNone();
foreach (PlexConnection activeConnection in maybeActiveConnection)
{
if (await _plexServerApiClient.Ping(activeConnection, token, cancellationToken))
{
_logger.LogDebug("Plex connection is still active at {Uri}", activeConnection.Uri);
var p = new PlexConnectionParametersViewModel(new Uri(activeConnection.Uri), token.AuthToken);
_memoryCache.Set(request, p, TimeSpan.FromSeconds(30));
return p;
}
}
private Task<Validation<BaseError, ConnectionParameters>> Validate(GetPlexConnectionParameters request) =>
PlexMediaSourceMustExist(request)
.BindT(MediaSourceMustHaveActiveConnection)
.BindT(MediaSourceMustHaveToken);
_logger.LogInformation("Plex connection is no longer active, searching for a new connection");
private Task<Validation<BaseError, PlexMediaSource>> PlexMediaSourceMustExist(
GetPlexConnectionParameters request) =>
_mediaSourceRepository.GetPlex(request.PlexMediaSourceId)
.Map(
v => v.ToValidation<BaseError>(
$"Plex media source {request.PlexMediaSourceId} does not exist."));
// check all connections for a working one
Option<PlexConnection> maybeConnection = await FindConnectionToActivate(mediaSource, token);
foreach (PlexConnection connection in maybeConnection)
{
var p = new PlexConnectionParametersViewModel(new Uri(connection.Uri), token.AuthToken);
_memoryCache.Set(request, p, TimeSpan.FromMinutes(30));
return p;
}
private Validation<BaseError, ConnectionParameters> MediaSourceMustHaveActiveConnection(
PlexMediaSource plexMediaSource)
{
Option<PlexConnection> maybeConnection =
plexMediaSource.Connections.SingleOrDefault(c => c.IsActive);
return maybeConnection.Map(connection => new ConnectionParameters(plexMediaSource, connection))
.ToValidation<BaseError>("Plex media source requires an active connection");
}
return BaseError.New($"Plex media source {request.PlexMediaSourceId} requires an active connection");
}
private async Task<Validation<BaseError, ConnectionParameters>> MediaSourceMustHaveToken(
ConnectionParameters connectionParameters)
{
Option<PlexServerAuthToken> maybeToken = await
_plexSecretStore.GetServerAuthToken(connectionParameters.PlexMediaSource.ClientIdentifier);
return maybeToken.Map(token => connectionParameters with { PlexServerAuthToken = token })
.ToValidation<BaseError>("Plex media source requires a token");
}
return BaseError.New($"Plex media source {request.PlexMediaSourceId} requires a token");
}
private sealed record ConnectionParameters(PlexMediaSource PlexMediaSource, PlexConnection ActiveConnection)
{
public PlexServerAuthToken PlexServerAuthToken { get; set; }
return BaseError.New($"Plex media source {request.PlexMediaSourceId} does not exist.");
}
}
@@ -1,5 +1,6 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Application.ProgramSchedules;
@@ -7,6 +8,7 @@ public record AddProgramScheduleItem(
int ProgramScheduleId,
StartType StartType,
TimeSpan? StartTime,
FixedStartTimeBehavior? FixedStartTimeBehavior,
PlayoutMode PlayoutMode,
ProgramScheduleItemCollectionType CollectionType,
int? CollectionId,
@@ -1,4 +1,5 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Application.ProgramSchedules;
@@ -7,4 +8,5 @@ public record CreateProgramSchedule(
bool KeepMultiPartEpisodesTogether,
bool TreatCollectionsAsShows,
bool ShuffleScheduleItems,
bool RandomStartPoint) : IRequest<Either<BaseError, CreateProgramScheduleResult>>;
bool RandomStartPoint,
FixedStartTimeBehavior FixedStartTimeBehavior) : IRequest<Either<BaseError, CreateProgramScheduleResult>>;
@@ -5,19 +5,14 @@ using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.ProgramSchedules;
public class CreateProgramScheduleHandler :
public class CreateProgramScheduleHandler(IDbContextFactory<TvContext> dbContextFactory) :
IRequestHandler<CreateProgramSchedule, Either<BaseError, CreateProgramScheduleResult>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public CreateProgramScheduleHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<Either<BaseError, CreateProgramScheduleResult>> Handle(
CreateProgramSchedule request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, ProgramSchedule> validation = await Validate(dbContext, request);
return await validation.Apply(ps => PersistProgramSchedule(dbContext, ps));
@@ -45,7 +40,8 @@ public class CreateProgramScheduleHandler :
KeepMultiPartEpisodesTogether = keepMultiPartEpisodesTogether,
TreatCollectionsAsShows = keepMultiPartEpisodesTogether && request.TreatCollectionsAsShows,
ShuffleScheduleItems = request.ShuffleScheduleItems,
RandomStartPoint = request.RandomStartPoint
RandomStartPoint = request.RandomStartPoint,
FixedStartTimeBehavior = request.FixedStartTimeBehavior
};
});
@@ -1,10 +1,12 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Application.ProgramSchedules;
public interface IProgramScheduleItemRequest
{
TimeSpan? StartTime { get; }
FixedStartTimeBehavior? FixedStartTimeBehavior { get; }
ProgramScheduleItemCollectionType CollectionType { get; }
int? CollectionId { get; }
int? MultiCollectionId { get; }
@@ -185,6 +185,7 @@ public abstract class ProgramScheduleItemCommandBase
ProgramScheduleId = programSchedule.Id,
Index = index,
StartTime = FixStartTime(item.StartTime),
FixedStartTimeBehavior = item.FixedStartTimeBehavior,
CollectionType = item.CollectionType,
CollectionId = item.CollectionId,
MultiCollectionId = item.MultiCollectionId,
@@ -211,6 +212,7 @@ public abstract class ProgramScheduleItemCommandBase
ProgramScheduleId = programSchedule.Id,
Index = index,
StartTime = FixStartTime(item.StartTime),
FixedStartTimeBehavior = item.FixedStartTimeBehavior,
CollectionType = item.CollectionType,
CollectionId = item.CollectionId,
MultiCollectionId = item.MultiCollectionId,
@@ -237,6 +239,7 @@ public abstract class ProgramScheduleItemCommandBase
ProgramScheduleId = programSchedule.Id,
Index = index,
StartTime = FixStartTime(item.StartTime),
FixedStartTimeBehavior = item.FixedStartTimeBehavior,
CollectionType = item.CollectionType,
CollectionId = item.CollectionId,
MultiCollectionId = item.MultiCollectionId,
@@ -264,6 +267,7 @@ public abstract class ProgramScheduleItemCommandBase
ProgramScheduleId = programSchedule.Id,
Index = index,
StartTime = FixStartTime(item.StartTime),
FixedStartTimeBehavior = item.FixedStartTimeBehavior,
CollectionType = item.CollectionType,
CollectionId = item.CollectionId,
MultiCollectionId = item.MultiCollectionId,
@@ -1,5 +1,6 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Application.ProgramSchedules;
@@ -7,6 +8,7 @@ public record ReplaceProgramScheduleItem(
int Index,
StartType StartType,
TimeSpan? StartTime,
FixedStartTimeBehavior? FixedStartTimeBehavior,
PlayoutMode PlayoutMode,
ProgramScheduleItemCollectionType CollectionType,
int? CollectionId,
@@ -1,4 +1,5 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Application.ProgramSchedules;
@@ -8,4 +9,5 @@ public record UpdateProgramSchedule(
bool KeepMultiPartEpisodesTogether,
bool TreatCollectionsAsShows,
bool ShuffleScheduleItems,
bool RandomStartPoint) : IRequest<Either<BaseError, UpdateProgramScheduleResult>>;
bool RandomStartPoint,
FixedStartTimeBehavior FixedStartTimeBehavior) : IRequest<Either<BaseError, UpdateProgramScheduleResult>>;
@@ -42,7 +42,8 @@ public class UpdateProgramScheduleHandler :
programSchedule.KeepMultiPartEpisodesTogether != request.KeepMultiPartEpisodesTogether ||
programSchedule.TreatCollectionsAsShows != request.TreatCollectionsAsShows ||
programSchedule.ShuffleScheduleItems != request.ShuffleScheduleItems ||
programSchedule.RandomStartPoint != request.RandomStartPoint;
programSchedule.RandomStartPoint != request.RandomStartPoint ||
programSchedule.FixedStartTimeBehavior != request.FixedStartTimeBehavior;
programSchedule.Name = request.Name;
programSchedule.KeepMultiPartEpisodesTogether = request.KeepMultiPartEpisodesTogether;
@@ -50,6 +51,7 @@ public class UpdateProgramScheduleHandler :
request.TreatCollectionsAsShows;
programSchedule.ShuffleScheduleItems = request.ShuffleScheduleItems;
programSchedule.RandomStartPoint = request.RandomStartPoint;
programSchedule.FixedStartTimeBehavior = request.FixedStartTimeBehavior;
await dbContext.SaveChangesAsync();
@@ -11,7 +11,8 @@ internal static class Mapper
programSchedule.KeepMultiPartEpisodesTogether,
programSchedule.TreatCollectionsAsShows,
programSchedule.ShuffleScheduleItems,
programSchedule.RandomStartPoint);
programSchedule.RandomStartPoint,
programSchedule.FixedStartTimeBehavior);
internal static ProgramScheduleItemViewModel ProjectToViewModel(ProgramScheduleItem programScheduleItem) =>
programScheduleItem switch
@@ -22,6 +23,7 @@ internal static class Mapper
duration.Index,
duration.StartType,
duration.StartTime,
duration.FixedStartTimeBehavior,
duration.CollectionType,
duration.Collection != null
? MediaCollections.Mapper.ProjectToViewModel(duration.Collection)
@@ -77,6 +79,7 @@ internal static class Mapper
flood.Index,
flood.StartType,
flood.StartTime,
flood.FixedStartTimeBehavior,
flood.CollectionType,
flood.Collection != null
? MediaCollections.Mapper.ProjectToViewModel(flood.Collection)
@@ -129,6 +132,7 @@ internal static class Mapper
multiple.Index,
multiple.StartType,
multiple.StartTime,
multiple.FixedStartTimeBehavior,
multiple.CollectionType,
multiple.Collection != null
? MediaCollections.Mapper.ProjectToViewModel(multiple.Collection)
@@ -182,6 +186,7 @@ internal static class Mapper
one.Index,
one.StartType,
one.StartTime,
one.FixedStartTimeBehavior,
one.CollectionType,
one.Collection != null
? MediaCollections.Mapper.ProjectToViewModel(one.Collection)
@@ -3,6 +3,7 @@ using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Application.Watermarks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Application.ProgramSchedules;
@@ -13,6 +14,7 @@ public record ProgramScheduleItemDurationViewModel : ProgramScheduleItemViewMode
int index,
StartType startType,
TimeSpan? startTime,
FixedStartTimeBehavior? fixedStartTimeBehavior,
ProgramScheduleItemCollectionType collectionType,
MediaCollectionViewModel collection,
MultiCollectionViewModel multiCollection,
@@ -40,6 +42,7 @@ public record ProgramScheduleItemDurationViewModel : ProgramScheduleItemViewMode
index,
startType,
startTime,
fixedStartTimeBehavior,
PlayoutMode.Duration,
collectionType,
collection,
@@ -3,6 +3,7 @@ using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Application.Watermarks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Application.ProgramSchedules;
@@ -13,6 +14,7 @@ public record ProgramScheduleItemFloodViewModel : ProgramScheduleItemViewModel
int index,
StartType startType,
TimeSpan? startTime,
FixedStartTimeBehavior? fixedStartTimeBehavior,
ProgramScheduleItemCollectionType collectionType,
MediaCollectionViewModel collection,
MultiCollectionViewModel multiCollection,
@@ -37,6 +39,7 @@ public record ProgramScheduleItemFloodViewModel : ProgramScheduleItemViewModel
index,
startType,
startTime,
fixedStartTimeBehavior,
PlayoutMode.Flood,
collectionType,
collection,
@@ -3,6 +3,7 @@ using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Application.Watermarks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Application.ProgramSchedules;
@@ -13,6 +14,7 @@ public record ProgramScheduleItemMultipleViewModel : ProgramScheduleItemViewMode
int index,
StartType startType,
TimeSpan? startTime,
FixedStartTimeBehavior? fixedStartTimeBehavior,
ProgramScheduleItemCollectionType collectionType,
MediaCollectionViewModel collection,
MultiCollectionViewModel multiCollection,
@@ -38,6 +40,7 @@ public record ProgramScheduleItemMultipleViewModel : ProgramScheduleItemViewMode
index,
startType,
startTime,
fixedStartTimeBehavior,
PlayoutMode.Multiple,
collectionType,
collection,
@@ -3,6 +3,7 @@ using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Application.Watermarks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Application.ProgramSchedules;
@@ -13,6 +14,7 @@ public record ProgramScheduleItemOneViewModel : ProgramScheduleItemViewModel
int index,
StartType startType,
TimeSpan? startTime,
FixedStartTimeBehavior? fixedStartTimeBehavior,
ProgramScheduleItemCollectionType collectionType,
MediaCollectionViewModel collection,
MultiCollectionViewModel multiCollection,
@@ -37,6 +39,7 @@ public record ProgramScheduleItemOneViewModel : ProgramScheduleItemViewModel
index,
startType,
startTime,
fixedStartTimeBehavior,
PlayoutMode.One,
collectionType,
collection,
@@ -3,6 +3,7 @@ using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Application.Watermarks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Application.ProgramSchedules;
@@ -11,6 +12,7 @@ public abstract record ProgramScheduleItemViewModel(
int Index,
StartType StartType,
TimeSpan? StartTime,
FixedStartTimeBehavior? FixedStartTimeBehavior,
PlayoutMode PlayoutMode,
ProgramScheduleItemCollectionType CollectionType,
MediaCollectionViewModel Collection,
@@ -1,4 +1,6 @@
namespace ErsatzTV.Application.ProgramSchedules;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Application.ProgramSchedules;
public record ProgramScheduleViewModel(
int Id,
@@ -6,4 +8,5 @@ public record ProgramScheduleViewModel(
bool KeepMultiPartEpisodesTogether,
bool TreatCollectionsAsShows,
bool ShuffleScheduleItems,
bool RandomStartPoint);
bool RandomStartPoint,
FixedStartTimeBehavior FixedStartTimeBehavior);
@@ -3,18 +3,14 @@ using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.ProgramSchedules;
public class GetAllProgramSchedulesHandler : IRequestHandler<GetAllProgramSchedules, List<ProgramScheduleViewModel>>
public class GetAllProgramSchedulesHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetAllProgramSchedules, List<ProgramScheduleViewModel>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public GetAllProgramSchedulesHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<List<ProgramScheduleViewModel>> Handle(
GetAllProgramSchedules request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.ProgramSchedules
.Map(
ps => new ProgramScheduleViewModel(
@@ -23,7 +19,8 @@ public class GetAllProgramSchedulesHandler : IRequestHandler<GetAllProgramSchedu
ps.KeepMultiPartEpisodesTogether,
ps.TreatCollectionsAsShows,
ps.ShuffleScheduleItems,
ps.RandomStartPoint))
ps.RandomStartPoint,
ps.FixedStartTimeBehavior))
.ToListAsync(cancellationToken);
}
}
+1 -1
View File
@@ -116,5 +116,5 @@ internal static class Mapper
Playouts.Mapper.GetDisplayTitle(playoutItem),
playoutItem.StartOffset.TimeOfDay,
playoutItem.FinishOffset.TimeOfDay,
Playouts.Mapper.GetDisplayDuration(playoutItem.FinishOffset - playoutItem.StartOffset));
playoutItem.GetDisplayDuration());
}
@@ -32,6 +32,7 @@ public class GetErrorProcessHandler : FFmpegProcessHandler<GetErrorProcess>
request.ErrorMessage,
request.HlsRealtime,
request.PtsOffset,
channel.FFmpegProfile.VaapiDisplay,
channel.FFmpegProfile.VaapiDriver,
channel.FFmpegProfile.VaapiDevice,
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames));
@@ -16,6 +16,7 @@ using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Streaming;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.FFmpeg.State;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
@@ -256,6 +257,28 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
ffmpegPath,
ffprobePath,
cancellationToken);
// override watermark as song_progress_overlay.png
if (videoVersion is BackgroundImageMediaVersion { IsSongWithProgress: true })
{
double ratio = channel.FFmpegProfile.Resolution.Width / (double)channel.FFmpegProfile.Resolution.Height;
bool is43 = Math.Abs(ratio - 4.0 / 3.0) < 0.01;
string image = is43 ? "song_progress_overlay_43.png" : "song_progress_overlay.png";
disableWatermarks = false;
playoutItemWatermark = new ChannelWatermark
{
Mode = ChannelWatermarkMode.Permanent,
Size = WatermarkSize.Scaled,
WidthPercent = 100,
HorizontalMarginPercent = 0,
VerticalMarginPercent = 0,
Opacity = 100,
Location = WatermarkLocation.TopLeft,
ImageSource = ChannelWatermarkImageSource.Resource,
Image = image
};
}
}
if (playoutItemWithPath.PlayoutItem.MediaItem is Image)
@@ -293,6 +316,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
request.StartAtZero ? start : now,
playoutItemWatermark,
maybeGlobalWatermark,
channel.FFmpegProfile.VaapiDisplay,
channel.FFmpegProfile.VaapiDriver,
channel.FFmpegProfile.VaapiDevice,
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames),
@@ -338,6 +362,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
"Channel is Offline",
request.HlsRealtime,
request.PtsOffset,
channel.FFmpegProfile.VaapiDisplay,
channel.FFmpegProfile.VaapiDriver,
channel.FFmpegProfile.VaapiDevice,
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames));
@@ -351,6 +376,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
error.Value,
request.HlsRealtime,
request.PtsOffset,
channel.FFmpegProfile.VaapiDisplay,
channel.FFmpegProfile.VaapiDriver,
channel.FFmpegProfile.VaapiDevice,
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames));
@@ -364,6 +390,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
"Channel is Offline",
request.HlsRealtime,
request.PtsOffset,
channel.FFmpegProfile.VaapiDisplay,
channel.FFmpegProfile.VaapiDriver,
channel.FFmpegProfile.VaapiDevice,
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames));
@@ -591,7 +618,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
return new PlayoutItemWithPath(
playoutItem,
$"http://localhost:{Settings.ListenPort}/media/plex/{plexMediaSourceId}/{pmf.Key}");
$"http://localhost:{Settings.StreamingPort}/media/plex/{plexMediaSourceId}/{pmf.Key}");
}
break;
@@ -609,7 +636,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
{
return new PlayoutItemWithPath(
playoutItem,
$"http://localhost:{Settings.ListenPort}/media/jellyfin/{itemId}");
$"http://localhost:{Settings.StreamingPort}/media/jellyfin/{itemId}");
}
// attempt to remotely stream emby
@@ -624,7 +651,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
{
return new PlayoutItemWithPath(
playoutItem,
$"http://localhost:{Settings.ListenPort}/media/emby/{itemId}");
$"http://localhost:{Settings.StreamingPort}/media/emby/{itemId}");
}
return new PlayoutItemDoesNotExistOnDisk(path);
@@ -135,7 +135,7 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
foreach (int playoutId in playoutIdsToCheck)
{
_entityLocker.LockPlayout(playoutId);
await _entityLocker.LockPlayout(playoutId);
}
_logger.LogDebug("Checking playouts {PlayoutIds} for text subtitles to extract", playoutIdsToCheck);
@@ -193,7 +193,7 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
foreach (int playoutId in playoutIdsToCheck)
{
_entityLocker.UnlockPlayout(playoutId);
await _entityLocker.UnlockPlayout(playoutId);
}
}
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
@@ -505,7 +505,7 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
foreach (int plexMediaSourceId in maybeId)
{
return $"http://localhost:{Settings.ListenPort}/media/plex/{plexMediaSourceId}/{pmf.Key}";
return $"http://localhost:{Settings.StreamingPort}/media/plex/{plexMediaSourceId}/{pmf.Key}";
}
break;
@@ -514,11 +514,11 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
return mediaItem switch
{
JellyfinMovie jellyfinMovie =>
$"http://localhost:{Settings.ListenPort}/media/jellyfin/{jellyfinMovie.ItemId}",
$"http://localhost:{Settings.StreamingPort}/media/jellyfin/{jellyfinMovie.ItemId}",
JellyfinEpisode jellyfinEpisode =>
$"http://localhost:{Settings.ListenPort}/media/jellyfin/{jellyfinEpisode.ItemId}",
EmbyMovie embyMovie => $"http://localhost:{Settings.ListenPort}/media/emby/{embyMovie.ItemId}",
EmbyEpisode embyEpisode => $"http://localhost:{Settings.ListenPort}/media/emby/{embyEpisode.ItemId}",
$"http://localhost:{Settings.StreamingPort}/media/jellyfin/{jellyfinEpisode.ItemId}",
EmbyMovie embyMovie => $"http://localhost:{Settings.StreamingPort}/media/emby/{embyMovie.ItemId}",
EmbyEpisode embyEpisode => $"http://localhost:{Settings.StreamingPort}/media/emby/{embyEpisode.ItemId}",
_ => file.Path
};
}
@@ -79,7 +79,7 @@ public class GetSubtitlePathByIdHandler : IRequestHandler<GetSubtitlePathById, E
{
foreach (string subtitlePath in maybeSubtitle.Map(s => s.Path))
{
return $"http://localhost:{Settings.ListenPort}/media/plex/{plexMediaSourceId}/{subtitlePath}";
return $"http://localhost:{Settings.StreamingPort}/media/plex/{plexMediaSourceId}/{subtitlePath}";
}
}
@@ -120,7 +120,7 @@ public class GetSubtitlePathByIdHandler : IRequestHandler<GetSubtitlePathById, E
string extension = Subtitle.ExtensionForCodec(subtitle.Codec);
var subtitlePath =
$"Videos/{jellyfinItemId}/{jellyfinItemId}/Subtitles/{index}/{index}/Stream.{extension}";
return $"http://localhost:{Settings.ListenPort}/media/jellyfin/{subtitlePath}";
return $"http://localhost:{Settings.StreamingPort}/media/jellyfin/{subtitlePath}";
}
}
@@ -160,7 +160,7 @@ public class GetSubtitlePathByIdHandler : IRequestHandler<GetSubtitlePathById, E
string extension = Subtitle.ExtensionForCodec(subtitle.Codec);
var subtitlePath =
$"Videos/{embyItemId}/{subtitle.Path}/Subtitles/{subtitle.StreamIndex}/Stream.{extension}";
return $"http://localhost:{Settings.ListenPort}/media/emby/{subtitlePath}";
return $"http://localhost:{Settings.StreamingPort}/media/emby/{subtitlePath}";
}
}
@@ -92,7 +92,12 @@ public class GetTroubleshootingInfoHandler : IRequestHandler<GetTroubleshootingI
if (!_memoryCache.TryGetValue("ffmpeg.render_devices", out List<string> vaapiDevices))
{
vaapiDevices = new List<string> { "/dev/dri/renderD128" };
vaapiDevices = ["/dev/dri/renderD128"];
}
if (!_memoryCache.TryGetValue("ffmpeg.vaapi_displays", out List<string> vaapiDisplays))
{
vaapiDisplays = ["drm"];
}
foreach (string qsvDevice in vaapiDevices)
@@ -109,19 +114,19 @@ public class GetTroubleshootingInfoHandler : IRequestHandler<GetTroubleshootingI
var allDrivers = new List<VaapiDriver>
{ VaapiDriver.iHD, VaapiDriver.i965, VaapiDriver.RadeonSI, VaapiDriver.Nouveau };
foreach (string display in vaapiDisplays)
foreach (VaapiDriver activeDriver in allDrivers)
foreach (string vaapiDevice in vaapiDevices)
{
foreach (string vaapiDevice in vaapiDevices)
foreach (string output in await _hardwareCapabilitiesFactory.GetVaapiOutput(
display,
Optional(GetDriverName(activeDriver)),
vaapiDevice))
{
foreach (string output in await _hardwareCapabilitiesFactory.GetVaapiOutput(
Optional(GetDriverName(activeDriver)),
vaapiDevice))
{
vaapiCapabilities +=
$"Checking driver {activeDriver} device {vaapiDevice}{Environment.NewLine}{Environment.NewLine}";
vaapiCapabilities += output;
vaapiCapabilities += Environment.NewLine + Environment.NewLine;
}
vaapiCapabilities +=
$"Checking display [{display}] driver [{activeDriver}] device [{vaapiDevice}]{Environment.NewLine}{Environment.NewLine}";
vaapiCapabilities += output;
vaapiCapabilities += Environment.NewLine + Environment.NewLine;
}
}
}
@@ -0,0 +1,37 @@
using ErsatzTV.Core.Domain;
using FluentAssertions;
using NUnit.Framework;
namespace ErsatzTV.Core.Tests.Domain;
[TestFixture]
public class PlayoutItemTests
{
[Test]
public void GetDisplayDuration_ShortDuration()
{
var item = new PlayoutItem
{
Start = DateTime.UtcNow.Date,
Finish = DateTime.UtcNow.Date.AddHours(3).AddMinutes(5).AddSeconds(4)
};
string actual = item.GetDisplayDuration();
actual.Should().Be("3:05:04");
}
[Test]
public void GetDisplayDuration_LongDuration()
{
var item = new PlayoutItem
{
Start = DateTime.UtcNow.Date,
Finish = DateTime.UtcNow.Date.AddHours(27).AddMinutes(5).AddSeconds(4)
};
string actual = item.GetDisplayDuration();
actual.Should().Be("27:05:04");
}
}
+14 -14
View File
@@ -7,24 +7,24 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Bugsnag" Version="3.1.0" />
<PackageReference Include="CliWrap" Version="3.6.6" />
<PackageReference Include="FluentAssertions" Version="6.12.1" />
<PackageReference Include="Bugsnag" Version="4.0.0" />
<PackageReference Include="CliWrap" Version="3.8.2" />
<PackageReference Include="FluentAssertions" Version="8.2.0" />
<PackageReference Include="LanguageExt.Core" Version="4.4.9" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="8.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.11.20">
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.2" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="8.0.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.13.61">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="NSubstitute" Version="5.1.0" />
<PackageReference Include="NUnit" Version="4.2.2" />
<PackageReference Include="NUnit3TestAdapter" Version="4.6.0" />
<PackageReference Include="Serilog" Version="4.0.1" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="NUnit" Version="4.3.2" />
<PackageReference Include="NUnit3TestAdapter" Version="5.0.0" />
<PackageReference Include="Serilog" Version="4.2.0" />
<PackageReference Include="Serilog.Extensions.Logging" Version="8.0.0" />
<PackageReference Include="Serilog.Sinks.Debug" Version="3.0.0" />
</ItemGroup>
@@ -356,6 +356,66 @@ public class FFmpegPlaybackSettingsCalculatorTests
actual.PadToDesiredResolution.Should().BeTrue();
}
[Test]
public void Should_ScaleBeyondMinSize_ForCrop_ForTransportStream()
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
Resolution = new Resolution { Width = 1280, Height = 720 },
ScalingBehavior = ScalingBehavior.Crop
};
var version = new MediaVersion { Width = 944, Height = 720, SampleAspectRatio = "1:1" };
FFmpegPlaybackSettings actual = FFmpegPlaybackSettingsCalculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
version,
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero,
false,
None);
IDisplaySize scaledSize = actual.ScaledSize.IfNone(new MediaVersion { Width = 0, Height = 0 });
scaledSize.Width.Should().Be(1280);
scaledSize.Height.Should().Be(976);
actual.PadToDesiredResolution.Should().BeFalse();
}
[Test]
public void Should_ScaleDownToMinSize_ForCrop_ForTransportStream()
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
Resolution = new Resolution { Width = 1280, Height = 720 },
ScalingBehavior = ScalingBehavior.Crop
};
var version = new MediaVersion { Width = 1920, Height = 816, SampleAspectRatio = "1:1" };
FFmpegPlaybackSettings actual = FFmpegPlaybackSettingsCalculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
version,
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero,
false,
None);
IDisplaySize scaledSize = actual.ScaledSize.IfNone(new MediaVersion { Width = 0, Height = 0 });
scaledSize.Width.Should().Be(1694);
scaledSize.Height.Should().Be(720);
actual.PadToDesiredResolution.Should().BeFalse();
}
[Test]
public void Should_NotPadToDesiredResolution_When_UnscaledContentIsUnderSized_ForHttpLiveStreaming()
{
@@ -71,6 +71,61 @@ public class FFmpegStreamSelectorTests
}
}
[Test]
public async Task Should_Select_Audio_Stream_With_Preferred_Title()
{
// skip movie/episode script paths by using other video
var mediaItem = new OtherVideo();
var mediaVersion = new MediaVersion
{
Streams =
[
new MediaStream
{
Index = 0,
MediaStreamKind = MediaStreamKind.Audio,
Channels = 2,
Language = "ja",
Title = "Some Title",
},
new MediaStream
{
Index = 1,
MediaStreamKind = MediaStreamKind.Audio,
Channels = 6,
Language = "eng",
Title = "Another Title",
Default = true
}
]
};
var audioVersion = new MediaItemAudioVersion(mediaItem, mediaVersion);
var channel = new Channel(Guid.NewGuid())
{
PreferredAudioTitle = "Some"
};
ISearchRepository searchRepository = Substitute.For<ISearchRepository>();
searchRepository.GetAllThreeLetterLanguageCodes(Arg.Any<List<string>>())
.Returns(Task.FromResult(new List<string> { "jpn", "eng" }));
var selector = new FFmpegStreamSelector(
new ScriptEngine(Substitute.For<ILogger<ScriptEngine>>()),
Substitute.For<IStreamSelectorRepository>(),
searchRepository,
Substitute.For<IConfigElementRepository>(),
Substitute.For<ILocalFileSystem>(),
Substitute.For<ILogger<FFmpegStreamSelector>>());
Option<MediaStream> selectedStream = await selector.SelectAudioStream(audioVersion, StreamingMode.TransportStream, channel, null, channel.PreferredAudioTitle);
selectedStream.IsSome.Should().BeTrue();
foreach (MediaStream stream in selectedStream)
{
stream.Language.Should().Be("ja");
}
}
[Test]
public async Task Should_Select_Subtitle_Stream_With_Preferred_Language()
{
@@ -65,7 +65,7 @@ public class FakeTelevisionRepository : ITelevisionRepository
public Task<bool> AddGenre(ShowMetadata metadata, Genre genre) => throw new NotSupportedException();
public Task<bool> AddGenre(EpisodeMetadata metadata, Genre genre) => throw new NotSupportedException();
public Task<bool> AddTag(Domain.Metadata metadata, Tag tag) => throw new NotSupportedException();
public Task<bool> AddTag(ErsatzTV.Core.Domain.Metadata metadata, Tag tag) => throw new NotSupportedException();
public Task<bool> AddStudio(ShowMetadata metadata, Studio studio) => throw new NotSupportedException();
public Task<bool> AddActor(ShowMetadata metadata, Actor actor) => throw new NotSupportedException();
@@ -0,0 +1,33 @@
using ErsatzTV.Core.Iptv;
using FluentAssertions;
using NUnit.Framework;
namespace ErsatzTV.Core.Tests.Iptv;
[TestFixture]
public class ChannelIdentifierTests
{
[TestCase("1.23", "1.23.etv")]
[TestCase("12.3", "12.3.etv")]
[TestCase("123", "123.etv")]
[TestCase("1.24", "1.24.etv")]
[TestCase("12.4", "12.4.etv")]
[TestCase("124", "124.etv")]
public void TestLegacy(string channelNumber, string expected)
{
string actual = ChannelIdentifier.LegacyFromNumber(channelNumber);
actual.Should().Be(expected);
}
[TestCase("1.23", "C1.23.150.ersatztv.org")]
[TestCase("12.3", "C12.3.198.ersatztv.org")]
[TestCase("123", "C123.246.ersatztv.org")]
[TestCase("1.24", "C1.24.151.ersatztv.org")]
[TestCase("12.4", "C12.4.199.ersatztv.org")]
[TestCase("124", "C124.247.ersatztv.org")]
public void TestNew(string channelNumber, string expected)
{
string actual = ChannelIdentifier.FromNumber(channelNumber);
actual.Should().Be(expected);
}
}
@@ -5,6 +5,7 @@ public record FFmpegFullProfileResponseModel(
string Name,
int ThreadCount,
int HardwareAcceleration,
string VaapiDisplay,
int VaapiDriver,
string VaapiDevice,
int ResolutionId,
+1
View File
@@ -29,6 +29,7 @@ public class Channel
public ChannelSubtitleMode SubtitleMode { get; set; }
public ChannelMusicVideoCreditsMode MusicVideoCreditsMode { get; set; }
public string MusicVideoCreditsTemplate { get; set; }
public ChannelSongVideoMode SongVideoMode { get; set; }
public ChannelProgressMode ProgressMode { get; set; }
public string WebEncodedName => WebUtility.UrlEncode(Name);
}
@@ -0,0 +1,7 @@
namespace ErsatzTV.Core.Domain;
public enum ChannelSongVideoMode
{
Default = 0,
WithProgress = 1
}
+3 -1
View File
@@ -30,5 +30,7 @@ public enum ChannelWatermarkMode
public enum ChannelWatermarkImageSource
{
Custom = 0,
ChannelLogo = 1
ChannelLogo = 1,
Resource = 100
}
+1
View File
@@ -8,6 +8,7 @@ public record FFmpegProfile
public string Name { get; set; }
public int ThreadCount { get; set; }
public HardwareAccelerationKind HardwareAcceleration { get; set; }
public string VaapiDisplay { get; set; }
public VaapiDriver VaapiDriver { get; set; }
public string VaapiDevice { get; set; }
public int? QsvExtraHardwareFrames { get; set; }
+3 -1
View File
@@ -9,5 +9,7 @@ public enum FillerKind
Tail = 4,
Fallback = 5,
GuideMode = 99
GuideMode = 99,
DecoDefault = 100
}
@@ -5,27 +5,27 @@ namespace ErsatzTV.Core.Domain;
public class BackgroundImageMediaVersion : MediaVersion
{
public static BackgroundImageMediaVersion ForPath(string path, IDisplaySize resolution) =>
public static BackgroundImageMediaVersion ForPath(string path, IDisplaySize resolution, bool isSongWithProgress = false) =>
new()
{
Chapters = new List<MediaChapter>(),
Chapters = [],
// image has been pre-generated with correct size
Height = resolution.Height,
Width = resolution.Width,
SampleAspectRatio = "1:1",
Streams = new List<MediaStream>
{
new()
Streams =
[
new MediaStream
{
MediaStreamKind = MediaStreamKind.Video,
Index = 0,
Codec = VideoFormat.GeneratedImage,
PixelFormat = new PixelFormatUnknown().Name // the resulting pixel format is unknown
}
},
MediaFiles = new List<MediaFile>
{
new() { Path = path }
}
],
MediaFiles = [new MediaFile { Path = path }],
IsSongWithProgress = isSongWithProgress
};
public bool IsSongWithProgress { get; private set; }
}
+17
View File
@@ -1,4 +1,5 @@
using System.Diagnostics;
using System.Globalization;
using ErsatzTV.Core.Domain.Filler;
namespace ErsatzTV.Core.Domain;
@@ -55,4 +56,20 @@ public class PlayoutItem
OutPoint = chapter.EndTime,
ChapterTitle = chapter.Title
};
public string GetDisplayDuration()
{
TimeSpan duration = FinishOffset - StartOffset;
if (duration >= TimeSpan.FromHours(24))
{
var ms = string.Format(CultureInfo.InvariantCulture, @"{0:mm\:ss}", duration);
return $"{(int)duration.TotalHours}:{ms}";
}
return string.Format(
CultureInfo.InvariantCulture,
duration.TotalHours >= 1 ? @"{0:h\:mm\:ss}" : @"{0:mm\:ss}",
duration);
}
}
+4 -1
View File
@@ -1,4 +1,6 @@
namespace ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Core.Domain;
public class ProgramSchedule
{
@@ -8,6 +10,7 @@ public class ProgramSchedule
public bool TreatCollectionsAsShows { get; set; }
public bool ShuffleScheduleItems { get; set; }
public bool RandomStartPoint { get; set; }
public FixedStartTimeBehavior FixedStartTimeBehavior { get; set; }
public List<ProgramScheduleItem> Items { get; set; }
public List<Playout> Playouts { get; set; }
public List<ProgramScheduleAlternate> ProgramScheduleAlternates { get; set; }
@@ -1,4 +1,5 @@
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Scheduling;
using Newtonsoft.Json;
namespace ErsatzTV.Core.Domain;
@@ -9,6 +10,7 @@ public abstract class ProgramScheduleItem
public int Index { get; set; }
public StartType StartType => StartTime.HasValue ? StartType.Fixed : StartType.Dynamic;
public TimeSpan? StartTime { get; set; }
public FixedStartTimeBehavior? FixedStartTimeBehavior { get; set; }
public ProgramScheduleItemCollectionType CollectionType { get; set; }
public GuideMode GuideMode { get; set; }
public string CustomTitle { get; set; }
+17 -14
View File
@@ -9,36 +9,39 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Bugsnag" Version="3.1.0" />
<PackageReference Include="Destructurama.Attributed" Version="4.0.0" />
<PackageReference Include="Bugsnag" Version="4.0.0" />
<PackageReference Include="Destructurama.Attributed" Version="5.1.0" />
<PackageReference Include="Flurl" Version="4.0.0" />
<PackageReference Include="LanguageExt.Core" Version="4.4.9" />
<PackageReference Include="LanguageExt.Transformers" Version="4.4.8" />
<PackageReference Include="MediatR" Version="12.4.1" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.1" />
<PackageReference Include="MediatR" Version="12.5.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.4" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.2" />
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.2" />
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.11.20">
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.13.61">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Serilog" Version="4.0.1" />
<PackageReference Include="Serilog" Version="4.2.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="SkiaSharp" Version="2.88.8" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="2.88.8" />
<PackageReference Include="SkiaSharp" Version="2.88.9" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="2.88.9" />
<PackageReference Include="TimeSpanParserUtil" Version="1.2.0" />
<PackageReference Include="YamlDotNet" Version="16.1.0" />
<PackageReference Include="YamlDotNet" Version="16.3.0" />
<!-- transitive; upgrading due to vuln -->
<PackageReference Include="System.Net.Http" Version="4.3.4" />
</ItemGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>ErsatzTV.Core.Tests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ErsatzTV.FFmpeg\ErsatzTV.FFmpeg.csproj" />
</ItemGroup>
+2 -2
View File
@@ -4,6 +4,6 @@ public record ConcatPlaylist(string Scheme, string Host, string ChannelNumber, s
{
public override string ToString() =>
$@"ffconcat version 1.0
file http://localhost:{Settings.ListenPort}/ffmpeg/stream/{ChannelNumber}?mode={Mode}
file http://localhost:{Settings.ListenPort}/ffmpeg/stream/{ChannelNumber}?mode={Mode}";
file http://localhost:{Settings.StreamingPort}/ffmpeg/stream/{ChannelNumber}?mode={Mode}
file http://localhost:{Settings.StreamingPort}/ffmpeg/stream/{ChannelNumber}?mode={Mode}";
}
@@ -59,6 +59,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
DateTimeOffset now,
Option<ChannelWatermark> playoutItemWatermark,
Option<ChannelWatermark> globalWatermark,
string vaapiDisplay,
VaapiDriver vaapiDriver,
string vaapiDevice,
Option<int> qsvExtraHardwareFrames,
@@ -107,7 +108,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
if (subtitle.SubtitleKind == SubtitleKind.Sidecar)
{
// proxy to avoid dealing with escaping
subtitle.Path = $"http://localhost:{Settings.ListenPort}/media/subtitle/{subtitle.Id}";
subtitle.Path = $"http://localhost:{Settings.StreamingPort}/media/subtitle/{subtitle.Id}";
}
}
@@ -335,8 +336,24 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
if (channel.FFmpegProfile.ScalingBehavior is ScalingBehavior.Crop)
{
paddedSize = ffmpegVideoStream.SquarePixelFrameSizeForCrop(
new FrameSize(channel.FFmpegProfile.Resolution.Width, channel.FFmpegProfile.Resolution.Height));
bool isTooSmallToCrop = videoVersion.Height < channel.FFmpegProfile.Resolution.Height ||
videoVersion.Width < channel.FFmpegProfile.Resolution.Width;
// if any dimension is smaller than the crop, scale beyond the crop (beyond the target resolution)
if (isTooSmallToCrop)
{
foreach (IDisplaySize size in playbackSettings.ScaledSize)
{
scaledSize = new FrameSize(size.Width, size.Height);
}
paddedSize = scaledSize;
}
else
{
paddedSize = ffmpegVideoStream.SquarePixelFrameSizeForCrop(
new FrameSize(channel.FFmpegProfile.Resolution.Width, channel.FFmpegProfile.Resolution.Height));
}
cropSize = new FrameSize(
channel.FFmpegProfile.Resolution.Width,
@@ -380,7 +397,9 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
hlsSegmentTemplate,
ptsOffset,
playbackSettings.ThreadCount,
qsvExtraHardwareFrames);
qsvExtraHardwareFrames,
videoVersion is BackgroundImageMediaVersion { IsSongWithProgress: true },
IsHdrTonemap: false);
_logger.LogDebug("FFmpeg desired state {FrameState}", desiredState);
@@ -391,6 +410,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
watermarkInputFile,
subtitleInputFile,
Option<ConcatInputFile>.None,
VaapiDisplayName(hwAccel, vaapiDisplay),
VaapiDriverName(hwAccel, vaapiDriver),
VaapiDeviceName(hwAccel, vaapiDevice),
FileSystemLayout.FFmpegReportsFolder,
@@ -411,6 +431,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
string errorMessage,
bool hlsRealtime,
long ptsOffset,
string vaapiDisplay,
VaapiDriver vaapiDriver,
string vaapiDevice,
Option<int> qsvExtraHardwareFrames)
@@ -531,7 +552,9 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
hlsSegmentTemplate,
ptsOffset,
Option<int>.None,
qsvExtraHardwareFrames);
qsvExtraHardwareFrames,
IsSongWithProgress: false,
IsHdrTonemap: false);
var ffmpegSubtitleStream = new ErsatzTV.FFmpeg.MediaStream(0, "ass", StreamKind.Video);
@@ -551,6 +574,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
None,
subtitleInputFile,
Option<ConcatInputFile>.None,
VaapiDisplayName(hwAccel, vaapiDisplay),
VaapiDriverName(hwAccel, vaapiDriver),
VaapiDeviceName(hwAccel, vaapiDevice),
FileSystemLayout.FFmpegReportsFolder,
@@ -572,7 +596,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
var resolution = new FrameSize(channel.FFmpegProfile.Resolution.Width, channel.FFmpegProfile.Resolution.Height);
var concatInputFile = new ConcatInputFile(
$"http://localhost:{Settings.ListenPort}/ffmpeg/concat/{channel.Number}?mode=ts-legacy",
$"http://localhost:{Settings.StreamingPort}/ffmpeg/concat/{channel.Number}?mode=ts-legacy",
resolution);
IPipelineBuilder pipelineBuilder = await _pipelineBuilderFactory.GetBuilder(
@@ -582,6 +606,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
None,
None,
concatInputFile,
Option<string>.None,
None,
None,
FileSystemLayout.FFmpegReportsFolder,
@@ -604,7 +629,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
{
var resolution = new FrameSize(channel.FFmpegProfile.Resolution.Width, channel.FFmpegProfile.Resolution.Height);
var concatInputFile = new ConcatInputFile(
$"http://localhost:{Settings.ListenPort}/ffmpeg/concat/{channel.Number}?mode=segmenter-v2",
$"http://localhost:{Settings.StreamingPort}/ffmpeg/concat/{channel.Number}?mode=segmenter-v2",
resolution);
FFmpegPlaybackSettings playbackSettings = FFmpegPlaybackSettingsCalculator.CalculateConcatSegmenterSettings(
@@ -699,6 +724,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
playbackSettings.VideoTrackTimeScale,
playbackSettings.Deinterlace);
Option<string> vaapiDisplay = VaapiDisplayName(hwAccel, channel.FFmpegProfile.VaapiDisplay);
Option<string> vaapiDriver = VaapiDriverName(hwAccel, channel.FFmpegProfile.VaapiDriver);
Option<string> vaapiDevice = VaapiDeviceName(hwAccel, channel.FFmpegProfile.VaapiDevice);
@@ -721,7 +747,9 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
hlsSegmentTemplate,
0,
playbackSettings.ThreadCount,
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames));
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames),
IsSongWithProgress: false,
IsHdrTonemap: false);
_logger.LogDebug("FFmpeg desired state {FrameState}", desiredState);
@@ -732,6 +760,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
watermarkInputFile,
subtitleInputFile,
concatInputFile,
vaapiDisplay,
vaapiDriver,
vaapiDevice,
FileSystemLayout.FFmpegReportsFolder,
@@ -761,7 +790,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
: $"&access_token={accessToken}";
var concatInputFile = new ConcatInputFile(
$"http://localhost:{Settings.ListenPort}/iptv/channel/{channel.Number}.m3u8?mode=segmenter{accessTokenQuery}",
$"http://localhost:{Settings.StreamingPort}/iptv/channel/{channel.Number}.m3u8?mode=segmenter{accessTokenQuery}",
resolution);
IPipelineBuilder pipelineBuilder = await _pipelineBuilderFactory.GetBuilder(
@@ -771,6 +800,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
None,
None,
concatInputFile,
Option<string>.None,
None,
None,
FileSystemLayout.FFmpegReportsFolder,
@@ -811,6 +841,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
None,
None,
Option<ConcatInputFile>.None,
Option<string>.None,
None,
None,
FileSystemLayout.FFmpegReportsFolder,
@@ -969,6 +1000,9 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
.WithEnvironmentVariables(environmentVariables.ToDictionary(e => e.Key, e => e.Value));
}
private static Option<string> VaapiDisplayName(HardwareAccelerationMode accelerationMode, string vaapiDisplay) =>
accelerationMode == HardwareAccelerationMode.Vaapi ? vaapiDisplay : Option<string>.None;
private static Option<string> VaapiDriverName(HardwareAccelerationMode accelerationMode, VaapiDriver driver)
{
if (accelerationMode == HardwareAccelerationMode.Vaapi)
@@ -99,7 +99,7 @@ public static class FFmpegPlaybackSettingsCalculator
}
IDisplaySize sizeAfterScaling = result.ScaledSize.IfNone(videoVersion);
if (!sizeAfterScaling.IsSameSizeAs(ffmpegProfile.Resolution))
if (!sizeAfterScaling.IsSameSizeAs(ffmpegProfile.Resolution) && ffmpegProfile.ScalingBehavior is not ScalingBehavior.Crop)
{
result.PadToDesiredResolution = true;
}
@@ -229,7 +229,8 @@ public static class FFmpegPlaybackSettingsCalculator
private static bool NeedToScale(FFmpegProfile ffmpegProfile, MediaVersion version) =>
IsIncorrectSize(ffmpegProfile.Resolution, version) ||
IsTooLarge(ffmpegProfile.Resolution, version) ||
IsOddSize(version);
IsOddSize(version) ||
TooSmallToCrop(ffmpegProfile, version);
private static bool IsIncorrectSize(IDisplaySize desiredResolution, MediaVersion version) =>
IsAnamorphic(version) ||
@@ -243,6 +244,16 @@ public static class FFmpegPlaybackSettingsCalculator
private static bool IsOddSize(MediaVersion version) =>
version.Height % 2 == 1 || version.Width % 2 == 1;
private static bool TooSmallToCrop(FFmpegProfile ffmpegProfile, MediaVersion version)
{
if (ffmpegProfile.ScalingBehavior is not ScalingBehavior.Crop)
{
return false;
}
return version.Height < ffmpegProfile.Resolution.Height || version.Width < ffmpegProfile.Resolution.Width;
}
private static DisplaySize CalculateScaledSize(FFmpegProfile ffmpegProfile, MediaVersion version)
{
IDisplaySize sarSize = SARSize(version);
@@ -256,12 +267,20 @@ public static class FFmpegPlaybackSettingsCalculator
int hh1 = hw1 * q / p;
int hh2 = targetSize.Height;
int hw2 = targetSize.Height * p / q;
if (hh1 <= targetSize.Height)
// crop needs to return whichever version has *both* dimensions >= required
// because it will never pad
if (ffmpegProfile.ScalingBehavior is ScalingBehavior.Crop)
{
return new DisplaySize(hw1, hh1);
if (hw1 >= targetSize.Width && hh1 >= targetSize.Height)
{
return new DisplaySize(hw1, hh1);
}
return new DisplaySize(hw2, hh2);
}
return new DisplaySize(hw2, hh2);
return hh1 <= targetSize.Height ? new DisplaySize(hw1, hh1) : new DisplaySize(hw2, hh2);
}
private static int Gcd(int a, int b)
@@ -179,6 +179,13 @@ public class FFmpegProcessService
{
switch (watermark.ImageSource)
{
// used for song progress overlay
case ChannelWatermarkImageSource.Resource:
return new WatermarkOptions(
await watermarkOverride.IfNoneAsync(watermark),
Path.Combine(FileSystemLayout.ResourcesCacheFolder, watermark.Image),
Option<int>.None,
false);
case ChannelWatermarkImageSource.Custom:
string customPath = _imageCache.GetPathForImage(
watermark.Image,
+1 -1
View File
@@ -275,7 +275,7 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
matchingTitle.Count,
title);
return PrioritizeDefault(streams);
return PrioritizeDefault(matchingTitle);
}
_logger.LogDebug("Unable to find audio stream with preferred title {Title}", title);
+52 -37
View File
@@ -41,8 +41,8 @@ public class SongVideoGenerator : ISongVideoGenerator
{
Id = -1,
Chapters = [],
Width = 192,
Height = 108,
Width = channel.FFmpegProfile.Resolution.Width / 10,
Height = channel.FFmpegProfile.Resolution.Height / 10,
SampleAspectRatio = "1:1",
Streams = [new MediaStream { MediaStreamKind = MediaStreamKind.Video, Index = 0, PixelFormat = "yuv420p" }]
};
@@ -64,12 +64,17 @@ public class SongVideoGenerator : ISongVideoGenerator
var boxBlur = false;
const int HORIZONTAL_MARGIN_PERCENT = 3;
const int VERTICAL_MARGIN_PERCENT = 5;
var verticalMarginPercent = 5;
const int WATERMARK_WIDTH_PERCENT = 25;
WatermarkLocation watermarkLocation = NextRandom(2) == 0
? WatermarkLocation.BottomLeft
: WatermarkLocation.BottomRight;
if (channel.SongVideoMode is ChannelSongVideoMode.WithProgress)
{
verticalMarginPercent += 10;
}
foreach (SongMetadata metadata in song.SongMetadata)
{
var fontSize = (int)Math.Round(channel.FFmpegProfile.Resolution.Height / 20.0);
@@ -121,24 +126,21 @@ public class SongVideoGenerator : ISongVideoGenerator
int leftMarginPercent = HORIZONTAL_MARGIN_PERCENT;
int rightMarginPercent = HORIZONTAL_MARGIN_PERCENT;
if (metadata.Artwork.Any(a => a.ArtworkKind == ArtworkKind.Thumbnail))
switch (watermarkLocation)
{
switch (watermarkLocation)
{
case WatermarkLocation.BottomLeft:
leftMarginPercent += WATERMARK_WIDTH_PERCENT + HORIZONTAL_MARGIN_PERCENT;
break;
case WatermarkLocation.BottomRight:
leftMarginPercent = rightMarginPercent = HORIZONTAL_MARGIN_PERCENT;
rightMarginPercent += WATERMARK_WIDTH_PERCENT + HORIZONTAL_MARGIN_PERCENT;
break;
}
case WatermarkLocation.BottomLeft:
leftMarginPercent += WATERMARK_WIDTH_PERCENT + HORIZONTAL_MARGIN_PERCENT;
break;
case WatermarkLocation.BottomRight:
leftMarginPercent = rightMarginPercent = HORIZONTAL_MARGIN_PERCENT;
rightMarginPercent += WATERMARK_WIDTH_PERCENT + HORIZONTAL_MARGIN_PERCENT;
break;
}
var leftMargin = (int)Math.Round(leftMarginPercent / 100.0 * channel.FFmpegProfile.Resolution.Width);
var rightMargin = (int)Math.Round(rightMarginPercent / 100.0 * channel.FFmpegProfile.Resolution.Width);
var verticalMargin =
(int)Math.Round(VERTICAL_MARGIN_PERCENT / 100.0 * channel.FFmpegProfile.Resolution.Height);
(int)Math.Round(verticalMarginPercent / 100.0 * channel.FFmpegProfile.Resolution.Height);
subtitleFile = await new SubtitleBuilder(_tempFilePool)
.WithResolution(channel.FFmpegProfile.Resolution)
@@ -156,30 +158,40 @@ public class SongVideoGenerator : ISongVideoGenerator
.BuildFile();
// use thumbnail (cover art) if present
foreach (Artwork artwork in Optional(
metadata.Artwork.Find(a => a.ArtworkKind == ArtworkKind.Thumbnail)))
{
// signal that we want to use cover art as watermark
videoVersion = new CoverArtMediaVersion
{
Chapters = new List<MediaChapter>(),
// always stretch cover art
Width = 192,
Height = 108,
SampleAspectRatio = "1:1",
Streams = new List<MediaStream>
// fall back to default art
Artwork artwork = await Optional(metadata.Artwork.Find(a => a.ArtworkKind == ArtworkKind.Thumbnail))
.IfNoneAsync(
new Artwork
{
new() { MediaStreamKind = MediaStreamKind.Video, Index = 0 }
}
};
Id = 0,
ArtworkKind = ArtworkKind.Thumbnail,
Path = Path.Combine(FileSystemLayout.ResourcesCacheFolder, "song_album_cover_512.png"),
});
string customPath = _imageCache.GetPathForImage(
artwork.Path,
ArtworkKind.Thumbnail,
Option<int>.None);
// signal that we want to use cover art as watermark
videoVersion = new CoverArtMediaVersion
{
Chapters = [],
// always stretch cover art
Width = channel.FFmpegProfile.Resolution.Width / 10,
Height = channel.FFmpegProfile.Resolution.Height / 10,
SampleAspectRatio = "1:1",
Streams = new List<MediaStream>
{
new() { MediaStreamKind = MediaStreamKind.Video, Index = 0 }
}
};
watermarkPath = customPath;
string customPath = _imageCache.GetPathForImage(
artwork.Path,
ArtworkKind.Thumbnail,
Option<int>.None);
watermarkPath = customPath;
// only blurhash real album art
if (artwork.Id > 0)
{
// randomize selected blur hash
var hashes = new List<string>
{
@@ -225,14 +237,17 @@ public class SongVideoGenerator : ISongVideoGenerator
watermarkPath,
watermarkLocation,
HORIZONTAL_MARGIN_PERCENT,
VERTICAL_MARGIN_PERCENT,
verticalMarginPercent,
WATERMARK_WIDTH_PERCENT,
cancellationToken);
foreach (string si in maybeSongImage.RightToSeq())
{
videoPath = si;
videoVersion = BackgroundImageMediaVersion.ForPath(si, channel.FFmpegProfile.Resolution);
videoVersion = BackgroundImageMediaVersion.ForPath(
si,
channel.FFmpegProfile.Resolution,
isSongWithProgress: channel.SongVideoMode is ChannelSongVideoMode.WithProgress);
}
return Tuple(videoPath, videoVersion);
+1 -1
View File
@@ -19,7 +19,7 @@ public class ChannelLogoGenerator : IChannelLogoGenerator
}
public static Option<string> GenerateChannelLogoUrl(Channel channel) =>
$"http://localhost:{Settings.ListenPort}{GetRoute}?{GetRouteQueryParamName}={channel.WebEncodedName}";
$"http://localhost:{Settings.StreamingPort}{GetRoute}?{GetRouteQueryParamName}={channel.WebEncodedName}";
public Either<BaseError, byte[]> GenerateChannelLogo(
string text,
@@ -28,6 +28,7 @@ public interface IFFmpegProcessService
DateTimeOffset now,
Option<ChannelWatermark> playoutItemWatermark,
Option<ChannelWatermark> globalWatermark,
string vaapiDisplay,
VaapiDriver vaapiDriver,
string vaapiDevice,
Option<int> qsvExtraHardwareFrames,
@@ -47,6 +48,7 @@ public interface IFFmpegProcessService
string errorMessage,
bool hlsRealtime,
long ptsOffset,
string vaapiDisplay,
VaapiDriver vaapiDriver,
string vaapiDevice,
Option<int> qsvExtraHardwareFrames);
@@ -7,7 +7,6 @@ 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, string>> GetAdminUserId(string address, string apiKey);
IAsyncEnumerable<Tuple<JellyfinMovie, int>> GetMovieLibraryItems(string address, string apiKey, JellyfinLibrary library);
@@ -9,7 +9,6 @@ public interface IEntityLocker
event EventHandler OnEmbyCollectionsChanged;
event EventHandler OnJellyfinCollectionsChanged;
event EventHandler OnPlexCollectionsChanged;
event EventHandler<int> OnPlayoutChanged;
bool LockLibrary(int libraryId);
bool UnlockLibrary(int libraryId);
bool IsLibraryLocked(int libraryId);
@@ -31,7 +30,7 @@ public interface IEntityLocker
bool LockPlexCollections();
bool UnlockPlexCollections();
bool ArePlexCollectionsLocked();
bool LockPlayout(int playoutId);
bool UnlockPlayout(int playoutId);
Task<bool> LockPlayout(int playoutId);
Task<bool> UnlockPlayout(int playoutId);
bool IsPlayoutLocked(int playoutId);
}
@@ -7,7 +7,8 @@ public interface IPlexServerApiClient
{
Task<bool> Ping(
PlexConnection connection,
PlexServerAuthToken token);
PlexServerAuthToken token,
CancellationToken cancellationToken);
Task<Either<BaseError, List<PlexLibrary>>> GetLibraries(
PlexConnection connection,
@@ -24,7 +24,7 @@ public interface ISearchIndex : IDisposable
IFallbackMetadataProvider fallbackMetadataProvider,
List<MediaItem> items);
Task<Unit> RemoveItems(IEnumerable<int> ids);
Task<bool> RemoveItems(IEnumerable<int> ids);
Task<SearchResult> Search(IClient client, string query, int skip, int limit);
void Commit();
}
+25
View File
@@ -0,0 +1,25 @@
using System.Globalization;
namespace ErsatzTV.Core.Iptv;
public static class ChannelIdentifier
{
public static string LegacyFromNumber(string channelNumber)
{
return $"{channelNumber}.etv";
}
public static string FromNumber(string channelNumber)
{
// get rid of any decimal (only two are allowed)
int number = (int)(decimal.Parse(channelNumber, CultureInfo.InvariantCulture) * 100);
int id = 0;
while (number != 0)
{
id += number % 10 + 48;
number /= 10;
}
return $"C{channelNumber}.{id}.ersatztv.org";
}
}
+1 -1
View File
@@ -86,7 +86,7 @@ public class ChannelPlaylist
sb.AppendLine(
CultureInfo.InvariantCulture,
$"#EXTINF:0 tvg-id=\"{channel.Number}.etv\" channel-id=\"{shortUniqueId}\" channel-number=\"{channel.Number}\" CUID=\"{shortUniqueId}\" tvg-chno=\"{channel.Number}\" tvg-name=\"{channel.Name}\" tvg-logo=\"{logo}\" group-title=\"{channel.Group}\" tvc-stream-vcodec=\"{vcodec}\" tvc-stream-acodec=\"{acodec}\", {channel.Name}");
$"#EXTINF:0 tvg-id=\"{ChannelIdentifier.FromNumber(channel.Number)}\" channel-id=\"{shortUniqueId}\" channel-number=\"{channel.Number}\" CUID=\"{shortUniqueId}\" tvg-chno=\"{channel.Number}\" tvg-name=\"{channel.Name}\" tvg-logo=\"{logo}\" group-title=\"{channel.Group}\" tvc-stream-vcodec=\"{vcodec}\" tvc-stream-acodec=\"{acodec}\", {channel.Name}");
sb.AppendLine(
CultureInfo.InvariantCulture,
$"{_scheme}://{_host}{_baseUrl}/iptv/channel/{channel.Number}.{format}");
@@ -0,0 +1,5 @@
using MediatR;
namespace ErsatzTV.Core.Notifications;
public record PlayoutUpdatedNotification(int PlayoutId, bool IsLocked) : INotification;
@@ -107,7 +107,10 @@ public class BlockPlayoutFillerBuilder(
Finish = current.UtcDateTime + itemDuration,
InPoint = TimeSpan.Zero,
OutPoint = itemDuration,
FillerKind = FillerKind.Fallback,
// FillerKind.Fallback will loop and avoid hw accel, so don't use that
FillerKind = FillerKind.DecoDefault,
CollectionKey = JsonConvert.SerializeObject(collectionKey, JsonSettings),
GuideStart = one.GuideStart,
GuideFinish = one.GuideFinish,
@@ -0,0 +1,7 @@
namespace ErsatzTV.Core.Scheduling;
public enum FixedStartTimeBehavior
{
Strict = 0,
Flexible = 1
}
@@ -195,6 +195,12 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
while (result.State.Index < state.Index)
{
result.MoveNext();
// previous state is no longer valid; playlist now has fewer items
if (result.State.Index == 0)
{
break;
}
}
return result;
@@ -71,7 +71,21 @@ public abstract class PlayoutModeSchedulerBase<T> : IPlayoutModeScheduler<T> whe
// itemStartTime.TotalMilliseconds);
// need to wrap to the next day if appropriate
startTime = startTime.TimeOfDay > itemStartTime ? result.AddDays(1) : result;
FixedStartTimeBehavior? fixedStartTimeBehavior = scheduleItem.FixedStartTimeBehavior;
if (fixedStartTimeBehavior is null && scheduleItem.ProgramSchedule is not null)
fixedStartTimeBehavior = scheduleItem.ProgramSchedule.FixedStartTimeBehavior;
switch (fixedStartTimeBehavior)
{
case FixedStartTimeBehavior.Flexible:
// only wait for times on the same day
if (result.Day == startTime.Day && result.TimeOfDay > startTime.TimeOfDay)
startTime = result;
break;
case FixedStartTimeBehavior.Strict:
default:
startTime = startTime.TimeOfDay > itemStartTime ? result.AddDays(1) : result;
break;
}
}
return startTime;
@@ -10,6 +10,7 @@ public interface IYamlPlayoutHandler
Task<bool> Handle(
YamlPlayoutContext context,
YamlPlayoutInstruction instruction,
PlayoutBuildMode mode,
ILogger<YamlPlayoutBuilder> logger,
CancellationToken cancellationToken);
}
@@ -11,6 +11,7 @@ public class YamlPlayoutAllHandler(EnumeratorCache enumeratorCache) : YamlPlayou
public override async Task<bool> Handle(
YamlPlayoutContext context,
YamlPlayoutInstruction instruction,
PlayoutBuildMode mode,
ILogger<YamlPlayoutBuilder> logger,
CancellationToken cancellationToken)
{
@@ -48,7 +49,7 @@ public class YamlPlayoutAllHandler(EnumeratorCache enumeratorCache) : YamlPlayou
//PreferredAudioTitle = scheduleItem.PreferredAudioTitle,
//PreferredSubtitleLanguageCode = scheduleItem.PreferredSubtitleLanguageCode,
//SubtitleMode = scheduleItem.SubtitleMode
GuideGroup = context.NextGuideGroup()
GuideGroup = context.PeekNextGuideGroup()
//GuideStart = effectiveBlock.Start.UtcDateTime,
//GuideFinish = blockFinish.UtcDateTime,
//BlockKey = JsonConvert.SerializeObject(effectiveBlock.BlockKey),
@@ -57,6 +58,7 @@ public class YamlPlayoutAllHandler(EnumeratorCache enumeratorCache) : YamlPlayou
};
context.Playout.Items.Add(playoutItem);
context.AdvanceGuideGroup();
// create history record
Option<PlayoutHistory> maybeHistory = GetHistoryForItem(

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