Compare commits

...
Author SHA1 Message Date
Jason DoveandGitHub 2be729c10e search show by language (#149) 2021-04-06 07:58:45 -05:00
Jason DoveandGitHub 0aac702853 search movies and music videos by language (#148) 2021-04-06 07:42:21 -05:00
Jason DoveandGitHub 3f406ac556 log viewer improvements (#147) 2021-04-06 07:25:05 -05:00
Jason DoveandGitHub 454e2edf7c add documentation link to ui (#146) 2021-04-06 06:39:48 -05:00
b3f4fa8c23 add documentation (#145)
* start to reorganize documentation

* revert readme tag changes

* revert readme tag changes pt2

* doc updates; doc theme updates

* doc updates

* publish docs from documentation branch

* use favicon for docs

* create channel

* collections, jellyfin client, schedule items and playout docs

* channels dvr

* tivimate

* scale tivimate screenshots

* add channels dvr server setup

* add copyright and social

* Added UnRAID Docker install, formatting fixes (#100)

Co-authored-by: Thaddeus Cooper <redacted@redacted.co.nz>

* minor doc updates

* readme tweak

* add basic plex documentation

Co-authored-by: suckerface <9060047+suckerface@users.noreply.github.com>
Co-authored-by: Thaddeus Cooper <redacted@redacted.co.nz>
2021-04-06 06:20:49 -05:00
Jason DoveandGitHub a6496db58d settings rework (#144)
* add hdhr tuner count setting

* code cleanup
2021-04-05 19:48:17 -05:00
Jason Dove 3eed79b5e1 Merge branch 'main' of github.com:jasongdove/ErsatzTV 2021-04-05 16:19:22 -05:00
Jason DoveandGitHub 79bfba6428 better search index thread fix (#143)
* Revert "fix search index threading (#141)"

This reverts commit 3fb6da0754.

* better search index thread fix
2021-04-05 16:18:17 -05:00
Jason Dove 9f6d4114a6 Merge branch 'main' of github.com:jasongdove/ErsatzTV 2021-04-05 16:06:28 -05:00
Jason DoveandGitHub 9809c60924 send all audio streams on hls channels with no preferred language (#142)
* Revert "fix search index threading (#141)"

This reverts commit 3fb6da0754.

* send all audio streams on hls channels with no preferred language
2021-04-05 16:06:13 -05:00
Jason Dove 16072fed1c Revert "fix search index threading (#141)"
This reverts commit 3fb6da0754.
2021-04-05 07:44:42 -05:00
Jason DoveandGitHub 3fb6da0754 fix search index threading (#141)
* fix search index threading

* code cleanup
2021-04-05 05:41:29 -05:00
Jason DoveandGitHub 24cdf6295f clean up fragment letter anchor code (#140) 2021-04-04 20:56:47 -05:00
Jason DoveandGitHub c1b41e2865 use fragment navigation with letter bar (#139) 2021-04-04 20:30:40 -05:00
Jason DoveandGitHub d249e95f12 fix poster width (#138) 2021-04-04 20:13:29 -05:00
Jason DoveandGitHub efae005447 use full preferred language names in ui (#137) 2021-04-04 18:30:42 -05:00
Jason DoveandGitHub cead787c55 force SAR 1:1 if missing (#136) 2021-04-04 18:00:39 -05:00
Jason DoveandGitHub 77a69af1a8 sort channels and schedules in playout editor (#135) 2021-04-04 16:24:46 -05:00
Jason DoveandGitHub 8fea24a3a5 add fallback metadata for music videos (#134) 2021-04-04 15:57:25 -05:00
Jason DoveandGitHub 6b44873474 add library scan progress detail (#133)
* add library scan progress detail

* scan plex libraries on plex thread
2021-04-04 10:44:10 -05:00
Jason DoveandGitHub c5ee5903b2 use table for collections ui (#132) 2021-04-03 16:21:06 -05:00
Jason DoveandGitHub 526eada48b channels, schedules, playouts paging/sorting (#131)
* add paging to playouts

* add sorting, paging to schedules

* fix channels sorting; add channels paging
2021-04-03 16:07:05 -05:00
Jason DoveandGitHub 7a0d65a433 fix epg with music videos (#130) 2021-04-03 15:38:12 -05:00
Jason DoveandGitHub 74c95249c3 add loudness normalization (#129)
* fix music video search result artwork

* add normalize loudness setting

* fix audio normalization

* fix music video thumbnails in collection items view

* fix ef core warnings querying playout item

* implement audio loudness normalization filter
2021-04-03 13:36:11 -05:00
Jason DoveandGitHub d4a2197dfa async fixes (#128)
* refactor local metadata provider

* resolve async warnings

* more async fixes
2021-04-03 11:01:20 -05:00
Jason DoveandGitHub 633586ddba add music videos library (#125)
* add music videos library

* add music video tables

* first pass at music video library scan

* support music videos in playouts

* display music videos in search results and collections

* fix music video thumbnails

* remove some obsolete fields
2021-04-02 18:28:45 -05:00
Jason DoveandGitHub da3e05b231 normalize video track timescale (#123) 2021-03-31 23:36:20 +00:00
Jason DoveandGitHub 9e6de7e2eb use proper type for plex timestamps (#124) 2021-03-31 21:30:30 +00:00
Jason DoveandGitHub 4097288fed normalize framerate (#122)
* normalize framerate

* simplify audio normalization settings
2021-03-31 09:34:52 +00:00
Jason DoveandGitHub 90f775aab4 ffmpeg tweaks (#121)
* save reports from ffmpeg concat process

* let ffmpeg determine thread count by default

* disable stdin for ffmpeg processes
2021-03-31 01:08:57 +00:00
Jason DoveandGitHub fc33c5cd05 add show title to playout details (#120) 2021-03-30 21:23:02 +00:00
Jason DoveandGitHub 37eee73ab7 clear search query when clicking nav links (#119) 2021-03-30 21:10:56 +00:00
Jason DoveandGitHub e7ebb32a1d navigate to schedule items after creating new schedule (#118) 2021-03-30 11:15:31 +00:00
Jason DoveandGitHub 9ea4459988 cache artwork async (#117) 2021-03-30 11:09:47 +00:00
Jason DoveandGitHub 745b03af73 add custom title option to schedule items (#116) 2021-03-29 21:46:03 +00:00
Jason DoveandGitHub a62c4ecfcf fix playout builds using duration or multiple (#115) 2021-03-29 20:01:46 +00:00
Jason DoveandGitHub c48f0a7d51 don't require preferred language on channels (#114) 2021-03-29 14:43:09 +00:00
Jason DoveandGitHub f2c105174b fix stream selection for non-normalized playback (#113) 2021-03-29 14:42:20 +00:00
Jason DoveandGitHub 076a88230e optimize local library scanning (#112) 2021-03-29 10:34:33 +00:00
Jason DoveandGitHub f06a04ed0e fix search index updates for local libraries (#111) 2021-03-29 10:28:38 +00:00
Jason DoveandGitHub 07d690a31f fix local tv library scanning (#110) 2021-03-29 10:20:18 +00:00
Jason Dove 001453714a fix playback on channel with no preferred language 2021-03-28 18:21:26 -05:00
Jason DoveandGitHub d303bc0158 add preferred language (#109)
* add explicit warning for zero/invalid duration media items

* set dateadded on plex media versions

* add media stream table

* save local media streams to db

* save plex media streams to db

* add preferred language settings (no validation)

* use preferred language if possible

* code cleanup

* proper language code validation

* force scan of all libraries to pull in media streams
2021-03-28 21:54:48 +00:00
Jason Dove 51b671dec7 load concat playlist from localhost 2021-03-28 06:48:10 -05:00
Jason DoveandGitHub a5e1cc7c3d allow trailing slash in plex path replacement (#108)
* add test for unc path replacement

* allow trailing slash in plex path replacement
2021-03-28 11:32:32 +00:00
Jason DoveandGitHub 9ba6686c44 iptv route consistency [no ci] (#107)
* use localhost in concat playlist

* expose all playlist artwork under /iptv
2021-03-28 11:32:13 +00:00
Jason DoveandGitHub 104d4a0cbd fix mixed platform directory mapping (#106)
* sync plex platform and platform version

* fix mixed-platform path replacements
2021-03-28 01:40:40 +00:00
Jason DoveandGitHub 22c4fe2a27 fix indexing shows without nfo metadata (#105) 2021-03-27 23:32:10 +00:00
Jason DoveandGitHub 7e0bdfdb40 fix epg channel sorting (#101) 2021-03-26 10:36:06 +00:00
Jason Dove 6bdaca0222 remove unused code [no ci] 2021-03-26 05:33:46 -05:00
Jason Dove 67aa3a5a46 Revert "update docker repos and tagging for ci"
This reverts commit 470fba275b.
2021-03-23 07:42:31 -05:00
Jason Dove a0332e242c Revert "update docker repos and tagging for release [no ci]"
This reverts commit cd74859d28.
2021-03-23 07:42:20 -05:00
Jason Dove cd74859d28 update docker repos and tagging for release [no ci] 2021-03-23 06:39:40 -05:00
Jason Dove 470fba275b update docker repos and tagging for ci 2021-03-23 06:20:58 -05:00
Jason DoveandGitHub e42b000b7f fix plex sign in (#99) 2021-03-23 02:09:05 +00:00
Jason DoveandGitHub 489f8d92ff properly store plex timestamps on update (#98) 2021-03-22 02:20:31 +00:00
Jason DoveandGitHub 527d3c6e4b attach existing episodes to correct show and season when adding nfo metadata (#97) 2021-03-22 01:57:32 +00:00
Jason DoveandGitHub c33c037188 use folder.ext when poster.ext is not found for movies or shows (#96) 2021-03-21 21:50:31 +00:00
Jason DoveandGitHub 4c70d61d48 metadata improvements (#95)
* fix episode fallback metadata processing, fix show fallback metadata year parsing

* fix sort title for "a" and "an"

* add and index studio metadata

* minimize circular logging with search index errors

* update plex movie sort titles as needed

* properly escape search links

* force refreshing all movie/show metadata
2021-03-21 18:43:08 +00:00
Jason DoveandGitHub 00fdc272e9 remove plex items from index after sign out (#94) 2021-03-21 15:23:53 +00:00
Jason DoveandGitHub f04c18c810 index release date for searching (#93) 2021-03-21 01:49:10 +00:00
Jason DoveandGitHub eca58dbe7f plex fixes (#92)
* fix updating plex path replacements

* fix adding/removing plex libraries

* fix adding/removing plex servers

* fix initial plex library sync after sign in

* code cleanup
2021-03-21 01:35:18 +00:00
Jason DoveandGitHub cf9479d2a9 log search indexing errors and continue indexing (#91) 2021-03-20 21:33:37 +00:00
Jason DoveandGitHub b6331331b0 use default ffmpeg profile with new channels (#90) 2021-03-20 20:56:15 +00:00
Jason DoveandGitHub ed365cfa43 keep search query in search field (#89)
* upgrade dependencies

* keep search query in search field
2021-03-20 20:45:02 +00:00
Jason Dove b3a1e71570 only search title by default, allow leading wildcards 2021-03-20 15:32:30 -05:00
Jason Dove 454343d14f prevent ui crash during index rebuild [no ci] 2021-03-20 11:23:36 -05:00
Jason DoveandGitHub c0a6677861 optimize memory use during search index rebuild (#88) 2021-03-20 16:08:28 +00:00
Jason DoveandGitHub 2efcbca2da search overhaul (#87)
* add letter bar with no links

* use lucene for search, add paged search results

* add search index version

* index library_name; rebuild index when folder is missing

* maintain index as local movies change

* fix tests

* maintain index as local shows change

* maintain index as plex movies change

* maintain index as plex shows change

* code cleanup

* add duplicate filter to search

* add links to letter bar

* code cleanup
2021-03-20 15:49:50 +00:00
Jason Dove f96efa9b2f fix normalize video codec setting 2021-03-19 16:00:23 -05:00
Jason DoveandGitHub f46041305c add docs to schedule items page (#86) 2021-03-19 02:10:56 +00:00
Jason DoveandGitHub 493a496b91 delete orphan plex media sources (#85)
* delete orphan plex media sources

* fix plex db warning on startup
2021-03-19 01:14:18 +00:00
Jason DoveandGitHub 739d074bc6 optimize local scanning (#84)
* optimize local scanning

* fix artwork updates

* fix adding genres and tags

* fix movie fallback metadata
2021-03-19 00:45:38 +00:00
Jason DoveandGitHub c5c28cb92d fix playback for media containing attached pictures (#83) 2021-03-18 01:49:30 +00:00
Jason DoveandGitHub 636bf0715b bug fixes (#82)
* fix crash rebuilding playlists from ui

* fix error creating first channel
2021-03-18 01:27:08 +00:00
Jason Dove 0ca15ee7a8 fix docker release [no ci] 2021-03-17 16:46:35 -05:00
Jason Dove 6565240eeb try ci with isolated builders 2021-03-17 16:31:16 -05:00
Jason Dove d64188927c try ci without docker cache 2021-03-17 16:11:44 -05:00
341 changed files with 43533 additions and 2261 deletions
+4
View File
@@ -79,3 +79,7 @@ indent_size=2
indent_style=space
indent_size=4
tab_width=4
[*.yml]
indent_style = space
indent_size = 2
+12 -21
View File
@@ -51,16 +51,17 @@ jobs:
final="${tag2/prealpha/$short}"
echo "GIT_TAG=${final}" >> $GITHUB_ENV
- name: Set up Docker Buildx
- name: Set up Docker Buildx Base
uses: docker/setup-buildx-action@v1
id: builder-base
- 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: Set up Docker Buildx NVIDIA
uses: docker/setup-buildx-action@v1
id: builder-nvidia
- name: Set up Docker Buildx VAAPI
uses: docker/setup-buildx-action@v1
id: builder-vaapi
- name: Login to DockerHub
uses: docker/login-action@v1
@@ -71,6 +72,7 @@ jobs:
- name: Build and push base
uses: docker/build-push-action@v2
with:
builder: ${{ steps.builder-base.outputs.name }}
context: .
file: ./docker/Dockerfile
push: true
@@ -79,19 +81,11 @@ jobs:
tags: |
jasongdove/ersatztv:develop
jasongdove/ersatztv:${{ github.sha }}
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache-new
- # Temporary fix
# https://github.com/docker/build-push-action/issues/252
# https://github.com/moby/buildkit/issues/1896
name: Move cache
run: |
rm -rf /tmp/.buildx-cache
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
- name: Build and push nvidia
uses: docker/build-push-action@v2
with:
builder: ${{ steps.builder-nvidia.outputs.name }}
context: .
file: ./docker/nvidia/Dockerfile
push: true
@@ -100,12 +94,11 @@ jobs:
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:
builder: ${{ steps.builder-vaapi.outputs.name }}
context: .
file: ./docker/vaapi/Dockerfile
push: true
@@ -114,5 +107,3 @@ jobs:
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
+19
View File
@@ -0,0 +1,19 @@
name: Publish docs via GitHub Pages
on:
push:
branches:
- main
jobs:
build:
name: Deploy docs
runs-on: ubuntu-latest
steps:
- name: Checkout master
uses: actions/checkout@v1
- name: Deploy docs
uses: mhausenblas/mkdocs-deploy-gh-pages@master
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CUSTOM_DOMAIN: ersatztv.org
+12 -14
View File
@@ -83,16 +83,17 @@ jobs:
echo "GIT_TAG=${tag:1}" >> $GITHUB_ENV
echo "DOCKER_TAG=${tag/-prealpha/}" >> $GITHUB_ENV
- name: Set up Docker Buildx
- name: Set up Docker Buildx Base
uses: docker/setup-buildx-action@v1
id: builder-base
- 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: Set up Docker Buildx NVIDIA
uses: docker/setup-buildx-action@v1
id: builder-nvidia
- name: Set up Docker Buildx VAAPI
uses: docker/setup-buildx-action@v1
id: builder-vaapi
- name: Login to DockerHub
uses: docker/login-action@v1
@@ -103,6 +104,7 @@ jobs:
- name: Build and push base
uses: docker/build-push-action@v2
with:
builder: ${{ steps.builder-base.outputs.name }}
context: .
file: ./docker/Dockerfile
push: true
@@ -111,12 +113,11 @@ jobs:
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:
builder: ${{ steps.builder-nvidia.outputs.name }}
context: .
file: ./docker/nvidia/Dockerfile
push: true
@@ -125,12 +126,11 @@ jobs:
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:
builder: ${{ steps.builder-vaapi.outputs.name }}
context: .
file: ./docker/vaapi/Dockerfile
push: true
@@ -139,5 +139,3 @@ jobs:
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
+3
View File
@@ -40,3 +40,6 @@ msbuild.wrn
core
scripts/generate-api-sdk/swagger.json
docker-compose.override.yml
@@ -8,5 +8,6 @@ namespace ErsatzTV.Application.Channels
string Name,
int FFmpegProfileId,
string Logo,
string PreferredLanguageCode,
StreamingMode StreamingMode);
}
@@ -11,5 +11,6 @@ namespace ErsatzTV.Application.Channels.Commands
string Number,
int FFmpegProfileId,
string Logo,
string PreferredLanguageCode,
StreamingMode StreamingMode) : IRequest<Either<BaseError, ChannelViewModel>>;
}
@@ -1,5 +1,7 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
@@ -9,6 +11,7 @@ using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.Channels.Mapper;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.Channels.Commands
{
@@ -36,9 +39,10 @@ namespace ErsatzTV.Application.Channels.Commands
_channelRepository.Add(c).Map(ProjectToViewModel);
private async Task<Validation<BaseError, Channel>> Validate(CreateChannel request) =>
(ValidateName(request), await ValidateNumber(request), await FFmpegProfileMustExist(request))
(ValidateName(request), await ValidateNumber(request), await FFmpegProfileMustExist(request),
ValidatePreferredLanguage(request))
.Apply(
(name, number, ffmpegProfileId) =>
(name, number, ffmpegProfileId, preferredLanguageCode) =>
{
var artwork = new List<Artwork>();
if (!string.IsNullOrWhiteSpace(request.Logo))
@@ -59,7 +63,8 @@ namespace ErsatzTV.Application.Channels.Commands
Number = number,
FFmpegProfileId = ffmpegProfileId,
StreamingMode = request.StreamingMode,
Artwork = artwork
Artwork = artwork,
PreferredLanguageCode = preferredLanguageCode
};
});
@@ -67,6 +72,13 @@ namespace ErsatzTV.Application.Channels.Commands
createChannel.NotEmpty(c => c.Name)
.Bind(_ => createChannel.NotLongerThan(50)(c => c.Name));
private Validation<BaseError, string> ValidatePreferredLanguage(CreateChannel createChannel) =>
Optional(createChannel.PreferredLanguageCode ?? string.Empty)
.Filter(
lc => string.IsNullOrWhiteSpace(lc) || CultureInfo.GetCultures(CultureTypes.NeutralCultures).Any(
ci => string.Equals(ci.ThreeLetterISOLanguageName, lc, StringComparison.OrdinalIgnoreCase)))
.ToValidation<BaseError>("Preferred language code is invalid");
private async Task<Validation<BaseError, string>> ValidateNumber(CreateChannel createChannel)
{
Option<Channel> maybeExistingChannel = await _channelRepository.GetByNumber(createChannel.Number);
@@ -12,5 +12,6 @@ namespace ErsatzTV.Application.Channels.Commands
string Number,
int FFmpegProfileId,
string Logo,
string PreferredLanguageCode,
StreamingMode StreamingMode) : IRequest<Either<BaseError, ChannelViewModel>>;
}
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
@@ -32,6 +33,7 @@ namespace ErsatzTV.Application.Channels.Commands
c.Name = update.Name;
c.Number = update.Number;
c.FFmpegProfileId = update.FFmpegProfileId;
c.PreferredLanguageCode = update.PreferredLanguageCode;
if (!string.IsNullOrWhiteSpace(update.Logo))
{
@@ -65,8 +67,9 @@ namespace ErsatzTV.Application.Channels.Commands
}
private async Task<Validation<BaseError, Channel>> Validate(UpdateChannel request) =>
(await ChannelMustExist(request), ValidateName(request), await ValidateNumber(request))
.Apply((channelToUpdate, _, _) => channelToUpdate);
(await ChannelMustExist(request), ValidateName(request), await ValidateNumber(request),
ValidatePreferredLanguage(request))
.Apply((channelToUpdate, _, _, _) => channelToUpdate);
private Task<Validation<BaseError, Channel>> ChannelMustExist(UpdateChannel updateChannel) =>
_channelRepository.Get(updateChannel.ChannelId)
@@ -79,7 +82,7 @@ namespace ErsatzTV.Application.Channels.Commands
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);
int matchId = await match.Map(c => c.Id).IfNoneAsync(updateChannel.ChannelId);
if (matchId == updateChannel.ChannelId)
{
if (Regex.IsMatch(updateChannel.Number, Channel.NumberValidator))
@@ -92,5 +95,12 @@ namespace ErsatzTV.Application.Channels.Commands
return BaseError.New("Channel number must be unique");
}
private Validation<BaseError, string> ValidatePreferredLanguage(UpdateChannel updateChannel) =>
Optional(updateChannel.PreferredLanguageCode ?? string.Empty)
.Filter(
lc => string.IsNullOrWhiteSpace(lc) || CultureInfo.GetCultures(CultureTypes.NeutralCultures).Any(
ci => string.Equals(ci.ThreeLetterISOLanguageName, lc, StringComparison.OrdinalIgnoreCase)))
.ToValidation<BaseError>("Preferred language code is invalid");
}
}
+1
View File
@@ -13,6 +13,7 @@ namespace ErsatzTV.Application.Channels
channel.Name,
channel.FFmpegProfileId,
GetLogo(channel),
channel.PreferredLanguageCode,
channel.StreamingMode);
private static string GetLogo(Channel channel) =>
@@ -3,9 +3,9 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.Channels.Mapper;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.Channels.Queries
{
@@ -15,7 +15,7 @@ namespace ErsatzTV.Application.Channels.Queries
public GetAllChannelsHandler(IChannelRepository channelRepository) => _channelRepository = channelRepository;
public Task<List<ChannelViewModel>> Handle(GetAllChannels request, CancellationToken cancellationToken) =>
_channelRepository.GetAll().Map(channels => channels.Map(ProjectToViewModel).ToList());
public async Task<List<ChannelViewModel>> Handle(GetAllChannels request, CancellationToken cancellationToken) =>
Optional(await _channelRepository.GetAll()).Flatten().Map(ProjectToViewModel).ToList();
}
}
@@ -2,11 +2,20 @@
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
<NoWarn>VSTHRD200</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AsyncFixer" Version="1.5.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="MediatR" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="5.0.0" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="16.9.60">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
<PackageReference Include="Winista.MimeDetect" Version="1.0.1" />
</ItemGroup>
@@ -11,17 +11,16 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
bool Transcode,
HardwareAccelerationKind HardwareAcceleration,
int ResolutionId,
bool NormalizeResolution,
bool NormalizeVideo,
string VideoCodec,
bool NormalizeVideoCodec,
int VideoBitrate,
int VideoBufferSize,
string AudioCodec,
bool NormalizeAudioCodec,
int AudioBitrate,
int AudioBufferSize,
int AudioVolume,
bool NormalizeLoudness,
int AudioChannels,
int AudioSampleRate,
bool NormalizeAudio) : IRequest<Either<BaseError, FFmpegProfileViewModel>>;
bool NormalizeAudio,
string FrameRate) : IRequest<Either<BaseError, FFmpegProfileViewModel>>;
}
@@ -43,19 +43,18 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
Transcode = request.Transcode,
HardwareAcceleration = request.HardwareAcceleration,
ResolutionId = resolutionId,
NormalizeResolution = request.NormalizeResolution,
NormalizeVideo = request.NormalizeVideo,
VideoCodec = request.VideoCodec,
NormalizeVideoCodec = request.NormalizeVideoCodec,
VideoBitrate = request.VideoBitrate,
VideoBufferSize = request.VideoBufferSize,
AudioCodec = request.AudioCodec,
NormalizeAudioCodec = request.NormalizeAudioCodec,
AudioBitrate = request.AudioBitrate,
AudioBufferSize = request.AudioBufferSize,
AudioVolume = request.AudioVolume,
NormalizeLoudness = request.NormalizeLoudness,
AudioChannels = request.AudioChannels,
AudioSampleRate = request.AudioSampleRate,
NormalizeAudio = request.NormalizeAudio
NormalizeAudio = request.NormalizeAudio,
FrameRate = request.FrameRate
});
private Validation<BaseError, string> ValidateName(CreateFFmpegProfile createFFmpegProfile) =>
@@ -63,7 +62,7 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
.Bind(_ => createFFmpegProfile.NotLongerThan(50)(x => x.Name));
private Validation<BaseError, int> ValidateThreadCount(CreateFFmpegProfile createFFmpegProfile) =>
createFFmpegProfile.AtLeast(1)(p => p.ThreadCount);
createFFmpegProfile.AtLeast(0)(p => p.ThreadCount);
private async Task<Validation<BaseError, int>> ResolutionMustExist(CreateFFmpegProfile createFFmpegProfile) =>
(await _resolutionRepository.Get(createFFmpegProfile.ResolutionId))
@@ -12,17 +12,16 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
bool Transcode,
HardwareAccelerationKind HardwareAcceleration,
int ResolutionId,
bool NormalizeResolution,
bool NormalizeVideo,
string VideoCodec,
bool NormalizeVideoCodec,
int VideoBitrate,
int VideoBufferSize,
string AudioCodec,
bool NormalizeAudioCodec,
int AudioBitrate,
int AudioBufferSize,
int AudioVolume,
bool NormalizeLoudness,
int AudioChannels,
int AudioSampleRate,
bool NormalizeAudio) : IRequest<Either<BaseError, FFmpegProfileViewModel>>;
bool NormalizeAudio,
string FrameRate) : IRequest<Either<BaseError, FFmpegProfileViewModel>>;
}
@@ -37,19 +37,18 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
p.Transcode = update.Transcode;
p.HardwareAcceleration = update.HardwareAcceleration;
p.ResolutionId = update.ResolutionId;
p.NormalizeResolution = update.NormalizeResolution;
p.NormalizeVideo = update.NormalizeVideo;
p.VideoCodec = update.VideoCodec;
p.NormalizeVideoCodec = update.NormalizeVideoCodec;
p.VideoBitrate = update.VideoBitrate;
p.VideoBufferSize = update.VideoBufferSize;
p.AudioCodec = update.AudioCodec;
p.NormalizeAudioCodec = update.NormalizeAudioCodec;
p.AudioBitrate = update.AudioBitrate;
p.AudioBufferSize = update.AudioBufferSize;
p.AudioVolume = update.AudioVolume;
p.NormalizeLoudness = update.NormalizeLoudness;
p.AudioChannels = update.AudioChannels;
p.AudioSampleRate = update.AudioSampleRate;
p.NormalizeAudio = update.NormalizeAudio;
p.FrameRate = update.FrameRate;
await _ffmpegProfileRepository.Update(p);
return ProjectToViewModel(p);
}
@@ -69,7 +68,7 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
.Bind(_ => updateFFmpegProfile.NotLongerThan(50)(x => x.Name));
private Validation<BaseError, int> ValidateThreadCount(UpdateFFmpegProfile updateFFmpegProfile) =>
updateFFmpegProfile.AtLeast(1)(p => p.ThreadCount);
updateFFmpegProfile.AtLeast(0)(p => p.ThreadCount);
private async Task<Validation<BaseError, int>> ResolutionMustExist(UpdateFFmpegProfile updateFFmpegProfile) =>
(await _resolutionRepository.Get(updateFFmpegProfile.ResolutionId))
@@ -71,70 +71,32 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
private async Task<Unit> ApplyUpdate(UpdateFFmpegSettings request)
{
await _configElementRepository.Get(ConfigElementKey.FFmpegPath).Match(
ce =>
{
ce.Value = request.Settings.FFmpegPath;
_configElementRepository.Update(ce);
},
() =>
{
var ce = new ConfigElement
{ Key = ConfigElementKey.FFmpegPath.Key, Value = request.Settings.FFmpegPath };
_configElementRepository.Add(ce);
});
await _configElementRepository.Get(ConfigElementKey.FFprobePath).Match(
ce =>
{
ce.Value = request.Settings.FFprobePath;
_configElementRepository.Update(ce);
},
() =>
{
var ce = new ConfigElement
{ Key = ConfigElementKey.FFprobePath.Key, Value = request.Settings.FFprobePath };
_configElementRepository.Add(ce);
});
await _configElementRepository.Get(ConfigElementKey.FFmpegDefaultProfileId).Match(
ce =>
{
ce.Value = request.Settings.DefaultFFmpegProfileId.ToString();
_configElementRepository.Update(ce);
},
() =>
{
var ce = new ConfigElement
{
Key = ConfigElementKey.FFmpegDefaultProfileId.Key,
Value = request.Settings.DefaultFFmpegProfileId.ToString()
};
_configElementRepository.Add(ce);
});
await _configElementRepository.Get(ConfigElementKey.FFmpegSaveReports).Match(
ce =>
{
ce.Value = request.Settings.SaveReports.ToString();
_configElementRepository.Update(ce);
},
() =>
{
var ce = new ConfigElement
{
Key = ConfigElementKey.FFmpegSaveReports.Key,
Value = request.Settings.SaveReports.ToString()
};
_configElementRepository.Add(ce);
});
await Upsert(ConfigElementKey.FFmpegPath, request.Settings.FFmpegPath);
await Upsert(ConfigElementKey.FFprobePath, request.Settings.FFprobePath);
await Upsert(ConfigElementKey.FFmpegDefaultProfileId, request.Settings.DefaultFFmpegProfileId.ToString());
await Upsert(ConfigElementKey.FFmpegSaveReports, request.Settings.SaveReports.ToString());
if (request.Settings.SaveReports && !Directory.Exists(FileSystemLayout.FFmpegReportsFolder))
{
Directory.CreateDirectory(FileSystemLayout.FFmpegReportsFolder);
}
await Upsert(ConfigElementKey.FFmpegPreferredLanguageCode, request.Settings.PreferredLanguageCode);
return Unit.Default;
}
private Task Upsert(ConfigElementKey key, string value) =>
_configElementRepository.Get(key).Match(
ce =>
{
ce.Value = value;
return _configElementRepository.Update(ce);
},
() =>
{
var ce = new ConfigElement { Key = key.Key, Value = value };
return _configElementRepository.Add(ce);
});
}
}
@@ -10,17 +10,16 @@ namespace ErsatzTV.Application.FFmpegProfiles
bool Transcode,
HardwareAccelerationKind HardwareAcceleration,
ResolutionViewModel Resolution,
bool NormalizeResolution,
bool NormalizeVideo,
string VideoCodec,
bool NormalizeVideoCodec,
int VideoBitrate,
int VideoBufferSize,
string AudioCodec,
bool NormalizeAudioCodec,
int AudioBitrate,
int AudioBufferSize,
int AudioVolume,
bool NormalizeLoudness,
int AudioChannels,
int AudioSampleRate,
bool NormalizeAudio);
bool NormalizeAudio,
string FrameRate);
}
@@ -5,6 +5,7 @@
public string FFmpegPath { get; set; }
public string FFprobePath { get; set; }
public int DefaultFFmpegProfileId { get; set; }
public string PreferredLanguageCode { get; set; }
public bool SaveReports { get; set; }
}
}
@@ -13,19 +13,18 @@ namespace ErsatzTV.Application.FFmpegProfiles
profile.Transcode,
profile.HardwareAcceleration,
Project(profile.Resolution),
profile.NormalizeResolution,
profile.NormalizeVideo,
profile.VideoCodec,
profile.NormalizeVideoCodec,
profile.VideoBitrate,
profile.VideoBufferSize,
profile.AudioCodec,
profile.NormalizeAudioCodec,
profile.AudioBitrate,
profile.AudioBufferSize,
profile.AudioVolume,
profile.NormalizeLoudness,
profile.AudioChannels,
profile.AudioSampleRate,
profile.NormalizeAudio);
profile.NormalizeAudio,
profile.FrameRate);
private static ResolutionViewModel Project(Resolution resolution) =>
new(resolution.Id, resolution.Name, resolution.Width, resolution.Height);
@@ -24,13 +24,16 @@ namespace ErsatzTV.Application.FFmpegProfiles.Queries
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegDefaultProfileId);
Option<bool> saveReports =
await _configElementRepository.GetValue<bool>(ConfigElementKey.FFmpegSaveReports);
Option<string> preferredLanguageCode =
await _configElementRepository.GetValue<string>(ConfigElementKey.FFmpegPreferredLanguageCode);
return new FFmpegSettingsViewModel
{
FFmpegPath = ffmpegPath.IfNone(string.Empty),
FFprobePath = ffprobePath.IfNone(string.Empty),
DefaultFFmpegProfileId = defaultFFmpegProfileId.IfNone(0),
SaveReports = saveReports.IfNone(false)
FFmpegPath = await ffmpegPath.IfNoneAsync(string.Empty),
FFprobePath = await ffprobePath.IfNoneAsync(string.Empty),
DefaultFFmpegProfileId = await defaultFFmpegProfileId.IfNoneAsync(0),
SaveReports = await saveReports.IfNoneAsync(false),
PreferredLanguageCode = await preferredLanguageCode.IfNoneAsync("eng")
};
}
}
@@ -0,0 +1,7 @@
using ErsatzTV.Core;
using LanguageExt;
namespace ErsatzTV.Application.HDHR.Commands
{
public record UpdateHDHRTunerCount(int TunerCount) : MediatR.IRequest<Either<BaseError, Unit>>;
}
@@ -0,0 +1,45 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.HDHR.Commands
{
public class UpdateHDHRTunerCountHandler : MediatR.IRequestHandler<UpdateHDHRTunerCount, Either<BaseError, Unit>>
{
private readonly IConfigElementRepository _configElementRepository;
public UpdateHDHRTunerCountHandler(IConfigElementRepository configElementRepository) =>
_configElementRepository = configElementRepository;
public Task<Either<BaseError, Unit>> Handle(
UpdateHDHRTunerCount request,
CancellationToken cancellationToken) =>
Validate(request)
.MapT(_ => Upsert(ConfigElementKey.HDHRTunerCount, request.TunerCount.ToString()))
.Bind(v => v.ToEitherAsync());
private Task<Validation<BaseError, Unit>> Validate(UpdateHDHRTunerCount request) =>
Optional(request.TunerCount)
.Filter(tc => tc > 0)
.Map(_ => Unit.Default)
.ToValidation<BaseError>("Tuner count must be greater than zero")
.AsTask();
private Task<Unit> Upsert(ConfigElementKey key, string value) =>
_configElementRepository.Get(key).Match(
ce =>
{
ce.Value = value;
return _configElementRepository.Update(ce);
},
() =>
{
var ce = new ConfigElement { Key = key.Key, Value = value };
return _configElementRepository.Add(ce);
}).ToUnit();
}
}
@@ -0,0 +1,6 @@
using MediatR;
namespace ErsatzTV.Application.HDHR.Queries
{
public record GetHDHRTunerCount : IRequest<int>;
}
@@ -0,0 +1,21 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.HDHR.Queries
{
public class GetHDHRTunerCountHandler : IRequestHandler<GetHDHRTunerCount, int>
{
private readonly IConfigElementRepository _configElementRepository;
public GetHDHRTunerCountHandler(IConfigElementRepository configElementRepository) =>
_configElementRepository = configElementRepository;
public Task<int> Handle(GetHDHRTunerCount request, CancellationToken cancellationToken) =>
_configElementRepository.GetValue<int>(ConfigElementKey.HDHRTunerCount)
.Map(result => result.IfNone(2));
}
}
@@ -1,8 +1,10 @@
using System.Threading;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using LanguageExt;
namespace ErsatzTV.Application.Libraries.Commands
@@ -11,9 +13,13 @@ namespace ErsatzTV.Application.Libraries.Commands
DeleteLocalLibraryPathHandler : MediatR.IRequestHandler<DeleteLocalLibraryPath, Either<BaseError, Unit>>
{
private readonly ILibraryRepository _libraryRepository;
private readonly ISearchIndex _searchIndex;
public DeleteLocalLibraryPathHandler(ILibraryRepository libraryRepository) =>
public DeleteLocalLibraryPathHandler(ILibraryRepository libraryRepository, ISearchIndex searchIndex)
{
_libraryRepository = libraryRepository;
_searchIndex = searchIndex;
}
public Task<Either<BaseError, Unit>> Handle(
DeleteLocalLibraryPath request,
@@ -22,8 +28,13 @@ namespace ErsatzTV.Application.Libraries.Commands
.MapT(DoDeletion)
.Bind(t => t.ToEitherAsync());
private Task<Unit> DoDeletion(LibraryPath libraryPath) =>
_libraryRepository.DeleteLocalPath(libraryPath.Id).ToUnit();
private async Task<Unit> DoDeletion(LibraryPath libraryPath)
{
List<int> ids = await _libraryRepository.GetMediaIdsByLocalPath(libraryPath.Id);
await _searchIndex.RemoveItems(ids);
await _libraryRepository.DeleteLocalPath(libraryPath.Id);
return Unit.Default;
}
private async Task<Validation<BaseError, LibraryPath>> MediaSourceMustExist(DeleteLocalLibraryPath request) =>
(await _libraryRepository.GetPath(request.LocalLibraryPathId))
@@ -18,7 +18,11 @@ namespace ErsatzTV.Application.Libraries.Queries
public Task<List<LibraryViewModel>> Handle(GetAllLibraries request, CancellationToken cancellationToken) =>
_libraryRepository.GetAll()
.Map(list => list.Filter(ShouldIncludeLibrary).Map(ProjectToViewModel).ToList());
.Map(
list => list.Filter(ShouldIncludeLibrary)
.OrderBy(l => l.MediaSource is LocalMediaSource ? 0 : 1)
.ThenBy(l => l.MediaKind)
.Map(ProjectToViewModel).ToList());
private static bool ShouldIncludeLibrary(Library library) =>
library switch
@@ -1,12 +1,12 @@
using System;
using Serilog.Events;
namespace ErsatzTV.Application.Logs
{
public record LogEntryViewModel(
int Id,
DateTime Timestamp,
string Level,
LogEventLevel Level,
string Exception,
string RenderedMessage,
string Properties);
string Message);
}
+35 -6
View File
@@ -1,16 +1,45 @@
using ErsatzTV.Core.Domain;
using System;
using System.Collections.Generic;
using ErsatzTV.Core.Domain;
using Newtonsoft.Json.Linq;
using Serilog.Events;
namespace ErsatzTV.Application.Logs
{
internal static class Mapper
{
internal static LogEntryViewModel ProjectToViewModel(LogEntry logEntry) =>
new(
internal static LogEntryViewModel ProjectToViewModel(LogEntry logEntry)
{
string message = logEntry.RenderedMessage;
if (!string.IsNullOrWhiteSpace(logEntry.Properties))
{
foreach (KeyValuePair<string, JToken> property in JObject.Parse(logEntry.Properties))
{
var token = $"{{{property.Key}}}";
if (message.Contains(token))
{
message = message.Replace(token, property.Value.ToString());
}
var destructureToken = $"{{@{property.Key}}}";
if (message.Contains(destructureToken))
{
message = message.Replace(destructureToken, property.Value.ToString());
}
}
}
if (!Enum.TryParse(logEntry.Level, out LogEventLevel level))
{
level = LogEventLevel.Debug;
}
return new LogEntryViewModel(
logEntry.Id,
logEntry.Timestamp,
logEntry.Level,
level,
logEntry.Exception,
logEntry.RenderedMessage,
logEntry.Properties);
message);
}
}
}
@@ -7,7 +7,8 @@ namespace ErsatzTV.Application.MediaCards
List<MovieCardViewModel> MovieCards,
List<TelevisionShowCardViewModel> ShowCards,
List<TelevisionSeasonCardViewModel> SeasonCards,
List<TelevisionEpisodeCardViewModel> EpisodeCards)
List<TelevisionEpisodeCardViewModel> EpisodeCards,
List<MusicVideoCardViewModel> MusicVideoCards)
{
public bool UseCustomPlaybackOrder { get; set; }
}
+10
View File
@@ -52,6 +52,14 @@ namespace ErsatzTV.Application.MediaCards
movieMetadata.SortTitle,
GetPoster(movieMetadata));
internal static MusicVideoCardViewModel ProjectToViewModel(MusicVideoMetadata musicVideoMetadata) =>
new(
musicVideoMetadata.MusicVideoId,
$"{musicVideoMetadata.Title} ({musicVideoMetadata.Artist})",
musicVideoMetadata.Year?.ToString(),
musicVideoMetadata.SortTitle,
GetThumbnail(musicVideoMetadata));
internal static CollectionCardResultsViewModel
ProjectToViewModel(Collection collection) =>
new(
@@ -64,6 +72,8 @@ namespace ErsatzTV.Application.MediaCards
collection.MediaItems.OfType<Show>().Map(s => ProjectToViewModel(s.ShowMetadata.Head())).ToList(),
collection.MediaItems.OfType<Season>().Map(ProjectToViewModel).ToList(),
collection.MediaItems.OfType<Episode>().Map(e => ProjectToViewModel(e.EpisodeMetadata.Head()))
.ToList(),
collection.MediaItems.OfType<MusicVideo>().Map(mv => ProjectToViewModel(mv.MusicVideoMetadata.Head()))
.ToList()) { UseCustomPlaybackOrder = collection.UseCustomPlaybackOrder };
private static int GetCustomIndex(Collection collection, int mediaItemId) =>
@@ -1,6 +1,8 @@
using System.Collections.Generic;
using ErsatzTV.Core.Search;
using LanguageExt;
namespace ErsatzTV.Application.MediaCards
{
public record MovieCardResultsViewModel(int Count, List<MovieCardViewModel> Cards);
public record MovieCardResultsViewModel(int Count, List<MovieCardViewModel> Cards, Option<SearchPageMap> PageMap);
}
@@ -0,0 +1,11 @@
using System.Collections.Generic;
using ErsatzTV.Core.Search;
using LanguageExt;
namespace ErsatzTV.Application.MediaCards
{
public record MusicVideoCardResultsViewModel(
int Count,
List<MusicVideoCardViewModel> Cards,
Option<SearchPageMap> PageMap);
}
@@ -0,0 +1,13 @@
namespace ErsatzTV.Application.MediaCards
{
public record MusicVideoCardViewModel
(int MusicVideoId, string Title, string Subtitle, string SortTitle, string Poster) : MediaCardViewModel(
MusicVideoId,
Title,
Subtitle,
SortTitle,
Poster)
{
public int CustomIndex { get; set; }
}
}
@@ -1,6 +0,0 @@
using MediatR;
namespace ErsatzTV.Application.MediaCards.Queries
{
public record GetMovieCards(int PageNumber, int PageSize) : IRequest<MovieCardResultsViewModel>;
}
@@ -1,30 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.MediaCards.Mapper;
namespace ErsatzTV.Application.MediaCards.Queries
{
public class
GetMovieCardsHandler : IRequestHandler<GetMovieCards, MovieCardResultsViewModel>
{
private readonly IMovieRepository _movieRepository;
public GetMovieCardsHandler(IMovieRepository movieRepository) => _movieRepository = movieRepository;
public async Task<MovieCardResultsViewModel> Handle(GetMovieCards request, CancellationToken cancellationToken)
{
int count = await _movieRepository.GetMovieCount();
List<MovieCardViewModel> results = await _movieRepository
.GetPagedMovies(request.PageNumber, request.PageSize)
.Map(list => list.Map(ProjectToViewModel).ToList());
return new MovieCardResultsViewModel(count, results);
}
}
}
@@ -1,8 +0,0 @@
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.MediaCards.Queries
{
public record GetSearchCards(string Query) : IRequest<Either<BaseError, SearchCardResultsViewModel>>;
}
@@ -1,43 +0,0 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.MediaCards.Mapper;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.MediaCards.Queries
{
public class GetSearchCardsHandler : IRequestHandler<GetSearchCards, Either<BaseError, SearchCardResultsViewModel>>
{
private readonly ISearchRepository _searchRepository;
public GetSearchCardsHandler(ISearchRepository searchRepository) => _searchRepository = searchRepository;
public Task<Either<BaseError, SearchCardResultsViewModel>> Handle(
GetSearchCards request,
CancellationToken cancellationToken) =>
request.Query.Split(":").Head() switch
{
"genre" => GenreSearch(request.Query.Replace("genre:", string.Empty)),
"tag" => TagSearch(request.Query.Replace("tag:", string.Empty)),
_ => TitleSearch(request.Query)
};
private Task<Either<BaseError, SearchCardResultsViewModel>> TitleSearch(string query) =>
Try(_searchRepository.SearchMediaItemsByTitle(query)).Sequence()
.Map(ProjectToSearchResults)
.Map(t => t.ToEither(ex => BaseError.New($"Failed to search: {ex.Message}")));
private Task<Either<BaseError, SearchCardResultsViewModel>> GenreSearch(string query) =>
Try(_searchRepository.SearchMediaItemsByGenre(query)).Sequence()
.Map(ProjectToSearchResults)
.Map(t => t.ToEither(ex => BaseError.New($"Failed to search: {ex.Message}")));
private Task<Either<BaseError, SearchCardResultsViewModel>> TagSearch(string query) =>
Try(_searchRepository.SearchMediaItemsByTag(query)).Sequence()
.Map(ProjectToSearchResults)
.Map(t => t.ToEither(ex => BaseError.New($"Failed to search: {ex.Message}")));
}
}
@@ -1,6 +0,0 @@
using MediatR;
namespace ErsatzTV.Application.MediaCards.Queries
{
public record GetTelevisionShowCards(int PageNumber, int PageSize) : IRequest<TelevisionShowCardResultsViewModel>;
}
@@ -1,33 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.MediaCards.Mapper;
namespace ErsatzTV.Application.MediaCards.Queries
{
public class
GetTelevisionShowCardsHandler : IRequestHandler<GetTelevisionShowCards, TelevisionShowCardResultsViewModel>
{
private readonly ITelevisionRepository _televisionRepository;
public GetTelevisionShowCardsHandler(ITelevisionRepository televisionRepository) =>
_televisionRepository = televisionRepository;
public async Task<TelevisionShowCardResultsViewModel> Handle(
GetTelevisionShowCards request,
CancellationToken cancellationToken)
{
int count = await _televisionRepository.GetShowCount();
List<TelevisionShowCardViewModel> results = await _televisionRepository
.GetPagedShows(request.PageNumber, request.PageSize)
.Map(list => list.Map(ProjectToViewModel).ToList());
return new TelevisionShowCardResultsViewModel(count, results);
}
}
}
@@ -1,6 +1,11 @@
using System.Collections.Generic;
using ErsatzTV.Core.Search;
using LanguageExt;
namespace ErsatzTV.Application.MediaCards
{
public record TelevisionShowCardResultsViewModel(int Count, List<TelevisionShowCardViewModel> Cards);
public record TelevisionShowCardResultsViewModel(
int Count,
List<TelevisionShowCardViewModel> Cards,
Option<SearchPageMap> PageMap);
}
@@ -5,5 +5,9 @@ using LanguageExt;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record AddItemsToCollection
(int CollectionId, List<int> MovieIds, List<int> ShowIds) : MediatR.IRequest<Either<BaseError, Unit>>;
(
int CollectionId,
List<int> MovieIds,
List<int> ShowIds,
List<int> MusicVideoIds) : MediatR.IRequest<Either<BaseError, Unit>>;
}
@@ -41,7 +41,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
{
if (await _mediaCollectionRepository.AddMediaItems(
request.CollectionId,
request.MovieIds.Append(request.ShowIds).ToList()))
request.MovieIds.Append(request.ShowIds).Append(request.MusicVideoIds).ToList()))
{
// rebuild all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
@@ -0,0 +1,8 @@
using ErsatzTV.Core;
using LanguageExt;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record AddMusicVideoToCollection
(int CollectionId, int MusicVideoId) : MediatR.IRequest<Either<BaseError, Unit>>;
}
@@ -0,0 +1,68 @@
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;
using LanguageExt;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public class
AddMusicVideoToCollectionHandler : MediatR.IRequestHandler<AddMusicVideoToCollection, Either<BaseError, Unit>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
private readonly IMusicVideoRepository _musicVideoRepository;
public AddMusicVideoToCollectionHandler(
IMediaCollectionRepository mediaCollectionRepository,
IMusicVideoRepository musicVideoRepository,
ChannelWriter<IBackgroundServiceRequest> channel)
{
_mediaCollectionRepository = mediaCollectionRepository;
_musicVideoRepository = musicVideoRepository;
_channel = channel;
}
public Task<Either<BaseError, Unit>> Handle(
AddMusicVideoToCollection request,
CancellationToken cancellationToken) =>
Validate(request)
.MapT(_ => ApplyAddMusicVideoRequest(request))
.Bind(v => v.ToEitherAsync());
private async Task<Unit> ApplyAddMusicVideoRequest(AddMusicVideoToCollection request)
{
if (await _mediaCollectionRepository.AddMediaItem(request.CollectionId, request.MusicVideoId))
{
// 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(AddMusicVideoToCollection request) =>
(await CollectionMustExist(request), await ValidateMusicVideo(request))
.Apply((_, _) => Unit.Default);
private Task<Validation<BaseError, Unit>> CollectionMustExist(AddMusicVideoToCollection request) =>
_mediaCollectionRepository.GetCollectionWithItems(request.CollectionId)
.MapT(_ => Unit.Default)
.Map(v => v.ToValidation<BaseError>("Collection does not exist."));
private Task<Validation<BaseError, Unit>> ValidateMusicVideo(AddMusicVideoToCollection request) =>
LoadMusicVideo(request)
.MapT(_ => Unit.Default)
.Map(v => v.ToValidation<BaseError>("Music video does not exist"));
private Task<Option<MusicVideo>> LoadMusicVideo(AddMusicVideoToCollection request) =>
_musicVideoRepository.GetMusicVideo(request.MusicVideoId);
}
}
@@ -39,7 +39,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
Option<CollectionItem> maybeCollectionItem =
c.CollectionItems.FirstOrDefault(ci => ci.MediaItemId == updateItem.MediaItemId);
maybeCollectionItem.IfSome(ci => ci.CustomIndex = updateItem.CustomIndex);
await maybeCollectionItem.IfSomeAsync(ci => ci.CustomIndex = updateItem.CustomIndex);
}
if (await _mediaCollectionRepository.Update(c))
@@ -32,7 +32,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
private async Task<Unit> ApplyUpdateRequest(Collection c, UpdateCollection request)
{
c.Name = request.Name;
request.UseCustomPlaybackOrder.IfSome(
await request.UseCustomPlaybackOrder.IfSomeAsync(
useCustomPlaybackOrder => c.UseCustomPlaybackOrder = useCustomPlaybackOrder);
if (await _mediaCollectionRepository.Update(c) && request.UseCustomPlaybackOrder.IsSome)
{
+1 -55
View File
@@ -1,5 +1,4 @@
using System;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaItems
{
@@ -8,59 +7,6 @@ namespace ErsatzTV.Application.MediaItems
internal static MediaItemViewModel ProjectToViewModel(MediaItem mediaItem) =>
new(mediaItem.Id, mediaItem.LibraryPathId);
internal static MediaItemSearchResultViewModel ProjectToSearchViewModel(MediaItem mediaItem) =>
mediaItem switch
{
Episode e => ProjectToSearchViewModel(e),
Movie m => ProjectToSearchViewModel(m),
_ => throw new ArgumentOutOfRangeException()
};
private static MediaItemSearchResultViewModel ProjectToSearchViewModel(Episode mediaItem) =>
new(
mediaItem.Id,
GetLibraryName(mediaItem),
"TV Show",
GetDisplayTitle(mediaItem),
GetDisplayDuration(mediaItem));
private static MediaItemSearchResultViewModel ProjectToSearchViewModel(Movie mediaItem) =>
new(
mediaItem.Id,
GetLibraryName(mediaItem),
"Movie",
GetDisplayTitle(mediaItem),
GetDisplayDuration(mediaItem));
private static string GetDisplayTitle(MediaItem mediaItem) =>
mediaItem switch
{
Episode e => e.EpisodeMetadata.HeadOrNone()
.Map(em => $"{em.Title} - s{e.Season.SeasonNumber:00}e{e.EpisodeNumber:00}")
.IfNone("[unknown episode]"),
Movie m => m.MovieMetadata.HeadOrNone().Map(mm => mm.Title).IfNone("[unknown movie]"),
_ => string.Empty
};
private static string GetDisplayDuration(MediaItem mediaItem)
{
MediaVersion version = mediaItem switch
{
Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem))
};
return string.Format(
version.Duration.TotalHours >= 1 ? @"{0:h\:mm\:ss}" : @"{0:mm\:ss}",
version.Duration);
}
// TODO: fix this when search is reimplemented
private static string GetLibraryName(MediaItem item) =>
"Library Name";
public static NamedMediaItemViewModel ProjectToViewModel(Show show) =>
new(show.Id, show.ShowMetadata.HeadOrNone().Map(sm => $"{sm?.Title} ({sm?.Year})").IfNone("???"));
@@ -0,0 +1,8 @@
using System.Collections.Generic;
using System.Globalization;
using MediatR;
namespace ErsatzTV.Application.MediaItems.Queries
{
public record GetAllLanguageCodes : IRequest<List<CultureInfo>>;
}
@@ -0,0 +1,35 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.MediaItems.Queries
{
public class GetAllLanguageCodesHandler : IRequestHandler<GetAllLanguageCodes, List<CultureInfo>>
{
private readonly IMediaItemRepository _mediaItemRepository;
public GetAllLanguageCodesHandler(IMediaItemRepository mediaItemRepository) =>
_mediaItemRepository = mediaItemRepository;
public async Task<List<CultureInfo>> Handle(GetAllLanguageCodes request, CancellationToken cancellationToken)
{
var result = new List<CultureInfo>();
CultureInfo[] allCultures = CultureInfo.GetCultures(CultureTypes.AllCultures);
List<string> allLanguageCodes = await _mediaItemRepository.GetAllLanguageCodes();
foreach (string code in allLanguageCodes)
{
Option<CultureInfo> maybeCulture = allCultures.Find(
ci => string.Equals(code, ci.ThreeLetterISOLanguageName, StringComparison.OrdinalIgnoreCase));
await maybeCulture.IfSomeAsync(cultureInfo => result.Add(cultureInfo));
}
return result;
}
}
}
@@ -1,7 +0,0 @@
using System.Collections.Generic;
using MediatR;
namespace ErsatzTV.Application.MediaItems.Queries
{
public record SearchAllMediaItems(string SearchString) : IRequest<List<MediaItemSearchResultViewModel>>;
}
@@ -1,23 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.MediaItems.Mapper;
namespace ErsatzTV.Application.MediaItems.Queries
{
public class SearchAllMediaItemsHandler : IRequestHandler<SearchAllMediaItems, List<MediaItemSearchResultViewModel>>
{
private readonly IMediaItemRepository _mediaItemRepository;
public SearchAllMediaItemsHandler(IMediaItemRepository mediaItemRepository) =>
_mediaItemRepository = mediaItemRepository;
public Task<List<MediaItemSearchResultViewModel>>
Handle(SearchAllMediaItems request, CancellationToken cancellationToken) =>
_mediaItemRepository.Search(request.SearchString).Map(list => list.Map(ProjectToSearchViewModel).ToList());
}
}
@@ -1,4 +1,5 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
@@ -7,6 +8,7 @@ using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Metadata;
using LanguageExt;
using MediatR;
using Microsoft.Extensions.Logging;
@@ -21,7 +23,9 @@ namespace ErsatzTV.Application.MediaSources.Commands
private readonly IEntityLocker _entityLocker;
private readonly ILibraryRepository _libraryRepository;
private readonly ILogger<ScanLocalLibraryHandler> _logger;
private readonly IMediator _mediator;
private readonly IMovieFolderScanner _movieFolderScanner;
private readonly IMusicVideoFolderScanner _musicVideoFolderScanner;
private readonly ITelevisionFolderScanner _televisionFolderScanner;
public ScanLocalLibraryHandler(
@@ -29,14 +33,18 @@ namespace ErsatzTV.Application.MediaSources.Commands
IConfigElementRepository configElementRepository,
IMovieFolderScanner movieFolderScanner,
ITelevisionFolderScanner televisionFolderScanner,
IMusicVideoFolderScanner musicVideoFolderScanner,
IEntityLocker entityLocker,
IMediator mediator,
ILogger<ScanLocalLibraryHandler> logger)
{
_libraryRepository = libraryRepository;
_configElementRepository = configElementRepository;
_movieFolderScanner = movieFolderScanner;
_televisionFolderScanner = televisionFolderScanner;
_musicVideoFolderScanner = musicVideoFolderScanner;
_entityLocker = entityLocker;
_mediator = mediator;
_logger = logger;
}
@@ -58,32 +66,62 @@ namespace ErsatzTV.Application.MediaSources.Commands
{
(LocalLibrary localLibrary, string ffprobePath, bool forceScan) = parameters;
var lastScan = new DateTimeOffset(localLibrary.LastScan ?? DateTime.MinValue, TimeSpan.Zero);
if (forceScan || lastScan < DateTimeOffset.Now - TimeSpan.FromHours(6))
var sw = new Stopwatch();
sw.Start();
for (var i = 0; i < localLibrary.Paths.Count; i++)
{
foreach (LibraryPath libraryPath in localLibrary.Paths)
LibraryPath libraryPath = localLibrary.Paths[i];
decimal progressMin = (decimal) i / localLibrary.Paths.Count;
decimal progressMax = (decimal) (i + 1) / localLibrary.Paths.Count;
var lastScan = new DateTimeOffset(libraryPath.LastScan ?? DateTime.MinValue, TimeSpan.Zero);
if (forceScan || lastScan < DateTimeOffset.Now - TimeSpan.FromHours(6))
{
switch (localLibrary.MediaKind)
{
case LibraryMediaKind.Movies:
await _movieFolderScanner.ScanFolder(libraryPath, ffprobePath);
await _movieFolderScanner.ScanFolder(
libraryPath,
ffprobePath,
lastScan,
progressMin,
progressMax);
break;
case LibraryMediaKind.Shows:
await _televisionFolderScanner.ScanFolder(libraryPath, ffprobePath);
await _televisionFolderScanner.ScanFolder(
libraryPath,
ffprobePath,
lastScan,
progressMin,
progressMax);
break;
case LibraryMediaKind.MusicVideos:
await _musicVideoFolderScanner.ScanFolder(
libraryPath,
ffprobePath,
lastScan,
progressMin,
progressMax);
break;
}
libraryPath.LastScan = DateTime.UtcNow;
await _libraryRepository.UpdateLastScan(libraryPath);
}
localLibrary.LastScan = DateTime.UtcNow;
await _libraryRepository.UpdateLastScan(localLibrary);
}
else
{
_logger.LogDebug(
"Skipping unforced scan of library {Name}",
localLibrary.Name);
await _mediator.Publish(new LibraryScanProgress(libraryPath.LibraryId, progressMax));
}
sw.Stop();
_logger.LogDebug(
"Scan of library {Name} completed in {Duration}",
localLibrary.Name,
TimeSpan.FromMilliseconds(sw.ElapsedMilliseconds));
await _mediator.Publish(new LibraryScanProgress(localLibrary.Id, 0));
_entityLocker.UnlockLibrary(localLibrary.Id);
return Unit.Default;
}
+2 -1
View File
@@ -16,7 +16,8 @@ namespace ErsatzTV.Application.Movies
Artwork(metadata, ArtworkKind.Poster),
Artwork(metadata, ArtworkKind.FanArt),
metadata.Genres.Map(g => g.Name).ToList(),
metadata.Tags.Map(t => t.Name).ToList());
metadata.Tags.Map(t => t.Name).ToList(),
metadata.Studios.Map(s => s.Name).ToList());
}
private static string Artwork(Metadata metadata, ArtworkKind artworkKind) =>
@@ -9,5 +9,6 @@ namespace ErsatzTV.Application.Movies
string Poster,
string FanArt,
List<string> Genres,
List<string> Tags);
List<string> Tags,
List<string> Studios);
}
+19 -8
View File
@@ -24,15 +24,25 @@ namespace ErsatzTV.Application.Playouts
private static PlayoutProgramScheduleViewModel Project(ProgramSchedule programSchedule) =>
new(programSchedule.Id, programSchedule.Name);
private static string GetDisplayTitle(MediaItem mediaItem) =>
mediaItem switch
private static string GetDisplayTitle(MediaItem mediaItem)
{
switch (mediaItem)
{
Episode e => e.EpisodeMetadata.HeadOrNone()
.Map(em => $"{em.Title} - s{e.Season.SeasonNumber:00}e{e.EpisodeNumber:00}")
.IfNone("[unknown episode]"),
Movie m => m.MovieMetadata.HeadOrNone().Map(mm => mm.Title).IfNone("[unknown movie]"),
_ => string.Empty
};
case Episode e:
string showTitle = e.Season.Show.ShowMetadata.HeadOrNone()
.Map(sm => $"{sm.Title} - ").IfNone(string.Empty);
return e.EpisodeMetadata.HeadOrNone()
.Map(em => $"{showTitle}s{e.Season.SeasonNumber:00}e{e.EpisodeNumber:00} - {em.Title}")
.IfNone("[unknown episode]");
case Movie m:
return m.MovieMetadata.HeadOrNone().Map(mm => mm.Title).IfNone("[unknown movie]");
case MusicVideo mv:
return mv.MusicVideoMetadata.HeadOrNone().Map(mvm => $"{mvm.Artist} - {mvm.Title}")
.IfNone("[unknown music video]");
default:
return string.Empty;
}
}
private static string GetDisplayDuration(MediaItem mediaItem)
{
@@ -40,6 +50,7 @@ namespace ErsatzTV.Application.Playouts
{
Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(),
MusicVideo mv => mv.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem))
};
@@ -1,9 +1,11 @@
using System.Threading;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using LanguageExt;
namespace ErsatzTV.Application.Plex.Commands
@@ -13,20 +15,24 @@ namespace ErsatzTV.Application.Plex.Commands
private readonly IEntityLocker _entityLocker;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IPlexSecretStore _plexSecretStore;
private readonly ISearchIndex _searchIndex;
public SignOutOfPlexHandler(
IMediaSourceRepository mediaSourceRepository,
IPlexSecretStore plexSecretStore,
IEntityLocker entityLocker)
IEntityLocker entityLocker,
ISearchIndex searchIndex)
{
_mediaSourceRepository = mediaSourceRepository;
_plexSecretStore = plexSecretStore;
_entityLocker = entityLocker;
_searchIndex = searchIndex;
}
public async Task<Either<BaseError, Unit>> Handle(SignOutOfPlex request, CancellationToken cancellationToken)
{
await _mediaSourceRepository.DeleteAllPlex();
List<int> ids = await _mediaSourceRepository.DeleteAllPlex();
await _searchIndex.RemoveItems(ids);
await _plexSecretStore.DeleteAll();
_entityLocker.UnlockPlex();
@@ -78,10 +78,10 @@ namespace ErsatzTV.Application.Plex.Commands
var existing = connectionParameters.PlexMediaSource.Libraries.OfType<PlexLibrary>().ToList();
var toAdd = libraries.Filter(library => existing.All(l => l.Key != library.Key)).ToList();
var toRemove = existing.Filter(library => libraries.All(l => l.Key != library.Key)).ToList();
connectionParameters.PlexMediaSource.Libraries.AddRange(toAdd);
toRemove.ForEach(c => connectionParameters.PlexMediaSource.Libraries.Remove(c));
return _mediaSourceRepository.Update(connectionParameters.PlexMediaSource);
return _mediaSourceRepository.UpdateLibraries(
connectionParameters.PlexMediaSource.Id,
toAdd,
toRemove);
},
error =>
{
@@ -4,7 +4,7 @@ using MediatR;
namespace ErsatzTV.Application.Plex.Commands
{
public interface ISynchronizePlexLibraryById : IRequest<Either<BaseError, string>>, IBackgroundServiceRequest
public interface ISynchronizePlexLibraryById : IRequest<Either<BaseError, string>>, IPlexBackgroundServiceRequest
{
int PlexLibraryId { get; }
bool ForceScan { get; }
@@ -20,6 +20,7 @@ namespace ErsatzTV.Application.Plex.Commands
IRequestHandler<SynchronizePlexLibraryByIdIfNeeded, Either<BaseError, string>>
{
private readonly IEntityLocker _entityLocker;
private readonly ILibraryRepository _libraryRepository;
private readonly ILogger<SynchronizePlexLibraryByIdHandler> _logger;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IPlexMovieLibraryScanner _plexMovieLibraryScanner;
@@ -31,6 +32,7 @@ namespace ErsatzTV.Application.Plex.Commands
IPlexSecretStore plexSecretStore,
IPlexMovieLibraryScanner plexMovieLibraryScanner,
IPlexTelevisionLibraryScanner plexTelevisionLibraryScanner,
ILibraryRepository libraryRepository,
IEntityLocker entityLocker,
ILogger<SynchronizePlexLibraryByIdHandler> logger)
{
@@ -38,6 +40,7 @@ namespace ErsatzTV.Application.Plex.Commands
_plexSecretStore = plexSecretStore;
_plexMovieLibraryScanner = plexMovieLibraryScanner;
_plexTelevisionLibraryScanner = plexTelevisionLibraryScanner;
_libraryRepository = libraryRepository;
_entityLocker = entityLocker;
_logger = logger;
}
@@ -78,7 +81,7 @@ namespace ErsatzTV.Application.Plex.Commands
}
parameters.Library.LastScan = DateTime.UtcNow;
await _mediaSourceRepository.Update(parameters.Library);
await _libraryRepository.UpdateLastScan(parameters.Library);
}
else
{
@@ -57,43 +57,32 @@ namespace ErsatzTV.Application.Plex.Commands
return allExisting;
}
private async Task SynchronizeServer(List<PlexMediaSource> allExisting, PlexMediaSource server)
private Task SynchronizeServer(List<PlexMediaSource> allExisting, PlexMediaSource server)
{
Option<PlexMediaSource> maybeExisting =
allExisting.Find(s => s.ClientIdentifier == server.ClientIdentifier);
await maybeExisting.Match(
return maybeExisting.Match(
existing =>
{
existing.Platform = server.Platform;
existing.PlatformVersion = server.PlatformVersion;
existing.ProductVersion = server.ProductVersion;
existing.ServerName = server.ServerName;
MergeConnections(existing.Connections, server.Connections);
if (existing.Connections.Any() && existing.Connections.All(c => !c.IsActive))
{
existing.Connections.Head().IsActive = true;
}
return _mediaSourceRepository.Update(existing);
var toAdd = server.Connections
.Filter(connection => existing.Connections.All(c => c.Uri != connection.Uri)).ToList();
var toRemove = existing.Connections
.Filter(connection => server.Connections.All(c => c.Uri != connection.Uri)).ToList();
return _mediaSourceRepository.Update(existing, toAdd, toRemove);
},
async () =>
{
await _mediaSourceRepository.Add(server);
if (server.Connections.Any())
{
server.Connections.Head().IsActive = true;
}
await _mediaSourceRepository.Update(server);
await _mediaSourceRepository.Add(server);
});
}
private void MergeConnections(
List<PlexConnection> existing,
List<PlexConnection> incoming)
{
var toAdd = incoming.Filter(connection => existing.All(c => c.Uri != connection.Uri)).ToList();
var toRemove = existing.Filter(connection => incoming.All(c => c.Uri != connection.Uri)).ToList();
existing.AddRange(toAdd);
toRemove.ForEach(c => existing.Remove(c));
}
}
}
@@ -4,6 +4,7 @@ using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using LanguageExt;
namespace ErsatzTV.Application.Plex.Commands
@@ -13,16 +14,23 @@ namespace ErsatzTV.Application.Plex.Commands
Either<BaseError, Unit>>
{
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly ISearchIndex _searchIndex;
public UpdatePlexLibraryPreferencesHandler(IMediaSourceRepository mediaSourceRepository) =>
public UpdatePlexLibraryPreferencesHandler(
IMediaSourceRepository mediaSourceRepository,
ISearchIndex searchIndex)
{
_mediaSourceRepository = mediaSourceRepository;
_searchIndex = searchIndex;
}
public async Task<Either<BaseError, Unit>> Handle(
UpdatePlexLibraryPreferences request,
CancellationToken cancellationToken)
{
var toDisable = request.Preferences.Filter(p => p.ShouldSyncItems == false).Map(p => p.Id).ToList();
await _mediaSourceRepository.DisablePlexLibrarySync(toDisable);
List<int> ids = await _mediaSourceRepository.DisablePlexLibrarySync(toDisable);
await _searchIndex.RemoveItems(ids);
IEnumerable<int> toEnable = request.Preferences.Filter(p => p.ShouldSyncItems).Map(p => p.Id);
await _mediaSourceRepository.EnablePlexLibrarySync(toEnable);
@@ -6,7 +6,6 @@ using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.Plex.Commands
{
@@ -35,20 +34,7 @@ namespace ErsatzTV.Application.Plex.Commands
var toRemove = plexMediaSource.PathReplacements.Filter(r => incoming.All(pr => pr.Id != r.Id)).ToList();
var toUpdate = incoming.Except(toAdd).ToList();
plexMediaSource.PathReplacements.AddRange(toAdd);
toRemove.ForEach(pr => plexMediaSource.PathReplacements.Remove(pr));
foreach (PlexPathReplacement pathReplacement in toUpdate)
{
Optional(plexMediaSource.PathReplacements.SingleOrDefault(pr => pr.Id == pathReplacement.Id))
.IfSome(
pr =>
{
pr.PlexPath = pathReplacement.PlexPath;
pr.LocalPath = pathReplacement.LocalPath;
});
}
return _mediaSourceRepository.Update(plexMediaSource).ToUnit();
return _mediaSourceRepository.UpdatePathReplacements(plexMediaSource.Id, toAdd, toUpdate, toRemove);
}
private static PlexPathReplacement Project(PlexPathReplacementItem vm) =>
@@ -16,5 +16,6 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
int? MediaItemId,
int? MultipleCount,
TimeSpan? PlayoutDuration,
bool? OfflineTail) : IRequest<Either<BaseError, ProgramScheduleItemViewModel>>, IProgramScheduleItemRequest;
bool? OfflineTail,
string CustomTitle) : IRequest<Either<BaseError, ProgramScheduleItemViewModel>>, IProgramScheduleItemRequest;
}
@@ -13,5 +13,6 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
int? MultipleCount { get; }
TimeSpan? PlayoutDuration { get; }
bool? OfflineTail { get; }
string CustomTitle { get; }
}
}
@@ -100,7 +100,8 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
StartTime = item.StartTime,
CollectionType = item.CollectionType,
CollectionId = item.CollectionId,
MediaItemId = item.MediaItemId
MediaItemId = item.MediaItemId,
CustomTitle = item.CustomTitle
},
PlayoutMode.One => new ProgramScheduleItemOne
{
@@ -109,7 +110,8 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
StartTime = item.StartTime,
CollectionType = item.CollectionType,
CollectionId = item.CollectionId,
MediaItemId = item.MediaItemId
MediaItemId = item.MediaItemId,
CustomTitle = item.CustomTitle
},
PlayoutMode.Multiple => new ProgramScheduleItemMultiple
{
@@ -119,7 +121,8 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
CollectionType = item.CollectionType,
CollectionId = item.CollectionId,
MediaItemId = item.MediaItemId,
Count = item.MultipleCount.GetValueOrDefault()
Count = item.MultipleCount.GetValueOrDefault(),
CustomTitle = item.CustomTitle
},
PlayoutMode.Duration => new ProgramScheduleItemDuration
{
@@ -130,7 +133,8 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
CollectionId = item.CollectionId,
MediaItemId = item.MediaItemId,
PlayoutDuration = item.PlayoutDuration.GetValueOrDefault(),
OfflineTail = item.OfflineTail.GetValueOrDefault()
OfflineTail = item.OfflineTail.GetValueOrDefault(),
CustomTitle = item.CustomTitle
},
_ => throw new NotSupportedException($"Unsupported playout mode {item.PlayoutMode}")
};
@@ -17,7 +17,8 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
int? MediaItemId,
int? MultipleCount,
TimeSpan? PlayoutDuration,
bool? OfflineTail) : IProgramScheduleItemRequest;
bool? OfflineTail,
string CustomTitle) : IProgramScheduleItemRequest;
public record ReplaceProgramScheduleItems
(int ProgramScheduleId, List<ReplaceProgramScheduleItem> Items) : IRequest<
@@ -28,7 +28,8 @@ namespace ErsatzTV.Application.ProgramSchedules
_ => null
},
duration.PlayoutDuration,
duration.OfflineTail),
duration.OfflineTail,
duration.CustomTitle),
ProgramScheduleItemFlood flood =>
new ProgramScheduleItemFloodViewModel(
flood.Id,
@@ -44,7 +45,8 @@ namespace ErsatzTV.Application.ProgramSchedules
Show show => MediaItems.Mapper.ProjectToViewModel(show),
Season season => MediaItems.Mapper.ProjectToViewModel(season),
_ => null
}),
},
flood.CustomTitle),
ProgramScheduleItemMultiple multiple =>
new ProgramScheduleItemMultipleViewModel(
multiple.Id,
@@ -61,7 +63,8 @@ namespace ErsatzTV.Application.ProgramSchedules
Season season => MediaItems.Mapper.ProjectToViewModel(season),
_ => null
},
multiple.Count),
multiple.Count,
multiple.CustomTitle),
ProgramScheduleItemOne one =>
new ProgramScheduleItemOneViewModel(
one.Id,
@@ -77,7 +80,8 @@ namespace ErsatzTV.Application.ProgramSchedules
Show show => MediaItems.Mapper.ProjectToViewModel(show),
Season season => MediaItems.Mapper.ProjectToViewModel(season),
_ => null
}),
},
one.CustomTitle),
_ => throw new NotSupportedException(
$"Unsupported program schedule item type {programScheduleItem.GetType().Name}")
};
@@ -16,7 +16,8 @@ namespace ErsatzTV.Application.ProgramSchedules
MediaCollectionViewModel collection,
NamedMediaItemViewModel mediaItem,
TimeSpan playoutDuration,
bool offlineTail) : base(
bool offlineTail,
string customTitle) : base(
id,
index,
startType,
@@ -24,7 +25,8 @@ namespace ErsatzTV.Application.ProgramSchedules
PlayoutMode.Duration,
collectionType,
collection,
mediaItem)
mediaItem,
customTitle)
{
PlayoutDuration = playoutDuration;
OfflineTail = offlineTail;
@@ -14,7 +14,8 @@ namespace ErsatzTV.Application.ProgramSchedules
TimeSpan? startTime,
ProgramScheduleItemCollectionType collectionType,
MediaCollectionViewModel collection,
NamedMediaItemViewModel mediaItem) : base(
NamedMediaItemViewModel mediaItem,
string customTitle) : base(
id,
index,
startType,
@@ -22,7 +23,8 @@ namespace ErsatzTV.Application.ProgramSchedules
PlayoutMode.Flood,
collectionType,
collection,
mediaItem)
mediaItem,
customTitle)
{
}
}
@@ -15,7 +15,8 @@ namespace ErsatzTV.Application.ProgramSchedules
ProgramScheduleItemCollectionType collectionType,
MediaCollectionViewModel collection,
NamedMediaItemViewModel mediaItem,
int count) : base(
int count,
string customTitle) : base(
id,
index,
startType,
@@ -23,7 +24,8 @@ namespace ErsatzTV.Application.ProgramSchedules
PlayoutMode.Multiple,
collectionType,
collection,
mediaItem) =>
mediaItem,
customTitle) =>
Count = count;
public int Count { get; }
@@ -14,7 +14,8 @@ namespace ErsatzTV.Application.ProgramSchedules
TimeSpan? startTime,
ProgramScheduleItemCollectionType collectionType,
MediaCollectionViewModel collection,
NamedMediaItemViewModel mediaItem) : base(
NamedMediaItemViewModel mediaItem,
string customTitle) : base(
id,
index,
startType,
@@ -22,7 +23,8 @@ namespace ErsatzTV.Application.ProgramSchedules
PlayoutMode.One,
collectionType,
collection,
mediaItem)
mediaItem,
customTitle)
{
}
}
@@ -13,7 +13,8 @@ namespace ErsatzTV.Application.ProgramSchedules
PlayoutMode PlayoutMode,
ProgramScheduleItemCollectionType CollectionType,
MediaCollectionViewModel Collection,
NamedMediaItemViewModel MediaItem)
NamedMediaItemViewModel MediaItem,
string CustomTitle)
{
public string Name => CollectionType switch
{
@@ -0,0 +1,6 @@
using LanguageExt;
namespace ErsatzTV.Application.Search.Commands
{
public record RebuildSearchIndex : MediatR.IRequest<Unit>, IBackgroundServiceRequest;
}
@@ -0,0 +1,80 @@
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using LanguageExt;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Search.Commands
{
public class RebuildSearchIndexHandler : MediatR.IRequestHandler<RebuildSearchIndex, Unit>
{
private readonly IConfigElementRepository _configElementRepository;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<RebuildSearchIndexHandler> _logger;
private readonly ISearchIndex _searchIndex;
private readonly ISearchRepository _searchRepository;
public RebuildSearchIndexHandler(
ISearchIndex searchIndex,
ISearchRepository searchRepository,
IConfigElementRepository configElementRepository,
ILocalFileSystem localFileSystem,
ILogger<RebuildSearchIndexHandler> logger)
{
_searchIndex = searchIndex;
_logger = logger;
_searchRepository = searchRepository;
_configElementRepository = configElementRepository;
_localFileSystem = localFileSystem;
}
public async Task<Unit> Handle(RebuildSearchIndex request, CancellationToken cancellationToken)
{
bool indexFolderExists = Directory.Exists(FileSystemLayout.SearchIndexFolder);
await _searchIndex.Initialize(_localFileSystem);
if (!indexFolderExists ||
await _configElementRepository.GetValue<int>(ConfigElementKey.SearchIndexVersion) <
_searchIndex.Version)
{
_logger.LogDebug("Migrating search index to version {Version}", _searchIndex.Version);
List<int> itemIds = await _searchRepository.GetItemIdsToIndex();
await _searchIndex.Rebuild(_searchRepository, itemIds);
Option<ConfigElement> maybeVersion =
await _configElementRepository.Get(ConfigElementKey.SearchIndexVersion);
await maybeVersion.Match(
version =>
{
version.Value = _searchIndex.Version.ToString();
return _configElementRepository.Update(version);
},
() =>
{
var configElement = new ConfigElement
{
Key = ConfigElementKey.SearchIndexVersion.Key,
Value = _searchIndex.Version.ToString()
};
return _configElementRepository.Add(configElement);
});
_logger.LogDebug("Done migrating search index");
}
else
{
_logger.LogDebug("Search index is already version {Version}", _searchIndex.Version);
}
return Unit.Default;
}
}
}
@@ -0,0 +1,7 @@
using ErsatzTV.Core.Search;
using MediatR;
namespace ErsatzTV.Application.Search.Queries
{
public record QuerySearchIndex(string Query) : IRequest<SearchResult>;
}
@@ -0,0 +1,18 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
using MediatR;
namespace ErsatzTV.Application.Search.Queries
{
public class QuerySearchIndexHandler : IRequestHandler<QuerySearchIndex, SearchResult>
{
private readonly ISearchIndex _searchIndex;
public QuerySearchIndexHandler(ISearchIndex searchIndex) => _searchIndex = searchIndex;
public Task<SearchResult> Handle(QuerySearchIndex request, CancellationToken cancellationToken) =>
_searchIndex.Search(request.Query, 0, 100);
}
}
@@ -0,0 +1,8 @@
using ErsatzTV.Application.MediaCards;
using MediatR;
namespace ErsatzTV.Application.Search.Queries
{
public record QuerySearchIndexMovies
(string Query, int PageNumber, int PageSize) : IRequest<MovieCardResultsViewModel>;
}
@@ -0,0 +1,42 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.MediaCards.Mapper;
namespace ErsatzTV.Application.Search.Queries
{
public class QuerySearchIndexMoviesHandler : IRequestHandler<QuerySearchIndexMovies, MovieCardResultsViewModel>
{
private readonly IMovieRepository _movieRepository;
private readonly ISearchIndex _searchIndex;
public QuerySearchIndexMoviesHandler(ISearchIndex searchIndex, IMovieRepository movieRepository)
{
_searchIndex = searchIndex;
_movieRepository = movieRepository;
}
public async Task<MovieCardResultsViewModel> Handle(
QuerySearchIndexMovies request,
CancellationToken cancellationToken)
{
SearchResult searchResult = await _searchIndex.Search(
request.Query,
(request.PageNumber - 1) * request.PageSize,
request.PageSize);
List<MovieCardViewModel> items = await _movieRepository
.GetMoviesForCards(searchResult.Items.Map(i => i.Id).ToList())
.Map(list => list.Map(ProjectToViewModel).ToList());
return new MovieCardResultsViewModel(searchResult.TotalCount, items, searchResult.PageMap);
}
}
}
@@ -0,0 +1,8 @@
using ErsatzTV.Application.MediaCards;
using MediatR;
namespace ErsatzTV.Application.Search.Queries
{
public record QuerySearchIndexMusicVideos
(string Query, int PageNumber, int PageSize) : IRequest<MusicVideoCardResultsViewModel>;
}
@@ -0,0 +1,44 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.MediaCards.Mapper;
namespace ErsatzTV.Application.Search.Queries
{
public class
QuerySearchIndexMusicVideosHandler : IRequestHandler<QuerySearchIndexMusicVideos, MusicVideoCardResultsViewModel
>
{
private readonly IMusicVideoRepository _musicVideoRepository;
private readonly ISearchIndex _searchIndex;
public QuerySearchIndexMusicVideosHandler(ISearchIndex searchIndex, IMusicVideoRepository musicVideoRepository)
{
_searchIndex = searchIndex;
_musicVideoRepository = musicVideoRepository;
}
public async Task<MusicVideoCardResultsViewModel> Handle(
QuerySearchIndexMusicVideos request,
CancellationToken cancellationToken)
{
SearchResult searchResult = await _searchIndex.Search(
request.Query,
(request.PageNumber - 1) * request.PageSize,
request.PageSize);
List<MusicVideoCardViewModel> items = await _musicVideoRepository
.GetMusicVideosForCards(searchResult.Items.Map(i => i.Id).ToList())
.Map(list => list.Map(ProjectToViewModel).ToList());
return new MusicVideoCardResultsViewModel(searchResult.TotalCount, items, searchResult.PageMap);
}
}
}
@@ -0,0 +1,8 @@
using ErsatzTV.Application.MediaCards;
using MediatR;
namespace ErsatzTV.Application.Search.Queries
{
public record QuerySearchIndexShows
(string Query, int PageNumber, int PageSize) : IRequest<TelevisionShowCardResultsViewModel>;
}
@@ -0,0 +1,43 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.MediaCards.Mapper;
namespace ErsatzTV.Application.Search.Queries
{
public class
QuerySearchIndexShowsHandler : IRequestHandler<QuerySearchIndexShows, TelevisionShowCardResultsViewModel>
{
private readonly ISearchIndex _searchIndex;
private readonly ITelevisionRepository _televisionRepository;
public QuerySearchIndexShowsHandler(ISearchIndex searchIndex, ITelevisionRepository televisionRepository)
{
_searchIndex = searchIndex;
_televisionRepository = televisionRepository;
}
public async Task<TelevisionShowCardResultsViewModel> Handle(
QuerySearchIndexShows request,
CancellationToken cancellationToken)
{
SearchResult searchResult = await _searchIndex.Search(
request.Query,
(request.PageNumber - 1) * request.PageSize,
request.PageSize);
List<TelevisionShowCardViewModel> items = await _televisionRepository
.GetShowsForCards(searchResult.Items.Map(i => i.Id).ToList())
.Map(list => list.Map(ProjectToViewModel).ToList());
return new TelevisionShowCardResultsViewModel(searchResult.TotalCount, items, searchResult.PageMap);
}
}
}
@@ -0,0 +1,10 @@
using System.Collections.Generic;
namespace ErsatzTV.Application.Search
{
public class SearchResultViewModel<T>
{
public int TotalCount { get; set; }
public List<T> Items { get; set; }
}
}
@@ -5,30 +5,38 @@ using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.Streaming.Queries
{
public class GetConcatProcessByChannelNumberHandler : FFmpegProcessHandler<GetConcatProcessByChannelNumber>
{
private readonly IConfigElementRepository _configElementRepository;
private readonly FFmpegProcessService _ffmpegProcessService;
public GetConcatProcessByChannelNumberHandler(
IChannelRepository channelRepository,
IConfigElementRepository configElementRepository,
FFmpegProcessService ffmpegProcessService)
: base(channelRepository, configElementRepository) =>
: base(channelRepository, configElementRepository)
{
_configElementRepository = configElementRepository;
_ffmpegProcessService = ffmpegProcessService;
}
protected override Task<Either<BaseError, Process>> GetProcess(
protected override async Task<Either<BaseError, Process>> GetProcess(
GetConcatProcessByChannelNumber request,
Channel channel,
string ffmpegPath) =>
Right<BaseError, Process>(
_ffmpegProcessService.ConcatChannel(
ffmpegPath,
channel,
request.Scheme,
request.Host)).AsTask();
string ffmpegPath)
{
bool saveReports = await _configElementRepository.GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
.Map(result => result.IfNone(false));
return _ffmpegProcessService.ConcatChannel(
ffmpegPath,
saveReports,
channel,
request.Scheme,
request.Host);
}
}
}
@@ -1,17 +1,14 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.Streaming.Queries
@@ -19,29 +16,26 @@ namespace ErsatzTV.Application.Streaming.Queries
public class
GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<GetPlayoutItemProcessByChannelNumber>
{
private readonly IConfigElementRepository _configElementRepository;
private readonly FFmpegProcessService _ffmpegProcessService;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<GetPlayoutItemProcessByChannelNumberHandler> _logger;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IConfigElementRepository _configElementRepository;
private readonly IPlayoutRepository _playoutRepository;
private readonly IPlexPathReplacementService _plexPathReplacementService;
public GetPlayoutItemProcessByChannelNumberHandler(
IChannelRepository channelRepository,
IConfigElementRepository configElementRepository,
IPlayoutRepository playoutRepository,
IMediaSourceRepository mediaSourceRepository,
FFmpegProcessService ffmpegProcessService,
ILocalFileSystem localFileSystem,
ILogger<GetPlayoutItemProcessByChannelNumberHandler> logger)
IPlexPathReplacementService plexPathReplacementService)
: base(channelRepository, configElementRepository)
{
_configElementRepository = configElementRepository;
_playoutRepository = playoutRepository;
_mediaSourceRepository = mediaSourceRepository;
_ffmpegProcessService = ffmpegProcessService;
_localFileSystem = localFileSystem;
_logger = logger;
_plexPathReplacementService = plexPathReplacementService;
}
protected override async Task<Either<BaseError, Process>> GetProcess(
@@ -62,6 +56,7 @@ namespace ErsatzTV.Application.Streaming.Queries
{
Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(),
MusicVideo mv => mv.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(playoutItemWithPath))
};
@@ -69,7 +64,7 @@ namespace ErsatzTV.Application.Streaming.Queries
.Map(result => result.IfNone(false));
return Right<BaseError, Process>(
_ffmpegProcessService.ForPlayoutItem(
await _ffmpegProcessService.ForPlayoutItem(
ffmpegPath,
saveReports,
channel,
@@ -159,6 +154,7 @@ namespace ErsatzTV.Application.Streaming.Queries
{
Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(),
MusicVideo mv => mv.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(playoutItem))
};
@@ -166,33 +162,16 @@ namespace ErsatzTV.Application.Streaming.Queries
string path = file.Path;
return playoutItem.MediaItem switch
{
PlexMovie plexMovie => await GetReplacementPlexPath(plexMovie.LibraryPathId, path),
PlexEpisode plexEpisode => await GetReplacementPlexPath(plexEpisode.LibraryPathId, path),
PlexMovie plexMovie => await _plexPathReplacementService.GetReplacementPlexPath(
plexMovie.LibraryPathId,
path),
PlexEpisode plexEpisode => await _plexPathReplacementService.GetReplacementPlexPath(
plexEpisode.LibraryPathId,
path),
_ => path
};
}
private async Task<string> GetReplacementPlexPath(int libraryPathId, string path)
{
List<PlexPathReplacement> replacements =
await _mediaSourceRepository.GetPlexPathReplacementsByLibraryId(libraryPathId);
// TODO: this might barf mixing platforms (i.e. plex on linux, etv on windows)
Option<PlexPathReplacement> maybeReplacement = replacements
.SingleOrDefault(r => path.StartsWith(r.PlexPath + Path.DirectorySeparatorChar));
return maybeReplacement.Match(
replacement =>
{
string finalPath = path.Replace(replacement.PlexPath, replacement.LocalPath);
_logger.LogInformation(
"Replacing plex path {PlexPath} with {LocalPath} resulting in {FinalPath}",
replacement.PlexPath,
replacement.LocalPath,
finalPath);
return finalPath;
},
() => path);
}
private record PlayoutItemWithPath(PlayoutItem PlayoutItem, string Path);
}
}
+3 -1
View File
@@ -16,7 +16,9 @@ namespace ErsatzTV.Application.Television
show.ShowMetadata.HeadOrNone().Map(GetPoster).IfNone(string.Empty),
show.ShowMetadata.HeadOrNone().Map(GetFanArt).IfNone(string.Empty),
show.ShowMetadata.HeadOrNone().Map(m => m.Genres.Map(g => g.Name).ToList()).IfNone(new List<string>()),
show.ShowMetadata.HeadOrNone().Map(m => m.Tags.Map(g => g.Name).ToList()).IfNone(new List<string>()));
show.ShowMetadata.HeadOrNone().Map(m => m.Tags.Map(g => g.Name).ToList()).IfNone(new List<string>()),
show.ShowMetadata.HeadOrNone().Map(m => m.Studios.Map(s => s.Name).ToList())
.IfNone(new List<string>()));
internal static TelevisionSeasonViewModel ProjectToViewModel(Season season) =>
new(
@@ -10,5 +10,6 @@ namespace ErsatzTV.Application.Television
string Poster,
string FanArt,
List<string> Genres,
List<string> Tags);
List<string> Tags,
List<string> Studios);
}
@@ -28,7 +28,7 @@ namespace ErsatzTV.CommandLine.Commands
public string Name { get; set; }
[CommandOption("thread-count", Description = "The number of threads")]
public int ThreadCount { get; set; } = 4;
public int ThreadCount { get; set; } = 0;
[CommandOption("transcode", Description = "Whether to transcode all media")]
public bool Transcode { get; set; } = true;
@@ -2,13 +2,22 @@
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
<NoWarn>VSTHRD200</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AsyncFixer" Version="1.5.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="FluentAssertions" Version="5.10.3" />
<PackageReference Include="LanguageExt.Core" Version="3.4.15" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="5.0.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.9.1" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="16.9.60">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Moq" Version="4.16.1" />
<PackageReference Include="NUnit" Version="3.13.1" />
<PackageReference Include="NUnit3TestAdapter" Version="3.17.0" />
@@ -18,7 +18,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{
var builder = new FFmpegComplexFilterBuilder();
Option<FFmpegComplexFilter> result = builder.Build();
Option<FFmpegComplexFilter> result = builder.Build(0, 1);
result.IsNone.Should().BeTrue();
}
@@ -30,15 +30,15 @@ namespace ErsatzTV.Core.Tests.FFmpeg
FFmpegComplexFilterBuilder builder = new FFmpegComplexFilterBuilder()
.WithAlignedAudio(duration);
Option<FFmpegComplexFilter> result = builder.Build();
Option<FFmpegComplexFilter> result = builder.Build(0, 1);
result.IsSome.Should().BeTrue();
result.IfSome(
filter =>
{
filter.ComplexFilter.Should().Be($"[0:a]apad=whole_dur={duration.TotalMilliseconds}ms[a]");
filter.ComplexFilter.Should().Be($"[0:1]apad=whole_dur={duration.TotalMilliseconds}ms[a]");
filter.AudioLabel.Should().Be("[a]");
filter.VideoLabel.Should().Be("0:v");
filter.VideoLabel.Should().Be("0:0");
});
}
@@ -50,36 +50,36 @@ namespace ErsatzTV.Core.Tests.FFmpeg
.WithAlignedAudio(duration)
.WithDeinterlace(true);
Option<FFmpegComplexFilter> result = builder.Build();
Option<FFmpegComplexFilter> result = builder.Build(0, 1);
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]");
$"[0:1]apad=whole_dur={duration.TotalMilliseconds}ms[a];[0:0]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, false, false, "[0:0]yadif=1[v]", "[v]")]
[TestCase(true, true, false, "[0:0]yadif=1,scale=1920:1000:flags=fast_bilinear,setsar=1[v]", "[v]")]
[TestCase(true, false, true, "[0:0]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]",
"[0:0]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, false, "[0:0]scale=1920:1000:flags=fast_bilinear,setsar=1[v]", "[v]")]
[TestCase(false, false, true, "[0:0]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]",
"[0:0]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,
@@ -101,55 +101,55 @@ namespace ErsatzTV.Core.Tests.FFmpeg
builder = builder.WithBlackBars(new Resolution { Width = 1920, Height = 1080 });
}
Option<FFmpegComplexFilter> result = builder.Build();
Option<FFmpegComplexFilter> result = builder.Build(0, 1);
result.IsSome.Should().BeTrue();
result.IfSome(
filter =>
{
filter.ComplexFilter.Should().Be(expectedVideoFilter);
filter.AudioLabel.Should().Be("0:a");
filter.AudioLabel.Should().Be("0:1");
filter.VideoLabel.Should().Be(expectedVideoLabel);
});
}
[Test]
[TestCase(true, false, false, "[0:v]deinterlace_qsv[v]", "[v]")]
[TestCase(true, false, false, "[0:0]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]",
"[0:0]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]",
"[0:0]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]",
"[0:0]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]",
"[0:0]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]",
"[0:0]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]",
"[0:0]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,
@@ -172,74 +172,74 @@ namespace ErsatzTV.Core.Tests.FFmpeg
builder = builder.WithBlackBars(new Resolution { Width = 1920, Height = 1080 });
}
Option<FFmpegComplexFilter> result = builder.Build();
Option<FFmpegComplexFilter> result = builder.Build(0, 1);
result.IsSome.Should().BeTrue();
result.IfSome(
filter =>
{
filter.ComplexFilter.Should().Be(expectedVideoFilter);
filter.AudioLabel.Should().Be("0:a");
filter.AudioLabel.Should().Be("0:1");
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, 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]",
// "[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]",
// "[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]",
// "[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]",
"[0:0]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]",
"[0:0]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]",
"[0:0]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]",
"[0:0]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]",
"[0:0]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]",
"[0:0]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,
@@ -262,104 +262,104 @@ namespace ErsatzTV.Core.Tests.FFmpeg
builder = builder.WithBlackBars(new Resolution { Width = 1920, Height = 1080 });
}
Option<FFmpegComplexFilter> result = builder.Build();
Option<FFmpegComplexFilter> result = builder.Build(0, 1);
result.IsSome.Should().BeTrue();
result.IfSome(
filter =>
{
filter.ComplexFilter.Should().Be(expectedVideoFilter);
filter.AudioLabel.Should().Be("0:a");
filter.AudioLabel.Should().Be("0:1");
filter.VideoLabel.Should().Be(expectedVideoLabel);
});
}
[Test]
[TestCase("h264", true, false, false, "[0:v]deinterlace_vaapi[v]", "[v]")]
[TestCase("h264", true, false, false, "[0:0]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]",
"[0:0]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]",
"[0:0]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]",
"[0:0]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]",
"[0:0]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]",
"[0:0]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]",
"[0:0]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, false, false, "[0:0]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]",
"[0:0]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]",
"[0:0]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]",
"[0:0]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]",
"[0:0]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]",
"[0:0]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]",
"[0:0]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,
@@ -384,14 +384,14 @@ namespace ErsatzTV.Core.Tests.FFmpeg
builder = builder.WithBlackBars(new Resolution { Width = 1920, Height = 1080 });
}
Option<FFmpegComplexFilter> result = builder.Build();
Option<FFmpegComplexFilter> result = builder.Build(0, 1);
result.IsSome.Should().BeTrue();
result.IfSome(
filter =>
{
filter.ComplexFilter.Should().Be(expectedVideoFilter);
filter.AudioLabel.Should().Be("0:a");
filter.AudioLabel.Should().Be("0:1");
filter.VideoLabel.Should().Be(expectedVideoLabel);
});
}
@@ -25,6 +25,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
StreamingMode.TransportStream,
ffmpegProfile,
new MediaVersion(),
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now);
@@ -40,6 +42,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
StreamingMode.HttpLiveStreaming,
ffmpegProfile,
new MediaVersion(),
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now);
@@ -55,6 +59,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
StreamingMode.TransportStream,
ffmpegProfile,
new MediaVersion(),
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now);
@@ -72,6 +78,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
StreamingMode.HttpLiveStreaming,
ffmpegProfile,
new MediaVersion(),
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now);
@@ -89,6 +97,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
StreamingMode.TransportStream,
ffmpegProfile,
new MediaVersion(),
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now);
@@ -104,6 +114,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
StreamingMode.HttpLiveStreaming,
ffmpegProfile,
new MediaVersion(),
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now);
@@ -121,6 +133,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
StreamingMode.TransportStream,
ffmpegProfile,
new MediaVersion(),
new MediaStream(),
new MediaStream(),
now,
now.AddMinutes(5));
@@ -139,6 +153,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
StreamingMode.HttpLiveStreaming,
ffmpegProfile,
new MediaVersion(),
new MediaStream(),
new MediaStream(),
now,
now.AddMinutes(5));
@@ -147,14 +163,16 @@ namespace ErsatzTV.Core.Tests.FFmpeg
}
[Test]
public void ShouldNot_SetScaledSize_When_NotNormalizingResolution_ForTransportStream()
public void ShouldNot_SetScaledSize_When_NotNormalizingVideo_ForTransportStream()
{
FFmpegProfile ffmpegProfile = TestProfile() with { NormalizeResolution = false };
FFmpegProfile ffmpegProfile = TestProfile() with { NormalizeVideo = false };
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
new MediaVersion(),
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now);
@@ -166,7 +184,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
NormalizeResolution = true,
NormalizeVideo = true,
Resolution = new Resolution { Width = 1920, Height = 1080 }
};
@@ -177,6 +195,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
StreamingMode.TransportStream,
ffmpegProfile,
version,
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now);
@@ -188,7 +208,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
NormalizeResolution = true,
NormalizeVideo = true,
Resolution = new Resolution { Width = 1920, Height = 1080 }
};
@@ -199,6 +219,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
StreamingMode.TransportStream,
ffmpegProfile,
version,
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now);
@@ -210,7 +232,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
NormalizeResolution = true,
NormalizeVideo = true,
Resolution = new Resolution { Width = 1920, Height = 1080 }
};
@@ -221,6 +243,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
StreamingMode.TransportStream,
ffmpegProfile,
version,
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now);
@@ -233,7 +257,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
NormalizeResolution = true,
NormalizeVideo = true,
Resolution = new Resolution { Width = 1920, Height = 1080 }
};
@@ -244,6 +268,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
StreamingMode.TransportStream,
ffmpegProfile,
version,
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now);
@@ -256,7 +282,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
NormalizeResolution = true,
NormalizeVideo = true,
Resolution = new Resolution { Width = 1920, Height = 1080 }
};
@@ -267,6 +293,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
StreamingMode.HttpLiveStreaming,
ffmpegProfile,
version,
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now);
@@ -275,11 +303,11 @@ namespace ErsatzTV.Core.Tests.FFmpeg
}
[Test]
public void Should_NotPadToDesiredResolution_When_NotNormalizingResolution()
public void Should_NotPadToDesiredResolution_When_NotNormalizingVideo()
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
NormalizeResolution = false,
NormalizeVideo = false,
Resolution = new Resolution { Width = 1920, Height = 1080 }
};
@@ -290,6 +318,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
StreamingMode.TransportStream,
ffmpegProfile,
version,
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now);
@@ -302,9 +332,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{
var ffmpegProfile = new FFmpegProfile
{
NormalizeResolution = true,
NormalizeVideo = true,
Resolution = new Resolution { Width = 1920, Height = 1080 },
NormalizeVideoCodec = false,
VideoCodec = "testCodec"
};
@@ -315,6 +344,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
StreamingMode.TransportStream,
ffmpegProfile,
version,
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now);
@@ -325,24 +356,25 @@ namespace ErsatzTV.Core.Tests.FFmpeg
[Test]
public void
Should_SetDesiredVideoCodec_When_ContentIsCorrectSize_And_NormalizingWrongCodec_ForTransportStream()
Should_SetDesiredVideoCodec_When_ContentIsCorrectSize_And_NormalizingVideo_ForTransportStream()
{
var ffmpegProfile = new FFmpegProfile
{
NormalizeResolution = true,
NormalizeVideo = true,
Resolution = new Resolution { Width = 1920, Height = 1080 },
NormalizeVideoCodec = true,
VideoCodec = "testCodec"
};
// not anamorphic
var version = new MediaVersion
{ Width = 1920, Height = 1080, SampleAspectRatio = "1:1", VideoCodec = "mpeg2video" };
{ Width = 1920, Height = 1080, SampleAspectRatio = "1:1" };
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
version,
new MediaStream { Codec = "mpeg2video" },
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now);
@@ -353,24 +385,25 @@ namespace ErsatzTV.Core.Tests.FFmpeg
[Test]
public void
Should_SetCopyVideoCodec_When_ContentIsCorrectSize_And_NormalizingWrongCodec_ForHttpLiveStreaming()
Should_SetCopyVideoCodec_When_ContentIsCorrectSize_And_NormalizingVideo_ForHttpLiveStreaming()
{
var ffmpegProfile = new FFmpegProfile
{
NormalizeResolution = true,
NormalizeVideo = true,
Resolution = new Resolution { Width = 1920, Height = 1080 },
NormalizeVideoCodec = true,
VideoCodec = "testCodec"
};
// not anamorphic
var version = new MediaVersion
{ Width = 1920, Height = 1080, SampleAspectRatio = "1:1", VideoCodec = "mpeg2video" };
{ Width = 1920, Height = 1080, SampleAspectRatio = "1:1" };
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.HttpLiveStreaming,
ffmpegProfile,
version,
new MediaStream { Codec = "mpeg2video" },
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now);
@@ -384,20 +417,21 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{
var ffmpegProfile = new FFmpegProfile
{
NormalizeResolution = true,
NormalizeVideo = true,
Resolution = new Resolution { Width = 1920, Height = 1080 },
NormalizeVideoCodec = true,
VideoCodec = "libx264"
};
// not anamorphic
var version = new MediaVersion
{ Width = 1920, Height = 1080, SampleAspectRatio = "1:1", VideoCodec = "libx264" };
{ Width = 1920, Height = 1080, SampleAspectRatio = "1:1" };
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
version,
new MediaStream { Codec = "libx264" },
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now);
@@ -408,24 +442,25 @@ namespace ErsatzTV.Core.Tests.FFmpeg
[Test]
public void
Should_SetCopyVideoCodec_When_ContentIsCorrectSize_And_NotNormalizingWrongCodec_ForTransportStream()
Should_SetCopyVideoCodec_When_ContentIsCorrectSize_And_NotNormalizingVideo_ForTransportStream()
{
var ffmpegProfile = new FFmpegProfile
{
NormalizeResolution = true,
NormalizeVideo = false,
Resolution = new Resolution { Width = 1920, Height = 1080 },
NormalizeVideoCodec = false,
VideoCodec = "libx264"
};
// not anamorphic
var version = new MediaVersion
{ Width = 1920, Height = 1080, SampleAspectRatio = "1:1", VideoCodec = "mpeg2video" };
{ Width = 1920, Height = 1080, SampleAspectRatio = "1:1" };
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
version,
new MediaStream { Codec = "mpeg2video" },
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now);
@@ -439,9 +474,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{
var ffmpegProfile = new FFmpegProfile
{
NormalizeResolution = true,
NormalizeVideo = true,
Resolution = new Resolution { Width = 1920, Height = 1080 },
NormalizeVideoCodec = false,
VideoBitrate = 2525
};
@@ -452,6 +486,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
StreamingMode.TransportStream,
ffmpegProfile,
version,
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now);
@@ -461,24 +497,25 @@ namespace ErsatzTV.Core.Tests.FFmpeg
}
[Test]
public void Should_SetVideoBitrate_When_ContentIsCorrectSize_And_NormalizingWrongCodec_ForTransportStream()
public void Should_SetVideoBitrate_When_ContentIsCorrectSize_And_NormalizingVideo_ForTransportStream()
{
var ffmpegProfile = new FFmpegProfile
{
NormalizeResolution = true,
NormalizeVideo = true,
Resolution = new Resolution { Width = 1920, Height = 1080 },
NormalizeVideoCodec = true,
VideoBitrate = 2525
};
// not anamorphic
var version = new MediaVersion
{ Width = 1920, Height = 1080, SampleAspectRatio = "1:1", VideoCodec = "mpeg2video" };
{ Width = 1920, Height = 1080, SampleAspectRatio = "1:1" };
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
version,
new MediaStream { Codec = "mpeg2video" },
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now);
@@ -492,9 +529,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{
var ffmpegProfile = new FFmpegProfile
{
NormalizeResolution = true,
NormalizeVideo = true,
Resolution = new Resolution { Width = 1920, Height = 1080 },
NormalizeVideoCodec = false,
VideoBufferSize = 2525
};
@@ -505,6 +541,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
StreamingMode.TransportStream,
ffmpegProfile,
version,
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now);
@@ -515,24 +553,25 @@ namespace ErsatzTV.Core.Tests.FFmpeg
[Test]
public void
Should_SetVideoBufferSize_When_ContentIsCorrectSize_And_NormalizingWrongCodec_ForTransportStream()
Should_SetVideoBufferSize_When_ContentIsCorrectSize_And_NormalizingVideo_ForTransportStream()
{
var ffmpegProfile = new FFmpegProfile
{
NormalizeResolution = true,
NormalizeVideo = true,
Resolution = new Resolution { Width = 1920, Height = 1080 },
NormalizeVideoCodec = true,
VideoBufferSize = 2525
};
// not anamorphic
var version = new MediaVersion
{ Width = 1920, Height = 1080, SampleAspectRatio = "1:1", VideoCodec = "mpeg2video" };
{ Width = 1920, Height = 1080, SampleAspectRatio = "1:1" };
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
version,
new MediaStream { Codec = "mpeg2video" },
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now);
@@ -542,62 +581,22 @@ namespace ErsatzTV.Core.Tests.FFmpeg
}
[Test]
public void Should_SetCopyAudioCodec_When_CorrectCodec_ForTransportStream()
public void Should_SetDesiredAudioCodec_When_NormalizingAudio_With_CorrectCodec_ForTransportStream()
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
NormalizeAudioCodec = true,
NormalizeAudio = true,
AudioCodec = "aac"
};
var version = new MediaVersion { AudioCodec = "aac" };
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
version,
DateTimeOffset.Now,
DateTimeOffset.Now);
actual.AudioCodec.Should().Be("copy");
}
[Test]
public void Should_SetCopyAudioCodec_When_NotNormalizingWrongCodec_ForTransportStream()
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
NormalizeAudioCodec = false,
AudioCodec = "aac"
};
var version = new MediaVersion { AudioCodec = "ac3" };
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
version,
DateTimeOffset.Now,
DateTimeOffset.Now);
actual.AudioCodec.Should().Be("copy");
}
[Test]
public void Should_SetDesiredAudioCodec_When_NormalizingWrongCodec_ForTransportStream()
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
NormalizeAudioCodec = true,
AudioCodec = "aac"
};
var version = new MediaVersion { AudioCodec = "ac3" };
var version = new MediaVersion();
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
version,
new MediaStream(),
new MediaStream { Codec = "aac" },
DateTimeOffset.Now,
DateTimeOffset.Now);
@@ -605,20 +604,22 @@ namespace ErsatzTV.Core.Tests.FFmpeg
}
[Test]
public void Should_SetCopyAudioCodec_When_NormalizingWrongCodec_ForHttpLiveStreaming()
public void Should_SetCopyAudioCodec_When_NotNormalizingAudio_ForTransportStream()
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
NormalizeAudioCodec = true,
NormalizeAudio = false,
AudioCodec = "aac"
};
var version = new MediaVersion { AudioCodec = "ac3" };
var version = new MediaVersion();
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.HttpLiveStreaming,
StreamingMode.TransportStream,
ffmpegProfile,
version,
new MediaStream(),
new MediaStream { Codec = "ac3" },
DateTimeOffset.Now,
DateTimeOffset.Now);
@@ -626,20 +627,69 @@ namespace ErsatzTV.Core.Tests.FFmpeg
}
[Test]
public void Should_SetAudioBitrate_When_NormalizingWrongCodec_ForTransportStream()
public void Should_SetDesiredAudioCodec_When_NormalizingAudio_ForTransportStream()
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
NormalizeAudioCodec = true,
AudioBitrate = 2424
NormalizeAudio = true,
AudioCodec = "aac"
};
var version = new MediaVersion { AudioCodec = "ac3" };
var version = new MediaVersion();
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
version,
new MediaStream(),
new MediaStream { Codec = "ac3" },
DateTimeOffset.Now,
DateTimeOffset.Now);
actual.AudioCodec.Should().Be("aac");
}
[Test]
public void Should_SetCopyAudioCodec_When_NormalizingAudio_ForHttpLiveStreaming()
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
NormalizeAudio = true,
AudioCodec = "aac"
};
var version = new MediaVersion();
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.HttpLiveStreaming,
ffmpegProfile,
version,
new MediaStream(),
new MediaStream { Codec = "ac3" },
DateTimeOffset.Now,
DateTimeOffset.Now);
actual.AudioCodec.Should().Be("copy");
}
[Test]
public void Should_SetAudioBitrate_When_NormalizingAudio_With_CorrectCodec_ForTransportStream()
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
NormalizeAudio = true,
AudioBitrate = 2424,
AudioCodec = "ac3"
};
var version = new MediaVersion();
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
version,
new MediaStream(),
new MediaStream { Codec = "ac3" },
DateTimeOffset.Now,
DateTimeOffset.Now);
@@ -647,20 +697,23 @@ namespace ErsatzTV.Core.Tests.FFmpeg
}
[Test]
public void Should_SetAudioBufferSize_When_NormalizingWrongCodec_ForTransportStream()
public void Should_SetAudioBufferSize_When_NormalizingAudio_With_CorrectCodec_ForTransportStream()
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
NormalizeAudioCodec = true,
AudioBufferSize = 2424
NormalizeAudio = true,
AudioBufferSize = 2424,
AudioCodec = "ac3"
};
var version = new MediaVersion { AudioCodec = "ac3" };
var version = new MediaVersion();
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
version,
new MediaStream(),
new MediaStream { Codec = "ac3" },
DateTimeOffset.Now,
DateTimeOffset.Now);
@@ -668,67 +721,23 @@ namespace ErsatzTV.Core.Tests.FFmpeg
}
[Test]
public void ShouldNot_SetAudioChannels_When_CorrectCodec_ForTransportStream()
public void Should_SetAudioChannels_When_NormalizingAudio_With_CorrectCodec_ForTransportStream()
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
NormalizeAudioCodec = true,
NormalizeAudio = true,
AudioCodec = "ac3",
AudioChannels = 6
};
var version = new MediaVersion { AudioCodec = "ac3" };
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
version,
DateTimeOffset.Now,
DateTimeOffset.Now);
actual.AudioChannels.IsNone.Should().BeTrue();
}
[Test]
public void ShouldNot_SetAudioSampleRate_When_CorrectCodec_ForTransportStream()
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
NormalizeAudioCodec = true,
NormalizeAudio = true,
AudioCodec = "ac3",
AudioSampleRate = 48
};
var version = new MediaVersion { AudioCodec = "ac3" };
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
version,
DateTimeOffset.Now,
DateTimeOffset.Now);
actual.AudioSampleRate.IsNone.Should().BeTrue();
}
[Test]
public void Should_SetAudioChannels_When_NormalizingWrongCodecAndAudio_ForTransportStream()
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
NormalizeAudioCodec = true,
NormalizeAudio = true,
AudioChannels = 6
};
var version = new MediaVersion { AudioCodec = "ac3" };
var version = new MediaVersion();
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
version,
new MediaStream(),
new MediaStream { Codec = "ac3" },
DateTimeOffset.Now,
DateTimeOffset.Now);
@@ -736,26 +745,144 @@ namespace ErsatzTV.Core.Tests.FFmpeg
}
[Test]
public void Should_SetAudioSampleRate_When_NormalizingWrongCodecAndAudio_ForTransportStream()
public void Should_SetAudioSampleRate_When_NormalizingAudio_With_CorrectCodec_ForTransportStream()
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
NormalizeAudioCodec = true,
NormalizeAudio = true,
AudioCodec = "ac3",
AudioSampleRate = 48
};
var version = new MediaVersion { AudioCodec = "ac3" };
var version = new MediaVersion();
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
version,
new MediaStream(),
new MediaStream { Codec = "ac3" },
DateTimeOffset.Now,
DateTimeOffset.Now);
actual.AudioSampleRate.IfNone(0).Should().Be(48);
}
[Test]
public void Should_SetAudioChannels_When_NormalizingAudio_ForTransportStream()
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
NormalizeAudio = true,
AudioChannels = 6
};
var version = new MediaVersion();
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
version,
new MediaStream(),
new MediaStream { Codec = "ac3" },
DateTimeOffset.Now,
DateTimeOffset.Now);
actual.AudioChannels.IfNone(0).Should().Be(6);
}
[Test]
public void Should_SetAudioSampleRate_When_NormalizingAudio_ForTransportStream()
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
NormalizeAudio = true,
AudioSampleRate = 48
};
var version = new MediaVersion();
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
version,
new MediaStream(),
new MediaStream { Codec = "ac3" },
DateTimeOffset.Now,
DateTimeOffset.Now);
actual.AudioSampleRate.IfNone(0).Should().Be(48);
}
[Test]
public void Should_SetAudioDuration_When_NormalizingAudio_With_CorrectCodec_ForTransportStream()
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
NormalizeAudio = true,
AudioSampleRate = 48,
AudioCodec = "ac3"
};
var version = new MediaVersion { Duration = TimeSpan.FromMinutes(2) };
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
version,
new MediaStream(),
new MediaStream { Codec = "ac3" },
DateTimeOffset.Now,
DateTimeOffset.Now);
actual.AudioDuration.IfNone(TimeSpan.MinValue).Should().Be(TimeSpan.FromMinutes(2));
}
[Test]
public void Should_SetNormalizeLoudness_When_NormalizingAudio_ForTransportStream()
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
NormalizeAudio = true,
NormalizeLoudness = true
};
var version = new MediaVersion();
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
version,
new MediaStream(),
new MediaStream { Codec = "ac3" },
DateTimeOffset.Now,
DateTimeOffset.Now);
actual.NormalizeLoudness.Should().BeTrue();
}
[Test]
public void Should_NotSetNormalizeLoudness_When_NotNormalizingAudio_ForTransportStream()
{
FFmpegProfile ffmpegProfile = TestProfile() with
{
NormalizeAudio = false,
NormalizeLoudness = true
};
var version = new MediaVersion();
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
ffmpegProfile,
version,
new MediaStream(),
new MediaStream { Codec = "ac3" },
DateTimeOffset.Now,
DateTimeOffset.Now);
actual.NormalizeLoudness.Should().BeFalse();
}
}
[TestFixture]
@@ -775,6 +902,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
StreamingMode.TransportStream,
ffmpegProfile,
new MediaVersion(),
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now);
@@ -36,6 +36,8 @@ namespace ErsatzTV.Core.Tests.Fakes
_folders = allFolders.Distinct().Map(f => new FakeFolderEntry(f)).ToList();
}
public Unit EnsureFolderExists(string folder) => Unit.Default;
public DateTime GetLastWriteTime(string path) =>
Optional(_files.SingleOrDefault(f => f.Path == path))
.Map(f => f.LastWriteTime)
@@ -54,8 +56,8 @@ namespace ErsatzTV.Core.Tests.Fakes
public Task<byte[]> ReadAllBytes(string path) => TestBytes.AsTask();
public Unit CopyFile(string source, string destination) =>
Unit.Default;
public Task<Either<BaseError, Unit>> CopyFile(string source, string destination) =>
Task.FromResult(Right<BaseError, Unit>(Unit.Default));
private static List<DirectoryInfo> Split(DirectoryInfo path)
{
+14 -14
View File
@@ -1,26 +1,26 @@
using System.Collections.Generic;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Metadata;
namespace ErsatzTV.Core.Tests.Fakes
{
public class FakeMovieWithPath : Movie
public class FakeMovieWithPath : MediaItemScanResult<Movie>
{
public FakeMovieWithPath(string path)
{
Path = path;
MediaVersions = new List<MediaVersion>
{
new()
: base(
new Movie
{
MediaFiles = new List<MediaFile>
MediaVersions = new List<MediaVersion>
{
new() { Path = path }
new()
{
MediaFiles = new List<MediaFile>
{
new() { Path = path }
}
}
}
}
};
}
public string Path { get; }
}) =>
IsAdded = true;
}
}
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Metadata;
using LanguageExt;
namespace ErsatzTV.Core.Tests.Fakes
@@ -11,12 +12,6 @@ namespace ErsatzTV.Core.Tests.Fakes
{
public Task<bool> AllShowsExist(List<int> showIds) => throw new NotSupportedException();
public Task<bool> Update(Show show) => throw new NotSupportedException();
public Task<bool> Update(Season season) => throw new NotSupportedException();
public Task<bool> Update(Episode episode) => throw new NotSupportedException();
public Task<List<Show>> GetAllShows() => throw new NotSupportedException();
public Task<Option<Show>> GetShow(int showId) => throw new NotSupportedException();
@@ -26,6 +21,8 @@ namespace ErsatzTV.Core.Tests.Fakes
public Task<List<ShowMetadata>> GetPagedShows(int pageNumber, int pageSize) =>
throw new NotSupportedException();
public Task<List<ShowMetadata>> GetShowsForCards(List<int> ids) => throw new NotSupportedException();
public Task<List<Episode>> GetShowItems(int showId) => throw new NotSupportedException();
public Task<List<Season>> GetAllSeasons() => throw new NotSupportedException();
@@ -49,7 +46,7 @@ namespace ErsatzTV.Core.Tests.Fakes
public Task<Option<Show>> GetShowByMetadata(int libraryPathId, ShowMetadata metadata) =>
throw new NotSupportedException();
public Task<Either<BaseError, Show>>
public Task<Either<BaseError, MediaItemScanResult<Show>>>
AddShow(int libraryPathId, string showFolder, ShowMetadata metadata) =>
throw new NotSupportedException();
@@ -65,9 +62,11 @@ namespace ErsatzTV.Core.Tests.Fakes
public Task<Unit> DeleteEmptySeasons(LibraryPath libraryPath) => throw new NotSupportedException();
public Task<Unit> DeleteEmptyShows(LibraryPath libraryPath) => throw new NotSupportedException();
public Task<List<int>> DeleteEmptyShows(LibraryPath libraryPath) => throw new NotSupportedException();
public Task<Either<BaseError, PlexShow>> GetOrAddPlexShow(PlexLibrary library, PlexShow item) =>
public Task<Either<BaseError, MediaItemScanResult<PlexShow>>> GetOrAddPlexShow(
PlexLibrary library,
PlexShow item) =>
throw new NotSupportedException();
public Task<Either<BaseError, PlexSeason>> GetOrAddPlexSeason(PlexLibrary library, PlexSeason item) =>
@@ -76,9 +75,12 @@ namespace ErsatzTV.Core.Tests.Fakes
public Task<Either<BaseError, PlexEpisode>> GetOrAddPlexEpisode(PlexLibrary library, PlexEpisode item) =>
throw new NotSupportedException();
public Task<Unit> AddGenre(ShowMetadata metadata, Genre genre) => throw new NotSupportedException();
public Task<bool> AddGenre(ShowMetadata metadata, Genre genre) => throw new NotSupportedException();
public Task<bool> AddTag(ShowMetadata metadata, Tag tag) => throw new NotSupportedException();
public Task<Unit> RemoveMissingPlexShows(PlexLibrary library, List<string> showKeys) =>
public Task<bool> AddStudio(ShowMetadata metadata, Studio studio) => throw new NotSupportedException();
public Task<List<int>> RemoveMissingPlexShows(PlexLibrary library, List<string> showKeys) =>
throw new NotSupportedException();
public Task<Unit> RemoveMissingPlexSeasons(string showKey, List<string> seasonKeys) =>
@@ -86,5 +88,13 @@ namespace ErsatzTV.Core.Tests.Fakes
public Task<Unit> RemoveMissingPlexEpisodes(string seasonKey, List<string> episodeKeys) =>
throw new NotSupportedException();
public Task<Unit> SetEpisodeNumber(Episode episode, int episodeNumber) => throw new NotSupportedException();
public Task<bool> Update(Show show) => throw new NotSupportedException();
public Task<bool> Update(Season season) => throw new NotSupportedException();
public Task<bool> Update(Episode episode) => throw new NotSupportedException();
}
}
@@ -9,14 +9,17 @@ using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Metadata;
using ErsatzTV.Core.Tests.Fakes;
using FluentAssertions;
using LanguageExt;
using MediatR;
using Microsoft.Extensions.Logging;
using Moq;
using NUnit.Framework;
using static LanguageExt.Prelude;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Core.Tests.Metadata
{
@@ -44,20 +47,24 @@ namespace ErsatzTV.Core.Tests.Metadata
_movieRepository = new Mock<IMovieRepository>();
_movieRepository.Setup(x => x.GetOrAdd(It.IsAny<LibraryPath>(), It.IsAny<string>()))
.Returns(
(LibraryPath _, string path) => Right<BaseError, Movie>(new FakeMovieWithPath(path)).AsTask());
(LibraryPath _, string path) =>
Right<BaseError, MediaItemScanResult<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>();
_localStatisticsProvider.Setup(x => x.RefreshStatistics(It.IsAny<string>(), It.IsAny<MediaItem>()))
.Returns<string, MediaItem>((_, _) => Right<BaseError, bool>(true).AsTask());
// fallback metadata adds metadata to a movie, so we need to replicate that here
_localMetadataProvider.Setup(x => x.RefreshFallbackMetadata(It.IsAny<MediaItem>()))
_localMetadataProvider.Setup(x => x.RefreshFallbackMetadata(It.IsAny<Movie>()))
.Returns(
(MediaItem mediaItem) =>
{
((Movie) mediaItem).MovieMetadata = new List<MovieMetadata> { new() };
return Unit.Default.AsTask();
return Task.FromResult(true);
});
_imageCache = new Mock<IImageCache>();
@@ -76,7 +83,12 @@ namespace ErsatzTV.Core.Tests.Metadata
);
var libraryPath = new LibraryPath { Path = BadFakeRoot };
Either<BaseError, Unit> result = await service.ScanFolder(libraryPath, FFprobePath);
Either<BaseError, Unit> result = await service.ScanFolder(
libraryPath,
FFprobePath,
DateTimeOffset.MinValue,
0,
1);
result.IsLeft.Should().BeTrue();
result.IfLeft(error => error.Should().BeOfType<MediaSourceInaccessible>());
@@ -96,7 +108,12 @@ namespace ErsatzTV.Core.Tests.Metadata
);
var libraryPath = new LibraryPath { Id = 1, Path = FakeRoot };
Either<BaseError, Unit> result = await service.ScanFolder(libraryPath, FFprobePath);
Either<BaseError, Unit> result = await service.ScanFolder(
libraryPath,
FFprobePath,
DateTimeOffset.MinValue,
0,
1);
result.IsRight.Should().BeTrue();
@@ -104,11 +121,14 @@ namespace ErsatzTV.Core.Tests.Metadata
_movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once);
_localStatisticsProvider.Verify(
x => x.RefreshStatistics(FFprobePath, It.Is<FakeMovieWithPath>(i => i.Path == moviePath)),
x => x.RefreshStatistics(
FFprobePath,
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
Times.Once);
_localMetadataProvider.Verify(
x => x.RefreshFallbackMetadata(It.Is<FakeMovieWithPath>(i => i.Path == moviePath)),
x => x.RefreshFallbackMetadata(
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
Times.Once);
}
@@ -129,7 +149,12 @@ namespace ErsatzTV.Core.Tests.Metadata
);
var libraryPath = new LibraryPath { Id = 1, Path = FakeRoot };
Either<BaseError, Unit> result = await service.ScanFolder(libraryPath, FFprobePath);
Either<BaseError, Unit> result = await service.ScanFolder(
libraryPath,
FFprobePath,
DateTimeOffset.MinValue,
0,
1);
result.IsRight.Should().BeTrue();
@@ -137,11 +162,15 @@ namespace ErsatzTV.Core.Tests.Metadata
_movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once);
_localStatisticsProvider.Verify(
x => x.RefreshStatistics(FFprobePath, It.Is<FakeMovieWithPath>(i => i.Path == moviePath)),
x => x.RefreshStatistics(
FFprobePath,
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
Times.Once);
_localMetadataProvider.Verify(
x => x.RefreshSidecarMetadata(It.Is<FakeMovieWithPath>(i => i.Path == moviePath), metadataPath),
x => x.RefreshSidecarMetadata(
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath),
metadataPath),
Times.Once);
}
@@ -162,7 +191,12 @@ namespace ErsatzTV.Core.Tests.Metadata
);
var libraryPath = new LibraryPath { Id = 1, Path = FakeRoot };
Either<BaseError, Unit> result = await service.ScanFolder(libraryPath, FFprobePath);
Either<BaseError, Unit> result = await service.ScanFolder(
libraryPath,
FFprobePath,
DateTimeOffset.MinValue,
0,
1);
result.IsRight.Should().BeTrue();
@@ -170,11 +204,15 @@ namespace ErsatzTV.Core.Tests.Metadata
_movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once);
_localStatisticsProvider.Verify(
x => x.RefreshStatistics(FFprobePath, It.Is<FakeMovieWithPath>(i => i.Path == moviePath)),
x => x.RefreshStatistics(
FFprobePath,
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
Times.Once);
_localMetadataProvider.Verify(
x => x.RefreshSidecarMetadata(It.Is<FakeMovieWithPath>(i => i.Path == moviePath), metadataPath),
x => x.RefreshSidecarMetadata(
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath),
metadataPath),
Times.Once);
}
@@ -199,7 +237,12 @@ namespace ErsatzTV.Core.Tests.Metadata
);
var libraryPath = new LibraryPath { Id = 1, Path = FakeRoot };
Either<BaseError, Unit> result = await service.ScanFolder(libraryPath, FFprobePath);
Either<BaseError, Unit> result = await service.ScanFolder(
libraryPath,
FFprobePath,
DateTimeOffset.MinValue,
0,
1);
result.IsRight.Should().BeTrue();
@@ -207,11 +250,63 @@ namespace ErsatzTV.Core.Tests.Metadata
_movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once);
_localStatisticsProvider.Verify(
x => x.RefreshStatistics(FFprobePath, It.Is<FakeMovieWithPath>(i => i.Path == moviePath)),
x => x.RefreshStatistics(
FFprobePath,
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
Times.Once);
_localMetadataProvider.Verify(
x => x.RefreshFallbackMetadata(It.Is<FakeMovieWithPath>(i => i.Path == moviePath)),
x => x.RefreshFallbackMetadata(
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
Times.Once);
_imageCache.Verify(
x => x.CopyArtworkToCache(posterPath, ArtworkKind.Poster),
Times.Once);
}
[Test]
public async Task NewMovie_Statistics_And_FallbackMetadata_And_FolderPoster(
[ValueSource(typeof(LocalFolderScanner), nameof(LocalFolderScanner.VideoFileExtensions))]
string videoExtension,
[ValueSource(typeof(LocalFolderScanner), nameof(LocalFolderScanner.ImageFileExtensions))]
string imageExtension)
{
string moviePath = Path.Combine(
FakeRoot,
Path.Combine("Movie (2020)", $"Movie (2020){videoExtension}"));
string posterPath = Path.Combine(
Path.GetDirectoryName(moviePath) ?? string.Empty,
$"folder.{imageExtension}");
MovieFolderScanner service = GetService(
new FakeFileEntry(moviePath) { LastWriteTime = DateTime.Now },
new FakeFileEntry(posterPath) { LastWriteTime = DateTime.Now }
);
var libraryPath = new LibraryPath { Id = 1, Path = FakeRoot };
Either<BaseError, Unit> result = await service.ScanFolder(
libraryPath,
FFprobePath,
DateTimeOffset.MinValue,
0,
1);
result.IsRight.Should().BeTrue();
_movieRepository.Verify(x => x.GetOrAdd(It.IsAny<LibraryPath>(), It.IsAny<string>()), Times.Once);
_movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once);
_localStatisticsProvider.Verify(
x => x.RefreshStatistics(
FFprobePath,
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
Times.Once);
_localMetadataProvider.Verify(
x => x.RefreshFallbackMetadata(
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
Times.Once);
_imageCache.Verify(
@@ -240,7 +335,12 @@ namespace ErsatzTV.Core.Tests.Metadata
);
var libraryPath = new LibraryPath { Id = 1, Path = FakeRoot };
Either<BaseError, Unit> result = await service.ScanFolder(libraryPath, FFprobePath);
Either<BaseError, Unit> result = await service.ScanFolder(
libraryPath,
FFprobePath,
DateTimeOffset.MinValue,
0,
1);
result.IsRight.Should().BeTrue();
@@ -248,11 +348,14 @@ namespace ErsatzTV.Core.Tests.Metadata
_movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once);
_localStatisticsProvider.Verify(
x => x.RefreshStatistics(FFprobePath, It.Is<FakeMovieWithPath>(i => i.Path == moviePath)),
x => x.RefreshStatistics(
FFprobePath,
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
Times.Once);
_localMetadataProvider.Verify(
x => x.RefreshFallbackMetadata(It.Is<FakeMovieWithPath>(i => i.Path == moviePath)),
x => x.RefreshFallbackMetadata(
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
Times.Once);
_imageCache.Verify(
@@ -280,7 +383,12 @@ namespace ErsatzTV.Core.Tests.Metadata
);
var libraryPath = new LibraryPath { Id = 1, Path = FakeRoot };
Either<BaseError, Unit> result = await service.ScanFolder(libraryPath, FFprobePath);
Either<BaseError, Unit> result = await service.ScanFolder(
libraryPath,
FFprobePath,
DateTimeOffset.MinValue,
0,
1);
result.IsRight.Should().BeTrue();
@@ -288,11 +396,14 @@ namespace ErsatzTV.Core.Tests.Metadata
_movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once);
_localStatisticsProvider.Verify(
x => x.RefreshStatistics(FFprobePath, It.Is<FakeMovieWithPath>(i => i.Path == moviePath)),
x => x.RefreshStatistics(
FFprobePath,
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
Times.Once);
_localMetadataProvider.Verify(
x => x.RefreshFallbackMetadata(It.Is<FakeMovieWithPath>(i => i.Path == moviePath)),
x => x.RefreshFallbackMetadata(
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
Times.Once);
}
@@ -316,7 +427,12 @@ namespace ErsatzTV.Core.Tests.Metadata
);
var libraryPath = new LibraryPath { Id = 1, Path = FakeRoot };
Either<BaseError, Unit> result = await service.ScanFolder(libraryPath, FFprobePath);
Either<BaseError, Unit> result = await service.ScanFolder(
libraryPath,
FFprobePath,
DateTimeOffset.MinValue,
0,
1);
result.IsRight.Should().BeTrue();
@@ -324,11 +440,14 @@ namespace ErsatzTV.Core.Tests.Metadata
_movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once);
_localStatisticsProvider.Verify(
x => x.RefreshStatistics(FFprobePath, It.Is<FakeMovieWithPath>(i => i.Path == moviePath)),
x => x.RefreshStatistics(
FFprobePath,
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
Times.Once);
_localMetadataProvider.Verify(
x => x.RefreshFallbackMetadata(It.Is<FakeMovieWithPath>(i => i.Path == moviePath)),
x => x.RefreshFallbackMetadata(
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
Times.Once);
}
@@ -346,7 +465,12 @@ namespace ErsatzTV.Core.Tests.Metadata
);
var libraryPath = new LibraryPath { Id = 1, Path = FakeRoot };
Either<BaseError, Unit> result = await service.ScanFolder(libraryPath, FFprobePath);
Either<BaseError, Unit> result = await service.ScanFolder(
libraryPath,
FFprobePath,
DateTimeOffset.MinValue,
0,
1);
result.IsRight.Should().BeTrue();
@@ -354,11 +478,14 @@ namespace ErsatzTV.Core.Tests.Metadata
_movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once);
_localStatisticsProvider.Verify(
x => x.RefreshStatistics(FFprobePath, It.Is<FakeMovieWithPath>(i => i.Path == moviePath)),
x => x.RefreshStatistics(
FFprobePath,
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
Times.Once);
_localMetadataProvider.Verify(
x => x.RefreshFallbackMetadata(It.Is<FakeMovieWithPath>(i => i.Path == moviePath)),
x => x.RefreshFallbackMetadata(
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
Times.Once);
}
@@ -378,7 +505,12 @@ namespace ErsatzTV.Core.Tests.Metadata
);
var libraryPath = new LibraryPath { Id = 1, Path = FakeRoot };
Either<BaseError, Unit> result = await service.ScanFolder(libraryPath, FFprobePath);
Either<BaseError, Unit> result = await service.ScanFolder(
libraryPath,
FFprobePath,
DateTimeOffset.MinValue,
0,
1);
result.IsRight.Should().BeTrue();
@@ -402,7 +534,12 @@ namespace ErsatzTV.Core.Tests.Metadata
);
var libraryPath = new LibraryPath { Id = 1, Path = FakeRoot };
Either<BaseError, Unit> result = await service.ScanFolder(libraryPath, FFprobePath);
Either<BaseError, Unit> result = await service.ScanFolder(
libraryPath,
FFprobePath,
DateTimeOffset.MinValue,
0,
1);
result.IsRight.Should().BeTrue();
@@ -417,7 +554,11 @@ namespace ErsatzTV.Core.Tests.Metadata
_movieRepository.Object,
_localStatisticsProvider.Object,
_localMetadataProvider.Object,
new Mock<IMetadataRepository>().Object,
_imageCache.Object,
new Mock<ISearchIndex>().Object,
new Mock<ISearchRepository>().Object,
new Mock<IMediator>().Object,
new Mock<ILogger<MovieFolderScanner>>().Object
);
}
@@ -0,0 +1,211 @@
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Runtime;
using ErsatzTV.Core.Plex;
using FluentAssertions;
using LanguageExt;
using Microsoft.Extensions.Logging;
using Moq;
using NUnit.Framework;
namespace ErsatzTV.Core.Tests.Plex
{
[TestFixture]
public class PlexPathReplacementServiceTests
{
[Test]
public async Task PlexWindows_To_EtvWindows()
{
var replacements = new List<PlexPathReplacement>
{
new()
{
Id = 1,
PlexPath = @"C:\Something\Some Shared Folder",
LocalPath = @"C:\Something Else\Some Shared Folder",
PlexMediaSource = new PlexMediaSource { Platform = "Windows" }
}
};
var repo = new Mock<IMediaSourceRepository>();
repo.Setup(x => x.GetPlexPathReplacementsByLibraryId(It.IsAny<int>())).Returns(replacements.AsTask());
var runtime = new Mock<IRuntimeInfo>();
runtime.Setup(x => x.IsOSPlatform(OSPlatform.Windows)).Returns(true);
var service = new PlexPathReplacementService(
repo.Object,
runtime.Object,
new Mock<ILogger<PlexPathReplacementService>>().Object);
string result = await service.GetReplacementPlexPath(
0,
@"C:\Something\Some Shared Folder\Some Movie\Some Movie.mkv");
result.Should().Be(@"C:\Something Else\Some Shared Folder\Some Movie\Some Movie.mkv");
}
[Test]
public async Task PlexWindows_To_EtvLinux()
{
var replacements = new List<PlexPathReplacement>
{
new()
{
Id = 1,
PlexPath = @"C:\Something\Some Shared Folder",
LocalPath = @"/mnt/something else/Some Shared Folder",
PlexMediaSource = new PlexMediaSource { Platform = "Windows" }
}
};
var repo = new Mock<IMediaSourceRepository>();
repo.Setup(x => x.GetPlexPathReplacementsByLibraryId(It.IsAny<int>())).Returns(replacements.AsTask());
var runtime = new Mock<IRuntimeInfo>();
runtime.Setup(x => x.IsOSPlatform(OSPlatform.Windows)).Returns(false);
var service = new PlexPathReplacementService(
repo.Object,
runtime.Object,
new Mock<ILogger<PlexPathReplacementService>>().Object);
string result = await service.GetReplacementPlexPath(
0,
@"C:\Something\Some Shared Folder\Some Movie\Some Movie.mkv");
result.Should().Be(@"/mnt/something else/Some Shared Folder/Some Movie/Some Movie.mkv");
}
[Test]
public async Task PlexWindows_To_EtvLinux_UncPath()
{
var replacements = new List<PlexPathReplacement>
{
new()
{
Id = 1,
PlexPath = @"\\192.168.1.100\Something\Some Shared Folder",
LocalPath = @"/mnt/something else/Some Shared Folder",
PlexMediaSource = new PlexMediaSource { Platform = "Windows" }
}
};
var repo = new Mock<IMediaSourceRepository>();
repo.Setup(x => x.GetPlexPathReplacementsByLibraryId(It.IsAny<int>())).Returns(replacements.AsTask());
var runtime = new Mock<IRuntimeInfo>();
runtime.Setup(x => x.IsOSPlatform(OSPlatform.Windows)).Returns(false);
var service = new PlexPathReplacementService(
repo.Object,
runtime.Object,
new Mock<ILogger<PlexPathReplacementService>>().Object);
string result = await service.GetReplacementPlexPath(
0,
@"\\192.168.1.100\Something\Some Shared Folder\Some Movie\Some Movie.mkv");
result.Should().Be(@"/mnt/something else/Some Shared Folder/Some Movie/Some Movie.mkv");
}
[Test]
public async Task PlexWindows_To_EtvLinux_UncPathWithTrailingSlash()
{
var replacements = new List<PlexPathReplacement>
{
new()
{
Id = 1,
PlexPath = @"\\192.168.1.100\Something\Some Shared Folder\",
LocalPath = @"/mnt/something else/Some Shared Folder/",
PlexMediaSource = new PlexMediaSource { Platform = "Windows" }
}
};
var repo = new Mock<IMediaSourceRepository>();
repo.Setup(x => x.GetPlexPathReplacementsByLibraryId(It.IsAny<int>())).Returns(replacements.AsTask());
var runtime = new Mock<IRuntimeInfo>();
runtime.Setup(x => x.IsOSPlatform(OSPlatform.Windows)).Returns(false);
var service = new PlexPathReplacementService(
repo.Object,
runtime.Object,
new Mock<ILogger<PlexPathReplacementService>>().Object);
string result = await service.GetReplacementPlexPath(
0,
@"\\192.168.1.100\Something\Some Shared Folder\Some Movie\Some Movie.mkv");
result.Should().Be(@"/mnt/something else/Some Shared Folder/Some Movie/Some Movie.mkv");
}
[Test]
public async Task PlexLinux_To_EtvWindows()
{
var replacements = new List<PlexPathReplacement>
{
new()
{
Id = 1,
PlexPath = @"/mnt/something/Some Shared Folder",
LocalPath = @"C:\Something Else\Some Shared Folder",
PlexMediaSource = new PlexMediaSource { Platform = "Linux" }
}
};
var repo = new Mock<IMediaSourceRepository>();
repo.Setup(x => x.GetPlexPathReplacementsByLibraryId(It.IsAny<int>())).Returns(replacements.AsTask());
var runtime = new Mock<IRuntimeInfo>();
runtime.Setup(x => x.IsOSPlatform(OSPlatform.Windows)).Returns(true);
var service = new PlexPathReplacementService(
repo.Object,
runtime.Object,
new Mock<ILogger<PlexPathReplacementService>>().Object);
string result = await service.GetReplacementPlexPath(
0,
@"/mnt/something/Some Shared Folder/Some Movie/Some Movie.mkv");
result.Should().Be(@"C:\Something Else\Some Shared Folder\Some Movie\Some Movie.mkv");
}
[Test]
public async Task PlexLinux_To_EtvLinux()
{
var replacements = new List<PlexPathReplacement>
{
new()
{
Id = 1,
PlexPath = @"/mnt/something/Some Shared Folder",
LocalPath = @"/mnt/something else/Some Shared Folder",
PlexMediaSource = new PlexMediaSource { Platform = "Linux" }
}
};
var repo = new Mock<IMediaSourceRepository>();
repo.Setup(x => x.GetPlexPathReplacementsByLibraryId(It.IsAny<int>())).Returns(replacements.AsTask());
var runtime = new Mock<IRuntimeInfo>();
runtime.Setup(x => x.IsOSPlatform(OSPlatform.Windows)).Returns(false);
var service = new PlexPathReplacementService(
repo.Object,
runtime.Object,
new Mock<ILogger<PlexPathReplacementService>>().Object);
string result = await service.GetReplacementPlexPath(
0,
@"/mnt/something/Some Shared Folder/Some Movie/Some Movie.mkv");
result.Should().Be(@"/mnt/something else/Some Shared Folder/Some Movie/Some Movie.mkv");
}
}
}
@@ -610,6 +610,190 @@ namespace ErsatzTV.Core.Tests.Scheduling
result.Items[5].MediaItemId.Should().Be(4);
}
[Test]
public async Task Alternating_MultipleContent_Should_Maintain_Counts()
{
var collectionOne = new Collection
{
Id = 1,
Name = "Multiple Items 1",
MediaItems = new List<MediaItem>
{
TestMovie(1, TimeSpan.FromHours(1), new DateTime(2020, 1, 1))
}
};
var collectionTwo = new Collection
{
Id = 2,
Name = "Multiple Items 2",
MediaItems = new List<MediaItem>
{
TestMovie(2, TimeSpan.FromHours(1), new DateTime(2020, 1, 1))
}
};
var fakeRepository = new FakeMediaCollectionRepository(
Map(
(collectionOne.Id, collectionOne.MediaItems.ToList()),
(collectionTwo.Id, collectionTwo.MediaItems.ToList())));
var items = new List<ProgramScheduleItem>
{
new ProgramScheduleItemMultiple
{
Id = 1,
Index = 1,
Collection = collectionOne,
CollectionId = collectionOne.Id,
StartTime = null,
Count = 3
},
new ProgramScheduleItemMultiple
{
Id = 2,
Index = 2,
Collection = collectionTwo,
CollectionId = collectionTwo.Id,
StartTime = null,
Count = 3
}
};
var playout = new Playout
{
ProgramSchedule = new ProgramSchedule
{
Items = items,
MediaCollectionPlaybackOrder = PlaybackOrder.Chronological
},
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
Anchor = new PlayoutAnchor
{
NextStart = HoursAfterMidnight(1).UtcDateTime,
NextScheduleItem = items[0],
NextScheduleItemId = 1,
MultipleRemaining = 2
}
};
var televisionRepo = new FakeTelevisionRepository();
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, _logger);
DateTimeOffset start = HoursAfterMidnight(0);
DateTimeOffset finish = start + TimeSpan.FromHours(5);
Playout result = await builder.BuildPlayoutItems(playout, start, finish);
result.Items.Count.Should().Be(4);
result.Items[0].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(1));
result.Items[0].MediaItemId.Should().Be(1);
result.Items[1].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(2));
result.Items[1].MediaItemId.Should().Be(1);
result.Items[2].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(3));
result.Items[2].MediaItemId.Should().Be(2);
result.Items[3].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(4));
result.Items[3].MediaItemId.Should().Be(2);
result.Anchor.NextScheduleItem.Should().Be(items[1]);
result.Anchor.MultipleRemaining.Should().Be(1);
}
[Test]
public async Task Alternating_Duration_Should_Maintain_Duration()
{
var collectionOne = new Collection
{
Id = 1,
Name = "Duration Items 1",
MediaItems = new List<MediaItem>
{
TestMovie(1, TimeSpan.FromHours(1), new DateTime(2020, 1, 1))
}
};
var collectionTwo = new Collection
{
Id = 2,
Name = "Duration Items 2",
MediaItems = new List<MediaItem>
{
TestMovie(2, TimeSpan.FromHours(1), new DateTime(2020, 1, 1))
}
};
var fakeRepository = new FakeMediaCollectionRepository(
Map(
(collectionOne.Id, collectionOne.MediaItems.ToList()),
(collectionTwo.Id, collectionTwo.MediaItems.ToList())));
var items = new List<ProgramScheduleItem>
{
new ProgramScheduleItemDuration
{
Id = 1,
Index = 1,
Collection = collectionOne,
CollectionId = collectionOne.Id,
StartTime = null,
PlayoutDuration = TimeSpan.FromHours(3),
OfflineTail = false
},
new ProgramScheduleItemDuration
{
Id = 2,
Index = 2,
Collection = collectionTwo,
CollectionId = collectionTwo.Id,
StartTime = null,
PlayoutDuration = TimeSpan.FromHours(3),
OfflineTail = false
}
};
var playout = new Playout
{
ProgramSchedule = new ProgramSchedule
{
Items = items,
MediaCollectionPlaybackOrder = PlaybackOrder.Chronological
},
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
Anchor = new PlayoutAnchor
{
NextStart = HoursAfterMidnight(1).UtcDateTime,
NextScheduleItem = items[0],
NextScheduleItemId = 1,
DurationFinish = HoursAfterMidnight(3).UtcDateTime
}
};
var televisionRepo = new FakeTelevisionRepository();
var builder = new PlayoutBuilder(fakeRepository, televisionRepo, _logger);
DateTimeOffset start = HoursAfterMidnight(0);
DateTimeOffset finish = start + TimeSpan.FromHours(5);
Playout result = await builder.BuildPlayoutItems(playout, start, finish);
result.Items.Count.Should().Be(4);
result.Items[0].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(1));
result.Items[0].MediaItemId.Should().Be(1);
result.Items[1].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(2));
result.Items[1].MediaItemId.Should().Be(1);
result.Items[2].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(3));
result.Items[2].MediaItemId.Should().Be(2);
result.Items[3].StartOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(4));
result.Items[3].MediaItemId.Should().Be(2);
result.Anchor.NextScheduleItem.Should().Be(items[1]);
result.Anchor.DurationFinish.Should().Be(HoursAfterMidnight(6).UtcDateTime);
}
private static DateTimeOffset HoursAfterMidnight(int hours)
{
DateTimeOffset now = DateTimeOffset.Now;

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