Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3b91e62ae | ||
|
|
54da3a3159 | ||
|
|
d53a2f8bbf | ||
|
|
c2cbb1d5ff | ||
|
|
bd231d57a7 | ||
|
|
77cb2c2270 | ||
|
|
5244d5076a | ||
|
|
9841640128 | ||
|
|
a256095e12 | ||
|
|
ed592bd0a0 | ||
|
|
5998fd2f5f | ||
|
|
4f536adc99 | ||
|
|
2637ff657d | ||
|
|
c4f7607a50 | ||
|
|
0f052631a4 | ||
|
|
b13b2b9805 | ||
|
|
51cdb372b9 | ||
|
|
363eb2c276 | ||
|
|
c6ea2c88df |
@@ -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
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace ErsatzTV.Application.Channels
|
||||
{
|
||||
public record ChannelViewModel(
|
||||
int Id,
|
||||
int Number,
|
||||
string Number,
|
||||
string Name,
|
||||
int FFmpegProfileId,
|
||||
string Logo,
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace ErsatzTV.Application.Channels.Commands
|
||||
public record CreateChannel
|
||||
(
|
||||
string Name,
|
||||
int Number,
|
||||
string Number,
|
||||
int FFmpegProfileId,
|
||||
string Logo,
|
||||
StreamingMode StreamingMode) : IRequest<Either<BaseError, ChannelViewModel>>;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
@@ -35,7 +36,7 @@ namespace ErsatzTV.Application.Channels.Commands
|
||||
_channelRepository.Add(c).Map(ProjectToViewModel);
|
||||
|
||||
private async Task<Validation<BaseError, Channel>> Validate(CreateChannel request) =>
|
||||
(ValidateName(request), ValidateNumber(request), await FFmpegProfileMustExist(request))
|
||||
(ValidateName(request), await ValidateNumber(request), await FFmpegProfileMustExist(request))
|
||||
.Apply(
|
||||
(name, number, ffmpegProfileId) =>
|
||||
{
|
||||
@@ -66,9 +67,21 @@ namespace ErsatzTV.Application.Channels.Commands
|
||||
createChannel.NotEmpty(c => c.Name)
|
||||
.Bind(_ => createChannel.NotLongerThan(50)(c => c.Name));
|
||||
|
||||
// TODO: validate number does not exist?
|
||||
private Validation<BaseError, int> ValidateNumber(CreateChannel createChannel) =>
|
||||
createChannel.AtLeast(1)(c => c.Number);
|
||||
private async Task<Validation<BaseError, string>> ValidateNumber(CreateChannel createChannel)
|
||||
{
|
||||
Option<Channel> maybeExistingChannel = await _channelRepository.GetByNumber(createChannel.Number);
|
||||
return maybeExistingChannel.Match<Validation<BaseError, string>>(
|
||||
_ => BaseError.New("Channel number must be unique"),
|
||||
() =>
|
||||
{
|
||||
if (Regex.IsMatch(createChannel.Number, @"^[0-9]+(\.[0-9])?$"))
|
||||
{
|
||||
return createChannel.Number;
|
||||
}
|
||||
|
||||
return BaseError.New("Invalid channel number; one decimal is allowed for subchannels");
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<Validation<BaseError, int>> FFmpegProfileMustExist(CreateChannel createChannel) =>
|
||||
(await _ffmpegProfileRepository.Get(createChannel.FFmpegProfileId))
|
||||
|
||||
@@ -9,7 +9,7 @@ namespace ErsatzTV.Application.Channels.Commands
|
||||
(
|
||||
int ChannelId,
|
||||
string Name,
|
||||
int Number,
|
||||
string Number,
|
||||
int FFmpegProfileId,
|
||||
string Logo,
|
||||
StreamingMode StreamingMode) : IRequest<Either<BaseError, ChannelViewModel>>;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
@@ -75,13 +76,18 @@ namespace ErsatzTV.Application.Channels.Commands
|
||||
updateChannel.NotEmpty(c => c.Name)
|
||||
.Bind(_ => updateChannel.NotLongerThan(50)(c => c.Name));
|
||||
|
||||
private async Task<Validation<BaseError, int>> ValidateNumber(UpdateChannel updateChannel)
|
||||
private async Task<Validation<BaseError, string>> ValidateNumber(UpdateChannel updateChannel)
|
||||
{
|
||||
Option<Channel> match = await _channelRepository.GetByNumber(updateChannel.Number);
|
||||
int matchId = match.Map(c => c.Id).IfNone(updateChannel.ChannelId);
|
||||
if (matchId == updateChannel.ChannelId)
|
||||
{
|
||||
return updateChannel.AtLeast(1)(c => c.Number);
|
||||
if (Regex.IsMatch(updateChannel.Number, @"^[0-9](\.[0-9])?$"))
|
||||
{
|
||||
return updateChannel.Number;
|
||||
}
|
||||
|
||||
return BaseError.New("Invalid channel number; one decimal is allowed for subchannels");
|
||||
}
|
||||
|
||||
return BaseError.New("Channel number must be unique");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -42,6 +42,7 @@ namespace ErsatzTV.Application.Images.Queries
|
||||
ArtworkKind.Poster => Path.Combine(FileSystemLayout.PosterCacheFolder, subfolder),
|
||||
ArtworkKind.Thumbnail => Path.Combine(FileSystemLayout.ThumbnailCacheFolder, subfolder),
|
||||
ArtworkKind.Logo => Path.Combine(FileSystemLayout.LogoCacheFolder, subfolder),
|
||||
ArtworkKind.FanArt => Path.Combine(FileSystemLayout.FanArtCacheFolder, subfolder),
|
||||
_ => FileSystemLayout.LegacyImageCacheFolder
|
||||
};
|
||||
|
||||
|
||||
@@ -16,11 +16,11 @@ namespace ErsatzTV.Application.MediaCards.Queries
|
||||
public GetCollectionCardsHandler(IMediaCollectionRepository collectionRepository) =>
|
||||
_collectionRepository = collectionRepository;
|
||||
|
||||
public async Task<Either<BaseError, CollectionCardResultsViewModel>> Handle(
|
||||
public Task<Either<BaseError, CollectionCardResultsViewModel>> Handle(
|
||||
GetCollectionCards request,
|
||||
CancellationToken cancellationToken) =>
|
||||
(await _collectionRepository.GetCollectionWithItemsUntracked(request.Id))
|
||||
.ToEither(BaseError.New("Unable to load collection"))
|
||||
.Map(ProjectToViewModel);
|
||||
_collectionRepository.GetCollectionWithItemsUntracked(request.Id)
|
||||
.Map(c => c.ToEither(BaseError.New("Unable to load collection")))
|
||||
.MapT(ProjectToViewModel);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -13,8 +13,12 @@ namespace ErsatzTV.Application.Movies
|
||||
metadata.Title,
|
||||
metadata.Year?.ToString(),
|
||||
metadata.Plot,
|
||||
Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster))
|
||||
.Match(a => a.Path, string.Empty));
|
||||
Artwork(metadata, ArtworkKind.Poster),
|
||||
Artwork(metadata, ArtworkKind.FanArt));
|
||||
}
|
||||
|
||||
private static string Artwork(Metadata metadata, ArtworkKind artworkKind) =>
|
||||
Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == artworkKind))
|
||||
.Match(a => a.Path, string.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace ErsatzTV.Application.Movies
|
||||
{
|
||||
public record MovieViewModel(string Title, string Year, string Plot, string Poster);
|
||||
public record MovieViewModel(string Title, string Year, string Plot, string Poster, string FanArt);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace ErsatzTV.Application.Playouts
|
||||
{
|
||||
public record PlayoutChannelViewModel(int Id, int Number, string Name);
|
||||
public record PlayoutChannelViewModel(int Id, string Number, string Name);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using System.Threading;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
@@ -6,6 +8,7 @@ using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using static ErsatzTV.Application.ProgramSchedules.Mapper;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Application.ProgramSchedules.Commands
|
||||
{
|
||||
@@ -22,22 +25,33 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
|
||||
CreateProgramSchedule request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Validate(request)
|
||||
.Map(PersistProgramSchedule)
|
||||
.ToEitherAsync();
|
||||
.MapT(PersistProgramSchedule)
|
||||
.Bind(v => v.ToEitherAsync());
|
||||
|
||||
private Task<ProgramScheduleViewModel> PersistProgramSchedule(ProgramSchedule c) =>
|
||||
_programScheduleRepository.Add(c).Map(ProjectToViewModel);
|
||||
|
||||
private Validation<BaseError, ProgramSchedule> Validate(CreateProgramSchedule request) =>
|
||||
private Task<Validation<BaseError, ProgramSchedule>> Validate(CreateProgramSchedule request) =>
|
||||
ValidateName(request)
|
||||
.Map(
|
||||
.MapT(
|
||||
name => new ProgramSchedule
|
||||
{
|
||||
Name = name, MediaCollectionPlaybackOrder = request.MediaCollectionPlaybackOrder
|
||||
});
|
||||
|
||||
private Validation<BaseError, string> ValidateName(CreateProgramSchedule createProgramSchedule) =>
|
||||
createProgramSchedule.NotEmpty(c => c.Name)
|
||||
private async Task<Validation<BaseError, string>> ValidateName(CreateProgramSchedule createProgramSchedule)
|
||||
{
|
||||
List<string> allNames = await _programScheduleRepository.GetAll()
|
||||
.Map(list => list.Map(c => c.Name).ToList());
|
||||
|
||||
Validation<BaseError, string> result1 = createProgramSchedule.NotEmpty(c => c.Name)
|
||||
.Bind(_ => createProgramSchedule.NotLongerThan(50)(c => c.Name));
|
||||
|
||||
var result2 = Optional(createProgramSchedule.Name)
|
||||
.Filter(name => !allNames.Contains(name))
|
||||
.ToValidation<BaseError>("Schedule name must be unique");
|
||||
|
||||
return (result1, result2).Apply((_, _) => createProgramSchedule.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,5 +5,5 @@ using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Streaming.Queries
|
||||
{
|
||||
public record FFmpegProcessRequest(int ChannelNumber) : IRequest<Either<BaseError, Process>>;
|
||||
public record FFmpegProcessRequest(string ChannelNumber) : IRequest<Either<BaseError, Process>>;
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@ using MediatR;
|
||||
namespace ErsatzTV.Application.Streaming.Queries
|
||||
{
|
||||
public record GetConcatPlaylistByChannelNumber
|
||||
(string Scheme, string Host, int ChannelNumber) : IRequest<Either<BaseError, ConcatPlaylist>>;
|
||||
(string Scheme, string Host, string ChannelNumber) : IRequest<Either<BaseError, ConcatPlaylist>>;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{
|
||||
public record GetConcatProcessByChannelNumber : FFmpegProcessRequest
|
||||
{
|
||||
public GetConcatProcessByChannelNumber(string scheme, string host, int channelNumber) : base(channelNumber)
|
||||
public GetConcatProcessByChannelNumber(string scheme, string host, string channelNumber) : base(channelNumber)
|
||||
{
|
||||
Scheme = scheme;
|
||||
Host = host;
|
||||
|
||||
@@ -5,5 +5,5 @@ using MediatR;
|
||||
namespace ErsatzTV.Application.Streaming.Queries
|
||||
{
|
||||
public record GetHlsPlaylistByChannelNumber
|
||||
(string Scheme, string Host, int ChannelNumber) : IRequest<Either<BaseError, string>>;
|
||||
(string Scheme, string Host, string ChannelNumber) : IRequest<Either<BaseError, string>>;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{
|
||||
public record GetPlayoutItemProcessByChannelNumber : FFmpegProcessRequest
|
||||
{
|
||||
public GetPlayoutItemProcessByChannelNumber(int channelNumber) : base(channelNumber)
|
||||
public GetPlayoutItemProcessByChannelNumber(string channelNumber) : base(channelNumber)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
+8
-3
@@ -72,11 +72,16 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
{
|
||||
if (channel.FFmpegProfile.Transcode)
|
||||
{
|
||||
return _ffmpegProcessService.ForOfflineImage(ffmpegPath, channel);
|
||||
Option<TimeSpan> maybeDuration = await _playoutRepository.GetNextItemStart(channel.Id, now)
|
||||
.MapT(nextStart => nextStart - now);
|
||||
|
||||
return _ffmpegProcessService.ForOfflineImage(ffmpegPath, channel, maybeDuration);
|
||||
}
|
||||
|
||||
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 BaseError.New(message);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
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("h264", true, false, false, "[0:v]deinterlace_vaapi[v]", "[v]")]
|
||||
[TestCase(
|
||||
"h264",
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
"[0:v]deinterlace_vaapi,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
"h264",
|
||||
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(
|
||||
"h264",
|
||||
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(
|
||||
"h264",
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
"[0:v]scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
"h264",
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
"[0:v]hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
"h264",
|
||||
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]")]
|
||||
[TestCase("mpeg4", true, false, false, "[0:v]hwupload,deinterlace_vaapi[v]", "[v]")]
|
||||
[TestCase(
|
||||
"mpeg4",
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
"[0:v]hwupload,deinterlace_vaapi,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
"mpeg4",
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
"[0:v]hwupload,deinterlace_vaapi,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
"mpeg4",
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
"[0:v]hwupload,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(
|
||||
"mpeg4",
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
"[0:v]hwupload,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
"mpeg4",
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
"[0:v]setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
"mpeg4",
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
"[0:v]hwupload,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(
|
||||
string codec,
|
||||
bool deinterlace,
|
||||
bool scale,
|
||||
bool pad,
|
||||
string expectedVideoFilter,
|
||||
string expectedVideoLabel)
|
||||
{
|
||||
FFmpegComplexFilterBuilder builder = new FFmpegComplexFilterBuilder()
|
||||
.WithHardwareAcceleration(HardwareAccelerationKind.Vaapi)
|
||||
.WithInputCodec(codec)
|
||||
.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)),
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace ErsatzTV.Core.Domain
|
||||
public Channel(Guid uniqueId) => UniqueId = uniqueId;
|
||||
public int Id { get; set; }
|
||||
public Guid UniqueId { get; init; }
|
||||
public int Number { get; set; }
|
||||
public string Number { get; set; }
|
||||
public string Name { get; set; }
|
||||
public int FFmpegProfileId { get; set; }
|
||||
public FFmpegProfile FFmpegProfile { get; set; }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ namespace ErsatzTV.Core.Domain
|
||||
public string SampleAspectRatio { get; set; }
|
||||
public string DisplayAspectRatio { get; set; }
|
||||
public string VideoCodec { get; set; }
|
||||
public string VideoProfile { get; set; }
|
||||
public string AudioCodec { get; set; }
|
||||
public VideoScanKind VideoScanKind { get; set; }
|
||||
public DateTime DateAdded { get; set; }
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
{
|
||||
Poster = 0,
|
||||
Thumbnail = 1,
|
||||
Logo = 2
|
||||
Logo = 2,
|
||||
FanArt = 3
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace ErsatzTV.Core.FFmpeg
|
||||
{
|
||||
public record ConcatPlaylist(string Scheme, string Host, int ChannelNumber)
|
||||
public record ConcatPlaylist(string Scheme, string Host, string ChannelNumber)
|
||||
{
|
||||
public override string ToString() =>
|
||||
$@"ffconcat version 1.0
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace ErsatzTV.Core.FFmpeg
|
||||
{
|
||||
public record FFmpegComplexFilter(string ComplexFilter, string VideoLabel, string AudioLabel);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
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 string _inputCodec;
|
||||
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 FFmpegComplexFilterBuilder WithInputCodec(string codec)
|
||||
{
|
||||
_inputCodec = codec;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Option<FFmpegComplexFilter> Build()
|
||||
{
|
||||
var complexFilter = new StringBuilder();
|
||||
|
||||
var videoLabel = "0:v";
|
||||
var audioLabel = "0:a";
|
||||
|
||||
HardwareAccelerationKind acceleration = _hardwareAccelerationKind.IfNone(HardwareAccelerationKind.None);
|
||||
bool isHardwareDecode = acceleration switch
|
||||
{
|
||||
HardwareAccelerationKind.Vaapi => _inputCodec != "mpeg4",
|
||||
HardwareAccelerationKind.Nvenc => true,
|
||||
HardwareAccelerationKind.Qsv => true,
|
||||
_ => false
|
||||
};
|
||||
|
||||
_audioDuration.IfSome(
|
||||
audioDuration =>
|
||||
{
|
||||
complexFilter.Append($"[{audioLabel}]");
|
||||
complexFilter.Append($"apad=whole_dur={audioDuration.TotalMilliseconds}ms");
|
||||
audioLabel = "[a]";
|
||||
complexFilter.Append(audioLabel);
|
||||
});
|
||||
|
||||
var filterQueue = new List<string>();
|
||||
|
||||
bool usesHardwareFilters = acceleration != HardwareAccelerationKind.None && !isHardwareDecode &&
|
||||
(_deinterlace || _scaleToSize.IsSome);
|
||||
if (usesHardwareFilters)
|
||||
{
|
||||
filterQueue.Add("hwupload");
|
||||
}
|
||||
|
||||
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 && (isHardwareDecode || usesHardwareFilters))
|
||||
{
|
||||
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,21 @@ 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);
|
||||
}
|
||||
|
||||
_complexFilterBuilder = _complexFilterBuilder.WithInputCodec(codec);
|
||||
|
||||
_arguments.Add("-i");
|
||||
_arguments.Add($"{input}");
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithFiltergraph(string graph)
|
||||
{
|
||||
_arguments.Add("-vf");
|
||||
@@ -164,21 +215,26 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithText(string text)
|
||||
public FFmpegProcessBuilder WithErrorText(IDisplaySize desiredResolution, string text)
|
||||
{
|
||||
const string FONT_FILE = "fontfile=Resources/Roboto-Regular.ttf";
|
||||
const string FONT_SIZE = "fontsize=30";
|
||||
const string FONT_SIZE = "fontsize=60";
|
||||
const string FONT_COLOR = "fontcolor=white";
|
||||
const string X = "x=(w-text_w)/2";
|
||||
const string Y = "y=(h-text_h)/2";
|
||||
const string Y = "y=(h-text_h)/3*2";
|
||||
|
||||
return WithFiltergraph($"drawtext={FONT_FILE}:{FONT_SIZE}:{FONT_COLOR}:{X}:{Y}:text='{text}'");
|
||||
return WithFilterComplex(
|
||||
$"[0:0]scale={desiredResolution.Width}:{desiredResolution.Height},drawtext={FONT_FILE}:{FONT_SIZE}:{FONT_COLOR}:{X}:{Y}:text='{text}'[v]",
|
||||
"[v]",
|
||||
"1:a");
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithDuration(TimeSpan duration) =>
|
||||
// _arguments.Add("-t");
|
||||
// _arguments.Add($"{duration:c}");
|
||||
this;
|
||||
public FFmpegProcessBuilder WithDuration(TimeSpan duration)
|
||||
{
|
||||
_arguments.Add("-t");
|
||||
_arguments.Add($"{duration:c}");
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithFormat(string format)
|
||||
{
|
||||
@@ -242,73 +298,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 +348,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 +374,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
using System.Diagnostics;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Core.FFmpeg
|
||||
{
|
||||
@@ -29,18 +30,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 +58,6 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
{
|
||||
builder = builder
|
||||
.WithDeinterlace(playbackSettings.Deinterlace)
|
||||
.WithSAR()
|
||||
.WithBlackBars(channel.FFmpegProfile.Resolution)
|
||||
.WithAlignedAudio(playbackSettings.AudioDuration)
|
||||
.WithFilterComplex();
|
||||
@@ -84,14 +84,14 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
.Build();
|
||||
}
|
||||
|
||||
public Process ForOfflineImage(string ffmpegPath, Channel channel)
|
||||
public Process ForOfflineImage(string ffmpegPath, Channel channel, Option<TimeSpan> duration)
|
||||
{
|
||||
FFmpegPlaybackSettings playbackSettings =
|
||||
_playbackSettingsCalculator.CalculateErrorSettings(channel.FFmpegProfile);
|
||||
|
||||
IDisplaySize desiredResolution = channel.FFmpegProfile.Resolution;
|
||||
|
||||
return new FFmpegProcessBuilder(ffmpegPath)
|
||||
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath)
|
||||
.WithThreads(1)
|
||||
.WithQuiet()
|
||||
.WithFormatFlags(playbackSettings.FormatFlags)
|
||||
@@ -99,17 +99,15 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
.WithLoopedImage("Resources/background.png")
|
||||
.WithLibavfilter()
|
||||
.WithInput("anullsrc")
|
||||
.WithFilterComplex(
|
||||
$"[0:0]scale={desiredResolution.Width}:{desiredResolution.Height}[video]",
|
||||
"[video]",
|
||||
"1:a")
|
||||
.WithErrorText(desiredResolution, "Channel is Offline")
|
||||
.WithPixfmt("yuv420p")
|
||||
.WithPlaybackArgs(playbackSettings)
|
||||
.WithMetadata(channel)
|
||||
.WithFormat("mpegts")
|
||||
.WithDuration(TimeSpan.FromSeconds(10)) // TODO: figure out when we're back online
|
||||
.WithPipe()
|
||||
.Build();
|
||||
.WithFormat("mpegts");
|
||||
|
||||
duration.IfSome(d => builder = builder.WithDuration(d));
|
||||
|
||||
return builder.WithPipe().Build();
|
||||
}
|
||||
|
||||
public Process ConcatChannel(string ffmpegPath, Channel channel, string scheme, string host)
|
||||
|
||||
@@ -24,5 +24,6 @@ namespace ErsatzTV.Core
|
||||
public static readonly string PosterCacheFolder = Path.Combine(ArtworkCacheFolder, "posters");
|
||||
public static readonly string ThumbnailCacheFolder = Path.Combine(ArtworkCacheFolder, "thumbnails");
|
||||
public static readonly string LogoCacheFolder = Path.Combine(ArtworkCacheFolder, "logos");
|
||||
public static readonly string FanArtCacheFolder = Path.Combine(ArtworkCacheFolder, "fanart");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ namespace ErsatzTV.Core.Hdhr
|
||||
_channel = channel;
|
||||
}
|
||||
|
||||
public string GuideNumber => _channel.Number.ToString();
|
||||
public string GuideNumber => _channel.Number;
|
||||
public string GuideName => _channel.Name;
|
||||
|
||||
public string URL => _channel.StreamingMode switch
|
||||
|
||||
@@ -9,7 +9,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
{
|
||||
Task<Channel> Add(Channel channel);
|
||||
Task<Option<Channel>> Get(int id);
|
||||
Task<Option<Channel>> GetByNumber(int number);
|
||||
Task<Option<Channel>> GetByNumber(string number);
|
||||
Task<List<Channel>> GetAll();
|
||||
Task<List<Channel>> GetAllForGuide();
|
||||
Task Update(Channel channel);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
Task<Option<Playout>> Get(int id);
|
||||
Task<Option<Playout>> GetFull(int id);
|
||||
Task<Option<PlayoutItem>> GetPlayoutItem(int channelId, DateTimeOffset now);
|
||||
Task<Option<DateTimeOffset>> GetNextItemStart(int channelId, DateTimeOffset now);
|
||||
Task<List<PlayoutItem>> GetPlayoutItems(int playoutId);
|
||||
Task<List<Playout>> GetAll();
|
||||
Task Update(Playout playout);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,10 +30,10 @@ namespace ErsatzTV.Core.Iptv
|
||||
xml.WriteStartElement("tv");
|
||||
xml.WriteAttributeString("generator-info-name", "ersatztv");
|
||||
|
||||
foreach (Channel channel in _channels)
|
||||
foreach (Channel channel in _channels.OrderBy(c => c.Number))
|
||||
{
|
||||
xml.WriteStartElement("channel");
|
||||
xml.WriteAttributeString("id", channel.Number.ToString());
|
||||
xml.WriteAttributeString("id", channel.Number);
|
||||
|
||||
xml.WriteStartElement("display-name");
|
||||
xml.WriteAttributeString("lang", "en");
|
||||
@@ -53,7 +53,7 @@ namespace ErsatzTV.Core.Iptv
|
||||
xml.WriteEndElement(); // channel
|
||||
}
|
||||
|
||||
foreach (Channel channel in _channels)
|
||||
foreach (Channel channel in _channels.OrderBy(c => c.Number))
|
||||
{
|
||||
foreach (PlayoutItem playoutItem in channel.Playouts.Collect(p => p.Items).OrderBy(i => i.Start))
|
||||
{
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -84,7 +87,7 @@ namespace ErsatzTV.Core.Iptv
|
||||
xml.WriteStartElement("programme");
|
||||
xml.WriteAttributeString("start", start);
|
||||
xml.WriteAttributeString("stop", stop);
|
||||
xml.WriteAttributeString("channel", channel.Number.ToString());
|
||||
xml.WriteAttributeString("channel", channel.Number);
|
||||
|
||||
xml.WriteStartElement("title");
|
||||
xml.WriteAttributeString("lang", "en");
|
||||
@@ -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");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using static LanguageExt.Prelude;
|
||||
@@ -25,7 +26,7 @@ namespace ErsatzTV.Core.Iptv
|
||||
|
||||
var xmltv = $"{_scheme}://{_host}/iptv/xmltv.xml";
|
||||
sb.AppendLine($"#EXTM3U url-tvg=\"{xmltv}\" x-tvg-url=\"{xmltv}\"");
|
||||
foreach (Channel channel in _channels)
|
||||
foreach (Channel channel in _channels.OrderBy(c => c.Number))
|
||||
{
|
||||
string logo = Optional(channel.Artwork).Flatten()
|
||||
.Filter(a => a.ArtworkKind == ArtworkKind.Logo)
|
||||
@@ -45,8 +46,11 @@ namespace ErsatzTV.Core.Iptv
|
||||
_ => "ts"
|
||||
};
|
||||
|
||||
string vcodec = channel.FFmpegProfile.VideoCodec.Split("_").Head();
|
||||
string acodec = channel.FFmpegProfile.AudioCodec;
|
||||
|
||||
sb.AppendLine(
|
||||
$"#EXTINF:0 tvg-id=\"{channel.Number}\" channel-id=\"{shortUniqueId}\" channel-number=\"{channel.Number}\" CUID=\"{shortUniqueId}\" tvg-chno=\"{channel.Number}\" tvg-name=\"{channel.Name}\" tvg-logo=\"{logo}\" group-title=\"ErsatzTV\", {channel.Name}");
|
||||
$"#EXTINF:0 tvg-id=\"{channel.Number}\" channel-id=\"{shortUniqueId}\" channel-number=\"{channel.Number}\" CUID=\"{shortUniqueId}\" tvg-chno=\"{channel.Number}\" tvg-name=\"{channel.Name}\" tvg-logo=\"{logo}\" group-title=\"ErsatzTV\" tvc-stream-vcodec=\"{vcodec}\" tvc-stream-acodec=\"{acodec}\", {channel.Name}");
|
||||
sb.AppendLine($"{_scheme}://{_host}/iptv/channel/{channel.Number}.{format}");
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
|
||||
return fileName != null ? GetMovieMetadata(fileName, metadata) : metadata;
|
||||
}
|
||||
|
||||
|
||||
public string GetSortTitle(string title)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
|
||||
@@ -97,10 +97,10 @@ namespace ErsatzTV.Core.Metadata
|
||||
|
||||
metadata.Artwork ??= new List<Artwork>();
|
||||
|
||||
Option<Artwork> maybePoster =
|
||||
Option<Artwork> maybeArtwork =
|
||||
Optional(metadata.Artwork).Flatten().FirstOrDefault(a => a.ArtworkKind == artworkKind);
|
||||
|
||||
bool shouldRefresh = maybePoster.Match(
|
||||
bool shouldRefresh = maybeArtwork.Match(
|
||||
artwork => artwork.DateUpdated < lastWriteTime,
|
||||
true);
|
||||
|
||||
@@ -109,7 +109,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
_logger.LogDebug("Refreshing {Attribute} from {Path}", artworkKind, artworkFile);
|
||||
string cacheName = _imageCache.CopyArtworkToCache(artworkFile, artworkKind);
|
||||
|
||||
maybePoster.Match(
|
||||
maybeArtwork.Match(
|
||||
artwork =>
|
||||
{
|
||||
artwork.Path = cacheName;
|
||||
|
||||
@@ -70,6 +70,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
mediaItemVersion.Width = version.Width;
|
||||
mediaItemVersion.Height = version.Height;
|
||||
mediaItemVersion.VideoCodec = version.VideoCodec;
|
||||
mediaItemVersion.VideoProfile = version.VideoProfile;
|
||||
mediaItemVersion.VideoScanKind = version.VideoScanKind;
|
||||
|
||||
return await _mediaItemRepository.Update(mediaItem) && durationChange;
|
||||
@@ -134,6 +135,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
version.Width = videoStream.width;
|
||||
version.Height = videoStream.height;
|
||||
version.VideoCodec = videoStream.codec_name;
|
||||
version.VideoProfile = (videoStream.profile ?? string.Empty).ToLowerInvariant();
|
||||
version.VideoScanKind = ScanKindFromFieldOrder(videoStream.field_order);
|
||||
}
|
||||
|
||||
@@ -157,6 +159,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
public record FFprobeStream(
|
||||
int index,
|
||||
string codec_name,
|
||||
string profile,
|
||||
string codec_type,
|
||||
int width,
|
||||
int height,
|
||||
|
||||
@@ -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?
|
||||
@@ -79,13 +80,23 @@ namespace ErsatzTV.Core.Metadata
|
||||
.GetOrAdd(libraryPath, file)
|
||||
.BindT(movie => UpdateStatistics(movie, ffprobePath).MapT(_ => movie))
|
||||
.BindT(UpdateMetadata)
|
||||
.BindT(UpdatePoster);
|
||||
.BindT(UpdatePoster)
|
||||
.BindT(UpdateFanArt);
|
||||
|
||||
maybeMovie.IfLeft(
|
||||
error => _logger.LogWarning("Error processing movie at {Path}: {Error}", file, error.Value));
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -129,7 +140,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
try
|
||||
{
|
||||
await LocatePoster(movie).IfSomeAsync(
|
||||
await LocateArtwork(movie, ArtworkKind.Poster).IfSomeAsync(
|
||||
async posterFile =>
|
||||
{
|
||||
MovieMetadata metadata = movie.MovieMetadata.Head();
|
||||
@@ -147,6 +158,28 @@ namespace ErsatzTV.Core.Metadata
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, Movie>> UpdateFanArt(Movie movie)
|
||||
{
|
||||
try
|
||||
{
|
||||
await LocateArtwork(movie, ArtworkKind.FanArt).IfSomeAsync(
|
||||
async posterFile =>
|
||||
{
|
||||
MovieMetadata metadata = movie.MovieMetadata.Head();
|
||||
if (RefreshArtwork(posterFile, metadata, ArtworkKind.FanArt))
|
||||
{
|
||||
await _movieRepository.Update(movie);
|
||||
}
|
||||
});
|
||||
|
||||
return movie;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private Option<string> LocateNfoFile(Movie movie)
|
||||
{
|
||||
string path = movie.MediaVersions.Head().MediaFiles.Head().Path;
|
||||
@@ -157,12 +190,19 @@ namespace ErsatzTV.Core.Metadata
|
||||
.HeadOrNone();
|
||||
}
|
||||
|
||||
private Option<string> LocatePoster(Movie movie)
|
||||
private Option<string> LocateArtwork(Movie movie, ArtworkKind artworkKind)
|
||||
{
|
||||
string segment = artworkKind switch
|
||||
{
|
||||
ArtworkKind.Poster => "poster",
|
||||
ArtworkKind.FanArt => "fanart",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(artworkKind))
|
||||
};
|
||||
|
||||
string path = movie.MediaVersions.Head().MediaFiles.Head().Path;
|
||||
string folder = Path.GetDirectoryName(path) ?? string.Empty;
|
||||
IEnumerable<string> possibleMoviePosters = ImageFileExtensions.Collect(
|
||||
ext => new[] { $"poster.{ext}", Path.GetFileNameWithoutExtension(path) + $"-poster.{ext}" })
|
||||
ext => new[] { $"{segment}.{ext}", Path.GetFileNameWithoutExtension(path) + $"-{segment}.{ext}" })
|
||||
.Map(f => Path.Combine(folder, f));
|
||||
Option<string> result = possibleMoviePosters.Filter(p => _localFileSystem.FileExists(p)).HeadOrNone();
|
||||
return result;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ namespace ErsatzTV.Infrastructure.Data
|
||||
|
||||
var defaultChannel = new Channel(Guid.NewGuid())
|
||||
{
|
||||
Number = 1,
|
||||
Number = "1",
|
||||
Name = "ErsatzTV",
|
||||
FFmpegProfile = defaultProfile,
|
||||
StreamingMode = StreamingMode.TransportStream
|
||||
|
||||
@@ -29,7 +29,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.SingleOrDefaultAsync(c => c.Id == id)
|
||||
.Map(Optional);
|
||||
|
||||
public Task<Option<Channel>> GetByNumber(int number) =>
|
||||
public Task<Option<Channel>> GetByNumber(string number) =>
|
||||
_dbContext.Channels
|
||||
.Include(c => c.FFmpegProfile)
|
||||
.ThenInclude(p => p.Resolution)
|
||||
@@ -39,6 +39,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
|
||||
public Task<List<Channel>> GetAll() =>
|
||||
_dbContext.Channels
|
||||
.Include(c => c.FFmpegProfile)
|
||||
.Include(c => c.Artwork)
|
||||
.ToListAsync();
|
||||
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -44,8 +44,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.OrderBy(p => p.Id) // https://github.com/dotnet/efcore/issues/22579#issuecomment-694772289
|
||||
.SingleOrDefaultAsync(p => p.Id == id);
|
||||
|
||||
public async Task<Option<PlayoutItem>> GetPlayoutItem(int channelId, DateTimeOffset now) =>
|
||||
await _dbContext.PlayoutItems
|
||||
public Task<Option<PlayoutItem>> GetPlayoutItem(int channelId, DateTimeOffset now) =>
|
||||
_dbContext.PlayoutItems
|
||||
.Where(pi => pi.Playout.ChannelId == channelId)
|
||||
.Where(pi => pi.Start <= now.UtcDateTime && pi.Finish > now.UtcDateTime)
|
||||
.Include(i => i.MediaItem)
|
||||
@@ -55,7 +55,17 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.ThenInclude(mi => (mi as Movie).MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync();
|
||||
.SingleOrDefaultAsync()
|
||||
.Map(Optional);
|
||||
|
||||
public Task<Option<DateTimeOffset>> GetNextItemStart(int channelId, DateTimeOffset now) =>
|
||||
_dbContext.PlayoutItems
|
||||
.Where(pi => pi.Playout.ChannelId == channelId)
|
||||
.Where(pi => pi.Finish > now.UtcDateTime)
|
||||
.OrderBy(pi => pi.Finish)
|
||||
.FirstOrDefaultAsync()
|
||||
.Map(Optional)
|
||||
.MapT(pi => pi.StartOffset);
|
||||
|
||||
public Task<List<PlayoutItem>> GetPlayoutItems(int playoutId) =>
|
||||
_dbContext.PlayoutItems
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -56,6 +56,7 @@ namespace ErsatzTV.Infrastructure.Images
|
||||
ArtworkKind.Poster => Path.Combine(FileSystemLayout.PosterCacheFolder, subfolder),
|
||||
ArtworkKind.Thumbnail => Path.Combine(FileSystemLayout.ThumbnailCacheFolder, subfolder),
|
||||
ArtworkKind.Logo => Path.Combine(FileSystemLayout.LogoCacheFolder, subfolder),
|
||||
ArtworkKind.FanArt => Path.Combine(FileSystemLayout.FanArtCacheFolder, subfolder),
|
||||
_ => FileSystemLayout.LegacyImageCacheFolder
|
||||
};
|
||||
string target = Path.Combine(baseFolder, hex);
|
||||
@@ -85,6 +86,7 @@ namespace ErsatzTV.Infrastructure.Images
|
||||
ArtworkKind.Poster => Path.Combine(FileSystemLayout.PosterCacheFolder, subfolder),
|
||||
ArtworkKind.Thumbnail => Path.Combine(FileSystemLayout.ThumbnailCacheFolder, subfolder),
|
||||
ArtworkKind.Logo => Path.Combine(FileSystemLayout.LogoCacheFolder, subfolder),
|
||||
ArtworkKind.FanArt => Path.Combine(FileSystemLayout.FanArtCacheFolder, subfolder),
|
||||
_ => FileSystemLayout.LegacyImageCacheFolder
|
||||
};
|
||||
string target = Path.Combine(baseFolder, hex);
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
Generated
+1497
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Add_MediaVersionVideoProfile : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder) =>
|
||||
migrationBuilder.AddColumn<string>(
|
||||
"VideoProfile",
|
||||
"MediaVersion",
|
||||
"TEXT",
|
||||
nullable: true);
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder) =>
|
||||
migrationBuilder.DropColumn(
|
||||
"VideoProfile",
|
||||
"MediaVersion");
|
||||
}
|
||||
}
|
||||
Generated
+1497
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Reset_MediaVersionDateUpdated : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder) =>
|
||||
migrationBuilder.Sql(@"UPDATE MediaVersion SET DateUpdated = '0001-01-01 00:00:00'");
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
+1497
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Update_ChannelNumberType : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder) =>
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
"Number",
|
||||
"Channel",
|
||||
"TEXT",
|
||||
nullable: true,
|
||||
oldClrType: typeof(int),
|
||||
oldType: "INTEGER");
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder) =>
|
||||
migrationBuilder.AlterColumn<int>(
|
||||
"Number",
|
||||
"Channel",
|
||||
"INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0,
|
||||
oldClrType: typeof(string),
|
||||
oldType: "TEXT",
|
||||
oldNullable: true);
|
||||
}
|
||||
}
|
||||
@@ -80,8 +80,8 @@ namespace ErsatzTV.Infrastructure.Migrations
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Number")
|
||||
.HasColumnType("INTEGER");
|
||||
b.Property<string>("Number")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("StreamingMode")
|
||||
.HasColumnType("INTEGER");
|
||||
@@ -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");
|
||||
|
||||
@@ -415,6 +418,9 @@ namespace ErsatzTV.Infrastructure.Migrations
|
||||
b.Property<string>("VideoCodec")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("VideoProfile")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("VideoScanKind")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ namespace ErsatzTV.Infrastructure.Plex.Models
|
||||
public int AudioChannels { get; set; }
|
||||
public string AudioCodec { get; set; }
|
||||
public string VideoCodec { get; set; }
|
||||
public string VideoProfile { get; set; }
|
||||
public string Container { get; set; }
|
||||
public string VideoFrameRate { get; set; }
|
||||
public List<PlexPartResponse> Part { get; set; }
|
||||
|
||||
@@ -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,
|
||||
@@ -127,6 +125,7 @@ namespace ErsatzTV.Infrastructure.Plex
|
||||
Height = media.Height,
|
||||
AudioCodec = media.AudioCodec,
|
||||
VideoCodec = media.VideoCodec,
|
||||
VideoProfile = media.VideoProfile,
|
||||
SampleAspectRatio = ConvertToSAR(media.AspectRatio),
|
||||
MediaFiles = new List<MediaFile>
|
||||
{
|
||||
@@ -150,7 +149,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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=drawtext/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=ersatztv/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=etvignore/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=fanart/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=faststart/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=featurette/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=featurettes/@EntryIndexedValue">True</s:Boolean>
|
||||
@@ -39,4 +40,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>
|
||||
@@ -14,6 +14,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ErsatzTV.Controllers
|
||||
{
|
||||
[ResponseCache(Duration = 3600)]
|
||||
[ApiController]
|
||||
[ApiExplorerSettings(IgnoreApi = true)]
|
||||
public class PostersController : ControllerBase
|
||||
@@ -37,6 +38,16 @@ namespace ErsatzTV.Controllers
|
||||
Right: r => new FileContentResult(r.Contents, r.MimeType));
|
||||
}
|
||||
|
||||
[HttpGet("/artwork/fanart/{fileName}")]
|
||||
public async Task<IActionResult> GetFanArt(string fileName)
|
||||
{
|
||||
Either<BaseError, ImageViewModel> imageContents =
|
||||
await _mediator.Send(new GetImageContents(fileName, ArtworkKind.FanArt));
|
||||
return imageContents.Match<IActionResult>(
|
||||
Left: _ => new NotFoundResult(),
|
||||
Right: r => new FileContentResult(r.Contents, r.MimeType));
|
||||
}
|
||||
|
||||
[HttpGet("/artwork/posters/plex/{plexMediaSourceId}/{*path}")]
|
||||
public async Task<IActionResult> GetPlexPoster(int plexMediaSourceId, string path)
|
||||
{
|
||||
|
||||
@@ -22,12 +22,12 @@ namespace ErsatzTV.Controllers
|
||||
}
|
||||
|
||||
[HttpGet("ffmpeg/concat/{channelNumber}")]
|
||||
public Task<IActionResult> GetConcatPlaylist(int channelNumber) =>
|
||||
public Task<IActionResult> GetConcatPlaylist(string channelNumber) =>
|
||||
_mediator.Send(new GetConcatPlaylistByChannelNumber(Request.Scheme, Request.Host.ToString(), channelNumber))
|
||||
.ToActionResult();
|
||||
|
||||
[HttpGet("ffmpeg/stream/{channelNumber}")]
|
||||
public Task<IActionResult> GetStream(int channelNumber) =>
|
||||
public Task<IActionResult> GetStream(string channelNumber) =>
|
||||
_mediator.Send(new GetPlayoutItemProcessByChannelNumber(channelNumber)).Map(
|
||||
result =>
|
||||
result.Match<IActionResult>(
|
||||
|
||||
@@ -37,7 +37,7 @@ namespace ErsatzTV.Controllers
|
||||
.Map<ChannelGuide, IActionResult>(Ok);
|
||||
|
||||
[HttpGet("iptv/channel/{channelNumber}.ts")]
|
||||
public Task<IActionResult> GetTransportStreamVideo(int channelNumber) =>
|
||||
public Task<IActionResult> GetTransportStreamVideo(string channelNumber) =>
|
||||
_mediator.Send(new GetConcatProcessByChannelNumber(Request.Scheme, Request.Host.ToString(), channelNumber))
|
||||
.Map(
|
||||
result => result.Match<IActionResult>(
|
||||
@@ -50,7 +50,7 @@ namespace ErsatzTV.Controllers
|
||||
error => BadRequest(error.Value)));
|
||||
|
||||
[HttpGet("iptv/channel/{channelNumber}.m3u8")]
|
||||
public Task<IActionResult> GetHttpLiveStreamingVideo(int channelNumber) =>
|
||||
public Task<IActionResult> GetHttpLiveStreamingVideo(string channelNumber) =>
|
||||
_mediator.Send(new GetHlsPlaylistByChannelNumber(Request.Scheme, Request.Host.ToString(), channelNumber))
|
||||
.Map(
|
||||
result => result.Match<IActionResult>(
|
||||
|
||||
@@ -36,4 +36,13 @@
|
||||
<ProjectReference Include="..\ErsatzTV.Infrastructure\ErsatzTV.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="Resources\background.png">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="Resources\Roboto-Regular.ttf">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -36,7 +36,10 @@ namespace ErsatzTV.Extensions
|
||||
.IfFail(
|
||||
ex =>
|
||||
{
|
||||
LogException(ex, "Error occured while migrating database; shutting down.", scope.ServiceProvider);
|
||||
LogException(
|
||||
ex,
|
||||
"Error occured while migrating database; shutting down.",
|
||||
scope.ServiceProvider);
|
||||
Environment.Exit(13);
|
||||
return unit;
|
||||
});
|
||||
|
||||
@@ -10,55 +10,57 @@
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IMediator Mediator
|
||||
|
||||
<div style="max-width: 400px;">
|
||||
<MudText Typo="Typo.h4" Class="mb-4">@(IsEdit ? "Edit Channel" : "Add Channel")</MudText>
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
|
||||
<div style="max-width: 400px;">
|
||||
<MudText Typo="Typo.h4" Class="mb-4">@(IsEdit ? "Edit Channel" : "Add Channel")</MudText>
|
||||
|
||||
<EditForm EditContext="_editContext" OnSubmit="@HandleSubmitAsync">
|
||||
<FluentValidator/>
|
||||
<MudCard>
|
||||
<MudCardContent>
|
||||
<MudTextField Label="Number" @bind-Value="_model.Number" For="@(() => _model.Number)" Immediate="true"/>
|
||||
<MudTextField Class="mt-3" Label="Name" @bind-Value="_model.Name" For="@(() => _model.Name)"/>
|
||||
<MudSelect Class="mt-3" Label="Streaming Mode" @bind-Value="_model.StreamingMode" For="@(() => _model.StreamingMode)">
|
||||
@foreach (StreamingMode streamingMode in Enum.GetValues<StreamingMode>())
|
||||
{
|
||||
<MudSelectItem Value="@streamingMode">@streamingMode</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudSelect Class="mt-3" Label="FFmpeg Profile" @bind-Value="_model.FFmpegProfileId" For="@(() => _model.FFmpegProfileId)"
|
||||
Disabled="@(_model.StreamingMode != StreamingMode.TransportStream)">
|
||||
@foreach (FFmpegProfileViewModel profile in _ffmpegProfiles)
|
||||
{
|
||||
<MudSelectItem Value="@profile.Id">@profile.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudGrid Class="mt-3" Style="align-items: center" Justify="Justify.Center">
|
||||
<MudItem xs="6">
|
||||
<InputFile id="fileInput" OnChange="UploadLogo" hidden/>
|
||||
@if (!string.IsNullOrWhiteSpace(_model.Logo))
|
||||
<EditForm EditContext="_editContext" OnSubmit="@HandleSubmitAsync">
|
||||
<FluentValidator/>
|
||||
<MudCard>
|
||||
<MudCardContent>
|
||||
<MudTextField Label="Number" @bind-Value="_model.Number" For="@(() => _model.Number)" Immediate="true"/>
|
||||
<MudTextField Class="mt-3" Label="Name" @bind-Value="_model.Name" For="@(() => _model.Name)"/>
|
||||
<MudSelect Class="mt-3" Label="Streaming Mode" @bind-Value="_model.StreamingMode" For="@(() => _model.StreamingMode)">
|
||||
@foreach (StreamingMode streamingMode in Enum.GetValues<StreamingMode>())
|
||||
{
|
||||
<MudElement HtmlTag="img" src="@($"iptv/logos/{_model.Logo}")" Style="max-height: 50px"/>
|
||||
<MudSelectItem Value="@streamingMode">@streamingMode</MudSelectItem>
|
||||
}
|
||||
</MudItem>
|
||||
<MudItem xs="6">
|
||||
<MudButton Class="ml-auto" HtmlTag="label"
|
||||
Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.CloudUpload"
|
||||
for="fileInput">
|
||||
Upload Logo
|
||||
</MudButton>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudCardContent>
|
||||
<MudCardActions>
|
||||
<MudButton ButtonType="ButtonType.Submit" Variant="Variant.Filled" Color="Color.Primary">
|
||||
@(IsEdit ? "Save Changes" : "Add Channel")
|
||||
</MudButton>
|
||||
</MudCardActions>
|
||||
</MudCard>
|
||||
</EditForm>
|
||||
</div>
|
||||
</MudSelect>
|
||||
<MudSelect Class="mt-3" Label="FFmpeg Profile" @bind-Value="_model.FFmpegProfileId" For="@(() => _model.FFmpegProfileId)"
|
||||
Disabled="@(_model.StreamingMode != StreamingMode.TransportStream)">
|
||||
@foreach (FFmpegProfileViewModel profile in _ffmpegProfiles)
|
||||
{
|
||||
<MudSelectItem Value="@profile.Id">@profile.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudGrid Class="mt-3" Style="align-items: center" Justify="Justify.Center">
|
||||
<MudItem xs="6">
|
||||
<InputFile id="fileInput" OnChange="UploadLogo" hidden/>
|
||||
@if (!string.IsNullOrWhiteSpace(_model.Logo))
|
||||
{
|
||||
<MudElement HtmlTag="img" src="@($"iptv/logos/{_model.Logo}")" Style="max-height: 50px"/>
|
||||
}
|
||||
</MudItem>
|
||||
<MudItem xs="6">
|
||||
<MudButton Class="ml-auto" HtmlTag="label"
|
||||
Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.CloudUpload"
|
||||
for="fileInput">
|
||||
Upload Logo
|
||||
</MudButton>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudCardContent>
|
||||
<MudCardActions>
|
||||
<MudButton ButtonType="ButtonType.Submit" Variant="Variant.Filled" Color="Color.Primary">
|
||||
@(IsEdit ? "Save Changes" : "Add Channel")
|
||||
</MudButton>
|
||||
</MudCardActions>
|
||||
</MudCard>
|
||||
</EditForm>
|
||||
</div>
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
|
||||
@@ -93,8 +95,9 @@
|
||||
else
|
||||
{
|
||||
// TODO: command for new channel
|
||||
int maxNumber = await Mediator.Send(new GetAllChannels()).Map(channels => channels.Max(c => c.Number));
|
||||
_model.Number = maxNumber + 1;
|
||||
int maxNumber = await Mediator.Send(new GetAllChannels())
|
||||
.Map(list => list.Map(c => int.TryParse(c.Number.Split(".").Head(), out int result) ? result : 0).Max());
|
||||
_model.Number = (maxNumber + 1).ToString();
|
||||
_model.Name = "New Channel";
|
||||
_model.FFmpegProfileId = _ffmpegProfiles.Head().Id;
|
||||
_model.StreamingMode = StreamingMode.TransportStream;
|
||||
@@ -124,7 +127,7 @@
|
||||
errorMessage.HeadOrNone().Match(
|
||||
error =>
|
||||
{
|
||||
Snackbar.Add($"Unexpected error saving channel: {error.Value}");
|
||||
Snackbar.Add(error.Value, Severity.Error);
|
||||
Logger.LogError("Unexpected error saving channel: {Error}", error.Value);
|
||||
},
|
||||
() => NavigationManager.NavigateTo("/channels"));
|
||||
@@ -144,7 +147,7 @@
|
||||
},
|
||||
error =>
|
||||
{
|
||||
Snackbar.Add($"Unexpected error saving channel logo: {error.Value}");
|
||||
Snackbar.Add($"Unexpected error saving channel logo: {error.Value}", Severity.Error);
|
||||
Logger.LogError("Unexpected error saving channel logo: {Error}", error.Value);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
@inject IDialogService Dialog
|
||||
@inject IMediator Mediator
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge">
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
|
||||
<MudTable Hover="true" Items="_channels">
|
||||
<ToolBarContent>
|
||||
<MudText Typo="Typo.h6">Channels</MudText>
|
||||
@@ -18,7 +18,7 @@
|
||||
<col style="width: 20%"/>
|
||||
<col style="width: 20%"/>
|
||||
<col style="width: 20%"/>
|
||||
<col style="width: 60px;"/>
|
||||
<col style="width: 120px;"/>
|
||||
</ColGroup>
|
||||
<HeaderContent>
|
||||
<MudTh>
|
||||
@@ -49,14 +49,18 @@
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd>
|
||||
<MudMenu Icon="@Icons.Material.Filled.MoreVert" Direction="Direction.Left" OffsetX="true">
|
||||
<MudMenuItem Icon="@Icons.Material.Filled.Edit" Link="@($"/channels/{context.Id}")">
|
||||
Edit
|
||||
</MudMenuItem>
|
||||
<MudMenuItem Icon="@Icons.Material.Filled.Delete" OnClick="@(_ => DeleteChannelAsync(context))">
|
||||
Delete
|
||||
</MudMenuItem>
|
||||
</MudMenu>
|
||||
<div style="align-items: center; display: flex;">
|
||||
<MudTooltip Text="Edit Channel">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit"
|
||||
Link="@($"/channels/{context.Id}")">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Delete Channel">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||
OnClick="@(_ => DeleteChannelAsync(context))">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
</div>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
@@ -89,5 +93,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadChannelsAsync() => _channels = await Mediator.Send(new GetAllChannels());
|
||||
private async Task LoadChannelsAsync() => _channels = await Mediator.Send(new GetAllChannels())
|
||||
.Map(list => list.OrderBy(c => c.Number).ToList());
|
||||
|
||||
}
|
||||
@@ -8,23 +8,25 @@
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IMediator Mediator
|
||||
|
||||
<div style="max-width: 400px;">
|
||||
<MudText Typo="Typo.h4" Class="mb-4">@(IsEdit ? "Edit Collection" : "Add Collection")</MudText>
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
|
||||
<div style="max-width: 400px;">
|
||||
<MudText Typo="Typo.h4" Class="mb-4">@(IsEdit ? "Edit Collection" : "Add Collection")</MudText>
|
||||
|
||||
<EditForm EditContext="_editContext" OnSubmit="@HandleSubmitAsync">
|
||||
<FluentValidator/>
|
||||
<MudCard>
|
||||
<MudCardContent>
|
||||
<MudTextField Class="mt-3" Label="Name" @bind-Value="_model.Name" For="@(() => _model.Name)"/>
|
||||
</MudCardContent>
|
||||
<MudCardActions>
|
||||
<MudButton ButtonType="ButtonType.Submit" Variant="Variant.Filled" Color="Color.Primary" Class="ml-auto">
|
||||
@(IsEdit ? "Save Changes" : "Add Collection")
|
||||
</MudButton>
|
||||
</MudCardActions>
|
||||
</MudCard>
|
||||
</EditForm>
|
||||
</div>
|
||||
<EditForm EditContext="_editContext" OnSubmit="@HandleSubmitAsync">
|
||||
<FluentValidator/>
|
||||
<MudCard>
|
||||
<MudCardContent>
|
||||
<MudTextField Class="mt-3" Label="Name" @bind-Value="_model.Name" For="@(() => _model.Name)"/>
|
||||
</MudCardContent>
|
||||
<MudCardActions>
|
||||
<MudButton ButtonType="ButtonType.Submit" Variant="Variant.Filled" Color="Color.Primary" Class="ml-auto">
|
||||
@(IsEdit ? "Save Changes" : "Add Collection")
|
||||
</MudButton>
|
||||
</MudCardActions>
|
||||
</MudCard>
|
||||
</EditForm>
|
||||
</div>
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
|
||||
|
||||
@@ -7,76 +7,79 @@
|
||||
@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>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit"
|
||||
Link="@($"/media/collections/{Id}/edit")"
|
||||
Style="margin-bottom: auto; margin-top: auto;"/>
|
||||
</div>
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
|
||||
<div class="mb-6" style="display: flex; flex-direction: row;">
|
||||
<MudText GutterBottom="true" Typo="Typo.h2">@_data.Name</MudText>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit"
|
||||
Link="@($"/media/collections/{Id}/edit")"
|
||||
Style="margin-bottom: auto; margin-top: auto;"/>
|
||||
</div>
|
||||
|
||||
@if (_data.MovieCards.Any())
|
||||
{
|
||||
<MudText GutterBottom="true" Typo="Typo.h4">Movies</MudText>
|
||||
@if (_data.MovieCards.Any())
|
||||
{
|
||||
<MudText GutterBottom="true" Typo="Typo.h4">Movies</MudText>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
|
||||
@foreach (MovieCardViewModel card in _data.MovieCards)
|
||||
{
|
||||
<MediaCard Data="@card"
|
||||
Link="@($"/media/movies/{card.MovieId}")"
|
||||
DeleteClicked="@RemoveMovieFromCollection"/>
|
||||
}
|
||||
</MudContainer>
|
||||
}
|
||||
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
|
||||
@foreach (MovieCardViewModel card in _data.MovieCards.OrderBy(m => m.SortTitle))
|
||||
{
|
||||
<MediaCard Data="@card"
|
||||
Link="@($"/media/movies/{card.MovieId}")"
|
||||
DeleteClicked="@RemoveMovieFromCollection"/>
|
||||
}
|
||||
</MudContainer>
|
||||
}
|
||||
|
||||
@if (_data.ShowCards.Any())
|
||||
{
|
||||
<MudText GutterBottom="true" Typo="Typo.h4">Television Shows</MudText>
|
||||
@if (_data.ShowCards.Any())
|
||||
{
|
||||
<MudText GutterBottom="true" Typo="Typo.h4">Television Shows</MudText>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
|
||||
@foreach (TelevisionShowCardViewModel card in _data.ShowCards)
|
||||
{
|
||||
<MediaCard Data="@card"
|
||||
Link="@($"/media/tv/shows/{card.TelevisionShowId}")"
|
||||
DeleteClicked="@RemoveShowFromCollection"/>
|
||||
}
|
||||
</MudContainer>
|
||||
}
|
||||
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
|
||||
@foreach (TelevisionShowCardViewModel card in _data.ShowCards.OrderBy(m => m.SortTitle))
|
||||
{
|
||||
<MediaCard Data="@card"
|
||||
Link="@($"/media/tv/shows/{card.TelevisionShowId}")"
|
||||
DeleteClicked="@RemoveShowFromCollection"/>
|
||||
}
|
||||
</MudContainer>
|
||||
}
|
||||
|
||||
@if (_data.SeasonCards.Any())
|
||||
{
|
||||
<MudText GutterBottom="true" Typo="Typo.h4">Television Seasons</MudText>
|
||||
@if (_data.SeasonCards.Any())
|
||||
{
|
||||
<MudText GutterBottom="true" Typo="Typo.h4">Television Seasons</MudText>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
|
||||
@foreach (TelevisionSeasonCardViewModel card in _data.SeasonCards)
|
||||
{
|
||||
<MediaCard Data="@card"
|
||||
Link="@($"/media/tv/seasons/{card.TelevisionSeasonId}")"
|
||||
Title="@card.ShowTitle"
|
||||
Subtitle="@card.Title"
|
||||
DeleteClicked="@RemoveSeasonFromCollection"/>
|
||||
}
|
||||
</MudContainer>
|
||||
}
|
||||
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
|
||||
@foreach (TelevisionSeasonCardViewModel card in _data.SeasonCards.OrderBy(m => m.SortTitle))
|
||||
{
|
||||
<MediaCard Data="@card"
|
||||
Link="@($"/media/tv/seasons/{card.TelevisionSeasonId}")"
|
||||
Title="@card.ShowTitle"
|
||||
Subtitle="@card.Title"
|
||||
DeleteClicked="@RemoveSeasonFromCollection"/>
|
||||
}
|
||||
</MudContainer>
|
||||
}
|
||||
|
||||
@if (_data.EpisodeCards.Any())
|
||||
{
|
||||
<MudText GutterBottom="true" Typo="Typo.h4">Television Episodes</MudText>
|
||||
@if (_data.EpisodeCards.Any())
|
||||
{
|
||||
<MudText GutterBottom="true" Typo="Typo.h4">Television Episodes</MudText>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
|
||||
@foreach (TelevisionEpisodeCardViewModel card in _data.EpisodeCards.OrderBy(e => e.Aired))
|
||||
{
|
||||
<MediaCard Data="@card"
|
||||
Link="@($"/media/tv/episodes/{card.EpisodeId}")"
|
||||
Title="@card.ShowTitle"
|
||||
Subtitle="@card.Title"
|
||||
ContainerClass="media-card-episode-container mx-2"
|
||||
CardClass="media-card-episode"
|
||||
DeleteClicked="@RemoveEpisodeFromCollection"
|
||||
ArtworkKind="@ArtworkKind.Thumbnail"/>
|
||||
}
|
||||
</MudContainer>
|
||||
}
|
||||
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
|
||||
@foreach (TelevisionEpisodeCardViewModel card in _data.EpisodeCards.OrderBy(e => e.Aired))
|
||||
{
|
||||
<MediaCard Data="@card"
|
||||
Link="@($"/media/tv/episodes/{card.EpisodeId}")"
|
||||
Title="@card.ShowTitle"
|
||||
Subtitle="@card.Title"
|
||||
ContainerClass="media-card-episode-container mx-2"
|
||||
CardClass="media-card-episode"
|
||||
DeleteClicked="@RemoveEpisodeFromCollection"
|
||||
ArtworkKind="@ArtworkKind.Thumbnail"/>
|
||||
}
|
||||
</MudContainer>
|
||||
}
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
|
||||
|
||||
@@ -6,21 +6,23 @@
|
||||
@inject IDialogService Dialog
|
||||
@inject IMediator Mediator
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
|
||||
@foreach (MediaCollectionViewModel card in _data)
|
||||
{
|
||||
<MediaCard Data="@card"
|
||||
Link="@($"/media/collections/{card.Id}")"
|
||||
ContainerClass="media-card-episode-container mr-4"
|
||||
CardClass="media-card-episode"
|
||||
DeleteClicked="@DeleteMediaCollection"/>
|
||||
}
|
||||
</MudContainer>
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
|
||||
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
|
||||
@foreach (MediaCollectionViewModel card in _data)
|
||||
{
|
||||
<MediaCard Data="@card"
|
||||
Link="@($"/media/collections/{card.Id}")"
|
||||
ContainerClass="media-card-episode-container mr-4"
|
||||
CardClass="media-card-episode"
|
||||
DeleteClicked="@DeleteMediaCollection"/>
|
||||
}
|
||||
</MudContainer>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.False">
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" Link="/media/collections/add" Class="mt-4">
|
||||
Add Collection
|
||||
</MudButton>
|
||||
<MudContainer MaxWidth="MaxWidth.False">
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" Link="/media/collections/add" Class="mt-4">
|
||||
Add Collection
|
||||
</MudButton>
|
||||
</MudContainer>
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
|
||||
+18
-14
@@ -5,7 +5,7 @@
|
||||
@inject IDialogService Dialog
|
||||
@inject IMediator Mediator
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge">
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
|
||||
<MudCard>
|
||||
<MudCardHeader>
|
||||
<CardHeaderContent>
|
||||
@@ -37,7 +37,7 @@
|
||||
<ToolBarContent>
|
||||
<MudText Typo="Typo.h6">FFmpeg Profiles</MudText>
|
||||
<MudToolBarSpacer></MudToolBarSpacer>
|
||||
<MudText Color="Color.Primary">Colored settings will be normalized</MudText>
|
||||
<MudText Color="Color.Tertiary">Colored settings will be normalized</MudText>
|
||||
</ToolBarContent>
|
||||
<ColGroup>
|
||||
<col/>
|
||||
@@ -45,7 +45,7 @@
|
||||
<col/>
|
||||
<col/>
|
||||
<col/>
|
||||
<col style="width: 60px;"/>
|
||||
<col style="width: 120px;"/>
|
||||
</ColGroup>
|
||||
<HeaderContent>
|
||||
<MudTh>Name</MudTh>
|
||||
@@ -61,29 +61,33 @@
|
||||
@(context.Transcode ? "Yes" : "No")
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Resolution">
|
||||
<MudText Color="@(context.Transcode && context.NormalizeResolution ? Color.Primary : Color.Inherit)">
|
||||
<MudText Color="@(context.Transcode && context.NormalizeResolution ? Color.Tertiary : Color.Inherit)">
|
||||
@context.Resolution.Name
|
||||
</MudText>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Video Codec">
|
||||
<MudText Color="@(context.Transcode && context.NormalizeVideoCodec ? Color.Primary : Color.Inherit)">
|
||||
<MudText Color="@(context.Transcode && context.NormalizeVideoCodec ? Color.Tertiary : Color.Inherit)">
|
||||
@context.VideoCodec
|
||||
</MudText>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Audio Codec">
|
||||
<MudText Color="@(context.Transcode && context.NormalizeAudioCodec ? Color.Primary : Color.Inherit)">
|
||||
<MudText Color="@(context.Transcode && context.NormalizeAudioCodec ? Color.Tertiary : Color.Inherit)">
|
||||
@context.AudioCodec
|
||||
</MudText>
|
||||
</MudTd>
|
||||
<MudTd>
|
||||
<MudMenu Icon="@Icons.Material.Filled.MoreVert" Direction="Direction.Left" OffsetX="true">
|
||||
<MudMenuItem Icon="@Icons.Material.Filled.Edit" Link="@($"/ffmpeg/{context.Id}")">
|
||||
Edit
|
||||
</MudMenuItem>
|
||||
<MudMenuItem Icon="@Icons.Material.Filled.Delete" OnClick="@(_ => DeleteProfileAsync(context))">
|
||||
Delete
|
||||
</MudMenuItem>
|
||||
</MudMenu>
|
||||
<div style="align-items: center; display: flex;">
|
||||
<MudTooltip Text="Edit Channel">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit"
|
||||
Link="@($"/ffmpeg/{context.Id}")">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Delete Channel">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete"
|
||||
OnClick="@(_ => DeleteProfileAsync(context))">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
</div>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
|
||||
@@ -10,90 +10,99 @@
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IMediator Mediator
|
||||
|
||||
<EditForm EditContext="_editContext" OnSubmit="@HandleSubmitAsync">
|
||||
<FluentValidator/>
|
||||
<MudCard>
|
||||
<MudCardHeader>
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.h5">@(IsEdit ? "Edit FFmpeg Profile" : "Add FFmpeg Profile")</MudText>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent>
|
||||
<MudGrid>
|
||||
<MudItem xs="12">
|
||||
<MudGrid Spacing="4" Justify="Justify.Center">
|
||||
<MudItem>
|
||||
<MudText Typo="Typo.h6">General</MudText>
|
||||
<MudTextField Label="Name" @bind-Value="_model.Name" For="@(() => _model.Name)"/>
|
||||
<MudElement HtmlTag="div" Class="mt-3">
|
||||
<MudTextField Label="Thread Count" @bind-Value="@_model.ThreadCount" For="@(() => _model.ThreadCount)"/>
|
||||
</MudElement>
|
||||
<MudElement HtmlTag="div" Class="mt-3">
|
||||
<MudSelect Disabled="@(!_model.Transcode)" Label="Preferred Resolution" @bind-Value="_model.Resolution" For="@(() => _model.Resolution)">
|
||||
@foreach (ResolutionViewModel resolution in _resolutions)
|
||||
{
|
||||
<MudSelectItem Value="@resolution">@resolution.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudElement>
|
||||
<MudElement HtmlTag="div" Class="mt-3">
|
||||
<MudCheckBox Label="Transcode" @bind-Checked="@_model.Transcode" For="@(() => _model.Transcode)"/>
|
||||
</MudElement>
|
||||
</MudItem>
|
||||
<MudItem>
|
||||
<MudText Typo="Typo.h6">Video</MudText>
|
||||
<MudTextField Disabled="@(!_model.Transcode)" Label="Codec" @bind-Value="_model.VideoCodec" For="@(() => _model.VideoCodec)"/>
|
||||
<MudElement HtmlTag="div" Class="mt-3">
|
||||
<MudTextField Disabled="@(!_model.Transcode)" Label="Bitrate" @bind-Value="_model.VideoBitrate" For="@(() => _model.VideoBitrate)" Adornment="Adornment.End" AdornmentText="kBit/s"/>
|
||||
</MudElement>
|
||||
<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>
|
||||
</MudItem>
|
||||
<MudItem>
|
||||
<MudText Typo="Typo.h6">Audio</MudText>
|
||||
<MudTextField Disabled="@(!_model.Transcode)" Label="Codec" @bind-Value="_model.AudioCodec" For="@(() => _model.AudioCodec)"/>
|
||||
<MudElement HtmlTag="div" Class="mt-3">
|
||||
<MudTextField Disabled="@(!_model.Transcode)" Label="Bitrate" @bind-Value="_model.AudioBitrate" For="@(() => _model.AudioBitrate)" Adornment="Adornment.End" AdornmentText="kBit/s"/>
|
||||
</MudElement>
|
||||
<MudElement HtmlTag="div" Class="mt-3">
|
||||
<MudTextField Disabled="@(!_model.Transcode)" Label="Buffer Size" @bind-Value="_model.AudioBufferSize" For="@(() => _model.AudioBufferSize)" Adornment="Adornment.End" AdornmentText="kBit"/>
|
||||
</MudElement>
|
||||
<MudElement HtmlTag="div" Class="mt-3">
|
||||
<MudTextField Disabled="@(!_model.Transcode)" Label="Volume" @bind-Value="_model.AudioVolume" For="@(() => _model.AudioVolume)" Adornment="Adornment.End" AdornmentText="%"/>
|
||||
</MudElement>
|
||||
<MudElement HtmlTag="div" Class="mt-3">
|
||||
<MudTextField Disabled="@(!_model.Transcode)" Label="Channels" @bind-Value="_model.AudioChannels" For="@(() => _model.AudioChannels)"/>
|
||||
</MudElement>
|
||||
<MudElement HtmlTag="div" Class="mt-3">
|
||||
<MudTextField Disabled="@(!_model.Transcode)" Label="Sample Rate" @bind-Value="_model.AudioSampleRate" For="@(() => _model.AudioSampleRate)" Adornment="Adornment.End" AdornmentText="kHz"/>
|
||||
</MudElement>
|
||||
</MudItem>
|
||||
<MudItem>
|
||||
<MudText Typo="Typo.h6">Normalization</MudText>
|
||||
<MudCheckBox Disabled="@(!_model.Transcode)" Label="Normalize Resolution" @bind-Checked="@_model.NormalizeResolution" For="@(() => _model.NormalizeResolution)"/>
|
||||
<MudElement HtmlTag="div" Class="mt-3">
|
||||
<MudCheckBox Disabled="@(!_model.Transcode)" Label="Normalize Video Codec" @bind-Checked="@_model.NormalizeVideoCodec" For="@(() => _model.NormalizeVideoCodec)"/>
|
||||
</MudElement>
|
||||
<MudElement HtmlTag="div" Class="mt-3">
|
||||
<MudCheckBox Disabled="@(!_model.Transcode)" Label="Normalize Audio Codec" @bind-Checked="@_model.NormalizeAudioCodec" For="@(() => _model.NormalizeAudioCodec)"/>
|
||||
</MudElement>
|
||||
<MudElement HtmlTag="div" Class="mt-3">
|
||||
<MudCheckBox Disabled="@(!_model.Transcode)" Label="Normalize Audio" @bind-Checked="@_model.NormalizeAudio" For="@(() => _model.NormalizeAudio)"/>
|
||||
</MudElement>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudCardContent>
|
||||
<MudCardActions>
|
||||
<MudButton ButtonType="ButtonType.Submit" Variant="Variant.Filled" Color="Color.Primary">
|
||||
@(IsEdit ? "Save Changes" : "Add Profile")
|
||||
</MudButton>
|
||||
</MudCardActions>
|
||||
</MudCard>
|
||||
|
||||
</EditForm>
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
|
||||
<EditForm EditContext="_editContext" OnSubmit="@HandleSubmitAsync">
|
||||
<FluentValidator/>
|
||||
<MudCard>
|
||||
<MudCardHeader>
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.h5">@(IsEdit ? "Edit FFmpeg Profile" : "Add FFmpeg Profile")</MudText>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent>
|
||||
<MudGrid>
|
||||
<MudItem xs="12">
|
||||
<MudGrid Spacing="4" Justify="Justify.Center">
|
||||
<MudItem>
|
||||
<MudText Typo="Typo.h6">General</MudText>
|
||||
<MudTextField Label="Name" @bind-Value="_model.Name" For="@(() => _model.Name)"/>
|
||||
<MudElement HtmlTag="div" Class="mt-3">
|
||||
<MudTextField Label="Thread Count" @bind-Value="@_model.ThreadCount" For="@(() => _model.ThreadCount)"/>
|
||||
</MudElement>
|
||||
<MudElement HtmlTag="div" Class="mt-3">
|
||||
<MudSelect Disabled="@(!_model.Transcode)" Label="Preferred Resolution" @bind-Value="_model.Resolution" For="@(() => _model.Resolution)">
|
||||
@foreach (ResolutionViewModel resolution in _resolutions)
|
||||
{
|
||||
<MudSelectItem Value="@resolution">@resolution.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudElement>
|
||||
<MudElement HtmlTag="div" Class="mt-3">
|
||||
<MudCheckBox Label="Transcode" @bind-Checked="@_model.Transcode" For="@(() => _model.Transcode)"/>
|
||||
</MudElement>
|
||||
</MudItem>
|
||||
<MudItem>
|
||||
<MudText Typo="Typo.h6">Video</MudText>
|
||||
<MudTextField Disabled="@(!_model.Transcode)" Label="Codec" @bind-Value="_model.VideoCodec" For="@(() => _model.VideoCodec)"/>
|
||||
<MudElement HtmlTag="div" Class="mt-3">
|
||||
<MudTextField Disabled="@(!_model.Transcode)" Label="Bitrate" @bind-Value="_model.VideoBitrate" For="@(() => _model.VideoBitrate)" Adornment="Adornment.End" AdornmentText="kBit/s"/>
|
||||
</MudElement>
|
||||
<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>
|
||||
<MudTextField Disabled="@(!_model.Transcode)" Label="Codec" @bind-Value="_model.AudioCodec" For="@(() => _model.AudioCodec)"/>
|
||||
<MudElement HtmlTag="div" Class="mt-3">
|
||||
<MudTextField Disabled="@(!_model.Transcode)" Label="Bitrate" @bind-Value="_model.AudioBitrate" For="@(() => _model.AudioBitrate)" Adornment="Adornment.End" AdornmentText="kBit/s"/>
|
||||
</MudElement>
|
||||
<MudElement HtmlTag="div" Class="mt-3">
|
||||
<MudTextField Disabled="@(!_model.Transcode)" Label="Buffer Size" @bind-Value="_model.AudioBufferSize" For="@(() => _model.AudioBufferSize)" Adornment="Adornment.End" AdornmentText="kBit"/>
|
||||
</MudElement>
|
||||
<MudElement HtmlTag="div" Class="mt-3">
|
||||
<MudTextField Disabled="@(!_model.Transcode)" Label="Volume" @bind-Value="_model.AudioVolume" For="@(() => _model.AudioVolume)" Adornment="Adornment.End" AdornmentText="%"/>
|
||||
</MudElement>
|
||||
<MudElement HtmlTag="div" Class="mt-3">
|
||||
<MudTextField Disabled="@(!_model.Transcode)" Label="Channels" @bind-Value="_model.AudioChannels" For="@(() => _model.AudioChannels)"/>
|
||||
</MudElement>
|
||||
<MudElement HtmlTag="div" Class="mt-3">
|
||||
<MudTextField Disabled="@(!_model.Transcode)" Label="Sample Rate" @bind-Value="_model.AudioSampleRate" For="@(() => _model.AudioSampleRate)" Adornment="Adornment.End" AdornmentText="kHz"/>
|
||||
</MudElement>
|
||||
</MudItem>
|
||||
<MudItem>
|
||||
<MudText Typo="Typo.h6">Normalization</MudText>
|
||||
<MudCheckBox Disabled="@(!_model.Transcode)" Label="Normalize Resolution" @bind-Checked="@_model.NormalizeResolution" For="@(() => _model.NormalizeResolution)"/>
|
||||
<MudElement HtmlTag="div" Class="mt-3">
|
||||
<MudCheckBox Disabled="@(!_model.Transcode)" Label="Normalize Video Codec" @bind-Checked="@_model.NormalizeVideoCodec" For="@(() => _model.NormalizeVideoCodec)"/>
|
||||
</MudElement>
|
||||
<MudElement HtmlTag="div" Class="mt-3">
|
||||
<MudCheckBox Disabled="@(!_model.Transcode)" Label="Normalize Audio Codec" @bind-Checked="@_model.NormalizeAudioCodec" For="@(() => _model.NormalizeAudioCodec)"/>
|
||||
</MudElement>
|
||||
<MudElement HtmlTag="div" Class="mt-3">
|
||||
<MudCheckBox Disabled="@(!_model.Transcode)" Label="Normalize Audio" @bind-Checked="@_model.NormalizeAudio" For="@(() => _model.NormalizeAudio)"/>
|
||||
</MudElement>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudCardContent>
|
||||
<MudCardActions>
|
||||
<MudButton ButtonType="ButtonType.Submit" Variant="Variant.Filled" Color="Color.Primary">
|
||||
@(IsEdit ? "Save Changes" : "Add Profile")
|
||||
</MudButton>
|
||||
</MudCardActions>
|
||||
</MudCard>
|
||||
</EditForm>
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
|
||||
|
||||
+70
-68
@@ -1,70 +1,72 @@
|
||||
@page "/"
|
||||
|
||||
<MudCard>
|
||||
<MudCardContent>
|
||||
<MudText Typo="Typo.h3">Welcome to ErsatzTV!</MudText>
|
||||
<MudElement HtmlTag="div" Class="mt-6">
|
||||
<MudText Typo="Typo.h4" GutterBottom="true">Channels</MudText>
|
||||
<MudText>
|
||||
<MudLink Href="/channels">Channels</MudLink> are not directly associated with any media. Channels have a <b>number</b>, a <b>name</b>, and a <b>streaming mode</b> that indicates how the channel will play media.
|
||||
</MudText>
|
||||
<MudText Class="mt-3">
|
||||
In <b>TransportStream</b> mode, the channel will also require an <b>FFmpeg profile</b> to configure transcoding and normalization.
|
||||
In <b>HttpLiveStreaming</b> mode, the channel will attempt to serve the channel's media without transcoding or normalization beyond the container format.
|
||||
</MudText>
|
||||
</MudElement>
|
||||
<MudElement HtmlTag="div" Class="mt-6">
|
||||
<MudText Typo="Typo.h4" GutterBottom="true">FFmpeg Profiles</MudText>
|
||||
<MudText>
|
||||
<MudLink Href="/ffmpeg">FFmpeg Profiles</MudLink> are collections of FFmpeg settings that are applied at the channel level.
|
||||
All content on a given channel will use the same FFmpeg settings. This also means the same content on different channels can use different settings.
|
||||
</MudText>
|
||||
</MudElement>
|
||||
<MudElement HtmlTag="div" Class="mt-6">
|
||||
<MudText Typo="Typo.h4" GutterBottom="true">Libraries</MudText>
|
||||
<MudText>
|
||||
Two local <MudLink Href="/media/libraries">libraries</MudLink> are available, one for each <b>media kind</b>: Shows and Movies. Libraries contain <b>paths</b> (folders) to regularly scan for media items.
|
||||
Support for Plex libraries is under active development; Jellyfin and Emby library support is planned.
|
||||
</MudText>
|
||||
</MudElement>
|
||||
<MudElement HtmlTag="div" Class="mt-6">
|
||||
<MudText Typo="Typo.h4" GutterBottom="true">Collections</MudText>
|
||||
<MudText>
|
||||
<MudLink Href="/media/collections">Collections</MudLink> have a <b>name</b> and contain a logical grouping of media items.
|
||||
Collections may contain shows, seasons, episodes or movies.
|
||||
Collections containing shows and seasons are automatically updated as media is added or removed from the linked shows and seasons.
|
||||
</MudText>
|
||||
</MudElement>
|
||||
<MudElement HtmlTag="div" Class="mt-6">
|
||||
<MudText Typo="Typo.h4" GutterBottom="true">Schedules</MudText>
|
||||
<MudText>
|
||||
<MudLink Href="/schedules">Schedules</MudLink> have a <b>name</b>, a <b>collection playback order</b> and <b>items</b> to continually loop through.
|
||||
</MudText>
|
||||
<MudText Class="mt-3 mb-2">Three <b>collection playback orders</b> are supported:</MudText>
|
||||
<ul class="mud-typography-body1">
|
||||
<li><b>Random</b> - to randomly play collection items; repeating is allowed before all collection items have been played.</li>
|
||||
<li><b>Shuffle</b> - to randomly play collection items; repeating is <i>not</i> allowed until all collection items have been played.</li>
|
||||
<li><b>Chronological</b> - to play collection items sorted by air date and then by season and episode number (for when multiple episodes aired on a single day).</li>
|
||||
</ul>
|
||||
<MudText Class="mt-3">
|
||||
Schedule items have a <b>start type</b>, a <b>start time</b>, a <b>collection</b> and a <b>playout mode</b>.
|
||||
</MudText>
|
||||
<MudText Class="mt-3">
|
||||
A <b>fixed</b> start type requires a <b>start time</b>, while a <b>dynamic</b> start type means the schedule item will start immediately after the preceding schedule item.
|
||||
</MudText>
|
||||
<MudText Class="mt-3 mb-2">Four <b>playout modes</b> are supported:</MudText>
|
||||
<ul class="mud-typography-body1">
|
||||
<li><b>One</b> - to play one media item from the collection before advancing to the next schedule item.</li>
|
||||
<li><b>Multiple</b> - to play a specified <b>count</b> of media items from the collection before advancing to the next schedule item.</li>
|
||||
<li><b>Duration</b> - to play the maximum number of complete media items that will fit in the specified <b>playout duration</b>, before either going offline for the remainder of the <b>playout duration</b> (an <b>offline tail</b>), or immediately advancing to the next schedule item.</li>
|
||||
<li><b>Flood</b> - to play media items from the collection forever, or until the next schedule item's <b>start time</b> if one exists.</li>
|
||||
</ul>
|
||||
</MudElement>
|
||||
<MudElement HtmlTag="div" Class="mt-6">
|
||||
<MudText Typo="Typo.h4" GutterBottom="true">Playouts</MudText>
|
||||
<MudText>
|
||||
<MudLink Href="/playouts">Playouts</MudLink> assign a <b>schedule</b> to a <b>channel</b> and individually track the ordered playback of collection items.
|
||||
</MudText>
|
||||
</MudElement>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
|
||||
<MudCard>
|
||||
<MudCardContent>
|
||||
<MudText Typo="Typo.h3">Welcome to ErsatzTV!</MudText>
|
||||
<MudElement HtmlTag="div" Class="mt-6">
|
||||
<MudText Typo="Typo.h4" GutterBottom="true">Channels</MudText>
|
||||
<MudText>
|
||||
<MudLink Href="/channels">Channels</MudLink> are not directly associated with any media. Channels have a <b>number</b>, a <b>name</b>, and a <b>streaming mode</b> that indicates how the channel will play media.
|
||||
</MudText>
|
||||
<MudText Class="mt-3">
|
||||
In <b>TransportStream</b> mode, the channel will also require an <b>FFmpeg profile</b> to configure transcoding and normalization.
|
||||
In <b>HttpLiveStreaming</b> mode, the channel will attempt to serve the channel's media without transcoding or normalization beyond the container format.
|
||||
</MudText>
|
||||
</MudElement>
|
||||
<MudElement HtmlTag="div" Class="mt-6">
|
||||
<MudText Typo="Typo.h4" GutterBottom="true">FFmpeg Profiles</MudText>
|
||||
<MudText>
|
||||
<MudLink Href="/ffmpeg">FFmpeg Profiles</MudLink> are collections of FFmpeg settings that are applied at the channel level.
|
||||
All content on a given channel will use the same FFmpeg settings. This also means the same content on different channels can use different settings.
|
||||
</MudText>
|
||||
</MudElement>
|
||||
<MudElement HtmlTag="div" Class="mt-6">
|
||||
<MudText Typo="Typo.h4" GutterBottom="true">Libraries</MudText>
|
||||
<MudText>
|
||||
Two local <MudLink Href="/media/libraries">libraries</MudLink> are available, one for each <b>media kind</b>: Shows and Movies. Libraries contain <b>paths</b> (folders) to regularly scan for media items.
|
||||
Support for Plex libraries is under active development; Jellyfin and Emby library support is planned.
|
||||
</MudText>
|
||||
</MudElement>
|
||||
<MudElement HtmlTag="div" Class="mt-6">
|
||||
<MudText Typo="Typo.h4" GutterBottom="true">Collections</MudText>
|
||||
<MudText>
|
||||
<MudLink Href="/media/collections">Collections</MudLink> have a <b>name</b> and contain a logical grouping of media items.
|
||||
Collections may contain shows, seasons, episodes or movies.
|
||||
Collections containing shows and seasons are automatically updated as media is added or removed from the linked shows and seasons.
|
||||
</MudText>
|
||||
</MudElement>
|
||||
<MudElement HtmlTag="div" Class="mt-6">
|
||||
<MudText Typo="Typo.h4" GutterBottom="true">Schedules</MudText>
|
||||
<MudText>
|
||||
<MudLink Href="/schedules">Schedules</MudLink> have a <b>name</b>, a <b>collection playback order</b> and <b>items</b> to continually loop through.
|
||||
</MudText>
|
||||
<MudText Class="mt-3 mb-2">Three <b>collection playback orders</b> are supported:</MudText>
|
||||
<ul class="mud-typography-body1">
|
||||
<li><b>Random</b> - to randomly play collection items; repeating is allowed before all collection items have been played.</li>
|
||||
<li><b>Shuffle</b> - to randomly play collection items; repeating is <i>not</i> allowed until all collection items have been played.</li>
|
||||
<li><b>Chronological</b> - to play collection items sorted by air date and then by season and episode number (for when multiple episodes aired on a single day).</li>
|
||||
</ul>
|
||||
<MudText Class="mt-3">
|
||||
Schedule items have a <b>start type</b>, a <b>start time</b>, a <b>collection</b> and a <b>playout mode</b>.
|
||||
</MudText>
|
||||
<MudText Class="mt-3">
|
||||
A <b>fixed</b> start type requires a <b>start time</b>, while a <b>dynamic</b> start type means the schedule item will start immediately after the preceding schedule item.
|
||||
</MudText>
|
||||
<MudText Class="mt-3 mb-2">Four <b>playout modes</b> are supported:</MudText>
|
||||
<ul class="mud-typography-body1">
|
||||
<li><b>One</b> - to play one media item from the collection before advancing to the next schedule item.</li>
|
||||
<li><b>Multiple</b> - to play a specified <b>count</b> of media items from the collection before advancing to the next schedule item.</li>
|
||||
<li><b>Duration</b> - to play the maximum number of complete media items that will fit in the specified <b>playout duration</b>, before either going offline for the remainder of the <b>playout duration</b> (an <b>offline tail</b>), or immediately advancing to the next schedule item.</li>
|
||||
<li><b>Flood</b> - to play media items from the collection forever, or until the next schedule item's <b>start time</b> if one exists.</li>
|
||||
</ul>
|
||||
</MudElement>
|
||||
<MudElement HtmlTag="div" Class="mt-6">
|
||||
<MudText Typo="Typo.h4" GutterBottom="true">Playouts</MudText>
|
||||
<MudText>
|
||||
<MudLink Href="/playouts">Playouts</MudLink> assign a <b>schedule</b> to a <b>channel</b> and individually track the ordered playback of collection items.
|
||||
</MudText>
|
||||
</MudElement>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudContainer>
|
||||
@@ -8,7 +8,7 @@
|
||||
@inject IEntityLocker Locker
|
||||
@inject ChannelWriter<IBackgroundServiceRequest> Channel
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge">
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
|
||||
<MudTable Hover="true" Items="_libraries" Dense="true">
|
||||
<ToolBarContent>
|
||||
<MudText Typo="Typo.h6">Libraries</MudText>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
@inject IDialogService Dialog
|
||||
@inject IMediator Mediator
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge">
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
|
||||
<MudTable Hover="true" Items="_libraryPaths" Dense="true">
|
||||
<ToolBarContent>
|
||||
<MudText Typo="Typo.h6">Library Paths</MudText>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
@inject IEntityLocker Locker
|
||||
@inject ChannelWriter<IBackgroundServiceRequest> Channel
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge">
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
|
||||
<MudText Typo="Typo.h4" Class="mb-4">@_library.Name - Add Local Library Path</MudText>
|
||||
<div style="max-width: 400px;">
|
||||
<EditForm EditContext="_editContext" OnSubmit="@HandleSubmitAsync">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
@using ErsatzTV.Application.Logs.Queries
|
||||
@inject IMediator Mediator
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge">
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
|
||||
<MudTable FixedHeader="true" Dense="true" Items="_logEntries">
|
||||
<HeaderContent>
|
||||
<MudTh>Timestamp</MudTh>
|
||||
|
||||
+33
-24
@@ -7,33 +7,42 @@
|
||||
@inject IDialogService Dialog
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Large">
|
||||
<MudBreadcrumbs Items="_breadcrumbs" Class="mb-6"></MudBreadcrumbs>
|
||||
<MudCard Class="mb-6">
|
||||
<div style="display: flex; flex-direction: row;">
|
||||
@if (!string.IsNullOrWhiteSpace(_movie.Poster))
|
||||
<MudContainer MaxWidth="MaxWidth.False" Style="padding: 0" Class="fanart-container">
|
||||
<div class="fanart-tint"></div>
|
||||
@if (!string.IsNullOrWhiteSpace(_movie.FanArt))
|
||||
{
|
||||
<img src="@($"/artwork/fanart/{_movie.FanArt}")" alt="fan art"/>
|
||||
}
|
||||
</MudContainer>
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Style="margin-top: 200px">
|
||||
<div style="display: flex; flex-direction: row;">
|
||||
@if (!string.IsNullOrWhiteSpace(_movie.Poster))
|
||||
{
|
||||
<img class="mud-elevation-2 mr-6"
|
||||
style="border-radius: 4px; max-height: 440px"
|
||||
src="@($"/artwork/posters/{_movie.Poster}")" alt="movie poster"/>
|
||||
}
|
||||
<div style="display: flex; flex-direction: column; height: 100%">
|
||||
<MudText Typo="Typo.h2" Class="media-item-title">@_movie.Title</MudText>
|
||||
<MudText Typo="Typo.subtitle1" Class="media-item-subtitle mb-6 mud-text-secondary">@_movie.Year</MudText>
|
||||
@if (!string.IsNullOrWhiteSpace(_movie.Plot))
|
||||
{
|
||||
<MudPaper Style="flex-shrink: 0;">
|
||||
<MudCardMedia Image="@($"/artwork/posters/{_movie.Poster}")" Style="height: 440px; width: 304px;"/>
|
||||
</MudPaper>
|
||||
<MudCard Elevation="2" Class="mb-6">
|
||||
<MudCardContent Class="mx-3 my-3" Style="height: 100%">
|
||||
<MudText Style="flex-grow: 1">@_movie.Plot</MudText>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
}
|
||||
<MudCardContent Class="mx-3 my-3">
|
||||
<div style="display: flex; flex-direction: column; height: 100%">
|
||||
<MudText Typo="Typo.h3">@_movie.Title</MudText>
|
||||
<MudText Typo="Typo.subtitle1" Class="mb-6 mud-text-secondary">@_movie.Year</MudText>
|
||||
<MudText Style="flex-grow: 1">@_movie.Plot</MudText>
|
||||
<div>
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Add"
|
||||
OnClick="@AddToCollection">
|
||||
Add To Collection
|
||||
</MudButton>
|
||||
</div>
|
||||
</div>
|
||||
</MudCardContent>
|
||||
<div>
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Add"
|
||||
OnClick="@AddToCollection">
|
||||
Add To Collection
|
||||
</MudButton>
|
||||
</div>
|
||||
</div>
|
||||
</MudCard>
|
||||
</div>
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
|
||||
@@ -10,30 +10,33 @@
|
||||
@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;">
|
||||
<MudIconButton Icon="@Icons.Material.Outlined.ChevronLeft"
|
||||
OnClick="@PrevPage"
|
||||
Disabled="@(PageNumber <= 1)">
|
||||
</MudIconButton>
|
||||
<MudText Style="flex-grow: 1"
|
||||
Align="Align.Center">
|
||||
@Math.Min((PageNumber - 1) * PageSize + 1, _data.Count)-@Math.Min(_data.Count, PageNumber * PageSize) of @_data.Count
|
||||
</MudText>
|
||||
<MudIconButton Icon="@Icons.Material.Outlined.ChevronRight"
|
||||
OnClick="@NextPage" Disabled="@(PageNumber * PageSize >= _data.Count)">
|
||||
</MudIconButton>
|
||||
</MudPaper>
|
||||
</MudContainer>
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
|
||||
<MudContainer MaxWidth="MaxWidth.Small" Class="mb-6" Style="max-width: 300px">
|
||||
<MudPaper Style="align-items: center; display: flex; justify-content: center;">
|
||||
<MudIconButton Icon="@Icons.Material.Outlined.ChevronLeft"
|
||||
OnClick="@PrevPage"
|
||||
Disabled="@(PageNumber <= 1)">
|
||||
</MudIconButton>
|
||||
<MudText Style="flex-grow: 1"
|
||||
Align="Align.Center">
|
||||
@Math.Min((PageNumber - 1) * PageSize + 1, _data.Count)-@Math.Min(_data.Count, PageNumber * PageSize) of @_data.Count
|
||||
</MudText>
|
||||
<MudIconButton Icon="@Icons.Material.Outlined.ChevronRight"
|
||||
OnClick="@NextPage" Disabled="@(PageNumber * PageSize >= _data.Count)">
|
||||
</MudIconButton>
|
||||
</MudPaper>
|
||||
</MudContainer>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
|
||||
@foreach (MovieCardViewModel card in _data.Cards.Where(d => !string.IsNullOrWhiteSpace(d.Title)))
|
||||
{
|
||||
<MediaCard Data="@card"
|
||||
Link="@($"/media/movies/{card.MovieId}")"
|
||||
AddToCollectionClicked="@AddToCollection"/>
|
||||
}
|
||||
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
|
||||
@foreach (MovieCardViewModel card in _data.Cards.Where(d => !string.IsNullOrWhiteSpace(d.Title)))
|
||||
{
|
||||
<MediaCard Data="@card"
|
||||
Link="@($"/media/movies/{card.MovieId}")"
|
||||
AddToCollectionClicked="@AddToCollection"/>
|
||||
}
|
||||
</MudContainer>
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user