Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5998fd2f5f | ||
|
|
4f536adc99 | ||
|
|
2637ff657d | ||
|
|
c4f7607a50 | ||
|
|
0f052631a4 | ||
|
|
b13b2b9805 | ||
|
|
51cdb372b9 | ||
|
|
363eb2c276 | ||
|
|
c6ea2c88df | ||
|
|
3ed83a276f |
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
+8
-4
@@ -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)),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Generated
+1494
File diff suppressed because it is too large
Load Diff
+20
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;">
|
||||
|
||||
@@ -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;">
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;">
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
private static readonly string InfoVersion = Assembly.GetEntryAssembly().GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion ?? "unknown";
|
||||
|
||||
private MudTextField<string> _textField;
|
||||
|
||||
|
||||
private MudTheme _ersatzTvTheme
|
||||
{
|
||||
get
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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"]
|
||||
@@ -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 ]
|
||||
@@ -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:
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user