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
183 changed files with 24507 additions and 931 deletions
+6 -3
View File
@@ -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
-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' }}
+46
View File
@@ -4,6 +4,52 @@ 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
@@ -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>>;
+3
View File
@@ -39,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.7" />
<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="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.12.19">
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.13.61">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
@@ -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);
}
@@ -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;
@@ -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);
}
}
@@ -618,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;
@@ -636,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
@@ -651,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}";
}
}
@@ -7,24 +7,24 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Bugsnag" Version="3.1.0" />
<PackageReference Include="CliWrap" Version="3.6.7" />
<PackageReference Include="FluentAssertions" Version="6.12.2" />
<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.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.11.1" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.12.19">
<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.3.0" />
<PackageReference Include="NUnit" Version="4.2.2" />
<PackageReference Include="NUnit3TestAdapter" Version="4.6.0" />
<PackageReference Include="Serilog" Version="4.1.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>
+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; }
+7 -7
View File
@@ -9,28 +9,28 @@
</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="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.12.19">
<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.1.0" />
<PackageReference Include="Serilog" Version="4.2.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<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.2.0" />
<PackageReference Include="YamlDotNet" Version="16.3.0" />
<!-- transitive; upgrading due to vuln -->
<PackageReference Include="System.Net.Http" Version="4.3.4" />
+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}";
}
@@ -108,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}";
}
}
@@ -398,7 +398,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
ptsOffset,
playbackSettings.ThreadCount,
qsvExtraHardwareFrames,
videoVersion is BackgroundImageMediaVersion { IsSongWithProgress: true });
videoVersion is BackgroundImageMediaVersion { IsSongWithProgress: true },
IsHdrTonemap: false);
_logger.LogDebug("FFmpeg desired state {FrameState}", desiredState);
@@ -552,7 +553,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
ptsOffset,
Option<int>.None,
qsvExtraHardwareFrames,
IsSongWithProgress: false);
IsSongWithProgress: false,
IsHdrTonemap: false);
var ffmpegSubtitleStream = new ErsatzTV.FFmpeg.MediaStream(0, "ass", StreamKind.Video);
@@ -594,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(
@@ -627,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(
@@ -746,7 +748,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
0,
playbackSettings.ThreadCount,
Optional(channel.FFmpegProfile.QsvExtraHardwareFrames),
IsSongWithProgress: false);
IsSongWithProgress: false,
IsHdrTonemap: false);
_logger.LogDebug("FFmpeg desired state {FrameState}", desiredState);
@@ -787,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(
+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,
@@ -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,
@@ -0,0 +1,5 @@
using MediatR;
namespace ErsatzTV.Core.Notifications;
public record PlayoutUpdatedNotification(int PlayoutId, bool IsLocked) : INotification;
@@ -0,0 +1,7 @@
namespace ErsatzTV.Core.Scheduling;
public enum FixedStartTimeBehavior
{
Strict = 0,
Flexible = 1
}
@@ -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;
+2 -1
View File
@@ -2,5 +2,6 @@
public static class Settings
{
public static int ListenPort { get; set; }
public static int UiPort { get; set; }
public static int StreamingPort { get; set; }
}
@@ -8,17 +8,17 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.2" />
<PackageReference Include="FluentAssertions" Version="8.2.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="NUnit" Version="4.2.2" />
<PackageReference Include="NUnit3TestAdapter" Version="4.6.0" />
<PackageReference Include="NUnit" Version="4.3.2" />
<PackageReference Include="NUnit3TestAdapter" Version="5.0.0" />
<PackageReference Include="System.IO.FileSystem.Primitives" Version="4.3.0" />
<PackageReference Include="System.Text.Encoding.Extensions" Version="4.3.0" />
<PackageReference Include="System.Runtime.Handles" Version="4.3.0" />
<PackageReference Include="System.Runtime.InteropServices" Version="4.3.0" />
<PackageReference Include="coverlet.collector" Version="6.0.2">
<PackageReference Include="coverlet.collector" Version="6.0.4">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
@@ -93,6 +93,7 @@ public class PipelineBuilderBaseTests
0,
Option<int>.None,
Option<int>.None,
false,
false);
var builder = new SoftwarePipelineBuilder(
@@ -187,6 +188,7 @@ public class PipelineBuilderBaseTests
0,
Option<int>.None,
Option<int>.None,
false,
false);
var builder = new SoftwarePipelineBuilder(
@@ -337,6 +339,7 @@ public class PipelineBuilderBaseTests
0,
Option<int>.None,
Option<int>.None,
false,
false);
var builder = new SoftwarePipelineBuilder(
@@ -425,6 +428,7 @@ public class PipelineBuilderBaseTests
0,
Option<int>.None,
Option<int>.None,
false,
false);
var builder = new SoftwarePipelineBuilder(
@@ -7,7 +7,8 @@ public class AmfHardwareCapabilities : IHardwareCapabilities
public FFmpegCapability CanDecode(
string videoFormat,
Option<string> videoProfile,
Option<IPixelFormat> maybePixelFormat) => FFmpegCapability.Software;
Option<IPixelFormat> maybePixelFormat,
bool isHdr) => FFmpegCapability.Software;
public FFmpegCapability CanEncode(
string videoFormat,
@@ -7,7 +7,8 @@ public class DefaultHardwareCapabilities : IHardwareCapabilities
public FFmpegCapability CanDecode(
string videoFormat,
Option<string> videoProfile,
Option<IPixelFormat> maybePixelFormat)
Option<IPixelFormat> maybePixelFormat,
bool isHdr)
{
int bitDepth = maybePixelFormat.Map(pf => pf.BitDepth).IfNone(8);
@@ -40,6 +40,8 @@ public class FFmpegCapabilities : IFFmpegCapabilities
HardwareAccelerationMode.Qsv => FFmpegKnownHardwareAcceleration.Qsv,
HardwareAccelerationMode.Vaapi => FFmpegKnownHardwareAcceleration.Vaapi,
HardwareAccelerationMode.VideoToolbox => FFmpegKnownHardwareAcceleration.VideoToolbox,
HardwareAccelerationMode.OpenCL => FFmpegKnownHardwareAcceleration.OpenCL,
HardwareAccelerationMode.Vulkan => FFmpegKnownHardwareAcceleration.Vulkan,
_ => Option<FFmpegKnownHardwareAcceleration>.None
};
@@ -3,6 +3,8 @@ namespace ErsatzTV.FFmpeg.Capabilities;
public record FFmpegKnownFilter
{
public static readonly FFmpegKnownFilter ScaleNpp = new("scale_npp");
public static readonly FFmpegKnownFilter TonemapOpenCL = new("tonemap_opencl");
public static readonly FFmpegKnownFilter Libplacebo = new("libplacebo");
private FFmpegKnownFilter(string Name) => this.Name = Name;
@@ -11,6 +13,7 @@ public record FFmpegKnownFilter
public static IList<string> AllFilters =>
new[]
{
ScaleNpp.Name
ScaleNpp.Name,
TonemapOpenCL.Name,
};
}
@@ -7,18 +7,21 @@ public record FFmpegKnownHardwareAcceleration
public static readonly FFmpegKnownHardwareAcceleration Qsv = new("qsv");
public static readonly FFmpegKnownHardwareAcceleration Vaapi = new("vaapi");
public static readonly FFmpegKnownHardwareAcceleration VideoToolbox = new("videotoolbox");
public static readonly FFmpegKnownHardwareAcceleration OpenCL = new("opencl");
public static readonly FFmpegKnownHardwareAcceleration Vulkan = new("vulkan");
private FFmpegKnownHardwareAcceleration(string Name) => this.Name = Name;
public string Name { get; }
public static IList<string> AllAccels =>
new[]
{
Amf.Name,
Cuda.Name,
Qsv.Name,
Vaapi.Name,
VideoToolbox.Name
};
[
Amf.Name,
Cuda.Name,
Qsv.Name,
Vaapi.Name,
VideoToolbox.Name,
OpenCL.Name,
Vulkan.Name
];
}
@@ -445,7 +445,7 @@ public class HardwareCapabilitiesFactory : IHardwareCapabilitiesFactory
Option<string> maybeLine = Optional(output.Split("\n").FirstOrDefault(x => x.Contains("GPU")));
foreach (string line in maybeLine)
{
const string ARCHITECTURE_PATTERN = @"SM\s+(\d\.\d)";
const string ARCHITECTURE_PATTERN = @"SM\s+(\d+\.\d+)";
Match match = Regex.Match(line, ARCHITECTURE_PATTERN);
if (match.Success && int.TryParse(match.Groups[1].Value.Replace(".", string.Empty), out int architecture))
{
@@ -7,7 +7,8 @@ public interface IHardwareCapabilities
public FFmpegCapability CanDecode(
string videoFormat,
Option<string> videoProfile,
Option<IPixelFormat> maybePixelFormat);
Option<IPixelFormat> maybePixelFormat,
bool isHdr);
public FFmpegCapability CanEncode(
string videoFormat,
@@ -7,7 +7,8 @@ public class NoHardwareCapabilities : IHardwareCapabilities
public FFmpegCapability CanDecode(
string videoFormat,
Option<string> videoProfile,
Option<IPixelFormat> maybePixelFormat) =>
Option<IPixelFormat> maybePixelFormat,
bool isHdr) =>
FFmpegCapability.Software;
public FFmpegCapability CanEncode(
@@ -29,8 +29,11 @@ public class NvidiaHardwareCapabilities : IHardwareCapabilities
public FFmpegCapability CanDecode(
string videoFormat,
Option<string> videoProfile,
Option<IPixelFormat> maybePixelFormat)
Option<IPixelFormat> maybePixelFormat,
bool isHdr)
{
// we use vulkan for hdr, so only support h264, hevc and av1 when isHdr == true
int bitDepth = maybePixelFormat.Map(pf => pf.BitDepth).IfNone(8);
bool isHardware = videoFormat switch
@@ -39,17 +42,17 @@ public class NvidiaHardwareCapabilities : IHardwareCapabilities
VideoFormat.Hevc => _architecture == 52 && _maxwellGm206.Contains(_model) || _architecture >= 60,
// pascal is required to decode vp9 10-bit
VideoFormat.Vp9 when bitDepth == 10 => _architecture >= 60,
VideoFormat.Vp9 when bitDepth == 10 => !isHdr && _architecture >= 60,
// some second gen maxwell can decode vp9, otherwise pascal is required
VideoFormat.Vp9 => _architecture == 52 && _maxwellGm206.Contains(_model) || _architecture >= 60,
VideoFormat.Vp9 => !isHdr && _architecture == 52 && _maxwellGm206.Contains(_model) || _architecture >= 60,
// no hardware decoding of 10-bit h264
VideoFormat.H264 => bitDepth < 10,
VideoFormat.Mpeg2Video => true,
VideoFormat.Mpeg2Video => !isHdr,
VideoFormat.Vc1 => true,
VideoFormat.Vc1 => !isHdr,
// too many issues with odd mpeg4 content, so use software
VideoFormat.Mpeg4 => false,
@@ -18,7 +18,8 @@ public class VaapiHardwareCapabilities : IHardwareCapabilities
public FFmpegCapability CanDecode(
string videoFormat,
Option<string> videoProfile,
Option<IPixelFormat> maybePixelFormat)
Option<IPixelFormat> maybePixelFormat,
bool isHdr)
{
int bitDepth = maybePixelFormat.Map(pf => pf.BitDepth).IfNone(8);
@@ -0,0 +1,13 @@
namespace ErsatzTV.FFmpeg.Decoder.Cuvid;
public class DecoderImplicitVulkan : DecoderBase
{
protected override FrameDataLocation OutputFrameDataLocation => FrameDataLocation.Hardware;
public override string Name => string.Empty;
public override string[] InputOptions(InputFile inputFile) =>
[
"-hwaccel_output_format",
"vulkan"
];
}
+2 -2
View File
@@ -9,9 +9,9 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CliWrap" Version="3.6.7" />
<PackageReference Include="CliWrap" Version="3.8.2" />
<PackageReference Include="LanguageExt.Core" Version="4.4.9" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.4" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.2" />
</ItemGroup>
+3 -1
View File
@@ -22,7 +22,8 @@ public record FFmpegState(
long PtsOffset,
Option<int> ThreadCount,
Option<int> MaybeQsvExtraHardwareFrames,
bool IsSongWithProgress)
bool IsSongWithProgress,
bool IsHdrTonemap)
{
public int QsvExtraHardwareFrames => MaybeQsvExtraHardwareFrames.IfNone(64);
@@ -47,5 +48,6 @@ public record FFmpegState(
0,
Option<int>.None,
Option<int>.None,
false,
false);
}
@@ -0,0 +1,16 @@
using ErsatzTV.FFmpeg.Format;
namespace ErsatzTV.FFmpeg.Filter.Cuda;
public class TonemapCudaFilter(IPixelFormat desiredPixelFormat) : BaseFilter
{
public override string Filter =>
$"libplacebo=tonemapping=auto:colorspace=bt709:color_primaries=bt709:color_trc=bt709:format={desiredPixelFormat.FFmpegName},hwupload_cuda";
public override FrameState NextState(FrameState currentState) =>
currentState with
{
PixelFormat = Some(desiredPixelFormat),
FrameDataLocation = FrameDataLocation.Hardware
};
}
@@ -0,0 +1,12 @@
namespace ErsatzTV.FFmpeg.Filter.Qsv;
public class TonemapQsvFilter : BaseFilter
{
public override string Filter => "vpp_qsv=tonemap=1";
public override FrameState NextState(FrameState currentState) =>
currentState with
{
FrameDataLocation = FrameDataLocation.Hardware
};
}
+1 -1
View File
@@ -20,7 +20,7 @@ public class TonemapFilter : BaseFilter
string pixelFormat = _currentState.PixelFormat.Match(pf => pf.FFmpegName, () => string.Empty);
var tonemap =
$"setparams=colorspace=bt2020c,zscale=transfer=linear,tonemap=hable,zscale=transfer=bt709,format={_desiredPixelFormat.FFmpegName}";
$"zscale=transfer=linear,tonemap=clip,zscale=transfer=bt709,format={_desiredPixelFormat.FFmpegName}";
if (_currentState.FrameDataLocation == FrameDataLocation.Hardware)
{
@@ -0,0 +1,31 @@
namespace ErsatzTV.FFmpeg.Filter.Vaapi;
public class PadVaapiFilter : BaseFilter
{
private readonly FrameState _currentState;
private readonly FrameSize _paddedSize;
public PadVaapiFilter(FrameState currentState, FrameSize paddedSize)
{
_currentState = currentState;
_paddedSize = paddedSize;
}
public override string Filter
{
get
{
var pad = $"pad_vaapi=w={_paddedSize.Width}:h={_paddedSize.Height}:x=-1:y=-1:color=black";
return _currentState.FrameDataLocation == FrameDataLocation.Hardware
? pad
: $"format=nv12|p010le|vaapi,hwupload,{pad}";
}
}
public override FrameState NextState(FrameState currentState) => currentState with
{
PaddedSize = _paddedSize,
FrameDataLocation = FrameDataLocation.Hardware
};
}
@@ -0,0 +1,12 @@
namespace ErsatzTV.FFmpeg.Filter.Vaapi;
public class TonemapVaapiFilter : BaseFilter
{
public override string Filter => "hwupload=derive_device=vaapi,hwmap=derive_device=opencl,tonemap_opencl,hwmap=derive_device=vaapi:reverse=1";
public override FrameState NextState(FrameState currentState) =>
currentState with
{
FrameDataLocation = FrameDataLocation.Hardware
};
}
@@ -1,6 +1,17 @@
namespace ErsatzTV.FFmpeg.GlobalOption.HardwareAcceleration;
public class CudaHardwareAccelerationOption : GlobalOption
public class CudaHardwareAccelerationOption(bool isVulkanHdr) : GlobalOption
{
public override string[] GlobalOptions => new[] { "-hwaccel", "cuda" };
public override string[] GlobalOptions
{
get
{
if (isVulkanHdr)
{
return ["-init_hw_device", "cuda=nv", "-init_hw_device", "vulkan=vk@nv", "-hwaccel", "vulkan"];
}
return ["-hwaccel", "cuda"];
}
}
}
+3 -1
View File
@@ -7,5 +7,7 @@ public enum HardwareAccelerationMode
Nvenc = 2,
Vaapi = 3,
VideoToolbox = 4,
Amf = 5
Amf = 5,
OpenCL = 6,
Vulkan = 7
}
+4
View File
@@ -30,6 +30,8 @@ public record VideoStream(
Codec,
StreamKind.Video)
{
public ColorParams ColorParams { get; private set; } = ColorParams;
public int BitDepth => PixelFormat.Map(pf => pf.BitDepth).IfNone(8);
public string SampleAspectRatio
@@ -152,6 +154,8 @@ public record VideoStream(
return result;
}
public void ResetColorParams(ColorParams colorParams) => ColorParams = colorParams;
private double GetSAR()
{
// some media servers don't provide sample aspect ratio so we have to calculate it
@@ -51,7 +51,8 @@ public class AmfPipelineBuilder : SoftwarePipelineBuilder
FFmpegCapability decodeCapability = _hardwareCapabilities.CanDecode(
videoStream.Codec,
videoStream.Profile,
videoStream.PixelFormat);
videoStream.PixelFormat,
videoStream.ColorParams.IsHdr);
FFmpegCapability encodeCapability = _hardwareCapabilities.CanEncode(
desiredState.VideoFormat,
desiredState.VideoProfile,
@@ -59,7 +59,8 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder
FFmpegCapability decodeCapability = _hardwareCapabilities.CanDecode(
videoStream.Codec,
videoStream.Profile,
videoStream.PixelFormat);
videoStream.PixelFormat,
videoStream.ColorParams.IsHdr);
FFmpegCapability encodeCapability = _hardwareCapabilities.CanEncode(
desiredState.VideoFormat,
desiredState.VideoProfile,
@@ -77,9 +78,13 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder
decodeCapability = FFmpegCapability.Software;
}
bool isHdrTonemap = decodeCapability == FFmpegCapability.Hardware
&& _ffmpegCapabilities.HasHardwareAcceleration(HardwareAccelerationMode.Vulkan)
&& videoStream.ColorParams.IsHdr;
if (decodeCapability == FFmpegCapability.Hardware || encodeCapability == FFmpegCapability.Hardware)
{
pipelineSteps.Add(new CudaHardwareAccelerationOption());
pipelineSteps.Add(new CudaHardwareAccelerationOption(isHdrTonemap));
}
// disable hw accel if decoder/encoder isn't supported
@@ -90,7 +95,9 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder
: HardwareAccelerationMode.None,
EncoderHardwareAccelerationMode = encodeCapability == FFmpegCapability.Hardware
? HardwareAccelerationMode.Nvenc
: HardwareAccelerationMode.None
: HardwareAccelerationMode.None,
IsHdrTonemap = isHdrTonemap
};
}
@@ -100,6 +107,14 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder
FFmpegState ffmpegState,
PipelineContext context)
{
// use implicit vulkan decoder with HDR tonemap
if (ffmpegState.IsHdrTonemap)
{
IDecoder decoder = new DecoderImplicitVulkan();
videoInputFile.AddOption(decoder);
return Some(decoder);
}
Option<IDecoder> maybeDecoder = (ffmpegState.DecoderHardwareAccelerationMode, videoStream.Codec) switch
{
(HardwareAccelerationMode.Nvenc, VideoFormat.Hevc) => new DecoderHevcCuvid(HardwareAccelerationMode.Nvenc),
@@ -166,6 +181,7 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder
// desiredState = desiredState with { PixelFormat = Some(pixelFormat) };
// }
currentState = SetTonemap(videoInputFile, videoStream, ffmpegState, desiredState, currentState);
currentState = SetDeinterlace(videoInputFile, context, currentState);
currentState = SetScale(videoInputFile, videoStream, context, ffmpegState, desiredState, currentState);
currentState = SetPad(videoInputFile, videoStream, desiredState, currentState);
@@ -307,7 +323,7 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder
// clearing color params will force it to be re-added
if (videoStream.Codec == "vp9")
{
videoStream = videoStream with { ColorParams = ColorParams.Unknown };
videoStream.ResetColorParams(ColorParams.Unknown);
}
if (!videoStream.ColorParams.IsBt709)
@@ -710,4 +726,35 @@ public class NvidiaPipelineBuilder : SoftwarePipelineBuilder
return currentState;
}
private static FrameState SetTonemap(
VideoInputFile videoInputFile,
VideoStream videoStream,
FFmpegState ffmpegState,
FrameState desiredState,
FrameState currentState)
{
if (videoStream.ColorParams.IsHdr)
{
foreach (IPixelFormat pixelFormat in desiredState.PixelFormat)
{
if (ffmpegState.IsHdrTonemap)
{
var filter = new TonemapCudaFilter(pixelFormat);
currentState = filter.NextState(currentState);
videoStream.ResetColorParams(ColorParams.Default);
videoInputFile.FilterSteps.Add(filter);
}
else
{
var filter = new TonemapFilter(currentState, pixelFormat);
currentState = filter.NextState(currentState);
videoStream.ResetColorParams(ColorParams.Default);
videoInputFile.FilterSteps.Add(filter);
}
}
}
return currentState;
}
}
@@ -40,20 +40,10 @@ public class PipelineBuilderFactory : IPipelineBuilderFactory
vaapiDriver,
vaapiDevice);
bool isHdrContent = videoInputFile.Any(vif => vif.VideoStreams.Any(vs => vs.ColorParams.IsHdr));
return hardwareAccelerationMode switch
{
HardwareAccelerationMode.Nvenc when capabilities is not NoHardwareCapabilities => new NvidiaPipelineBuilder(
ffmpegCapabilities,
capabilities,
hardwareAccelerationMode,
videoInputFile,
audioInputFile,
watermarkInputFile,
subtitleInputFile,
concatInputFile,
reportsFolder,
fontsFolder,
_logger),
HardwareAccelerationMode.Vaapi when capabilities is not NoHardwareCapabilities => new VaapiPipelineBuilder(
ffmpegCapabilities,
capabilities,
@@ -66,6 +56,20 @@ public class PipelineBuilderFactory : IPipelineBuilderFactory
reportsFolder,
fontsFolder,
_logger),
HardwareAccelerationMode.Nvenc when capabilities is not NoHardwareCapabilities => new NvidiaPipelineBuilder(
ffmpegCapabilities,
capabilities,
hardwareAccelerationMode,
videoInputFile,
audioInputFile,
watermarkInputFile,
subtitleInputFile,
concatInputFile,
reportsFolder,
fontsFolder,
_logger),
HardwareAccelerationMode.Qsv when capabilities is not NoHardwareCapabilities => new QsvPipelineBuilder(
ffmpegCapabilities,
capabilities,
@@ -78,6 +82,20 @@ public class PipelineBuilderFactory : IPipelineBuilderFactory
reportsFolder,
fontsFolder,
_logger),
// force software pipeline when content is HDR (and not VAAPI or NVENC or QSV)
_ when isHdrContent => new SoftwarePipelineBuilder(
ffmpegCapabilities,
HardwareAccelerationMode.None,
videoInputFile,
audioInputFile,
watermarkInputFile,
subtitleInputFile,
concatInputFile,
reportsFolder,
fontsFolder,
_logger),
HardwareAccelerationMode.VideoToolbox when capabilities is not NoHardwareCapabilities => new
VideoToolboxPipelineBuilder(
ffmpegCapabilities,
@@ -91,6 +109,7 @@ public class PipelineBuilderFactory : IPipelineBuilderFactory
reportsFolder,
fontsFolder,
_logger),
HardwareAccelerationMode.Amf when capabilities is not NoHardwareCapabilities => new AmfPipelineBuilder(
ffmpegCapabilities,
capabilities,
@@ -103,6 +122,7 @@ public class PipelineBuilderFactory : IPipelineBuilderFactory
reportsFolder,
fontsFolder,
_logger),
_ => new SoftwarePipelineBuilder(
ffmpegCapabilities,
HardwareAccelerationMode.None,
+36 -3
View File
@@ -61,7 +61,8 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder
FFmpegCapability decodeCapability = _hardwareCapabilities.CanDecode(
videoStream.Codec,
videoStream.Profile,
videoStream.PixelFormat);
videoStream.PixelFormat,
videoStream.ColorParams.IsHdr);
FFmpegCapability encodeCapability = _hardwareCapabilities.CanEncode(
desiredState.VideoFormat,
desiredState.VideoProfile,
@@ -75,7 +76,7 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder
pipelineSteps.Add(new QsvHardwareAccelerationOption(ffmpegState.VaapiDevice));
bool isHevcOrH264 = videoStream.Codec is VideoFormat.Hevc or VideoFormat.H264;
bool isHevcOrH264 = videoStream.Codec is /*VideoFormat.Hevc or*/ VideoFormat.H264;
bool is10Bit = videoStream.PixelFormat.Map(pf => pf.BitDepth).IfNone(8) == 10;
// 10-bit hevc/h264 qsv decoders have issues, so use software
@@ -168,6 +169,7 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder
}
// _logger.LogDebug("After decode: {PixelFormat}", currentState.PixelFormat);
currentState = SetTonemap(videoInputFile, videoStream, ffmpegState, desiredState, currentState);
currentState = SetDeinterlace(videoInputFile, context, ffmpegState, currentState);
// _logger.LogDebug("After deinterlace: {PixelFormat}", currentState.PixelFormat);
currentState = SetScale(videoInputFile, videoStream, context, ffmpegState, desiredState, currentState);
@@ -271,7 +273,7 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder
IPixelFormat formatForDownload = pixelFormat;
bool usesVppQsv =
videoInputFile.FilterSteps.Any(f => f is QsvFormatFilter or ScaleQsvFilter or DeinterlaceQsvFilter);
videoInputFile.FilterSteps.Any(f => f is QsvFormatFilter or ScaleQsvFilter or DeinterlaceQsvFilter or TonemapQsvFilter);
// if we have no filters, check whether we need to convert pixel format
// since qsv doesn't seem to like doing that at the encoder
@@ -637,4 +639,35 @@ public class QsvPipelineBuilder : SoftwarePipelineBuilder
return currentState;
}
private static FrameState SetTonemap(
VideoInputFile videoInputFile,
VideoStream videoStream,
FFmpegState ffmpegState,
FrameState desiredState,
FrameState currentState)
{
if (videoStream.ColorParams.IsHdr)
{
foreach (IPixelFormat pixelFormat in desiredState.PixelFormat)
{
if (ffmpegState.DecoderHardwareAccelerationMode == HardwareAccelerationMode.Qsv)
{
var filter = new TonemapQsvFilter();
currentState = filter.NextState(currentState);
videoStream.ResetColorParams(ColorParams.Default);
videoInputFile.FilterSteps.Add(filter);
}
else
{
var filter = new TonemapFilter(currentState, pixelFormat);
currentState = filter.NextState(currentState);
videoStream.ResetColorParams(ColorParams.Default);
videoInputFile.FilterSteps.Add(filter);
}
}
}
return currentState;
}
}
@@ -104,6 +104,7 @@ public class SoftwarePipelineBuilder : PipelineBuilderBase
{
SetDeinterlace(videoInputFile, context, currentState);
currentState = SetTonemap(videoInputFile, videoStream, desiredState, currentState);
currentState = SetScale(videoInputFile, videoStream, desiredState, currentState);
currentState = SetPad(videoInputFile, videoStream, desiredState, currentState);
currentState = SetCrop(videoInputFile, desiredState, currentState);
@@ -317,6 +318,26 @@ public class SoftwarePipelineBuilder : PipelineBuilderBase
return currentState;
}
private static FrameState SetTonemap(
VideoInputFile videoInputFile,
VideoStream videoStream,
FrameState desiredState,
FrameState currentState)
{
if (videoStream.ColorParams.IsHdr)
{
foreach (IPixelFormat pixelFormat in desiredState.PixelFormat)
{
var tonemapStep = new TonemapFilter(currentState, pixelFormat);
currentState = tonemapStep.NextState(currentState);
videoStream.ResetColorParams(ColorParams.Default);
videoInputFile.FilterSteps.Add(tonemapStep);
}
}
return currentState;
}
private static FrameState SetScale(
VideoInputFile videoInputFile,
VideoStream videoStream,
@@ -17,6 +17,7 @@ namespace ErsatzTV.FFmpeg.Pipeline;
public class VaapiPipelineBuilder : SoftwarePipelineBuilder
{
private readonly IFFmpegCapabilities _ffmpegCapabilities;
private readonly IHardwareCapabilities _hardwareCapabilities;
private readonly ILogger _logger;
@@ -43,6 +44,7 @@ public class VaapiPipelineBuilder : SoftwarePipelineBuilder
fontsFolder,
logger)
{
_ffmpegCapabilities = ffmpegCapabilities;
_hardwareCapabilities = hardwareCapabilities;
_logger = logger;
}
@@ -63,7 +65,8 @@ public class VaapiPipelineBuilder : SoftwarePipelineBuilder
FFmpegCapability decodeCapability = _hardwareCapabilities.CanDecode(
videoStream.Codec,
videoStream.Profile,
videoStream.PixelFormat);
videoStream.PixelFormat,
videoStream.ColorParams.IsHdr);
FFmpegCapability encodeCapability = _hardwareCapabilities.CanEncode(
desiredState.VideoFormat,
desiredState.VideoProfile,
@@ -168,6 +171,8 @@ public class VaapiPipelineBuilder : SoftwarePipelineBuilder
// _logger.LogDebug("After decode: {PixelFormat}", currentState.PixelFormat);
currentState = SetTonemap(videoInputFile, videoStream, ffmpegState, desiredState, currentState);
currentState = SetDeinterlace(videoInputFile, context, ffmpegState, currentState);
// _logger.LogDebug("After deinterlace: {PixelFormat}", currentState.PixelFormat);
@@ -531,7 +536,7 @@ public class VaapiPipelineBuilder : SoftwarePipelineBuilder
{
if (desiredState.CroppedSize.IsNone && currentState.PaddedSize != desiredState.PaddedSize)
{
var padStep = new PadFilter(currentState, desiredState.PaddedSize);
var padStep = new PadVaapiFilter(currentState, desiredState.PaddedSize);
currentState = padStep.NextState(currentState);
videoInputFile.FilterSteps.Add(padStep);
}
@@ -619,4 +624,35 @@ public class VaapiPipelineBuilder : SoftwarePipelineBuilder
return currentState;
}
private FrameState SetTonemap(
VideoInputFile videoInputFile,
VideoStream videoStream,
FFmpegState ffmpegState,
FrameState desiredState,
FrameState currentState)
{
if (videoStream.ColorParams.IsHdr)
{
foreach (IPixelFormat pixelFormat in desiredState.PixelFormat)
{
if (ffmpegState.DecoderHardwareAccelerationMode == HardwareAccelerationMode.Vaapi && _ffmpegCapabilities.HasFilter(FFmpegKnownFilter.TonemapOpenCL))
{
var filter = new TonemapVaapiFilter();
currentState = filter.NextState(currentState);
videoStream.ResetColorParams(ColorParams.Default);
videoInputFile.FilterSteps.Add(filter);
}
else
{
var filter = new TonemapFilter(currentState, pixelFormat);
currentState = filter.NextState(currentState);
videoStream.ResetColorParams(ColorParams.Default);
videoInputFile.FilterSteps.Add(filter);
}
}
}
return currentState;
}
}
@@ -52,7 +52,8 @@ public class VideoToolboxPipelineBuilder : SoftwarePipelineBuilder
FFmpegCapability decodeCapability = _hardwareCapabilities.CanDecode(
videoStream.Codec,
videoStream.Profile,
videoStream.PixelFormat);
videoStream.PixelFormat,
videoStream.ColorParams.IsHdr);
FFmpegCapability encodeCapability = _hardwareCapabilities.CanEncode(
desiredState.VideoFormat,
desiredState.VideoProfile,
@@ -16,8 +16,8 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.10" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.15" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.3" />
</ItemGroup>
</Project>
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class Add_ProgramSchedule_FixedStartTimeBehavior : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "FixedStartTimeBehavior",
table: "ProgramSchedule",
type: "int",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "FixedStartTimeBehavior",
table: "ProgramSchedule");
}
}
}
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class Add_ProgramScheduleItem_FixedStartTimeBehavior : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "FixedStartTimeBehavior",
table: "ProgramScheduleItem",
type: "int",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "FixedStartTimeBehavior",
table: "ProgramScheduleItem");
}
}
}
@@ -17,7 +17,7 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.10")
.HasAnnotation("ProductVersion", "8.0.15")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
@@ -1969,6 +1969,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<int>("FixedStartTimeBehavior")
.HasColumnType("int");
b.Property<bool>("KeepMultiPartEpisodesTogether")
.HasColumnType("tinyint(1)");
@@ -2053,6 +2056,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<int>("FillWithGroupMode")
.HasColumnType("int");
b.Property<int?>("FixedStartTimeBehavior")
.HasColumnType("int");
b.Property<int>("GuideMode")
.HasColumnType("int");
@@ -12,9 +12,9 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Dapper" Version="2.1.35" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.10" />
<PackageReference Include="Dapper" Version="2.1.66" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.15" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.15" />
</ItemGroup>
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class Add_ProgramSchedule_FixedStartTimeBehavior : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "FixedStartTimeBehavior",
table: "ProgramSchedule",
type: "INTEGER",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "FixedStartTimeBehavior",
table: "ProgramSchedule");
}
}
}
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class Add_ProgramScheduleItem_FixedStartTimeBehavior : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "FixedStartTimeBehavior",
table: "ProgramScheduleItem",
type: "INTEGER",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "FixedStartTimeBehavior",
table: "ProgramScheduleItem");
}
}
}
@@ -15,7 +15,7 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.10");
modelBuilder.HasAnnotation("ProductVersion", "8.0.15");
modelBuilder.Entity("ErsatzTV.Core.Domain.Actor", b =>
{
@@ -1868,6 +1868,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("FixedStartTimeBehavior")
.HasColumnType("INTEGER");
b.Property<bool>("KeepMultiPartEpisodesTogether")
.HasColumnType("INTEGER");
@@ -1948,6 +1951,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<int>("FillWithGroupMode")
.HasColumnType("INTEGER");
b.Property<int?>("FixedStartTimeBehavior")
.HasColumnType("INTEGER");
b.Property<int>("GuideMode")
.HasColumnType("INTEGER");
@@ -9,16 +9,16 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="FluentAssertions" Version="8.2.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
<PackageReference Include="NSubstitute" Version="5.3.0" />
<PackageReference Include="NUnit" Version="4.2.2" />
<PackageReference Include="NUnit3TestAdapter" Version="4.6.0" />
<PackageReference Include="NUnit.Analyzers" Version="4.4.0">
<PackageReference Include="NUnit" Version="4.3.2" />
<PackageReference Include="NUnit3TestAdapter" Version="5.0.0" />
<PackageReference Include="NUnit.Analyzers" Version="4.7.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.2">
<PackageReference Include="coverlet.collector" Version="6.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
@@ -11,31 +11,31 @@
<ItemGroup>
<PackageReference Include="Blurhash.ImageSharp" Version="4.0.0" />
<PackageReference Include="CliWrap" Version="3.6.7" />
<PackageReference Include="Dapper" Version="2.1.35" />
<PackageReference Include="Elastic.Clients.Elasticsearch" Version="8.15.10" />
<PackageReference Include="Jint" Version="4.1.0" />
<PackageReference Include="CliWrap" Version="3.8.2" />
<PackageReference Include="Dapper" Version="2.1.66" />
<PackageReference Include="Elastic.Clients.Elasticsearch" Version="9.0.0" />
<PackageReference Include="Jint" Version="4.2.2" />
<PackageReference Include="Lucene.Net" Version="4.8.0-beta00017" />
<PackageReference Include="Lucene.Net.Analysis.Common" Version="4.8.0-beta00017" />
<PackageReference Include="Lucene.Net.QueryParser" Version="4.8.0-beta00017" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.10">
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.15" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.15">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.10" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.12.19">
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.15" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.13.61">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Refit" Version="8.0.0" />
<PackageReference Include="Refit.Newtonsoft.Json" Version="8.0.0" />
<PackageReference Include="Refit.Xml" Version="8.0.0" />
<PackageReference Include="Scriban.Signed" Version="5.12.0" />
<PackageReference Include="Scriban.Signed" Version="6.2.1" />
<PackageReference Include="TagLibSharp" Version="2.3.0" />
<!-- transitive; upgrading due to vuln -->
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.5" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.8" />
<PackageReference Include="System.Net.Http" Version="4.3.4" />
</ItemGroup>
@@ -9,8 +9,8 @@ namespace ErsatzTV.Infrastructure.Health.Checks;
public class FFmpegVersionHealthCheck : BaseHealthCheck, IFFmpegVersionHealthCheck
{
private const string BundledVersion = "6.1";
private const string BundledVersionVaapi = "6.1";
private const string WindowsVersionPrefix = "n6.1";
private const string BundledVersionVaapi = "7.1.1";
private const string WindowsVersionPrefix = "n7.1.1";
private static readonly string[] FFmpegVersionArguments = { "-version" };
@@ -84,6 +84,7 @@ public class FFmpegVersionHealthCheck : BaseHealthCheck, IFFmpegVersionHealthChe
}
if (!version.StartsWith("6.1", StringComparison.OrdinalIgnoreCase) &&
!version.StartsWith("7.1.1", StringComparison.OrdinalIgnoreCase) &&
!version.StartsWith(WindowsVersionPrefix, StringComparison.OrdinalIgnoreCase) &&
version != BundledVersion &&
version != BundledVersionVaapi)
@@ -27,8 +27,6 @@ public interface IJellyfinApi
[Header("X-Emby-Token")]
string apiKey,
[Query]
string userId,
[Query]
string parentId,
[Query]
string fields =
@@ -49,8 +47,6 @@ public interface IJellyfinApi
[Header("X-Emby-Token")]
string apiKey,
[Query]
string userId,
[Query]
string parentId,
[Query]
string fields =
@@ -69,8 +65,6 @@ public interface IJellyfinApi
[Header("X-Emby-Token")]
string apiKey,
[Query]
string userId,
[Query]
string parentId,
[Query]
string fields = "Path,DateCreated,Etag,Taglines,ProviderIds",
@@ -88,8 +82,6 @@ public interface IJellyfinApi
[Header("X-Emby-Token")]
string apiKey,
[Query]
string userId,
[Query]
string parentId,
[Query]
string fields = "Path,Genres,Tags,DateCreated,Etag,Overview,ProviderIds,People,Chapters",
@@ -107,8 +99,6 @@ public interface IJellyfinApi
[Header("X-Emby-Token")]
string apiKey,
[Query]
string userId,
[Query]
string parentId,
[Query]
string fields = "Etag",
@@ -126,8 +116,6 @@ public interface IJellyfinApi
[Header("X-Emby-Token")]
string apiKey,
[Query]
string userId,
[Query]
string parentId,
[Query]
string fields = "Etag",
@@ -144,7 +132,5 @@ public interface IJellyfinApi
public Task<JellyfinPlaybackInfoResponse> GetPlaybackInfo(
[Header("X-Emby-Token")]
string apiKey,
[Query]
string userId,
string itemId);
}
@@ -73,26 +73,6 @@ public class JellyfinApiClient : IJellyfinApiClient
}
}
public async Task<Either<BaseError, string>> GetAdminUserId(string address, string apiKey)
{
try
{
IJellyfinApi service = RestService.For<IJellyfinApi>(address);
List<JellyfinUserResponse> users = await service.GetUsers(apiKey);
Option<string> maybeUserId = users
.Filter(user => user.Policy.IsAdministrator && !user.Policy.IsDisabled && user.Policy.EnableAllFolders)
.Map(user => user.Id)
.HeadOrNone();
return maybeUserId.ToEither(BaseError.New("Unable to locate jellyfin admin user"));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting jellyfin admin user id");
return BaseError.New(ex.Message);
}
}
public IAsyncEnumerable<Tuple<JellyfinMovie, int>> GetMovieLibraryItems(
string address,
string apiKey,
@@ -102,9 +82,8 @@ public class JellyfinApiClient : IJellyfinApiClient
library,
library.MediaSourceId,
library.ItemId,
(service, userId, itemId, skip, pageSize) => service.GetMovieLibraryItems(
(service, itemId, skip, pageSize) => service.GetMovieLibraryItems(
apiKey,
userId,
itemId,
startIndex: skip,
limit: pageSize),
@@ -119,9 +98,8 @@ public class JellyfinApiClient : IJellyfinApiClient
library,
library.MediaSourceId,
library.ItemId,
(service, userId, itemId, skip, pageSize) => service.GetShowLibraryItems(
(service, itemId, skip, pageSize) => service.GetShowLibraryItems(
apiKey,
userId,
itemId,
startIndex: skip,
limit: pageSize),
@@ -137,9 +115,8 @@ public class JellyfinApiClient : IJellyfinApiClient
library,
library.MediaSourceId,
showId,
(service, userId, _, skip, pageSize) => service.GetSeasonLibraryItems(
(service, _, skip, pageSize) => service.GetSeasonLibraryItems(
apiKey,
userId,
showId,
startIndex: skip,
limit: pageSize),
@@ -155,9 +132,8 @@ public class JellyfinApiClient : IJellyfinApiClient
library,
library.MediaSourceId,
seasonId,
(service, userId, _, skip, pageSize) => service.GetEpisodeLibraryItems(
(service, _, skip, pageSize) => service.GetEpisodeLibraryItems(
apiKey,
userId,
seasonId,
startIndex: skip,
limit: pageSize),
@@ -177,9 +153,8 @@ public class JellyfinApiClient : IJellyfinApiClient
None,
mediaSourceId,
itemId,
(service, userId, _, skip, pageSize) => service.GetCollectionLibraryItems(
(service, _, skip, pageSize) => service.GetCollectionLibraryItems(
apiKey,
userId,
itemId,
startIndex: skip,
limit: pageSize),
@@ -199,9 +174,8 @@ public class JellyfinApiClient : IJellyfinApiClient
None,
mediaSourceId,
collectionId,
(service, userId, _, skip, pageSize) => service.GetCollectionItems(
(service, _, skip, pageSize) => service.GetCollectionItems(
apiKey,
userId,
collectionId,
startIndex: skip,
limit: pageSize),
@@ -215,15 +189,10 @@ public class JellyfinApiClient : IJellyfinApiClient
{
try
{
if (_memoryCache.TryGetValue($"jellyfin_admin_user_id.{library.MediaSourceId}", out string userId))
{
IJellyfinApi service = RestService.For<IJellyfinApi>(address);
JellyfinPlaybackInfoResponse playbackInfo = await service.GetPlaybackInfo(apiKey, userId, itemId);
Option<MediaVersion> maybeVersion = ProjectToMediaVersion(playbackInfo);
return maybeVersion.ToEither(() => BaseError.New("Unable to locate Jellyfin statistics"));
}
return BaseError.New("Jellyfin admin user id is not available");
IJellyfinApi service = RestService.For<IJellyfinApi>(address);
JellyfinPlaybackInfoResponse playbackInfo = await service.GetPlaybackInfo(apiKey, itemId);
Option<MediaVersion> maybeVersion = ProjectToMediaVersion(playbackInfo);
return maybeVersion.ToEither(() => BaseError.New("Unable to locate Jellyfin statistics"));
}
catch (Exception ex)
{
@@ -232,34 +201,31 @@ public class JellyfinApiClient : IJellyfinApiClient
}
}
private async IAsyncEnumerable<Tuple<TItem, int>> GetPagedLibraryItems<TItem>(
private static async IAsyncEnumerable<Tuple<TItem, int>> GetPagedLibraryItems<TItem>(
string address,
Option<JellyfinLibrary> maybeLibrary,
int mediaSourceId,
string parentId,
Func<IJellyfinApi, string, string, int, int, Task<JellyfinLibraryItemsResponse>> getItems,
Func<IJellyfinApi, string, int, int, Task<JellyfinLibraryItemsResponse>> getItems,
Func<Option<JellyfinLibrary>, JellyfinLibraryItemResponse, Option<TItem>> mapper)
{
if (_memoryCache.TryGetValue($"jellyfin_admin_user_id.{mediaSourceId}", out string userId))
IJellyfinApi service = RestService.For<IJellyfinApi>(address);
const int PAGE_SIZE = 10;
int pages = int.MaxValue;
for (var i = 0; i < pages; i++)
{
IJellyfinApi service = RestService.For<IJellyfinApi>(address);
int skip = i * PAGE_SIZE;
const int PAGE_SIZE = 10;
JellyfinLibraryItemsResponse result = await getItems(service, parentId, skip, PAGE_SIZE);
int pages = int.MaxValue;
for (var i = 0; i < pages; i++)
// update page count
pages = Math.Min(pages, (result.TotalRecordCount - 1) / PAGE_SIZE + 1);
foreach (TItem item in result.Items.Map(item => mapper(maybeLibrary, item)).Somes())
{
int skip = i * PAGE_SIZE;
JellyfinLibraryItemsResponse result = await getItems(service, userId, parentId, skip, PAGE_SIZE);
// update page count
pages = Math.Min(pages, (result.TotalRecordCount - 1) / PAGE_SIZE + 1);
foreach (TItem item in result.Items.Map(item => mapper(maybeLibrary, item)).Somes())
{
yield return new Tuple<TItem, int>(item, result.TotalRecordCount);
}
yield return new Tuple<TItem, int>(item, result.TotalRecordCount);
}
}
}

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