Compare commits

...
Author SHA1 Message Date
Jason Dove 5998fd2f5f more docker tag tweaks [no ci] 2021-03-07 10:38:56 -06:00
Jason Dove 4f536adc99 fix nvidia tag 2021-03-07 10:26:20 -06:00
Jason Dove 2637ff657d fix readme link 2021-03-07 10:16:52 -06:00
Jason DoveandGitHub c4f7607a50 publish docker images on merge to main (#44)
* test docker build and push

* enable for test branch

* try to get tag another way

* add nvidia and vaapi pushes

* try to get tag again

* still looking for the tag

* include sha in version

* only build and push docker on merges to main

* push docker images on release

* add hw accel info to readme
2021-03-07 16:12:51 +00:00
Jason DoveandGitHub 0f052631a4 try to fix vaapi by always using nv12 or vaapi pixel format (#42) 2021-03-07 12:22:18 +00:00
Jason DoveandGitHub b13b2b9805 Hardware-accelerated transcoding (#41)
* add qsv transcoding support

* add basic nvenc support

* add nvenc to docker-compose

* add vaapi hardware acceleration

* add vaapi driver to dockerfile

* raise ffmpeg log level

* lots of progress with nvenc, qsv and vaapi remain untested

* qsv fixes

* code cleanup
2021-03-06 22:07:28 +00:00
Jason DoveandGitHub 51cdb372b9 Remove missing media (#40)
* remove movies that are no longer present on disk

* remove missing episodes, empty seasons, empty shows
2021-03-06 12:43:38 +00:00
Jason DoveandGitHub 363eb2c276 Rebuild playouts with modified collections (#39)
* rebuild playouts when items are removed from collections

* rebuild playouts when items are added to collections

* simplify logic
2021-03-05 00:50:26 +00:00
Jason DoveandGitHub c6ea2c88df remember selected collection (#36) 2021-03-04 03:22:15 +00:00
Jason DoveandGitHub 3ed83a276f fix database migration (#35) 2021-03-02 18:32:14 +00:00
66 changed files with 2874 additions and 187 deletions
+79 -2
View File
@@ -4,9 +4,8 @@ on:
push:
branches:
- main
- develop
jobs:
build:
build_and_test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
@@ -32,3 +31,81 @@ jobs:
- name: Test
run: dotnet test --no-restore --verbosity normal
build_and_push:
name: Build & Publish to Docker Hub
needs: build_and_test
runs-on: ubuntu-latest
if: github.event_name == 'push' && !contains(github.event.head_commit.message, '[no ci]')
steps:
- name: Checkout
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Extract Git Tag
shell: bash
run: |
tag=$(git describe --tags --abbrev=0)
tag2="${tag:1}"
short=$(git rev-parse --short HEAD)
final="${tag2/prealpha/$short}"
echo "GIT_TAG=${final}" >> $GITHUB_ENV
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Cache Docker layers
uses: actions/cache@v2.1.4
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
- name: Login to DockerHub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Build and push base
uses: docker/build-push-action@v2
with:
context: .
file: ./docker/Dockerfile
push: true
build-args: |
INFO_VERSION=${{ env.GIT_TAG }}-docker
tags: |
jasongdove/ersatztv:develop
jasongdove/ersatztv:${{ github.sha }}
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache,mode=max
- name: Build and push nvidia
uses: docker/build-push-action@v2
with:
context: .
file: ./docker/nvidia/Dockerfile
push: true
build-args: |
INFO_VERSION=${{ env.GIT_TAG }}-docker-nvidia
tags: |
jasongdove/ersatztv:develop-nvidia
jasongdove/ersatztv:${{ github.sha }}-nvidia
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache,mode=max
- name: Build and push vaapi
uses: docker/build-push-action@v2
with:
context: .
file: ./docker/vaapi/Dockerfile
push: true
build-args: |
INFO_VERSION=${{ env.GIT_TAG }}-docker-vaapi
tags: |
jasongdove/ersatztv:develop-vaapi
jasongdove/ersatztv:${{ github.sha }}-vaapi
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache,mode=max
+74
View File
@@ -67,3 +67,77 @@ jobs:
ErsatzTV*.tar.gz
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build_and_push:
name: Build & Publish to Docker Hub
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Extract Git Tag
shell: bash
run: |
tag=$(git describe --tags --abbrev=0)
echo "GIT_TAG=${tag:1}" >> $GITHUB_ENV
echo "DOCKER_TAG=${tag/-prealpha/}" >> $GITHUB_ENV
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Cache Docker layers
uses: actions/cache@v2.1.4
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
- name: Login to DockerHub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Build and push base
uses: docker/build-push-action@v2
with:
context: .
file: ./docker/Dockerfile
push: true
build-args: |
INFO_VERSION=${{ env.GIT_TAG }}-docker
tags: |
jasongdove/ersatztv:latest
jasongdove/ersatztv:${{ env.DOCKER_TAG }}
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache,mode=max
- name: Build and push nvidia
uses: docker/build-push-action@v2
with:
context: .
file: ./docker/nvidia/Dockerfile
push: true
build-args: |
INFO_VERSION=${{ env.GIT_TAG }}-docker-nvidia
tags: |
jasongdove/ersatztv:latest-nvidia
jasongdove/ersatztv:${{ env.DOCKER_TAG }}-nvidia
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache,mode=max
- name: Build and push vaapi
uses: docker/build-push-action@v2
with:
context: .
file: ./docker/vaapi/Dockerfile
push: true
build-args: |
INFO_VERSION=${{ env.GIT_TAG }}-docker-vaapi
tags: |
jasongdove/ersatztv:latest-vaapi
jasongdove/ersatztv:${{ env.DOCKER_TAG }}-vaapi
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache,mode=max
@@ -1,4 +1,5 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using LanguageExt;
using MediatR;
@@ -8,6 +9,7 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
string Name,
int ThreadCount,
bool Transcode,
HardwareAccelerationKind HardwareAcceleration,
int ResolutionId,
bool NormalizeResolution,
string VideoCodec,
@@ -41,6 +41,7 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
Name = name,
ThreadCount = threadCount,
Transcode = request.Transcode,
HardwareAcceleration = request.HardwareAcceleration,
ResolutionId = resolutionId,
NormalizeResolution = request.NormalizeResolution,
VideoCodec = request.VideoCodec,
@@ -1,4 +1,5 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using LanguageExt;
using MediatR;
@@ -9,6 +10,7 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
string Name,
int ThreadCount,
bool Transcode,
HardwareAccelerationKind HardwareAcceleration,
int ResolutionId,
bool NormalizeResolution,
string VideoCodec,
@@ -35,6 +35,7 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
p.Name = update.Name;
p.ThreadCount = update.ThreadCount;
p.Transcode = update.Transcode;
p.HardwareAcceleration = update.HardwareAcceleration;
p.ResolutionId = update.ResolutionId;
p.NormalizeResolution = update.NormalizeResolution;
p.VideoCodec = update.VideoCodec;
@@ -1,4 +1,5 @@
using ErsatzTV.Application.Resolutions;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.FFmpegProfiles
{
@@ -7,6 +8,7 @@ namespace ErsatzTV.Application.FFmpegProfiles
string Name,
int ThreadCount,
bool Transcode,
HardwareAccelerationKind HardwareAcceleration,
ResolutionViewModel Resolution,
bool NormalizeResolution,
string VideoCodec,
@@ -11,6 +11,7 @@ namespace ErsatzTV.Application.FFmpegProfiles
profile.Name,
profile.ThreadCount,
profile.Transcode,
profile.HardwareAcceleration,
Project(profile.Resolution),
profile.NormalizeResolution,
profile.VideoCodec,
@@ -1,5 +1,7 @@
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using ErsatzTV.Application.Playouts.Commands;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
@@ -9,15 +11,18 @@ namespace ErsatzTV.Application.MediaCollections.Commands
public class
AddEpisodeToCollectionHandler : MediatR.IRequestHandler<AddEpisodeToCollection, Either<BaseError, Unit>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
private readonly ITelevisionRepository _televisionRepository;
public AddEpisodeToCollectionHandler(
IMediaCollectionRepository mediaCollectionRepository,
ITelevisionRepository televisionRepository)
ITelevisionRepository televisionRepository,
ChannelWriter<IBackgroundServiceRequest> channel)
{
_mediaCollectionRepository = mediaCollectionRepository;
_televisionRepository = televisionRepository;
_channel = channel;
}
public Task<Either<BaseError, Unit>> Handle(
@@ -27,8 +32,20 @@ namespace ErsatzTV.Application.MediaCollections.Commands
.MapT(_ => ApplyAddTelevisionEpisodeRequest(request))
.Bind(v => v.ToEitherAsync());
private Task<Unit> ApplyAddTelevisionEpisodeRequest(AddEpisodeToCollection request) =>
_mediaCollectionRepository.AddMediaItem(request.CollectionId, request.EpisodeId);
private async Task<Unit> ApplyAddTelevisionEpisodeRequest(AddEpisodeToCollection request)
{
if (await _mediaCollectionRepository.AddMediaItem(request.CollectionId, request.EpisodeId))
{
// rebuild all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(request.CollectionId))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, true));
}
}
return Unit.Default;
}
private async Task<Validation<BaseError, Unit>> Validate(AddEpisodeToCollection request) =>
(await CollectionMustExist(request), await ValidateEpisode(request))
@@ -1,5 +1,7 @@
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using ErsatzTV.Application.Playouts.Commands;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
@@ -7,17 +9,21 @@ using LanguageExt;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public class AddMovieToCollectionHandler : MediatR.IRequestHandler<AddMovieToCollection, Either<BaseError, Unit>>
public class
AddMovieToCollectionHandler : MediatR.IRequestHandler<AddMovieToCollection, Either<BaseError, Unit>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
private readonly IMovieRepository _movieRepository;
public AddMovieToCollectionHandler(
IMediaCollectionRepository mediaCollectionRepository,
IMovieRepository movieRepository)
IMovieRepository movieRepository,
ChannelWriter<IBackgroundServiceRequest> channel)
{
_mediaCollectionRepository = mediaCollectionRepository;
_movieRepository = movieRepository;
_channel = channel;
}
public Task<Either<BaseError, Unit>> Handle(
@@ -27,8 +33,20 @@ namespace ErsatzTV.Application.MediaCollections.Commands
.MapT(_ => ApplyAddMoviesRequest(request))
.Bind(v => v.ToEitherAsync());
private Task<Unit> ApplyAddMoviesRequest(AddMovieToCollection request) =>
_mediaCollectionRepository.AddMediaItem(request.CollectionId, request.MovieId);
private async Task<Unit> ApplyAddMoviesRequest(AddMovieToCollection request)
{
if (await _mediaCollectionRepository.AddMediaItem(request.CollectionId, request.MovieId))
{
// rebuild all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(request.CollectionId))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, true));
}
}
return Unit.Default;
}
private async Task<Validation<BaseError, Unit>> Validate(AddMovieToCollection request) =>
(await CollectionMustExist(request), await ValidateMovies(request))
@@ -1,5 +1,7 @@
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using ErsatzTV.Application.Playouts.Commands;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
@@ -7,17 +9,21 @@ using LanguageExt;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public class AddSeasonToCollectionHandler : MediatR.IRequestHandler<AddSeasonToCollection, Either<BaseError, Unit>>
public class
AddSeasonToCollectionHandler : MediatR.IRequestHandler<AddSeasonToCollection, Either<BaseError, Unit>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
private readonly ITelevisionRepository _televisionRepository;
public AddSeasonToCollectionHandler(
IMediaCollectionRepository mediaCollectionRepository,
ITelevisionRepository televisionRepository)
ITelevisionRepository televisionRepository,
ChannelWriter<IBackgroundServiceRequest> channel)
{
_mediaCollectionRepository = mediaCollectionRepository;
_televisionRepository = televisionRepository;
_channel = channel;
}
public Task<Either<BaseError, Unit>> Handle(
@@ -27,8 +33,20 @@ namespace ErsatzTV.Application.MediaCollections.Commands
.MapT(_ => ApplyAddTelevisionSeasonRequest(request))
.Bind(v => v.ToEitherAsync());
private async Task<Unit> ApplyAddTelevisionSeasonRequest(AddSeasonToCollection request) =>
await _mediaCollectionRepository.AddMediaItem(request.CollectionId, request.SeasonId);
private async Task<Unit> ApplyAddTelevisionSeasonRequest(AddSeasonToCollection request)
{
if (await _mediaCollectionRepository.AddMediaItem(request.CollectionId, request.SeasonId))
{
// rebuild all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(request.CollectionId))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, true));
}
}
return Unit.Default;
}
private async Task<Validation<BaseError, Unit>> Validate(AddSeasonToCollection request) =>
(await CollectionMustExist(request), await ValidateSeason(request))
@@ -1,5 +1,7 @@
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using ErsatzTV.Application.Playouts.Commands;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
@@ -9,15 +11,18 @@ namespace ErsatzTV.Application.MediaCollections.Commands
{
public class AddShowToCollectionHandler : MediatR.IRequestHandler<AddShowToCollection, Either<BaseError, Unit>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
private readonly ITelevisionRepository _televisionRepository;
public AddShowToCollectionHandler(
IMediaCollectionRepository mediaCollectionRepository,
ITelevisionRepository televisionRepository)
ITelevisionRepository televisionRepository,
ChannelWriter<IBackgroundServiceRequest> channel)
{
_mediaCollectionRepository = mediaCollectionRepository;
_televisionRepository = televisionRepository;
_channel = channel;
}
public Task<Either<BaseError, Unit>> Handle(
@@ -27,8 +32,22 @@ namespace ErsatzTV.Application.MediaCollections.Commands
.MapT(_ => ApplyAddTelevisionShowRequest(request))
.Bind(v => v.ToEitherAsync());
private Task<Unit> ApplyAddTelevisionShowRequest(AddShowToCollection request)
=> _mediaCollectionRepository.AddMediaItem(request.CollectionId, request.ShowId);
private async Task<Unit> ApplyAddTelevisionShowRequest(AddShowToCollection request)
{
var result = new Unit();
if (await _mediaCollectionRepository.AddMediaItem(request.CollectionId, request.ShowId))
{
// rebuild all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(request.CollectionId))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, true));
}
}
return result;
}
private async Task<Validation<BaseError, Unit>> Validate(AddShowToCollection request) =>
(await CollectionMustExist(request), await ValidateShow(request))
@@ -1,6 +1,8 @@
using System.Linq;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using ErsatzTV.Application.Playouts.Commands;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
@@ -11,20 +13,25 @@ namespace ErsatzTV.Application.MediaCollections.Commands
public class
RemoveItemsFromCollectionHandler : MediatR.IRequestHandler<RemoveItemsFromCollection, Either<BaseError, Unit>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
public RemoveItemsFromCollectionHandler(
IMediaCollectionRepository mediaCollectionRepository) =>
IMediaCollectionRepository mediaCollectionRepository,
ChannelWriter<IBackgroundServiceRequest> channel)
{
_mediaCollectionRepository = mediaCollectionRepository;
_channel = channel;
}
public Task<Either<BaseError, Unit>> Handle(
RemoveItemsFromCollection request,
CancellationToken cancellationToken) =>
Validate(request)
.MapT(collection => ApplyAddTelevisionEpisodeRequest(request, collection))
.MapT(collection => ApplyRemoveItemsRequest(request, collection))
.Bind(v => v.ToEitherAsync());
private Task<Unit> ApplyAddTelevisionEpisodeRequest(
private async Task<Unit> ApplyRemoveItemsRequest(
RemoveItemsFromCollection request,
Collection collection)
{
@@ -34,9 +41,16 @@ namespace ErsatzTV.Application.MediaCollections.Commands
itemsToRemove.ForEach(m => collection.MediaItems.Remove(m));
return itemsToRemove.Any()
? _mediaCollectionRepository.Update(collection).ToUnit()
: Task.FromResult(Unit.Default);
if (itemsToRemove.Any() && await _mediaCollectionRepository.Update(collection))
{
// rebuild all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository.PlayoutIdsUsingCollection(collection.Id))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, true));
}
}
return Unit.Default;
}
private Task<Validation<BaseError, Collection>> Validate(
@@ -10,6 +10,7 @@ using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.Streaming.Queries
{
@@ -68,15 +69,18 @@ namespace ErsatzTV.Application.Streaming.Queries
playoutItem.StartOffset,
now);
},
async () =>
() =>
{
if (channel.FFmpegProfile.Transcode)
{
return _ffmpegProcessService.ForOfflineImage(ffmpegPath, channel);
return Right<BaseError, Process>(_ffmpegProcessService.ForOfflineImage(ffmpegPath, channel))
.AsTask();
}
return BaseError.New(
$"Unable to locate playout item for channel {channel.Number}; offline image is unavailable because transcoding is disabled in ffmpeg profile '{channel.FFmpegProfile.Name}'");
var message =
$"Unable to locate playout item for channel {channel.Number}; offline image is unavailable because transcoding is disabled in ffmpeg profile '{channel.FFmpegProfile.Name}'";
return Left<BaseError, Process>(BaseError.New(message)).AsTask();
});
}
@@ -0,0 +1,349 @@
using System;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
using FluentAssertions;
using LanguageExt;
using NUnit.Framework;
namespace ErsatzTV.Core.Tests.FFmpeg
{
[TestFixture]
public class FFmpegComplexFilterBuilderTests
{
[TestFixture]
public class Build
{
[Test]
public void Should_Return_None_With_No_Filters()
{
var builder = new FFmpegComplexFilterBuilder();
Option<FFmpegComplexFilter> result = builder.Build();
result.IsNone.Should().BeTrue();
}
[Test]
public void Should_Return_Audio_Filter_With_AudioDuration()
{
var duration = TimeSpan.FromMinutes(54);
FFmpegComplexFilterBuilder builder = new FFmpegComplexFilterBuilder()
.WithAlignedAudio(duration);
Option<FFmpegComplexFilter> result = builder.Build();
result.IsSome.Should().BeTrue();
result.IfSome(
filter =>
{
filter.ComplexFilter.Should().Be($"[0:a]apad=whole_dur={duration.TotalMilliseconds}ms[a]");
filter.AudioLabel.Should().Be("[a]");
filter.VideoLabel.Should().Be("0:v");
});
}
[Test]
public void Should_Return_Audio_And_Video_Filter()
{
var duration = TimeSpan.FromMinutes(54);
FFmpegComplexFilterBuilder builder = new FFmpegComplexFilterBuilder()
.WithAlignedAudio(duration)
.WithDeinterlace(true);
Option<FFmpegComplexFilter> result = builder.Build();
result.IsSome.Should().BeTrue();
result.IfSome(
filter =>
{
filter.ComplexFilter.Should().Be(
$"[0:a]apad=whole_dur={duration.TotalMilliseconds}ms[a];[0:v]yadif=1[v]");
filter.AudioLabel.Should().Be("[a]");
filter.VideoLabel.Should().Be("[v]");
});
}
[Test]
[TestCase(true, false, false, "[0:v]yadif=1[v]", "[v]")]
[TestCase(true, true, false, "[0:v]yadif=1,scale=1920:1000:flags=fast_bilinear,setsar=1[v]", "[v]")]
[TestCase(true, false, true, "[0:v]yadif=1,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2[v]", "[v]")]
[TestCase(
true,
true,
true,
"[0:v]yadif=1,scale=1920:1000:flags=fast_bilinear,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2[v]",
"[v]")]
[TestCase(false, true, false, "[0:v]scale=1920:1000:flags=fast_bilinear,setsar=1[v]", "[v]")]
[TestCase(false, false, true, "[0:v]setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2[v]", "[v]")]
[TestCase(
false,
true,
true,
"[0:v]scale=1920:1000:flags=fast_bilinear,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2[v]",
"[v]")]
public void Should_Return_Software_Video_Filter(
bool deinterlace,
bool scale,
bool pad,
string expectedVideoFilter,
string expectedVideoLabel)
{
FFmpegComplexFilterBuilder builder = new FFmpegComplexFilterBuilder()
.WithDeinterlace(deinterlace);
if (scale)
{
builder = builder.WithScaling(new Resolution { Width = 1920, Height = 1000 });
}
if (pad)
{
builder = builder.WithBlackBars(new Resolution { Width = 1920, Height = 1080 });
}
Option<FFmpegComplexFilter> result = builder.Build();
result.IsSome.Should().BeTrue();
result.IfSome(
filter =>
{
filter.ComplexFilter.Should().Be(expectedVideoFilter);
filter.AudioLabel.Should().Be("0:a");
filter.VideoLabel.Should().Be(expectedVideoLabel);
});
}
[Test]
[TestCase(true, false, false, "[0:v]deinterlace_qsv[v]", "[v]")]
[TestCase(
true,
true,
false,
"[0:v]deinterlace_qsv,scale_qsv=w=1920:h=1000,hwdownload,format=nv12,setsar=1,hwupload=extra_hw_frames=64[v]",
"[v]")]
[TestCase(
true,
false,
true,
"[0:v]deinterlace_qsv,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload=extra_hw_frames=64[v]",
"[v]")]
[TestCase(
true,
true,
true,
"[0:v]deinterlace_qsv,scale_qsv=w=1920:h=1000,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload=extra_hw_frames=64[v]",
"[v]")]
[TestCase(
false,
true,
false,
"[0:v]scale_qsv=w=1920:h=1000,hwdownload,format=nv12,setsar=1,hwupload=extra_hw_frames=64[v]",
"[v]")]
[TestCase(
false,
false,
true,
"[0:v]hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload=extra_hw_frames=64[v]",
"[v]")]
[TestCase(
false,
true,
true,
"[0:v]scale_qsv=w=1920:h=1000,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload=extra_hw_frames=64[v]",
"[v]")]
public void Should_Return_QSV_Video_Filter(
bool deinterlace,
bool scale,
bool pad,
string expectedVideoFilter,
string expectedVideoLabel)
{
FFmpegComplexFilterBuilder builder = new FFmpegComplexFilterBuilder()
.WithHardwareAcceleration(HardwareAccelerationKind.Qsv)
.WithDeinterlace(deinterlace);
if (scale)
{
builder = builder.WithScaling(new Resolution { Width = 1920, Height = 1000 });
}
if (pad)
{
builder = builder.WithBlackBars(new Resolution { Width = 1920, Height = 1080 });
}
Option<FFmpegComplexFilter> result = builder.Build();
result.IsSome.Should().BeTrue();
result.IfSome(
filter =>
{
filter.ComplexFilter.Should().Be(expectedVideoFilter);
filter.AudioLabel.Should().Be("0:a");
filter.VideoLabel.Should().Be(expectedVideoLabel);
});
}
[Test]
// TODO: get yadif_cuda working in docker
// [TestCase(true, false, false, "[0:v]yadif_cuda[v]", "[v]")]
// [TestCase(
// true,
// true,
// false,
// "[0:v]yadif_cuda,scale_npp=1920:1000:format=yuv420p,hwdownload,setsar=1,hwupload[v]",
// "[v]")]
// [TestCase(
// true,
// false,
// true,
// "[0:v]yadif_cuda,hwdownload,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
// "[v]")]
// [TestCase(
// true,
// true,
// true,
// "[0:v]yadif_cuda,scale_npp=1920:1000:format=yuv420p,hwdownload,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
// "[v]")]
[TestCase(
true,
true,
false,
"[0:v]scale_npp=1920:1000,hwdownload,format=nv12,setsar=1,hwupload[v]",
"[v]")]
[TestCase(
true,
false,
true,
"[0:v]hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")]
[TestCase(
true,
true,
true,
"[0:v]scale_npp=1920:1000,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")]
[TestCase(
false,
true,
false,
"[0:v]scale_npp=1920:1000,hwdownload,format=nv12,setsar=1,hwupload[v]",
"[v]")]
[TestCase(
false,
false,
true,
"[0:v]hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")]
[TestCase(
false,
true,
true,
"[0:v]scale_npp=1920:1000,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")]
public void Should_Return_NVENC_Video_Filter(
bool deinterlace,
bool scale,
bool pad,
string expectedVideoFilter,
string expectedVideoLabel)
{
FFmpegComplexFilterBuilder builder = new FFmpegComplexFilterBuilder()
.WithHardwareAcceleration(HardwareAccelerationKind.Nvenc)
.WithDeinterlace(deinterlace);
if (scale)
{
builder = builder.WithScaling(new Resolution { Width = 1920, Height = 1000 });
}
if (pad)
{
builder = builder.WithBlackBars(new Resolution { Width = 1920, Height = 1080 });
}
Option<FFmpegComplexFilter> result = builder.Build();
result.IsSome.Should().BeTrue();
result.IfSome(
filter =>
{
filter.ComplexFilter.Should().Be(expectedVideoFilter);
filter.AudioLabel.Should().Be("0:a");
filter.VideoLabel.Should().Be(expectedVideoLabel);
});
}
[Test]
[TestCase(true, false, false, "[0:v]deinterlace_vaapi[v]", "[v]")]
[TestCase(
true,
true,
false,
"[0:v]deinterlace_vaapi,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
"[v]")]
[TestCase(
true,
false,
true,
"[0:v]deinterlace_vaapi,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")]
[TestCase(
true,
true,
true,
"[0:v]deinterlace_vaapi,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")]
[TestCase(
false,
true,
false,
"[0:v]scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
"[v]")]
[TestCase(
false,
false,
true,
"[0:v]hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")]
[TestCase(
false,
true,
true,
"[0:v]scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")]
public void Should_Return_VAAPI_Video_Filter(
bool deinterlace,
bool scale,
bool pad,
string expectedVideoFilter,
string expectedVideoLabel)
{
FFmpegComplexFilterBuilder builder = new FFmpegComplexFilterBuilder()
.WithHardwareAcceleration(HardwareAccelerationKind.Vaapi)
.WithDeinterlace(deinterlace);
if (scale)
{
builder = builder.WithScaling(new Resolution { Width = 1920, Height = 1000 });
}
if (pad)
{
builder = builder.WithBlackBars(new Resolution { Width = 1920, Height = 1080 });
}
Option<FFmpegComplexFilter> result = builder.Build();
result.IsSome.Should().BeTrue();
result.IfSome(
filter =>
{
filter.ComplexFilter.Should().Be(expectedVideoFilter);
filter.AudioLabel.Should().Be("0:a");
filter.VideoLabel.Should().Be(expectedVideoLabel);
});
}
}
}
}
@@ -9,6 +9,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
[TestFixture]
public class FFmpegPlaybackSettingsCalculatorTests
{
[TestFixture]
public class CalculateSettings
{
private readonly FFmpegPlaybackSettingsCalculator _calculator;
@@ -273,6 +274,29 @@ namespace ErsatzTV.Core.Tests.FFmpeg
actual.PadToDesiredResolution.Should().BeFalse();
}
[Test]
public void Should_NotPadToDesiredResolution_When_NotNormalizingResolution()
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
NormalizeResolution = false,
Resolution = new Resolution { Width = 1920, Height = 1080 }
};
// not anamorphic
var version = new MediaVersion { Width = 1918, Height = 1080, SampleAspectRatio = "1:1" };
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
version,
DateTimeOffset.Now,
DateTimeOffset.Now);
actual.ScaledSize.IsNone.Should().BeTrue();
actual.PadToDesiredResolution.Should().BeFalse();
}
[Test]
public void Should_SetDesiredVideoCodec_When_ContentIsPadded_ForTransportStream()
{
@@ -732,9 +756,33 @@ namespace ErsatzTV.Core.Tests.FFmpeg
actual.AudioSampleRate.IfNone(0).Should().Be(48);
}
private FFmpegProfile TestProfile() =>
new() { Resolution = new Resolution { Width = 1920, Height = 1080 } };
}
[TestFixture]
public class CalculateSettingsQsv
{
private readonly FFmpegPlaybackSettingsCalculator _calculator;
public CalculateSettingsQsv() => _calculator = new FFmpegPlaybackSettingsCalculator();
[Test]
public void Should_UseHardwareAcceleration()
{
FFmpegProfile ffmpegProfile =
TestProfile() with { HardwareAcceleration = HardwareAccelerationKind.Qsv };
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
new MediaVersion(),
DateTimeOffset.Now,
DateTimeOffset.Now);
actual.HardwareAcceleration.Should().Be(HardwareAccelerationKind.Qsv);
}
}
private static FFmpegProfile TestProfile() =>
new() { Resolution = new Resolution { Width = 1920, Height = 1080 } };
}
}
@@ -14,23 +14,15 @@ namespace ErsatzTV.Core.Tests.Fakes
private readonly Map<int, List<MediaItem>> _data;
public FakeMediaCollectionRepository(Map<int, List<MediaItem>> data) => _data = data;
public Task<Collection> Add(Collection collection) => throw new NotSupportedException();
public Task<Unit> AddMediaItem(int collectionId, int mediaItemId) => throw new NotSupportedException();
public Task<bool> AddMediaItem(int collectionId, int mediaItemId) => throw new NotSupportedException();
public Task<Option<Collection>> Get(int id) => throw new NotSupportedException();
public Task<Option<Collection>> GetCollectionWithItems(int id) => throw new NotSupportedException();
public Task<Option<Collection>> GetCollectionWithItemsUntracked(int id) => throw new NotSupportedException();
public Task<List<Collection>> GetAll() => throw new NotSupportedException();
public Task<Option<List<MediaItem>>> GetItems(int id) => Some(_data[id].ToList()).AsTask();
public Task Update(Collection collection) => throw new NotSupportedException();
Task<bool> IMediaCollectionRepository.Update(Collection collection) => throw new NotSupportedException();
public Task Delete(int collectionId) => throw new NotSupportedException();
public Task<Option<List<MediaItem>>> GetSimpleMediaCollectionItems(int id) => throw new NotSupportedException();
public Task<List<int>> PlayoutIdsUsingCollection(int collectionId) => throw new NotSupportedException();
}
}
@@ -57,11 +57,12 @@ namespace ErsatzTV.Core.Tests.Fakes
public Task<Either<BaseError, Episode>> GetOrAddEpisode(Season season, LibraryPath libraryPath, string path) =>
throw new NotSupportedException();
public Task<Unit> DeleteEmptyShows() => throw new NotSupportedException();
public Task<IEnumerable<string>> FindEpisodePaths(LibraryPath libraryPath) => throw new NotSupportedException();
public Task<Option<Show>> GetShowByPath(int mediaSourceId, string path) => throw new NotSupportedException();
public Task<Unit> DeleteByPath(LibraryPath libraryPath, string path) => throw new NotSupportedException();
public Task<Unit> DeleteMissingSources(int localMediaSourceId, List<string> allFolders) =>
throw new NotSupportedException();
public Task<Unit> DeleteEmptySeasons(LibraryPath libraryPath) => throw new NotSupportedException();
public Task<Unit> DeleteEmptyShows(LibraryPath libraryPath) => throw new NotSupportedException();
}
}
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
@@ -44,6 +45,8 @@ namespace ErsatzTV.Core.Tests.Metadata
_movieRepository.Setup(x => x.GetOrAdd(It.IsAny<LibraryPath>(), It.IsAny<string>()))
.Returns(
(LibraryPath _, string path) => Right<BaseError, Movie>(new FakeMovieWithPath(path)).AsTask());
_movieRepository.Setup(x => x.FindMoviePaths(It.IsAny<LibraryPath>()))
.Returns(new List<string>().AsEnumerable().AsTask());
_localStatisticsProvider = new Mock<ILocalStatisticsProvider>();
_localMetadataProvider = new Mock<ILocalMetadataProvider>();
@@ -359,6 +362,55 @@ namespace ErsatzTV.Core.Tests.Metadata
Times.Once);
}
[Test]
public async Task RenamedMovie_Should_Delete_Old_Movie()
{
string movieFolder = Path.Combine(FakeRoot, "Movie (2020)");
string oldMoviePath = Path.Combine(movieFolder, "Movie (2020).avi");
_movieRepository.Setup(x => x.FindMoviePaths(It.IsAny<LibraryPath>()))
.Returns(new List<string> { oldMoviePath }.AsEnumerable().AsTask());
string moviePath = Path.Combine(movieFolder, "Movie (2020).mkv");
MovieFolderScanner service = GetService(
new FakeFileEntry(moviePath) { LastWriteTime = DateTime.Now }
);
var libraryPath = new LibraryPath { Id = 1, Path = FakeRoot };
Either<BaseError, Unit> result = await service.ScanFolder(libraryPath, FFprobePath);
result.IsRight.Should().BeTrue();
_movieRepository.Verify(x => x.DeleteByPath(It.IsAny<LibraryPath>(), It.IsAny<string>()), Times.Once);
_movieRepository.Verify(x => x.DeleteByPath(libraryPath, oldMoviePath), Times.Once);
}
[Test]
public async Task DeletedMovieAndFolder_Should_Delete_Old_Movie()
{
string movieFolder = Path.Combine(FakeRoot, "Movie (2020)");
string oldMoviePath = Path.Combine(movieFolder, "Movie (2020).avi");
_movieRepository.Setup(x => x.FindMoviePaths(It.IsAny<LibraryPath>()))
.Returns(new List<string> { oldMoviePath }.AsEnumerable().AsTask());
string moviePath = Path.Combine(movieFolder, "Movie (2020).mkv");
MovieFolderScanner service = GetService(
new FakeFileEntry(moviePath) { LastWriteTime = DateTime.Now }
);
var libraryPath = new LibraryPath { Id = 1, Path = FakeRoot };
Either<BaseError, Unit> result = await service.ScanFolder(libraryPath, FFprobePath);
result.IsRight.Should().BeTrue();
_movieRepository.Verify(x => x.DeleteByPath(It.IsAny<LibraryPath>(), It.IsAny<string>()), Times.Once);
_movieRepository.Verify(x => x.DeleteByPath(libraryPath, oldMoviePath), Times.Once);
}
private MovieFolderScanner GetService(params FakeFileEntry[] files) =>
new(
new FakeLocalFileSystem(new List<FakeFileEntry>(files)),
+1
View File
@@ -6,6 +6,7 @@
public string Name { get; set; }
public int ThreadCount { get; set; }
public bool Transcode { get; set; }
public HardwareAccelerationKind HardwareAcceleration { get; set; }
public int ResolutionId { get; set; }
public Resolution Resolution { get; set; }
public bool NormalizeResolution { get; set; }
@@ -0,0 +1,10 @@
namespace ErsatzTV.Core.Domain
{
public enum HardwareAccelerationKind
{
None = 0,
Qsv = 1,
Nvenc = 2,
Vaapi = 3
}
}
@@ -0,0 +1,4 @@
namespace ErsatzTV.Core.FFmpeg
{
public record FFmpegComplexFilter(string ComplexFilter, string VideoLabel, string AudioLabel);
}
@@ -0,0 +1,151 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
using LanguageExt;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.FFmpeg
{
public class FFmpegComplexFilterBuilder
{
private Option<TimeSpan> _audioDuration = None;
private bool _deinterlace;
private Option<HardwareAccelerationKind> _hardwareAccelerationKind = None;
private Option<IDisplaySize> _padToSize = None;
private Option<IDisplaySize> _scaleToSize = None;
public FFmpegComplexFilterBuilder WithHardwareAcceleration(HardwareAccelerationKind hardwareAccelerationKind)
{
_hardwareAccelerationKind = Some(hardwareAccelerationKind);
return this;
}
public FFmpegComplexFilterBuilder WithScaling(IDisplaySize scaleToSize)
{
_scaleToSize = Some(scaleToSize);
return this;
}
public FFmpegComplexFilterBuilder WithBlackBars(IDisplaySize padToSize)
{
_padToSize = Some(padToSize);
return this;
}
public FFmpegComplexFilterBuilder WithDeinterlace(bool deinterlace)
{
_deinterlace = deinterlace;
return this;
}
public FFmpegComplexFilterBuilder WithAlignedAudio(Option<TimeSpan> audioDuration)
{
_audioDuration = audioDuration;
return this;
}
public Option<FFmpegComplexFilter> Build()
{
var complexFilter = new StringBuilder();
var videoLabel = "0:v";
var audioLabel = "0:a";
HardwareAccelerationKind acceleration = _hardwareAccelerationKind.IfNone(HardwareAccelerationKind.None);
_audioDuration.IfSome(
audioDuration =>
{
complexFilter.Append($"[{audioLabel}]");
complexFilter.Append($"apad=whole_dur={audioDuration.TotalMilliseconds}ms");
audioLabel = "[a]";
complexFilter.Append(audioLabel);
});
var filterQueue = new List<string>();
if (_deinterlace)
{
string filter = acceleration switch
{
HardwareAccelerationKind.Qsv => "deinterlace_qsv",
HardwareAccelerationKind.Nvenc => "", // TODO: yadif_cuda support in docker
HardwareAccelerationKind.Vaapi => "deinterlace_vaapi",
_ => "yadif=1"
};
if (!string.IsNullOrWhiteSpace(filter))
{
filterQueue.Add(filter);
}
}
_scaleToSize.IfSome(
size =>
{
string filter = acceleration switch
{
HardwareAccelerationKind.Qsv => $"scale_qsv=w={size.Width}:h={size.Height}",
HardwareAccelerationKind.Nvenc => $"scale_npp={size.Width}:{size.Height}",
HardwareAccelerationKind.Vaapi => $"scale_vaapi=w={size.Width}:h={size.Height}",
_ => $"scale={size.Width}:{size.Height}:flags=fast_bilinear"
};
if (!string.IsNullOrWhiteSpace(filter))
{
filterQueue.Add(filter);
}
});
if (_scaleToSize.IsSome || _padToSize.IsSome)
{
if (acceleration != HardwareAccelerationKind.None)
{
filterQueue.Add("hwdownload");
string format = acceleration switch
{
HardwareAccelerationKind.Vaapi => "format=nv12|vaapi",
_ => "format=nv12"
};
filterQueue.Add(format);
}
filterQueue.Add("setsar=1");
}
_padToSize.IfSome(size => filterQueue.Add($"pad={size.Width}:{size.Height}:(ow-iw)/2:(oh-ih)/2"));
if ((_scaleToSize.IsSome || _padToSize.IsSome) && acceleration != HardwareAccelerationKind.None)
{
string upload = acceleration switch
{
HardwareAccelerationKind.Qsv => "hwupload=extra_hw_frames=64",
_ => "hwupload"
};
filterQueue.Add(upload);
}
if (filterQueue.Any())
{
// TODO: any audio filter
if (_audioDuration.IsSome)
{
complexFilter.Append(';');
}
complexFilter.Append($"[{videoLabel}]");
complexFilter.Append(string.Join(",", filterQueue));
videoLabel = "[v]";
complexFilter.Append(videoLabel);
}
var filterResult = complexFilter.ToString();
return string.IsNullOrWhiteSpace(filterResult)
? Option<FFmpegComplexFilter>.None
: new FFmpegComplexFilter(filterResult, videoLabel, audioLabel);
}
}
}
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
using LanguageExt;
@@ -9,6 +10,8 @@ namespace ErsatzTV.Core.FFmpeg
{
public int ThreadCount { get; set; }
public List<string> FormatFlags { get; set; }
public HardwareAccelerationKind HardwareAcceleration { get; set; }
public string VideoDecoder { get; set; }
public bool RealtimeOutput => true;
public Option<TimeSpan> StreamSeek { get; set; }
public Option<IDisplaySize> ScaledSize { get; set; }
@@ -67,6 +67,8 @@ namespace ErsatzTV.Core.FFmpeg
result.Deinterlace = false;
break;
case StreamingMode.TransportStream:
result.HardwareAcceleration = ffmpegProfile.HardwareAcceleration;
if (NeedToScale(ffmpegProfile, version))
{
IDisplaySize scaledSize = CalculateScaledSize(ffmpegProfile, version);
@@ -77,7 +79,7 @@ namespace ErsatzTV.Core.FFmpeg
}
IDisplaySize sizeAfterScaling = result.ScaledSize.IfNone(version);
if (!sizeAfterScaling.IsSameSizeAs(ffmpegProfile.Resolution))
if (ffmpegProfile.NormalizeResolution && !sizeAfterScaling.IsSameSizeAs(ffmpegProfile.Resolution))
{
result.PadToDesiredResolution = true;
}
+67 -68
View File
@@ -21,7 +21,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
@@ -31,10 +30,16 @@ namespace ErsatzTV.Core.FFmpeg
{
internal class FFmpegProcessBuilder
{
private static readonly Dictionary<string, string> QsvMap = new()
{
{ "h264", "h264_qsv" },
{ "hevc", "hevc_qsv" },
{ "mpeg2video", "mpeg2_qsv" }
};
private readonly List<string> _arguments = new();
private readonly Queue<string> _audioFilters = new();
private readonly string _ffmpegPath;
private readonly Queue<string> _videoFilters = new();
private FFmpegComplexFilterBuilder _complexFilterBuilder = new();
public FFmpegProcessBuilder(string ffmpegPath) => _ffmpegPath = ffmpegPath;
@@ -45,6 +50,37 @@ namespace ErsatzTV.Core.FFmpeg
return this;
}
public FFmpegProcessBuilder WithHardwareAcceleration(HardwareAccelerationKind hwAccel)
{
switch (hwAccel)
{
case HardwareAccelerationKind.Qsv:
_arguments.Add("-hwaccel");
_arguments.Add("qsv");
_arguments.Add("-init_hw_device");
_arguments.Add("qsv=qsv:MFX_IMPL_hw_any");
break;
case HardwareAccelerationKind.Nvenc:
_arguments.Add("-hwaccel");
_arguments.Add("cuda");
_arguments.Add("-hwaccel_output_format");
_arguments.Add("cuda");
break;
case HardwareAccelerationKind.Vaapi:
_arguments.Add("-hwaccel");
_arguments.Add("vaapi");
_arguments.Add("-vaapi_device");
_arguments.Add("/dev/dri/renderD128");
_arguments.Add("-hwaccel_output_format");
_arguments.Add("vaapi");
break;
}
_complexFilterBuilder = _complexFilterBuilder.WithHardwareAcceleration(hwAccel);
return this;
}
public FFmpegProcessBuilder WithRealtimeOutput(bool realtimeOutput)
{
if (realtimeOutput)
@@ -109,6 +145,19 @@ namespace ErsatzTV.Core.FFmpeg
return this;
}
public FFmpegProcessBuilder WithInputCodec(string input, HardwareAccelerationKind hwAccel, string codec)
{
if (hwAccel == HardwareAccelerationKind.Qsv && QsvMap.TryGetValue(codec, out string qsvCodec))
{
_arguments.Add("-c:v");
_arguments.Add(qsvCodec);
}
_arguments.Add("-i");
_arguments.Add($"{input}");
return this;
}
public FFmpegProcessBuilder WithFiltergraph(string graph)
{
_arguments.Add("-vf");
@@ -242,73 +291,44 @@ namespace ErsatzTV.Core.FFmpeg
return this;
}
public FFmpegProcessBuilder WithScaling(IDisplaySize displaySize, string algorithm)
public FFmpegProcessBuilder WithScaling(IDisplaySize displaySize)
{
_videoFilters.Enqueue($"scale={displaySize.Width}:{displaySize.Height}:flags={algorithm}");
_complexFilterBuilder = _complexFilterBuilder.WithScaling(displaySize);
return this;
}
public FFmpegProcessBuilder WithBlackBars(IDisplaySize displaySize)
{
_videoFilters.Enqueue($"pad={displaySize.Width}:{displaySize.Height}:(ow-iw)/2:(oh-ih)/2");
_complexFilterBuilder = _complexFilterBuilder.WithBlackBars(displaySize);
return this;
}
public FFmpegProcessBuilder WithAlignedAudio(Option<TimeSpan> audioDuration)
{
audioDuration.IfSome(duration => _audioFilters.Enqueue($"apad=whole_dur={duration.TotalMilliseconds}ms"));
_complexFilterBuilder = _complexFilterBuilder.WithAlignedAudio(audioDuration);
return this;
}
public FFmpegProcessBuilder WithDeinterlace(bool deinterlace, string algorithm = "yadif=1")
public FFmpegProcessBuilder WithDeinterlace(bool deinterlace)
{
if (deinterlace)
{
_videoFilters.Enqueue(algorithm);
}
return this;
}
public FFmpegProcessBuilder WithSAR()
{
// TODO: minsiz?
_videoFilters.Enqueue("setsar=1");
_complexFilterBuilder = _complexFilterBuilder.WithDeinterlace(deinterlace);
return this;
}
public FFmpegProcessBuilder WithFilterComplex()
{
var complexFilter = new StringBuilder();
var videoLabel = "0:v";
var audioLabel = "0:a";
bool hasVideoFilters = _videoFilters.Any();
if (hasVideoFilters)
{
(string filter, string finalLabel) = GenerateVideoFilter(_videoFilters);
complexFilter.Append(filter);
videoLabel = finalLabel;
}
if (_audioFilters.Any())
{
if (hasVideoFilters)
Option<FFmpegComplexFilter> maybeFilter = _complexFilterBuilder.Build();
maybeFilter.IfSome(
filter =>
{
complexFilter.Append(';');
}
(string filter, string finalLabel) = GenerateAudioFilter(_audioFilters);
complexFilter.Append(filter);
audioLabel = finalLabel;
}
var complex = complexFilter.ToString();
if (!string.IsNullOrWhiteSpace(complex))
{
_arguments.Add("-filter_complex");
_arguments.Add(complex);
}
_arguments.Add("-filter_complex");
_arguments.Add(filter.ComplexFilter);
videoLabel = filter.VideoLabel;
audioLabel = filter.AudioLabel;
});
_arguments.Add("-map");
_arguments.Add(videoLabel);
@@ -321,7 +341,7 @@ namespace ErsatzTV.Core.FFmpeg
public FFmpegProcessBuilder WithQuiet()
{
_arguments.AddRange(new[] { "-hide_banner", "-loglevel", "panic", "-nostats" });
_arguments.AddRange(new[] { "-hide_banner", "-loglevel", "error", "-nostats" });
return this;
}
@@ -347,26 +367,5 @@ namespace ErsatzTV.Core.FFmpeg
StartInfo = startInfo
};
}
private FilterResult GenerateVideoFilter(Queue<string> filterQueue) =>
GenerateFilter(filterQueue, "null", 'v');
private FilterResult GenerateAudioFilter(Queue<string> filterQueue) =>
GenerateFilter(filterQueue, "anull", 'a');
private static FilterResult GenerateFilter(Queue<string> filterQueue, string nullFilter, char av)
{
var filter = new StringBuilder();
var index = 0;
filter.Append($"[0:{av}]{nullFilter}[{av}{index}]");
while (filterQueue.TryDequeue(out string result))
{
filter.Append($";[{av}{index}]{result}[{av}{++index}]");
}
return new FilterResult(filter.ToString(), $"[{av}{index}]");
}
private record FilterResult(string Filter, string FinalLabel);
}
}
+3 -4
View File
@@ -29,18 +29,18 @@ namespace ErsatzTV.Core.FFmpeg
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath)
.WithThreads(playbackSettings.ThreadCount)
.WithHardwareAcceleration(playbackSettings.HardwareAcceleration)
.WithQuiet()
.WithFormatFlags(playbackSettings.FormatFlags)
.WithRealtimeOutput(playbackSettings.RealtimeOutput)
.WithSeek(playbackSettings.StreamSeek)
.WithInput(path);
.WithInputCodec(path, playbackSettings.HardwareAcceleration, version.VideoCodec);
playbackSettings.ScaledSize.Match(
scaledSize =>
{
builder = builder.WithDeinterlace(playbackSettings.Deinterlace)
.WithScaling(scaledSize, playbackSettings.ScalingAlgorithm)
.WithSAR();
.WithScaling(scaledSize);
scaledSize = scaledSize.PadToEven();
if (NeedToPad(channel.FFmpegProfile.Resolution, scaledSize))
@@ -57,7 +57,6 @@ namespace ErsatzTV.Core.FFmpeg
{
builder = builder
.WithDeinterlace(playbackSettings.Deinterlace)
.WithSAR()
.WithBlackBars(channel.FFmpegProfile.Resolution)
.WithAlignedAudio(playbackSettings.AudioDuration)
.WithFilterComplex();
@@ -8,13 +8,14 @@ namespace ErsatzTV.Core.Interfaces.Repositories
public interface IMediaCollectionRepository
{
Task<Collection> Add(Collection collection);
Task<Unit> AddMediaItem(int collectionId, int mediaItemId);
Task<bool> AddMediaItem(int collectionId, int mediaItemId);
Task<Option<Collection>> Get(int id);
Task<Option<Collection>> GetCollectionWithItems(int id);
Task<Option<Collection>> GetCollectionWithItemsUntracked(int id);
Task<List<Collection>> GetAll();
Task<Option<List<MediaItem>>> GetItems(int id);
Task Update(Collection collection);
Task<bool> Update(Collection collection);
Task Delete(int collectionId);
Task<List<int>> PlayoutIdsUsingCollection(int collectionId);
}
}
@@ -13,5 +13,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<bool> Update(Movie movie);
Task<int> GetMovieCount();
Task<List<MovieMetadata>> GetPagedMovies(int pageNumber, int pageSize);
Task<IEnumerable<string>> FindMoviePaths(LibraryPath libraryPath);
Task<Unit> DeleteByPath(LibraryPath libraryPath, string path);
}
}
@@ -27,6 +27,9 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<Either<BaseError, Show>> AddShow(int libraryPathId, string showFolder, ShowMetadata metadata);
Task<Either<BaseError, Season>> GetOrAddSeason(Show show, int libraryPathId, int seasonNumber);
Task<Either<BaseError, Episode>> GetOrAddEpisode(Season season, LibraryPath libraryPath, string path);
Task<Unit> DeleteEmptyShows();
Task<IEnumerable<string>> FindEpisodePaths(LibraryPath libraryPath);
Task<Unit> DeleteByPath(LibraryPath libraryPath, string path);
Task<Unit> DeleteEmptySeasons(LibraryPath libraryPath);
Task<Unit> DeleteEmptyShows(LibraryPath libraryPath);
}
}
+7 -4
View File
@@ -62,15 +62,18 @@ namespace ErsatzTV.Core.Iptv
string title = playoutItem.MediaItem switch
{
Movie m => m.MovieMetadata.HeadOrNone().Map(mm => mm.Title ?? string.Empty).IfNone("[unknown movie]"),
Episode e => e.EpisodeMetadata.HeadOrNone().Map(em => em.Title ?? string.Empty).IfNone("[unknown episode]"),
Movie m => m.MovieMetadata.HeadOrNone().Map(mm => mm.Title ?? string.Empty)
.IfNone("[unknown movie]"),
Episode e => e.EpisodeMetadata.HeadOrNone().Map(em => em.Title ?? string.Empty)
.IfNone("[unknown episode]"),
_ => "[unknown]"
};
string description = playoutItem.MediaItem switch
{
Movie m => m.MovieMetadata.HeadOrNone().Map(mm => mm.Plot ?? string.Empty).IfNone(string.Empty),
Episode e => e.EpisodeMetadata.HeadOrNone().Map(em => em.Plot ?? string.Empty).IfNone(string.Empty),
Episode e => e.EpisodeMetadata.HeadOrNone().Map(em => em.Plot ?? string.Empty)
.IfNone(string.Empty),
_ => string.Empty
};
@@ -108,7 +111,7 @@ namespace ErsatzTV.Core.Iptv
xml.WriteAttributeString("system", "onscreen");
xml.WriteString($"S{s:00}E{e:00}");
xml.WriteEndElement(); // episode-num
xml.WriteStartElement("episode-num");
xml.WriteAttributeString("system", "xmltv_ns");
xml.WriteString($"{s - 1}.{e - 1}.0/1");
@@ -34,7 +34,7 @@ namespace ErsatzTV.Core.Metadata
return fileName != null ? GetMovieMetadata(fileName, metadata) : metadata;
}
public string GetSortTitle(string title)
{
if (string.IsNullOrWhiteSpace(title))
@@ -71,6 +71,7 @@ namespace ErsatzTV.Core.Metadata
continue;
}
foreach (string file in allFiles.OrderBy(identity))
{
// TODO: optimize dbcontext use here, do we need tracking? can we make partial updates with dapper?
@@ -86,6 +87,15 @@ namespace ErsatzTV.Core.Metadata
}
}
foreach (string path in await _movieRepository.FindMoviePaths(libraryPath))
{
if (!_localFileSystem.FileExists(path))
{
_logger.LogInformation("Removing missing movie at {Path}", path);
await _movieRepository.DeleteByPath(libraryPath, path);
}
}
return Unit.Default;
}
@@ -63,7 +63,17 @@ namespace ErsatzTV.Core.Metadata
_ => Task.FromResult(Unit.Default));
}
await _televisionRepository.DeleteEmptyShows();
foreach (string path in await _televisionRepository.FindEpisodePaths(libraryPath))
{
if (!_localFileSystem.FileExists(path))
{
_logger.LogInformation("Removing missing episode at {Path}", path);
await _televisionRepository.DeleteByPath(libraryPath, path);
}
}
await _televisionRepository.DeleteEmptySeasons(libraryPath);
await _televisionRepository.DeleteEmptyShows(libraryPath);
return Unit.Default;
}
@@ -30,8 +30,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
}
public async Task<Unit> AddMediaItem(int collectionId, int mediaItemId)
public async Task<bool> AddMediaItem(int collectionId, int mediaItemId)
{
var modified = false;
Option<Collection> maybeCollection = await _dbContext.Collections
.Include(c => c.MediaItems)
.OrderBy(c => c.Id)
@@ -52,12 +54,12 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
async mediaItem =>
{
collection.MediaItems.Add(mediaItem);
await _dbContext.SaveChangesAsync();
modified = await _dbContext.SaveChangesAsync() > 0;
});
}
});
return Unit.Default;
return modified;
}
public Task<Option<Collection>> Get(int id) =>
@@ -121,10 +123,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public Task<Option<List<MediaItem>>> GetItems(int id) =>
Get(id).MapT(GetItemsForCollection).Bind(x => x.Sequence());
public Task Update(Collection collection)
public Task<bool> Update(Collection collection)
{
_dbContext.Collections.Update(collection);
return _dbContext.SaveChangesAsync();
return _dbContext.SaveChangesAsync().Map(result => result > 0);
}
public async Task Delete(int collectionId)
@@ -134,6 +136,16 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
await _dbContext.SaveChangesAsync();
}
public Task<List<int>> PlayoutIdsUsingCollection(int collectionId) =>
_dbConnection.QueryAsync<int>(
@"SELECT DISTINCT p.Id
FROM Playout p
INNER JOIN ProgramSchedule PS on p.ProgramScheduleId = PS.Id
INNER JOIN ProgramScheduleItem PSI on p.Anchor_NextScheduleItemId = PSI.Id
WHERE PSI.CollectionId = @CollectionId",
new { CollectionId = collectionId })
.Map(result => result.ToList());
private async Task<List<MediaItem>> GetItemsForCollection(Collection collection)
{
var result = new List<MediaItem>();
@@ -173,7 +173,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
await _dbConnection.ExecuteAsync(
"UPDATE PlexLibrary SET ShouldSyncItems = 0 WHERE Id IN @ids",
new { ids = libraryIds });
await _dbConnection.ExecuteAsync(
"UPDATE Library SET LastScan = null WHERE Id IN @ids",
new { ids = libraryIds });
@@ -93,6 +93,38 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.OrderBy(mm => mm.SortTitle)
.ToListAsync();
public Task<IEnumerable<string>> FindMoviePaths(LibraryPath libraryPath) =>
_dbConnection.QueryAsync<string>(
@"SELECT MF.Path
FROM MediaFile MF
INNER JOIN MediaVersion MV on MF.MediaVersionId = MV.Id
INNER JOIN Movie M on MV.MovieId = M.Id
INNER JOIN MediaItem MI on M.Id = MI.Id
WHERE MI.LibraryPathId = @LibraryPathId",
new { LibraryPathId = libraryPath.Id });
public async Task<Unit> DeleteByPath(LibraryPath libraryPath, string path)
{
IEnumerable<int> ids = await _dbConnection.QueryAsync<int>(
@"SELECT M.Id
FROM Movie M
INNER JOIN MediaItem MI on M.Id = MI.Id
INNER JOIN MediaVersion MV on M.Id = MV.MovieId
INNER JOIN MediaFile MF on MV.Id = MF.MediaVersionId
WHERE MI.LibraryPathId = @LibraryPathId AND MF.Path = @Path",
new { LibraryPathId = libraryPath.Id, Path = path });
foreach (int movieId in ids)
{
Movie movie = await _dbContext.Movies.FindAsync(movieId);
_dbContext.Movies.Remove(movie);
}
await _dbContext.SaveChangesAsync();
return Unit.Default;
}
private async Task<Either<BaseError, Movie>> AddMovie(int libraryPathId, string path)
{
try
@@ -122,7 +154,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
}
}
private async Task<Either<BaseError, PlexMovie>> AddPlexMovie(TvContext context, PlexLibrary library, PlexMovie item)
private async Task<Either<BaseError, PlexMovie>> AddPlexMovie(
TvContext context,
PlexLibrary library,
PlexMovie item)
{
try
{
@@ -230,9 +230,55 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
() => AddEpisode(season, libraryPath.Id, path));
}
public Task<Unit> DeleteEmptyShows() =>
public Task<IEnumerable<string>> FindEpisodePaths(LibraryPath libraryPath) =>
_dbConnection.QueryAsync<string>(
@"SELECT MF.Path
FROM MediaFile MF
INNER JOIN MediaVersion MV on MF.MediaVersionId = MV.Id
INNER JOIN Episode E on MV.EpisodeId = E.Id
INNER JOIN MediaItem MI on E.Id = MI.Id
WHERE MI.LibraryPathId = @LibraryPathId",
new { LibraryPathId = libraryPath.Id });
public async Task<Unit> DeleteByPath(LibraryPath libraryPath, string path)
{
IEnumerable<int> ids = await _dbConnection.QueryAsync<int>(
@"SELECT E.Id
FROM Episode E
INNER JOIN MediaItem MI on E.Id = MI.Id
INNER JOIN MediaVersion MV on E.Id = MV.EpisodeId
INNER JOIN MediaFile MF on MV.Id = MF.MediaVersionId
WHERE MI.LibraryPathId = @LibraryPathId AND MF.Path = @Path",
new { LibraryPathId = libraryPath.Id, Path = path });
foreach (int episodeId in ids)
{
Episode episode = await _dbContext.Episodes.FindAsync(episodeId);
_dbContext.Episodes.Remove(episode);
}
await _dbContext.SaveChangesAsync();
return Unit.Default;
}
public Task<Unit> DeleteEmptySeasons(LibraryPath libraryPath) =>
_dbContext.Seasons
.Filter(s => s.LibraryPathId == libraryPath.Id)
.Filter(s => s.Episodes.Count == 0)
.ToListAsync()
.Bind(
list =>
{
_dbContext.Seasons.RemoveRange(list);
return _dbContext.SaveChangesAsync();
})
.ToUnit();
public Task<Unit> DeleteEmptyShows(LibraryPath libraryPath) =>
_dbContext.Shows
.Where(s => s.Seasons.Count == 0)
.Filter(s => s.LibraryPathId == libraryPath.Id)
.Filter(s => s.Seasons.Count == 0)
.ToListAsync()
.Bind(
list =>
@@ -11,7 +11,7 @@ namespace ErsatzTV.Infrastructure.Migrations
migrationBuilder.Sql(
@$"INSERT INTO NewMovieMetadata (Outline, Plot, Tagline, MovieId, MetadataKind, Title, OriginalTitle, SortTitle, ReleaseDate, DateAdded, DateUpdated)
SELECT mm.Outline, mm.Plot, mm.Tagline, m.Id, mm.Source, mm.Title, null, mm.SortTitle, mm.Premiered, '{now}', mm.LastWriteTime
SELECT mm.Outline, mm.Plot, mm.Tagline, m.Id, mm.Source, mm.Title, null, mm.SortTitle, mm.Premiered, '{now}', IFNULL(mm.LastWriteTime, '0001-01-01 00:00:00')
FROM MovieMetadata mm
INNER JOIN Movie m ON mm.MovieId = m.Id");
}
@@ -0,0 +1,20 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_FFmpegProfileHardwareAcceleration : Migration
{
protected override void Up(MigrationBuilder migrationBuilder) =>
migrationBuilder.AddColumn<int>(
"HardwareAcceleration",
"FFmpegProfile",
"INTEGER",
nullable: false,
defaultValue: 0);
protected override void Down(MigrationBuilder migrationBuilder) =>
migrationBuilder.DropColumn(
"HardwareAcceleration",
"FFmpegProfile");
}
}
@@ -231,6 +231,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<int>("AudioVolume")
.HasColumnType("INTEGER");
b.Property<int>("HardwareAcceleration")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
@@ -18,10 +18,8 @@ namespace ErsatzTV.Infrastructure.Plex
{
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
public PlexServerApiClient(IFallbackMetadataProvider fallbackMetadataProvider)
{
public PlexServerApiClient(IFallbackMetadataProvider fallbackMetadataProvider) =>
_fallbackMetadataProvider = fallbackMetadataProvider;
}
public async Task<Either<BaseError, List<PlexLibrary>>> GetLibraries(
PlexConnection connection,
@@ -150,7 +148,7 @@ namespace ErsatzTV.Infrastructure.Plex
}
private static string ConvertToSAR(double aspectRatio) => "1:1";
// TODO: fix this with more detailed stats pull from plex for each item
// Math.Abs(aspectRatio - 1) < 0.01 ? "1:1" : $"{(int) (aspectRatio * 100)}:100";
// TODO: fix this with more detailed stats pull from plex for each item
// Math.Abs(aspectRatio - 1) < 0.01 ? "1:1" : $"{(int) (aspectRatio * 100)}:100";
}
}
+1
View File
@@ -39,4 +39,5 @@
<s:Boolean x:Key="/Default/UserDictionary/Words/=probesize/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=setsar/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=tvshow/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Vaapi/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=yadif/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
+10 -1
View File
@@ -33,7 +33,16 @@ namespace ErsatzTV.Extensions
.Bind(services => Try(GetDbContext(services)))
.Bind(ctx => Try(Migrate(ctx, scope.ServiceProvider)))
.Bind(ctx => Try(InitializeDb(ctx)))
.IfFail(ex => LogException(ex, "Error occured while seeding database", scope.ServiceProvider));
.IfFail(
ex =>
{
LogException(
ex,
"Error occured while migrating database; shutting down.",
scope.ServiceProvider);
Environment.Exit(13);
return unit;
});
private static Unit CleanCache(IServiceScope scope) =>
Try(() => scope.ServiceProvider)
+1
View File
@@ -7,6 +7,7 @@
@inject ILogger<CollectionItems> Logger
@inject ISnackbar Snackbar
@inject IDialogService Dialog
@inject ChannelWriter<IBackgroundServiceRequest> Channel
<div class="mb-6" style="display: flex; flex-direction: row;">
<MudText GutterBottom="true" Typo="Typo.h2">@_data.Name</MudText>
+8
View File
@@ -49,6 +49,14 @@
<MudElement HtmlTag="div" Class="mt-3">
<MudTextField Disabled="@(!_model.Transcode)" Label="Buffer Size" @bind-Value="_model.VideoBufferSize" For="@(() => _model.VideoBufferSize)" Adornment="Adornment.End" AdornmentText="kBit"/>
</MudElement>
<MudElement HtmlTag="div" Class="mt-3">
<MudSelect Disabled="@(!_model.Transcode)" Label="Hardware Acceleration" @bind-Value="_model.HardwareAcceleration" For="@(() => _model.HardwareAcceleration)">
@foreach (HardwareAccelerationKind hwAccel in Enum.GetValues<HardwareAccelerationKind>())
{
<MudSelectItem Value="@hwAccel">@hwAccel</MudSelectItem>
}
</MudSelect>
</MudElement>
</MudItem>
<MudItem>
<MudText Typo="Typo.h6">Audio</MudText>
+1
View File
@@ -10,6 +10,7 @@
@inject IMediator Mediator
@inject IDialogService Dialog
@inject NavigationManager NavigationManager
@inject ChannelWriter<IBackgroundServiceRequest> Channel
<MudContainer MaxWidth="MaxWidth.Small" Class="mb-6" Style="max-width: 300px">
<MudPaper Style="align-items: center; display: flex; justify-content: center;">
+1
View File
@@ -11,6 +11,7 @@
@inject ILogger<Search> Logger
@inject ISnackbar Snackbar
@inject IDialogService Dialog
@inject ChannelWriter<IBackgroundServiceRequest> Channel
<MudContainer MaxWidth="MaxWidth.ExtraLarge">
<div class="mb-6" style="display: flex; flex-direction: row;">
+3 -2
View File
@@ -7,12 +7,13 @@
@using ErsatzTV.Application.MediaCollections.Commands
@using ErsatzTV.Application.ProgramSchedules
@using ErsatzTV.Application.ProgramSchedules.Commands
@using Unit=LanguageExt.Unit;
@using Unit = LanguageExt.Unit
@inject IMediator Mediator
@inject ILogger<TelevisionEpisodeList> Logger
@inject ISnackbar Snackbar
@inject IDialogService Dialog
@inject NavigationManager NavigationManager
@inject ChannelWriter<IBackgroundServiceRequest> Channel
<MudContainer MaxWidth="MaxWidth.Large">
<MudBreadcrumbs Items="_breadcrumbs" Class="mb-6"></MudBreadcrumbs>
@@ -122,7 +123,7 @@
NavigationManager.NavigateTo($"/schedules/{schedule.Id}/items");
}
}
private async Task AddEpisodeToCollection(MediaCardViewModel card)
{
if (card is TelevisionEpisodeCardViewModel episode)
+3 -2
View File
@@ -7,12 +7,13 @@
@using ErsatzTV.Application.MediaCollections.Commands
@using ErsatzTV.Application.ProgramSchedules
@using ErsatzTV.Application.ProgramSchedules.Commands
@using Unit=LanguageExt.Unit;
@using Unit = LanguageExt.Unit
@inject IMediator Mediator
@inject ILogger<TelevisionSeasonList> Logger
@inject ISnackbar Snackbar
@inject IDialogService Dialog
@inject NavigationManager NavigationManager
@inject ChannelWriter<IBackgroundServiceRequest> Channel
<MudContainer MaxWidth="MaxWidth.Large">
<MudBreadcrumbs Items="_breadcrumbs" Class="mb-6"></MudBreadcrumbs>
@@ -116,7 +117,7 @@
NavigationManager.NavigateTo($"/schedules/{schedule.Id}/items");
}
}
private async Task AddSeasonToCollection(MediaCardViewModel card)
{
if (card is TelevisionSeasonCardViewModel season)
+1
View File
@@ -8,6 +8,7 @@
@inject ISnackbar Snackbar
@inject IMediator Mediator
@inject IDialogService Dialog
@inject ChannelWriter<IBackgroundServiceRequest> Channel
<MudContainer MaxWidth="MaxWidth.Small" Class="mb-6" Style="max-width: 300px">
<MudPaper Style="align-items: center; display: flex; justify-content: center;">
+15 -3
View File
@@ -1,6 +1,8 @@
@using ErsatzTV.Application.MediaCollections
@using Microsoft.Extensions.Caching.Memory
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.MediaCollections.Queries
@inject IMediator Mediator
@inject IMemoryCache MemoryCache
<MudDialog>
<DialogContent>
@@ -46,12 +48,22 @@
private MediaCollectionViewModel _selectedCollection;
protected override async Task OnParametersSetAsync() =>
protected override async Task OnParametersSetAsync()
{
_collections = await Mediator.Send(new GetAllCollections());
if (MemoryCache.TryGetValue("AddToCollectionDialog.SelectedCollectionId", out int id))
{
_selectedCollection = _collections.SingleOrDefault(c => c.Id == id);
}
}
private string FormatText() => $"Select the collection to add the {EntityType} {EntityName}";
private void Submit() => MudDialog.Close(DialogResult.Ok(_selectedCollection));
private void Submit()
{
MemoryCache.Set("AddToCollectionDialog.SelectedCollectionId", _selectedCollection.Id);
MudDialog.Close(DialogResult.Ok(_selectedCollection));
}
private void Cancel() => MudDialog.Cancel();
+1 -1
View File
@@ -67,7 +67,7 @@
private static readonly string InfoVersion = Assembly.GetEntryAssembly().GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion ?? "unknown";
private MudTextField<string> _textField;
private MudTheme _ersatzTvTheme
{
get
+1 -1
View File
@@ -21,7 +21,7 @@
{
<MudIconButton Color="Color.Tertiary"
Icon="@Icons.Material.Filled.PlaylistAdd"
Style="bottom: 0; position: absolute; left: 0;"
Style="bottom: 0; left: 0; position: absolute;"
OnClick="@(() => AddToCollectionClicked.InvokeAsync(Data))"/>
}
@if (DeleteClicked.HasDelegate)
@@ -1,10 +1,16 @@
using ErsatzTV.ViewModels;
using System.Collections.Generic;
using ErsatzTV.Core.Domain;
using ErsatzTV.ViewModels;
using FluentValidation;
namespace ErsatzTV.Validators
{
public class FFmpegProfileEditViewModelValidator : AbstractValidator<FFmpegProfileEditViewModel>
{
private static readonly List<string> QsvEncoders = new() { "h264_qsv", "hevc_qsv", "mpeg2_qsv" };
private static readonly List<string> NvencEncoders = new() { "h264_nvenc", "hevc_nvenc" };
private static readonly List<string> VaapiEncoders = new() { "h264_vaapi", "hevc_vaapi", "mpeg2_vaapi" };
public FFmpegProfileEditViewModelValidator()
{
RuleFor(x => x.Name).NotEmpty();
@@ -23,6 +29,38 @@ namespace ErsatzTV.Validators
RuleFor(x => x.AudioVolume).GreaterThanOrEqualTo(0);
RuleFor(x => x.AudioChannels).GreaterThan(0);
});
When(
x => x.HardwareAcceleration == HardwareAccelerationKind.Qsv,
() =>
{
RuleFor(x => x.VideoCodec).Must(c => QsvEncoders.Contains(c))
.WithMessage("QSV codec is required (h264_qsv, hevc_qsv, mpeg2_qsv)");
});
When(
x => x.HardwareAcceleration == HardwareAccelerationKind.Nvenc,
() =>
{
RuleFor(x => x.VideoCodec).Must(c => NvencEncoders.Contains(c))
.WithMessage("NVENC codec is required (h264_nvenc, hevc_nvenc)");
});
When(
x => x.HardwareAcceleration == HardwareAccelerationKind.Vaapi,
() =>
{
RuleFor(x => x.VideoCodec).Must(c => VaapiEncoders.Contains(c))
.WithMessage("VAAPI codec is required (h264_vaapi, hevc_vaapi, mpeg2_vaapi)");
});
When(
x => x.HardwareAcceleration == HardwareAccelerationKind.None,
() =>
{
RuleFor(x => x.VideoCodec).Must(c => !QsvEncoders.Contains(c) && !NvencEncoders.Contains(c))
.WithMessage("Hardware acceleration is required for this codec");
});
}
}
}
@@ -1,6 +1,7 @@
using ErsatzTV.Application.FFmpegProfiles;
using ErsatzTV.Application.FFmpegProfiles.Commands;
using ErsatzTV.Application.Resolutions;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.ViewModels
{
@@ -27,6 +28,7 @@ namespace ErsatzTV.ViewModels
Resolution = viewModel.Resolution;
ThreadCount = viewModel.ThreadCount;
Transcode = viewModel.Transcode;
HardwareAcceleration = viewModel.HardwareAcceleration;
VideoBitrate = viewModel.VideoBitrate;
VideoBufferSize = viewModel.VideoBufferSize;
VideoCodec = viewModel.VideoCodec;
@@ -47,6 +49,7 @@ namespace ErsatzTV.ViewModels
public ResolutionViewModel Resolution { get; set; }
public int ThreadCount { get; set; }
public bool Transcode { get; set; }
public HardwareAccelerationKind HardwareAcceleration { get; set; }
public int VideoBitrate { get; set; }
public int VideoBufferSize { get; set; }
public string VideoCodec { get; set; }
@@ -56,6 +59,7 @@ namespace ErsatzTV.ViewModels
Name,
ThreadCount,
Transcode,
HardwareAcceleration,
Resolution.Id,
NormalizeResolution,
VideoCodec,
@@ -78,6 +82,7 @@ namespace ErsatzTV.ViewModels
Name,
ThreadCount,
Transcode,
HardwareAcceleration,
Resolution.Id,
NormalizeResolution,
VideoCodec,
+3 -2
View File
@@ -1,9 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="/mstile-150x150.png"/>
<square150x150logo src="/mstile-150x150.png" />
<TileColor>#da532c</TileColor>
</tile>
</msapplication>
</browserconfig>
</browserconfig>
+12 -29
View File
@@ -4,13 +4,9 @@
flex-wrap: wrap;
}
.media-card-container {
width: 152px;
}
.media-card-container { width: 152px; }
.media-card-episode-container {
width: 392px;
}
.media-card-episode-container { width: 392px; }
.media-card {
display: flex;
@@ -22,12 +18,9 @@
width: 152px;
}
.media-card-episode {
width: 392px;
}
.media-card-episode { width: 392px; }
.media-card:hover { /*filter: brightness(75%);*/
}
.media-card:hover { /*filter: brightness(75%);*/ }
.media-card-title {
overflow: hidden;
@@ -36,9 +29,7 @@
width: 100%;
}
.media-card-poster-placeholder {
font-weight: bold;
}
.media-card-poster-placeholder { font-weight: bold; }
.media-card-menu {
bottom: 0;
@@ -47,9 +38,7 @@
right: 0;
}
.media-card:hover .media-card-menu {
display: block;
}
.media-card:hover .media-card-menu { display: block; }
.media-card-overlay {
background: rgba(0, 0, 0, 0.4);
@@ -63,26 +52,20 @@
transition: opacity 0.2s;
}
.media-card-overlay:hover {
opacity: 1;
}
.media-card-overlay:hover { opacity: 1; }
.search-bar .mud-input {
height: 42px;
}
.search-bar .mud-input { height: 42px; }
.search-bar {
background-color: rgba(255, 255, 255, .15);
margin-bottom: 5px;
height: 42px;
border-radius: 4px;
height: 42px;
margin-bottom: 5px;
}
.search-bar div .mud-input-root {
color: #fafafa;
}
.search-bar div .mud-input-root { color: #fafafa; }
.search-bar .mud-input.mud-input-outlined .mud-input-outlined-border {
border: none;
border-radius: 4px;
}
}
+19
View File
@@ -11,6 +11,7 @@ Want to join the community or have a question? Join us on [Discord](https://disc
- Channel-specific streaming mode (MPEG-TS or HLS) and transcoding settings
- Collection-based scheduling, with collections containing television shows, seasons, episodes and movies
- Powerful scheduling options such as chronological collection playback throughout the day or over multiple days
- [Hardware-accelerated transcoding](#Hardware-Transcoding) options (QSV, NVENC, VAAPI)
## In Development
@@ -53,6 +54,24 @@ docker run -d \
After running ErsatzTV for the first time, configure it by visiting the web UI at http://[address]:8409.
### Software Transcoding
The following docker tags are available with software transcoding:
* `develop` - merges to `main` branch
* `latest` - latest release
### Hardware Transcoding
The following docker tags are available with hardware-accelerated transcoding:
* `develop-nvidia` - merges to `main` branch
* `develop-vaapi` - merges to `main` branch
* `latest-nvidia` - latest release
* `latest-vaapi` - latest release
QSV transcoding has not been tested in docker.
## Development
See [development documentation](docs/development.md).
+40
View File
@@ -0,0 +1,40 @@
FROM mcr.microsoft.com/dotnet/aspnet:5.0-focal-amd64 AS dotnet-runtime
FROM jrottenberg/ffmpeg:4.3-ubuntu2004 AS runtime-base
COPY --from=dotnet-runtime /usr/share/dotnet /usr/share/dotnet
RUN apt-get update && apt-get install -y libicu-dev
# https://hub.docker.com/_/microsoft-dotnet
FROM mcr.microsoft.com/dotnet/sdk:5.0 AS build
RUN apt-get update && apt-get install -y ca-certificates
WORKDIR /source
# copy csproj and restore as distinct layers
COPY *.sln .
COPY ErsatzTV/*.csproj ./ErsatzTV/
COPY generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/*.csproj ./generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/
COPY ErsatzTV.Application/*.csproj ./ErsatzTV.Application/
COPY ErsatzTV.CommandLine/*.csproj ./ErsatzTV.CommandLine/
COPY ErsatzTV.Core/*.csproj ./ErsatzTV.Core/
COPY ErsatzTV.Core.Tests/*.csproj ./ErsatzTV.Core.Tests/
COPY ErsatzTV.Infrastructure/*.csproj ./ErsatzTV.Infrastructure/
RUN dotnet restore -r linux-x64
# copy everything else and build app
COPY ErsatzTV/. ./ErsatzTV/
COPY generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/. ./generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/
COPY ErsatzTV.Application/. ./ErsatzTV.Application/
COPY ErsatzTV.CommandLine/. ./ErsatzTV.CommandLine/
COPY ErsatzTV.Core/. ./ErsatzTV.Core/
COPY ErsatzTV.Core.Tests/. ./ErsatzTV.Core.Tests/
COPY ErsatzTV.Infrastructure/. ./ErsatzTV.Infrastructure/
WORKDIR /source/ErsatzTV
ARG INFO_VERSION="unknown"
RUN dotnet publish -c release -o /app -r linux-x64 --self-contained false --no-restore /p:InformationalVersion=${INFO_VERSION}
# final stage/image
FROM runtime-base
WORKDIR /app
EXPOSE 8409
COPY --from=build /app ./
ENTRYPOINT ["./ErsatzTV"]
+15
View File
@@ -0,0 +1,15 @@
version: "3.1"
services:
ersatztv:
build:
context: ..
dockerfile: docker/nvidia/Dockerfile
environment:
NVIDIA_VISIBLE_DEVICES: all
NVIDIA_DRIVER_CAPABILITIES: compute,utility,video
deploy:
resources:
reservations:
devices:
- capabilities: [ gpu ]
+8
View File
@@ -0,0 +1,8 @@
version: "3.1"
services:
ersatztv:
build:
dockerfile: docker/vaapi/Dockerfile
devices:
- /dev/dri/renderD128:/dev/dri/renderD128
@@ -3,13 +3,14 @@
services:
ersatztv:
build:
context: .
context: ..
dockerfile: docker/Dockerfile
args:
INFO_VERSION: "docker-compose-develop"
ports:
- "8409:8409"
volumes:
- ersatztv:/root/.local/share/ersatztv
#- /media/shared:/media/shared:ro
#- /media/shared:/media/shared:ro
volumes:
ersatztv:
+40
View File
@@ -0,0 +1,40 @@
FROM mcr.microsoft.com/dotnet/aspnet:5.0-focal-amd64 AS dotnet-runtime
FROM jrottenberg/ffmpeg:4.3-nvidia1804 AS runtime-base
COPY --from=dotnet-runtime /usr/share/dotnet /usr/share/dotnet
RUN apt-get update && apt-get install -y libicu-dev
# https://hub.docker.com/_/microsoft-dotnet
FROM mcr.microsoft.com/dotnet/sdk:5.0 AS build
RUN apt-get update && apt-get install -y ca-certificates
WORKDIR /source
# copy csproj and restore as distinct layers
COPY *.sln .
COPY ErsatzTV/*.csproj ./ErsatzTV/
COPY generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/*.csproj ./generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/
COPY ErsatzTV.Application/*.csproj ./ErsatzTV.Application/
COPY ErsatzTV.CommandLine/*.csproj ./ErsatzTV.CommandLine/
COPY ErsatzTV.Core/*.csproj ./ErsatzTV.Core/
COPY ErsatzTV.Core.Tests/*.csproj ./ErsatzTV.Core.Tests/
COPY ErsatzTV.Infrastructure/*.csproj ./ErsatzTV.Infrastructure/
RUN dotnet restore -r linux-x64
# copy everything else and build app
COPY ErsatzTV/. ./ErsatzTV/
COPY generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/. ./generated/ErsatzTV.Api.Sdk/src/ErsatzTV.Api.Sdk/
COPY ErsatzTV.Application/. ./ErsatzTV.Application/
COPY ErsatzTV.CommandLine/. ./ErsatzTV.CommandLine/
COPY ErsatzTV.Core/. ./ErsatzTV.Core/
COPY ErsatzTV.Core.Tests/. ./ErsatzTV.Core.Tests/
COPY ErsatzTV.Infrastructure/. ./ErsatzTV.Infrastructure/
WORKDIR /source/ErsatzTV
ARG INFO_VERSION="unknown"
RUN dotnet publish -c release -o /app -r linux-x64 --self-contained false --no-restore /p:InformationalVersion=${INFO_VERSION}
# final stage/image
FROM runtime-base
WORKDIR /app
EXPOSE 8409
COPY --from=build /app ./
ENTRYPOINT ["./ErsatzTV"]
+5 -2
View File
@@ -1,5 +1,8 @@
FROM mcr.microsoft.com/dotnet/aspnet:5.0-focal-amd64 AS runtime-base
RUN apt-get update && apt-get install -y ffmpeg
FROM mcr.microsoft.com/dotnet/aspnet:5.0-focal-amd64 AS dotnet-runtime
FROM jrottenberg/ffmpeg:4.3-vaapi1804 AS runtime-base
COPY --from=dotnet-runtime /usr/share/dotnet /usr/share/dotnet
RUN apt-get update && apt-get install -y libicu-dev
# https://hub.docker.com/_/microsoft-dotnet
FROM mcr.microsoft.com/dotnet/sdk:5.0 AS build