Browse Source

Initial commit

pull/1/head
Jason Dove 4 years ago
commit
4d52e115b5
  1. 18
      .config/dotnet-tools.json
  2. 7
      .dockerignore
  3. 81
      .editorconfig
  4. 42
      .gitignore
  5. 23
      Dockerfile
  6. 12
      ErsatzTV.Application/Channels/ChannelViewModel.cs
  7. 15
      ErsatzTV.Application/Channels/Commands/CreateChannel.cs
  8. 58
      ErsatzTV.Application/Channels/Commands/CreateChannelHandler.cs
  9. 9
      ErsatzTV.Application/Channels/Commands/DeleteChannel.cs
  10. 28
      ErsatzTV.Application/Channels/Commands/DeleteChannelHandler.cs
  11. 16
      ErsatzTV.Application/Channels/Commands/UpdateChannel.cs
  12. 60
      ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs
  13. 10
      ErsatzTV.Application/Channels/Mapper.cs
  14. 7
      ErsatzTV.Application/Channels/Queries/GetAllChannels.cs
  15. 21
      ErsatzTV.Application/Channels/Queries/GetAllChannelsHandler.cs
  16. 7
      ErsatzTV.Application/Channels/Queries/GetChannelById.cs
  17. 20
      ErsatzTV.Application/Channels/Queries/GetChannelByIdHandler.cs
  18. 7
      ErsatzTV.Application/Channels/Queries/GetChannelGuide.cs
  19. 20
      ErsatzTV.Application/Channels/Queries/GetChannelGuideHandler.cs
  20. 8
      ErsatzTV.Application/Channels/Queries/GetChannelLineup.cs
  21. 22
      ErsatzTV.Application/Channels/Queries/GetChannelLineupHandler.cs
  22. 7
      ErsatzTV.Application/Channels/Queries/GetChannelPlaylist.cs
  23. 21
      ErsatzTV.Application/Channels/Queries/GetChannelPlaylistHandler.cs
  24. 18
      ErsatzTV.Application/ErsatzTV.Application.csproj
  25. 25
      ErsatzTV.Application/FFmpegProfiles/Commands/CreateFFmpegProfile.cs
  26. 72
      ErsatzTV.Application/FFmpegProfiles/Commands/CreateFFmpegProfileHandler.cs
  27. 9
      ErsatzTV.Application/FFmpegProfiles/Commands/DeleteFFmpegProfile.cs
  28. 32
      ErsatzTV.Application/FFmpegProfiles/Commands/DeleteFFmpegProfileHandler.cs
  29. 10
      ErsatzTV.Application/FFmpegProfiles/Commands/NewFFmpegProfile.cs
  30. 40
      ErsatzTV.Application/FFmpegProfiles/Commands/NewFFmpegProfileHandler.cs
  31. 26
      ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegProfile.cs
  32. 78
      ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegProfileHandler.cs
  33. 6
      ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegSettings.cs
  34. 70
      ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegSettingsHandler.cs
  35. 24
      ErsatzTV.Application/FFmpegProfiles/FFmpegProfileViewModel.cs
  36. 9
      ErsatzTV.Application/FFmpegProfiles/FFmpegSettingsViewModel.cs
  37. 32
      ErsatzTV.Application/FFmpegProfiles/Mapper.cs
  38. 7
      ErsatzTV.Application/FFmpegProfiles/Queries/GetAllFFmpegProfiles.cs
  39. 23
      ErsatzTV.Application/FFmpegProfiles/Queries/GetAllFFmpegProfilesHandler.cs
  40. 7
      ErsatzTV.Application/FFmpegProfiles/Queries/GetFFmpegProfileById.cs
  41. 23
      ErsatzTV.Application/FFmpegProfiles/Queries/GetFFmpegProfileByIdHandler.cs
  42. 6
      ErsatzTV.Application/FFmpegProfiles/Queries/GetFFmpegSettings.cs
  43. 34
      ErsatzTV.Application/FFmpegProfiles/Queries/GetFFmpegSettingsHandler.cs
  44. 6
      ErsatzTV.Application/IBackgroundServiceRequest.cs
  45. 6
      ErsatzTV.Application/IPlexBackgroundServiceRequest.cs
  46. 9
      ErsatzTV.Application/Images/Commands/SaveImageToDisk.cs
  47. 43
      ErsatzTV.Application/Images/Commands/SaveImageToDiskHandler.cs
  48. 5
      ErsatzTV.Application/Images/ImageViewModel.cs
  49. 8
      ErsatzTV.Application/Images/Queries/GetImageContents.cs
  50. 44
      ErsatzTV.Application/Images/Queries/GetImageContentsHandler.cs
  51. 9
      ErsatzTV.Application/MediaCollections/Commands/CreateSimpleMediaCollection.cs
  52. 49
      ErsatzTV.Application/MediaCollections/Commands/CreateSimpleMediaCollectionHandler.cs
  53. 9
      ErsatzTV.Application/MediaCollections/Commands/DeleteSimpleMediaCollection.cs
  54. 34
      ErsatzTV.Application/MediaCollections/Commands/DeleteSimpleMediaCollectionHandler.cs
  55. 11
      ErsatzTV.Application/MediaCollections/Commands/ReplaceSimpleMediaCollectionItems.cs
  56. 65
      ErsatzTV.Application/MediaCollections/Commands/ReplaceSimpleMediaCollectionItemsHandler.cs
  57. 47
      ErsatzTV.Application/MediaCollections/Commands/UpdateChannelHandler.cs
  58. 8
      ErsatzTV.Application/MediaCollections/Commands/UpdateSimpleMediaCollection.cs
  59. 19
      ErsatzTV.Application/MediaCollections/Mapper.cs
  60. 4
      ErsatzTV.Application/MediaCollections/MediaCollectionSummaryViewModel.cs
  61. 4
      ErsatzTV.Application/MediaCollections/MediaCollectionViewModel.cs
  62. 7
      ErsatzTV.Application/MediaCollections/Queries/GetAllMediaCollections.cs
  63. 24
      ErsatzTV.Application/MediaCollections/Queries/GetAllMediaCollectionsHandler.cs
  64. 7
      ErsatzTV.Application/MediaCollections/Queries/GetAllSimpleMediaCollections.cs
  65. 25
      ErsatzTV.Application/MediaCollections/Queries/GetAllSimpleMediaCollectionsHandler.cs
  66. 7
      ErsatzTV.Application/MediaCollections/Queries/GetMediaCollectionSummaries.cs
  67. 27
      ErsatzTV.Application/MediaCollections/Queries/GetMediaCollectionSummariesHandler.cs
  68. 7
      ErsatzTV.Application/MediaCollections/Queries/GetSimpleMediaCollectionById.cs
  69. 25
      ErsatzTV.Application/MediaCollections/Queries/GetSimpleMediaCollectionByIdHandler.cs
  70. 9
      ErsatzTV.Application/MediaCollections/Queries/GetSimpleMediaCollectionItems.cs
  71. 26
      ErsatzTV.Application/MediaCollections/Queries/GetSimpleMediaCollectionItemsHandler.cs
  72. 4
      ErsatzTV.Application/MediaItems/AggregateMediaItemViewModel.cs
  73. 8
      ErsatzTV.Application/MediaItems/Commands/CreateMediaItem.cs
  74. 96
      ErsatzTV.Application/MediaItems/Commands/CreateMediaItemHandler.cs
  75. 9
      ErsatzTV.Application/MediaItems/Commands/DeleteMediaItem.cs
  76. 31
      ErsatzTV.Application/MediaItems/Commands/DeleteMediaItemHandler.cs
  77. 8
      ErsatzTV.Application/MediaItems/Commands/RefreshMediaItem.cs
  78. 9
      ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemCollections.cs
  79. 46
      ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemCollectionsHandler.cs
  80. 9
      ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemMetadata.cs
  81. 52
      ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemMetadataHandler.cs
  82. 9
      ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemStatistics.cs
  83. 65
      ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemStatisticsHandler.cs
  84. 13
      ErsatzTV.Application/MediaItems/Mapper.cs
  85. 4
      ErsatzTV.Application/MediaItems/MediaItemViewModel.cs
  86. 9
      ErsatzTV.Application/MediaItems/Queries/GetAggregateMediaItems.cs
  87. 43
      ErsatzTV.Application/MediaItems/Queries/GetAggregateMediaItemsHandler.cs
  88. 7
      ErsatzTV.Application/MediaItems/Queries/GetAllMediaItems.cs
  89. 23
      ErsatzTV.Application/MediaItems/Queries/GetAllMediaItemsHandler.cs
  90. 7
      ErsatzTV.Application/MediaItems/Queries/GetMediaItemById.cs
  91. 23
      ErsatzTV.Application/MediaItems/Queries/GetMediaItemByIdHandler.cs
  92. 10
      ErsatzTV.Application/MediaSources/Commands/CreateLocalMediaSource.cs
  93. 78
      ErsatzTV.Application/MediaSources/Commands/CreateLocalMediaSourceHandler.cs
  94. 9
      ErsatzTV.Application/MediaSources/Commands/DeleteLocalMediaSource.cs
  95. 50
      ErsatzTV.Application/MediaSources/Commands/DeleteLocalMediaSourceHandler.cs
  96. 9
      ErsatzTV.Application/MediaSources/Commands/ScanLocalMediaSource.cs
  97. 56
      ErsatzTV.Application/MediaSources/Commands/ScanLocalMediaSourceHandler.cs
  98. 8
      ErsatzTV.Application/MediaSources/Commands/StartPlexPinFlow.cs
  99. 38
      ErsatzTV.Application/MediaSources/Commands/StartPlexPinFlowHandler.cs
  100. 7
      ErsatzTV.Application/MediaSources/Commands/SynchronizePlexLibraries.cs
  101. Some files were not shown because too many files have changed in this diff Show More

18
.config/dotnet-tools.json

@ -0,0 +1,18 @@ @@ -0,0 +1,18 @@
{
"version": 1,
"isRoot": true,
"tools": {
"jetbrains.resharper.globaltools": {
"version": "2020.3.2",
"commands": [
"jb"
]
},
"swashbuckle.aspnetcore.cli": {
"version": "5.6.2",
"commands": [
"swagger"
]
}
}
}

7
.dockerignore

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
docs/
sdk/
**/.vs/
**/bin/
**/obj/
.idea/
Dockerfile

81
.editorconfig

@ -0,0 +1,81 @@ @@ -0,0 +1,81 @@
[*]
charset=utf-8-bom
end_of_line=lf
trim_trailing_whitespace=false
insert_final_newline=false
indent_style=space
indent_size=4
# Microsoft .NET properties
csharp_new_line_before_members_in_object_initializers=false
csharp_preferred_modifier_order=public, private, protected, internal, new, abstract, virtual, sealed, override, static, readonly, extern, unsafe, volatile, async:suggestion
csharp_style_expression_bodied_accessors=true:suggestion
csharp_style_expression_bodied_constructors=true:none
csharp_style_expression_bodied_methods=true:none
csharp_style_expression_bodied_properties=true:suggestion
csharp_style_var_elsewhere=false:suggestion
csharp_style_var_for_built_in_types=false:suggestion
csharp_style_var_when_type_is_apparent=true:suggestion
dotnet_naming_rule.local_constants_rule.severity=warning
dotnet_naming_rule.local_constants_rule.style=all_upper_style
dotnet_naming_rule.local_constants_rule.symbols=local_constants_symbols
dotnet_naming_style.all_upper_style.capitalization=all_upper
dotnet_naming_style.all_upper_style.word_separator=_
dotnet_naming_symbols.local_constants_symbols.applicable_accessibilities=*
dotnet_naming_symbols.local_constants_symbols.applicable_kinds=local
dotnet_naming_symbols.local_constants_symbols.required_modifiers=const
dotnet_style_parentheses_in_arithmetic_binary_operators=never_if_unnecessary:none
dotnet_style_parentheses_in_other_binary_operators=never_if_unnecessary:none
dotnet_style_parentheses_in_relational_binary_operators=never_if_unnecessary:none
dotnet_style_predefined_type_for_locals_parameters_members=true:suggestion
dotnet_style_predefined_type_for_member_access=true:suggestion
dotnet_style_qualification_for_event=false:suggestion
dotnet_style_qualification_for_field=false:suggestion
dotnet_style_qualification_for_method=false:suggestion
dotnet_style_qualification_for_property=false:suggestion
dotnet_style_require_accessibility_modifiers=for_non_interface_members:suggestion
# ReSharper properties
resharper_autodetect_indent_settings=true
resharper_braces_for_for=required
resharper_braces_for_foreach=required
resharper_braces_for_ifelse=required
resharper_braces_for_while=required
resharper_csharp_insert_final_newline=true
resharper_csharp_max_attribute_length_for_same_line=0
resharper_csharp_place_accessorholder_attribute_on_same_line=never
resharper_csharp_place_field_attribute_on_same_line=if_owner_is_single_line
resharper_csharp_wrap_after_declaration_lpar=true
resharper_csharp_wrap_after_invocation_lpar=true
resharper_csharp_wrap_arguments_style=chop_if_long
resharper_csharp_wrap_parameters_style=chop_if_long
resharper_enforce_line_ending_style=true
resharper_for_built_in_types=use_var_when_evident
resharper_space_within_single_line_array_initializer_braces=true
resharper_use_indent_from_vs=false
resharper_wrap_array_initializer_style=chop_if_long
# ReSharper inspection severities
resharper_arrange_redundant_parentheses_highlighting=hint
resharper_arrange_this_qualifier_highlighting=hint
resharper_arrange_type_member_modifiers_highlighting=hint
resharper_arrange_type_modifiers_highlighting=hint
resharper_built_in_type_reference_style_for_member_access_highlighting=hint
resharper_built_in_type_reference_style_highlighting=hint
resharper_redundant_base_qualifier_highlighting=warning
resharper_suggest_var_or_type_built_in_types_highlighting=hint
resharper_suggest_var_or_type_elsewhere_highlighting=hint
resharper_suggest_var_or_type_simple_types_highlighting=hint
resharper_web_config_module_not_resolved_highlighting=warning
resharper_web_config_type_not_resolved_highlighting=warning
resharper_web_config_wrong_module_highlighting=warning
[{*.har,*.inputactions,*.jsb2,*.jsb3,*.json,.babelrc,.eslintrc,.stylelintrc,bowerrc,jest.config}]
indent_style=space
indent_size=2
[*.{appxmanifest,asax,ascx,aspx,build,cg,cginc,compute,cs,cshtml,dtd,fs,fsi,fsscript,fsx,hlsl,hlsli,hlslinc,master,ml,mli,nuspec,razor,resw,resx,shader,skin,usf,ush,vb,xaml,xamlx,xoml,xsd}]
indent_style=space
indent_size=4
tab_width=4

42
.gitignore vendored

@ -0,0 +1,42 @@ @@ -0,0 +1,42 @@
*.swp
*.*~
project.lock.json
.DS_Store
*.pyc
nupkg/
# Visual Studio Code
.vscode
# Rider
.idea
# User-specific files
*.suo
*.user
*.userosscache
*.sln.docstates
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
build/
bld/
[Bb]in/
[Oo]bj/
[Oo]ut/
msbuild.log
msbuild.err
msbuild.wrn
# Visual Studio 2015
.vs/
*.sqlite3*
core
scripts/generate-api-sdk/swagger.json

23
Dockerfile

@ -0,0 +1,23 @@ @@ -0,0 +1,23 @@
FROM mcr.microsoft.com/dotnet/aspnet:5.0-focal-amd64 AS runtime-base
RUN apt-get update && apt-get install -y ffmpeg
# https://hub.docker.com/_/microsoft-dotnet
FROM mcr.microsoft.com/dotnet/sdk:5.0 AS build
WORKDIR /source
# copy csproj and restore as distinct layers
COPY *.sln .
COPY ErsatzTV/*.csproj ./ErsatzTV/
COPY ErsatzTV.Tests/*.csproj ./ErsatzTV.Tests/
RUN dotnet restore -r linux-x64
# copy everything else and build app
COPY ErsatzTV/. ./ErsatzTV/
WORKDIR /source/ErsatzTV
RUN dotnet publish -c release -o /app -r linux-x64 --self-contained false --no-restore
# final stage/image
FROM runtime-base
WORKDIR /app
COPY --from=build /app ./
ENTRYPOINT ["./ErsatzTV"]

12
ErsatzTV.Application/Channels/ChannelViewModel.cs

@ -0,0 +1,12 @@ @@ -0,0 +1,12 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Channels
{
public record ChannelViewModel(
int Id,
int Number,
string Name,
int FFmpegProfileId,
string Logo,
StreamingMode StreamingMode);
}

15
ErsatzTV.Application/Channels/Commands/CreateChannel.cs

@ -0,0 +1,15 @@ @@ -0,0 +1,15 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.Channels.Commands
{
public record CreateChannel
(
string Name,
int Number,
int FFmpegProfileId,
string Logo,
StreamingMode StreamingMode) : IRequest<Either<BaseError, ChannelViewModel>>;
}

58
ErsatzTV.Application/Channels/Commands/CreateChannelHandler.cs

@ -0,0 +1,58 @@ @@ -0,0 +1,58 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.Channels.Mapper;
namespace ErsatzTV.Application.Channels.Commands
{
public class CreateChannelHandler : IRequestHandler<CreateChannel, Either<BaseError, ChannelViewModel>>
{
private readonly IChannelRepository _channelRepository;
private readonly IFFmpegProfileRepository _ffmpegProfileRepository;
public CreateChannelHandler(
IChannelRepository channelRepository,
IFFmpegProfileRepository ffmpegProfileRepository)
{
_channelRepository = channelRepository;
_ffmpegProfileRepository = ffmpegProfileRepository;
}
public Task<Either<BaseError, ChannelViewModel>> Handle(
CreateChannel request,
CancellationToken cancellationToken) =>
Validate(request)
.MapT(PersistChannel)
.Bind(v => v.ToEitherAsync());
private Task<ChannelViewModel> PersistChannel(Channel c) =>
_channelRepository.Add(c).Map(ProjectToViewModel);
private async Task<Validation<BaseError, Channel>> Validate(CreateChannel request) =>
(ValidateName(request), ValidateNumber(request), await FFmpegProfileMustExist(request))
.Apply(
(name, number, ffmpegProfileId) => new Channel(Guid.NewGuid())
{
Name = name, Number = number, FFmpegProfileId = ffmpegProfileId,
StreamingMode = request.StreamingMode
});
private Validation<BaseError, string> ValidateName(CreateChannel createChannel) =>
createChannel.NotEmpty(c => c.Name)
.Bind(_ => createChannel.NotLongerThan(50)(c => c.Name));
// TODO: validate number does not exist?
private Validation<BaseError, int> ValidateNumber(CreateChannel createChannel) =>
createChannel.AtLeast(1)(c => c.Number);
private async Task<Validation<BaseError, int>> FFmpegProfileMustExist(CreateChannel createChannel) =>
(await _ffmpegProfileRepository.Get(createChannel.FFmpegProfileId))
.ToValidation<BaseError>($"FFmpegProfile {createChannel.FFmpegProfileId} does not exist.")
.Map(c => c.Id);
}
}

9
ErsatzTV.Application/Channels/Commands/DeleteChannel.cs

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
using System.Threading.Tasks;
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.Channels.Commands
{
public record DeleteChannel(int ChannelId) : IRequest<Either<BaseError, Task>>;
}

28
ErsatzTV.Application/Channels/Commands/DeleteChannelHandler.cs

@ -0,0 +1,28 @@ @@ -0,0 +1,28 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.Channels.Commands
{
public class DeleteChannelHandler : IRequestHandler<DeleteChannel, Either<BaseError, Task>>
{
private readonly IChannelRepository _channelRepository;
public DeleteChannelHandler(IChannelRepository channelRepository) => _channelRepository = channelRepository;
public async Task<Either<BaseError, Task>> Handle(DeleteChannel request, CancellationToken cancellationToken) =>
(await ChannelMustExist(request))
.Map(DoDeletion)
.ToEither<Task>();
private Task DoDeletion(int channelId) => _channelRepository.Delete(channelId);
private async Task<Validation<BaseError, int>> ChannelMustExist(DeleteChannel deleteChannel) =>
(await _channelRepository.Get(deleteChannel.ChannelId))
.ToValidation<BaseError>($"Channel {deleteChannel.ChannelId} does not exist.")
.Map(c => c.Id);
}
}

16
ErsatzTV.Application/Channels/Commands/UpdateChannel.cs

@ -0,0 +1,16 @@ @@ -0,0 +1,16 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.Channels.Commands
{
public record UpdateChannel
(
int ChannelId,
string Name,
int Number,
int FFmpegProfileId,
string Logo,
StreamingMode StreamingMode) : IRequest<Either<BaseError, ChannelViewModel>>;
}

60
ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs

@ -0,0 +1,60 @@ @@ -0,0 +1,60 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.Channels.Mapper;
namespace ErsatzTV.Application.Channels.Commands
{
public class UpdateChannelHandler : IRequestHandler<UpdateChannel, Either<BaseError, ChannelViewModel>>
{
private readonly IChannelRepository _channelRepository;
public UpdateChannelHandler(IChannelRepository channelRepository) => _channelRepository = channelRepository;
public Task<Either<BaseError, ChannelViewModel>> Handle(
UpdateChannel request,
CancellationToken cancellationToken) =>
Validate(request)
.MapT(c => ApplyUpdateRequest(c, request))
.Bind(v => v.ToEitherAsync());
private async Task<ChannelViewModel> ApplyUpdateRequest(Channel c, UpdateChannel update)
{
c.Name = update.Name;
c.Number = update.Number;
c.FFmpegProfileId = update.FFmpegProfileId;
c.Logo = update.Logo;
c.StreamingMode = update.StreamingMode;
await _channelRepository.Update(c);
return ProjectToViewModel(c);
}
private async Task<Validation<BaseError, Channel>> Validate(UpdateChannel request) =>
(await ChannelMustExist(request), ValidateName(request), await ValidateNumber(request))
.Apply((channelToUpdate, _, _) => channelToUpdate);
private Task<Validation<BaseError, Channel>> ChannelMustExist(UpdateChannel updateChannel) =>
_channelRepository.Get(updateChannel.ChannelId)
.Map(v => v.ToValidation<BaseError>("Channel does not exist."));
private Validation<BaseError, string> ValidateName(UpdateChannel updateChannel) =>
updateChannel.NotEmpty(c => c.Name)
.Bind(_ => updateChannel.NotLongerThan(50)(c => c.Name));
private async Task<Validation<BaseError, int>> ValidateNumber(UpdateChannel updateChannel)
{
Option<Channel> match = await _channelRepository.GetByNumber(updateChannel.Number);
int matchId = match.Map(c => c.Id).IfNone(updateChannel.ChannelId);
if (matchId == updateChannel.ChannelId)
{
return updateChannel.AtLeast(1)(c => c.Number);
}
return BaseError.New("Channel number must be unique");
}
}
}

10
ErsatzTV.Application/Channels/Mapper.cs

@ -0,0 +1,10 @@ @@ -0,0 +1,10 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Channels
{
internal static class Mapper
{
internal static ChannelViewModel ProjectToViewModel(Channel channel) =>
new(channel.Id, channel.Number, channel.Name, channel.FFmpegProfileId, channel.Logo, channel.StreamingMode);
}
}

7
ErsatzTV.Application/Channels/Queries/GetAllChannels.cs

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
using System.Collections.Generic;
using MediatR;
namespace ErsatzTV.Application.Channels.Queries
{
public record GetAllChannels : IRequest<List<ChannelViewModel>>;
}

21
ErsatzTV.Application/Channels/Queries/GetAllChannelsHandler.cs

@ -0,0 +1,21 @@ @@ -0,0 +1,21 @@
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.Channels.Mapper;
namespace ErsatzTV.Application.Channels.Queries
{
public class GetAllChannelsHandler : IRequestHandler<GetAllChannels, List<ChannelViewModel>>
{
private readonly IChannelRepository _channelRepository;
public GetAllChannelsHandler(IChannelRepository channelRepository) => _channelRepository = channelRepository;
public Task<List<ChannelViewModel>> Handle(GetAllChannels request, CancellationToken cancellationToken) =>
_channelRepository.GetAll().Map(channels => channels.Map(ProjectToViewModel).ToList());
}
}

7
ErsatzTV.Application/Channels/Queries/GetChannelById.cs

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.Channels.Queries
{
public record GetChannelById(int Id) : IRequest<Option<ChannelViewModel>>;
}

20
ErsatzTV.Application/Channels/Queries/GetChannelByIdHandler.cs

@ -0,0 +1,20 @@ @@ -0,0 +1,20 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.Channels.Mapper;
namespace ErsatzTV.Application.Channels.Queries
{
public class GetChannelByIdHandler : IRequestHandler<GetChannelById, Option<ChannelViewModel>>
{
private readonly IChannelRepository _channelRepository;
public GetChannelByIdHandler(IChannelRepository channelRepository) => _channelRepository = channelRepository;
public Task<Option<ChannelViewModel>> Handle(GetChannelById request, CancellationToken cancellationToken) =>
_channelRepository.Get(request.Id)
.MapT(ProjectToViewModel);
}
}

7
ErsatzTV.Application/Channels/Queries/GetChannelGuide.cs

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
using ErsatzTV.Core.Iptv;
using MediatR;
namespace ErsatzTV.Application.Channels.Queries
{
public record GetChannelGuide(string Scheme, string Host) : IRequest<ChannelGuide>;
}

20
ErsatzTV.Application/Channels/Queries/GetChannelGuideHandler.cs

@ -0,0 +1,20 @@ @@ -0,0 +1,20 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Iptv;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.Channels.Queries
{
public class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, ChannelGuide>
{
private readonly IChannelRepository _channelRepository;
public GetChannelGuideHandler(IChannelRepository channelRepository) => _channelRepository = channelRepository;
public Task<ChannelGuide> Handle(GetChannelGuide request, CancellationToken cancellationToken) =>
_channelRepository.GetAllForGuide()
.Map(channels => new ChannelGuide(request.Scheme, request.Host, channels));
}
}

8
ErsatzTV.Application/Channels/Queries/GetChannelLineup.cs

@ -0,0 +1,8 @@ @@ -0,0 +1,8 @@
using System.Collections.Generic;
using ErsatzTV.Core.Hdhr;
using MediatR;
namespace ErsatzTV.Application.Channels.Queries
{
public record GetChannelLineup(string Scheme, string Host) : IRequest<List<LineupItem>>;
}

22
ErsatzTV.Application/Channels/Queries/GetChannelLineupHandler.cs

@ -0,0 +1,22 @@ @@ -0,0 +1,22 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Hdhr;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.Channels.Queries
{
public class GetChannelLineupHandler : IRequestHandler<GetChannelLineup, List<LineupItem>>
{
private readonly IChannelRepository _channelRepository;
public GetChannelLineupHandler(IChannelRepository channelRepository) => _channelRepository = channelRepository;
public Task<List<LineupItem>> Handle(GetChannelLineup request, CancellationToken cancellationToken) =>
_channelRepository.GetAll()
.Map(channels => channels.Map(c => new LineupItem(request.Scheme, request.Host, c)).ToList());
}
}

7
ErsatzTV.Application/Channels/Queries/GetChannelPlaylist.cs

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
using ErsatzTV.Core.Iptv;
using MediatR;
namespace ErsatzTV.Application.Channels.Queries
{
public record GetChannelPlaylist(string Scheme, string Host) : IRequest<ChannelPlaylist>;
}

21
ErsatzTV.Application/Channels/Queries/GetChannelPlaylistHandler.cs

@ -0,0 +1,21 @@ @@ -0,0 +1,21 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Iptv;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.Channels.Queries
{
public class GetChannelPlaylistHandler : IRequestHandler<GetChannelPlaylist, ChannelPlaylist>
{
private readonly IChannelRepository _channelRepository;
public GetChannelPlaylistHandler(IChannelRepository channelRepository) =>
_channelRepository = channelRepository;
public Task<ChannelPlaylist> Handle(GetChannelPlaylist request, CancellationToken cancellationToken) =>
_channelRepository.GetAll()
.Map(channels => new ChannelPlaylist(request.Scheme, request.Host, channels));
}
}

18
ErsatzTV.Application/ErsatzTV.Application.csproj

@ -0,0 +1,18 @@ @@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MediatR" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="5.0.0" />
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
<PackageReference Include="Winista.MimeDetect" Version="1.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ErsatzTV.Core\ErsatzTV.Core.csproj" />
</ItemGroup>
</Project>

25
ErsatzTV.Application/FFmpegProfiles/Commands/CreateFFmpegProfile.cs

@ -0,0 +1,25 @@ @@ -0,0 +1,25 @@
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.FFmpegProfiles.Commands
{
public record CreateFFmpegProfile(
string Name,
int ThreadCount,
bool Transcode,
int ResolutionId,
bool NormalizeResolution,
string VideoCodec,
bool NormalizeVideoCodec,
int VideoBitrate,
int VideoBufferSize,
string AudioCodec,
bool NormalizeAudioCodec,
int AudioBitrate,
int AudioBufferSize,
int AudioVolume,
int AudioChannels,
int AudioSampleRate,
bool NormalizeAudio) : IRequest<Either<BaseError, FFmpegProfileViewModel>>;
}

72
ErsatzTV.Application/FFmpegProfiles/Commands/CreateFFmpegProfileHandler.cs

@ -0,0 +1,72 @@ @@ -0,0 +1,72 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.FFmpegProfiles.Mapper;
namespace ErsatzTV.Application.FFmpegProfiles.Commands
{
public class
CreateFFmpegProfileHandler : IRequestHandler<CreateFFmpegProfile, Either<BaseError, FFmpegProfileViewModel>>
{
private readonly IFFmpegProfileRepository _ffmpegProfileRepository;
private readonly IResolutionRepository _resolutionRepository;
public CreateFFmpegProfileHandler(
IFFmpegProfileRepository ffmpegProfileRepository,
IResolutionRepository resolutionRepository)
{
_ffmpegProfileRepository = ffmpegProfileRepository;
_resolutionRepository = resolutionRepository;
}
public Task<Either<BaseError, FFmpegProfileViewModel>> Handle(
CreateFFmpegProfile request,
CancellationToken cancellationToken) =>
Validate(request)
.MapT(PersistFFmpegProfile)
.Bind(v => v.ToEitherAsync());
private Task<FFmpegProfileViewModel> PersistFFmpegProfile(FFmpegProfile ffmpegProfile) =>
_ffmpegProfileRepository.Add(ffmpegProfile).Map(ProjectToViewModel);
private async Task<Validation<BaseError, FFmpegProfile>> Validate(CreateFFmpegProfile request) =>
(ValidateName(request), ValidateThreadCount(request), await ResolutionMustExist(request))
.Apply(
(name, threadCount, resolutionId) => new FFmpegProfile
{
Name = name,
ThreadCount = threadCount,
Transcode = request.Transcode,
ResolutionId = resolutionId,
NormalizeResolution = request.NormalizeResolution,
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,
AudioChannels = request.AudioChannels,
AudioSampleRate = request.AudioSampleRate,
NormalizeAudio = request.NormalizeAudio
});
private Validation<BaseError, string> ValidateName(CreateFFmpegProfile createFFmpegProfile) =>
createFFmpegProfile.NotEmpty(x => x.Name)
.Bind(_ => createFFmpegProfile.NotLongerThan(50)(x => x.Name));
private Validation<BaseError, int> ValidateThreadCount(CreateFFmpegProfile createFFmpegProfile) =>
createFFmpegProfile.AtLeast(1)(p => p.ThreadCount);
private async Task<Validation<BaseError, int>> ResolutionMustExist(CreateFFmpegProfile createFFmpegProfile) =>
(await _resolutionRepository.Get(createFFmpegProfile.ResolutionId))
.ToValidation<BaseError>($"[Resolution] {createFFmpegProfile.ResolutionId} does not exist.")
.Map(c => c.Id);
}
}

9
ErsatzTV.Application/FFmpegProfiles/Commands/DeleteFFmpegProfile.cs

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
using System.Threading.Tasks;
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.FFmpegProfiles.Commands
{
public record DeleteFFmpegProfile(int FFmpegProfileId) : IRequest<Either<BaseError, Task>>;
}

32
ErsatzTV.Application/FFmpegProfiles/Commands/DeleteFFmpegProfileHandler.cs

@ -0,0 +1,32 @@ @@ -0,0 +1,32 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.FFmpegProfiles.Commands
{
public class DeleteFFmpegProfileHandler : IRequestHandler<DeleteFFmpegProfile, Either<BaseError, Task>>
{
private readonly IFFmpegProfileRepository _ffmpegProfileRepository;
public DeleteFFmpegProfileHandler(IFFmpegProfileRepository ffmpegProfileRepository) =>
_ffmpegProfileRepository = ffmpegProfileRepository;
public async Task<Either<BaseError, Task>> Handle(
DeleteFFmpegProfile request,
CancellationToken cancellationToken) =>
(await FFmpegProfileMustExist(request))
.Map(DoDeletion)
.ToEither<Task>();
private Task DoDeletion(int channelId) => _ffmpegProfileRepository.Delete(channelId);
private async Task<Validation<BaseError, int>> FFmpegProfileMustExist(
DeleteFFmpegProfile deleteFFmpegProfile) =>
(await _ffmpegProfileRepository.Get(deleteFFmpegProfile.FFmpegProfileId))
.ToValidation<BaseError>($"FFmpegProfile {deleteFFmpegProfile.FFmpegProfileId} does not exist.")
.Map(c => c.Id);
}
}

10
ErsatzTV.Application/FFmpegProfiles/Commands/NewFFmpegProfile.cs

@ -0,0 +1,10 @@ @@ -0,0 +1,10 @@
using MediatR;
namespace ErsatzTV.Application.FFmpegProfiles.Commands
{
/// <summary>
/// Requests a new ffmpeg profile (view model) that contains
/// appropriate default values.
/// </summary>
public record NewFFmpegProfile : IRequest<FFmpegProfileViewModel>;
}

40
ErsatzTV.Application/FFmpegProfiles/Commands/NewFFmpegProfileHandler.cs

@ -0,0 +1,40 @@ @@ -0,0 +1,40 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using static LanguageExt.Prelude;
using static ErsatzTV.Application.FFmpegProfiles.Mapper;
namespace ErsatzTV.Application.FFmpegProfiles.Commands
{
public class NewFFmpegProfileHandler : IRequestHandler<NewFFmpegProfile, FFmpegProfileViewModel>
{
private readonly IConfigElementRepository _configElementRepository;
private readonly IResolutionRepository _resolutionRepository;
public NewFFmpegProfileHandler(
IResolutionRepository resolutionRepository,
IConfigElementRepository configElementRepository)
{
_resolutionRepository = resolutionRepository;
_configElementRepository = configElementRepository;
}
public async Task<FFmpegProfileViewModel> Handle(NewFFmpegProfile request, CancellationToken cancellationToken)
{
int defaultResolutionId = await _configElementRepository
.GetValue<int>(ConfigElementKey.FFmpegDefaultResolutionId)
.IfNoneAsync(0);
List<Resolution> allResolutions = await _resolutionRepository.GetAll();
Option<Resolution> maybeDefaultResolution = allResolutions.Find(r => r.Id == defaultResolutionId);
Resolution defaultResolution = maybeDefaultResolution.Match(identity, () => allResolutions.Head());
return ProjectToViewModel(FFmpegProfile.New("New Profile", defaultResolution));
}
}
}

26
ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegProfile.cs

@ -0,0 +1,26 @@ @@ -0,0 +1,26 @@
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.FFmpegProfiles.Commands
{
public record UpdateFFmpegProfile(
int FFmpegProfileId,
string Name,
int ThreadCount,
bool Transcode,
int ResolutionId,
bool NormalizeResolution,
string VideoCodec,
bool NormalizeVideoCodec,
int VideoBitrate,
int VideoBufferSize,
string AudioCodec,
bool NormalizeAudioCodec,
int AudioBitrate,
int AudioBufferSize,
int AudioVolume,
int AudioChannels,
int AudioSampleRate,
bool NormalizeAudio) : IRequest<Either<BaseError, FFmpegProfileViewModel>>;
}

78
ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegProfileHandler.cs

@ -0,0 +1,78 @@ @@ -0,0 +1,78 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.FFmpegProfiles.Mapper;
namespace ErsatzTV.Application.FFmpegProfiles.Commands
{
public class
UpdateFFmpegProfileHandler : IRequestHandler<UpdateFFmpegProfile, Either<BaseError, FFmpegProfileViewModel>>
{
private readonly IFFmpegProfileRepository _ffmpegProfileRepository;
private readonly IResolutionRepository _resolutionRepository;
public UpdateFFmpegProfileHandler(
IFFmpegProfileRepository ffmpegProfileRepository,
IResolutionRepository resolutionRepository)
{
_ffmpegProfileRepository = ffmpegProfileRepository;
_resolutionRepository = resolutionRepository;
}
public Task<Either<BaseError, FFmpegProfileViewModel>> Handle(
UpdateFFmpegProfile request,
CancellationToken cancellationToken) =>
Validate(request)
.MapT(c => ApplyUpdateRequest(c, request))
.Bind(v => v.ToEitherAsync());
private async Task<FFmpegProfileViewModel> ApplyUpdateRequest(FFmpegProfile p, UpdateFFmpegProfile update)
{
p.Name = update.Name;
p.ThreadCount = update.ThreadCount;
p.Transcode = update.Transcode;
p.ResolutionId = update.ResolutionId;
p.NormalizeResolution = update.NormalizeResolution;
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.AudioChannels = update.AudioChannels;
p.AudioSampleRate = update.AudioSampleRate;
p.NormalizeAudio = update.NormalizeAudio;
await _ffmpegProfileRepository.Update(p);
return ProjectToViewModel(p);
}
private async Task<Validation<BaseError, FFmpegProfile>> Validate(UpdateFFmpegProfile request) =>
(await FFmpegProfileMustExist(request), ValidateName(request), ValidateThreadCount(request),
await ResolutionMustExist(request))
.Apply((ffmpegProfileToUpdate, _, _, _) => ffmpegProfileToUpdate);
private async Task<Validation<BaseError, FFmpegProfile>> FFmpegProfileMustExist(
UpdateFFmpegProfile updateFFmpegProfile) =>
(await _ffmpegProfileRepository.Get(updateFFmpegProfile.FFmpegProfileId))
.ToValidation<BaseError>("FFmpegProfile does not exist.");
private Validation<BaseError, string> ValidateName(UpdateFFmpegProfile updateFFmpegProfile) =>
updateFFmpegProfile.NotEmpty(x => x.Name)
.Bind(_ => updateFFmpegProfile.NotLongerThan(50)(x => x.Name));
private Validation<BaseError, int> ValidateThreadCount(UpdateFFmpegProfile updateFFmpegProfile) =>
updateFFmpegProfile.AtLeast(1)(p => p.ThreadCount);
private async Task<Validation<BaseError, int>> ResolutionMustExist(UpdateFFmpegProfile updateFFmpegProfile) =>
(await _resolutionRepository.Get(updateFFmpegProfile.ResolutionId))
.ToValidation<BaseError>($"[Resolution] {updateFFmpegProfile.ResolutionId} does not exist.")
.Map(c => c.Id);
}
}

6
ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegSettings.cs

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
using MediatR;
namespace ErsatzTV.Application.FFmpegProfiles.Commands
{
public record UpdateFFmpegSettings(FFmpegSettingsViewModel Settings) : IRequest;
}

70
ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegSettingsHandler.cs

@ -0,0 +1,70 @@ @@ -0,0 +1,70 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using Unit = MediatR.Unit;
namespace ErsatzTV.Application.FFmpegProfiles.Commands
{
public class UpdateFFmpegSettingsHandler : IRequestHandler<UpdateFFmpegSettings>
{
private readonly IConfigElementRepository _configElementRepository;
public UpdateFFmpegSettingsHandler(IConfigElementRepository configElementRepository) =>
_configElementRepository = configElementRepository;
public async Task<Unit> Handle(UpdateFFmpegSettings request, CancellationToken cancellationToken)
{
Option<ConfigElement> ffmpegPath = await _configElementRepository.Get(ConfigElementKey.FFmpegPath);
Option<ConfigElement> ffprobePath = await _configElementRepository.Get(ConfigElementKey.FFprobePath);
Option<ConfigElement> defaultFFmpegProfileId =
await _configElementRepository.Get(ConfigElementKey.FFmpegDefaultProfileId);
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);
});
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);
});
defaultFFmpegProfileId.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);
});
return Unit.Value;
}
}
}

24
ErsatzTV.Application/FFmpegProfiles/FFmpegProfileViewModel.cs

@ -0,0 +1,24 @@ @@ -0,0 +1,24 @@
using ErsatzTV.Application.Resolutions;
namespace ErsatzTV.Application.FFmpegProfiles
{
public record FFmpegProfileViewModel(
int Id,
string Name,
int ThreadCount,
bool Transcode,
ResolutionViewModel Resolution,
bool NormalizeResolution,
string VideoCodec,
bool NormalizeVideoCodec,
int VideoBitrate,
int VideoBufferSize,
string AudioCodec,
bool NormalizeAudioCodec,
int AudioBitrate,
int AudioBufferSize,
int AudioVolume,
int AudioChannels,
int AudioSampleRate,
bool NormalizeAudio);
}

9
ErsatzTV.Application/FFmpegProfiles/FFmpegSettingsViewModel.cs

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
namespace ErsatzTV.Application.FFmpegProfiles
{
public class FFmpegSettingsViewModel
{
public string FFmpegPath { get; set; }
public string FFprobePath { get; set; }
public int DefaultFFmpegProfileId { get; set; }
}
}

32
ErsatzTV.Application/FFmpegProfiles/Mapper.cs

@ -0,0 +1,32 @@ @@ -0,0 +1,32 @@
using ErsatzTV.Application.Resolutions;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.FFmpegProfiles
{
internal static class Mapper
{
internal static FFmpegProfileViewModel ProjectToViewModel(FFmpegProfile profile) =>
new(
profile.Id,
profile.Name,
profile.ThreadCount,
profile.Transcode,
Project(profile.Resolution),
profile.NormalizeResolution,
profile.VideoCodec,
profile.NormalizeVideoCodec,
profile.VideoBitrate,
profile.VideoBufferSize,
profile.AudioCodec,
profile.NormalizeAudioCodec,
profile.AudioBitrate,
profile.AudioBufferSize,
profile.AudioVolume,
profile.AudioChannels,
profile.AudioSampleRate,
profile.NormalizeAudio);
private static ResolutionViewModel Project(Resolution resolution) =>
new(resolution.Id, resolution.Name, resolution.Width, resolution.Height);
}
}

7
ErsatzTV.Application/FFmpegProfiles/Queries/GetAllFFmpegProfiles.cs

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
using System.Collections.Generic;
using MediatR;
namespace ErsatzTV.Application.FFmpegProfiles.Queries
{
public record GetAllFFmpegProfiles : IRequest<List<FFmpegProfileViewModel>>;
}

23
ErsatzTV.Application/FFmpegProfiles/Queries/GetAllFFmpegProfilesHandler.cs

@ -0,0 +1,23 @@ @@ -0,0 +1,23 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Interfaces.Repositories;
using MediatR;
using static ErsatzTV.Application.FFmpegProfiles.Mapper;
namespace ErsatzTV.Application.FFmpegProfiles.Queries
{
public class GetAllFFmpegProfilesHandler : IRequestHandler<GetAllFFmpegProfiles, List<FFmpegProfileViewModel>>
{
private readonly IFFmpegProfileRepository _ffmpegProfileRepository;
public GetAllFFmpegProfilesHandler(IFFmpegProfileRepository ffmpegProfileRepository) =>
_ffmpegProfileRepository = ffmpegProfileRepository;
public async Task<List<FFmpegProfileViewModel>> Handle(
GetAllFFmpegProfiles request,
CancellationToken cancellationToken) =>
(await _ffmpegProfileRepository.GetAll()).Map(ProjectToViewModel).ToList();
}
}

7
ErsatzTV.Application/FFmpegProfiles/Queries/GetFFmpegProfileById.cs

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.FFmpegProfiles.Queries
{
public record GetFFmpegProfileById(int Id) : IRequest<Option<FFmpegProfileViewModel>>;
}

23
ErsatzTV.Application/FFmpegProfiles/Queries/GetFFmpegProfileByIdHandler.cs

@ -0,0 +1,23 @@ @@ -0,0 +1,23 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.FFmpegProfiles.Mapper;
namespace ErsatzTV.Application.FFmpegProfiles.Queries
{
public class GetFFmpegProfileByIdHandler : IRequestHandler<GetFFmpegProfileById, Option<FFmpegProfileViewModel>>
{
private readonly IFFmpegProfileRepository _ffmpegProfileRepository;
public GetFFmpegProfileByIdHandler(IFFmpegProfileRepository ffmpegProfileRepository) =>
_ffmpegProfileRepository = ffmpegProfileRepository;
public Task<Option<FFmpegProfileViewModel>> Handle(
GetFFmpegProfileById request,
CancellationToken cancellationToken) =>
_ffmpegProfileRepository.Get(request.Id)
.MapT(ProjectToViewModel);
}
}

6
ErsatzTV.Application/FFmpegProfiles/Queries/GetFFmpegSettings.cs

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
using MediatR;
namespace ErsatzTV.Application.FFmpegProfiles.Queries
{
public record GetFFmpegSettings : IRequest<FFmpegSettingsViewModel>;
}

34
ErsatzTV.Application/FFmpegProfiles/Queries/GetFFmpegSettingsHandler.cs

@ -0,0 +1,34 @@ @@ -0,0 +1,34 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.FFmpegProfiles.Queries
{
public class GetFFmpegSettingsHandler : IRequestHandler<GetFFmpegSettings, FFmpegSettingsViewModel>
{
private readonly IConfigElementRepository _configElementRepository;
public GetFFmpegSettingsHandler(IConfigElementRepository configElementRepository) =>
_configElementRepository = configElementRepository;
public async Task<FFmpegSettingsViewModel> Handle(
GetFFmpegSettings request,
CancellationToken cancellationToken)
{
Option<string> ffmpegPath = await _configElementRepository.GetValue<string>(ConfigElementKey.FFmpegPath);
Option<string> ffprobePath = await _configElementRepository.GetValue<string>(ConfigElementKey.FFprobePath);
Option<int> defaultFFmpegProfileId =
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegDefaultProfileId);
return new FFmpegSettingsViewModel
{
FFmpegPath = ffmpegPath.IfNone(string.Empty),
FFprobePath = ffprobePath.IfNone(string.Empty),
DefaultFFmpegProfileId = defaultFFmpegProfileId.IfNone(0)
};
}
}
}

6
ErsatzTV.Application/IBackgroundServiceRequest.cs

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
namespace ErsatzTV.Application
{
public interface IBackgroundServiceRequest
{
}
}

6
ErsatzTV.Application/IPlexBackgroundServiceRequest.cs

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
namespace ErsatzTV.Application
{
public interface IPlexBackgroundServiceRequest
{
}
}

9
ErsatzTV.Application/Images/Commands/SaveImageToDisk.cs

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.Images.Commands
{
// ReSharper disable once SuggestBaseTypeForParameter
public record SaveImageToDisk(byte[] Buffer) : IRequest<Either<BaseError, string>>;
}

43
ErsatzTV.Application/Images/Commands/SaveImageToDiskHandler.cs

@ -0,0 +1,43 @@ @@ -0,0 +1,43 @@
using System;
using System.IO;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.Images.Commands
{
public class SaveImageToDiskHandler : IRequestHandler<SaveImageToDisk, Either<BaseError, string>>
{
private static readonly SHA1CryptoServiceProvider Crypto;
static SaveImageToDiskHandler() => Crypto = new SHA1CryptoServiceProvider();
public async Task<Either<BaseError, string>> Handle(
SaveImageToDisk request,
CancellationToken cancellationToken)
{
try
{
byte[] hash = Crypto.ComputeHash(request.Buffer);
string hex = BitConverter.ToString(hash).Replace("-", string.Empty);
string fileName = Path.Combine(FileSystemLayout.ImageCacheFolder, hex);
if (!Directory.Exists(FileSystemLayout.ImageCacheFolder))
{
Directory.CreateDirectory(FileSystemLayout.ImageCacheFolder);
}
await File.WriteAllBytesAsync(fileName, request.Buffer, cancellationToken);
return hex;
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
}
}
}
}

5
ErsatzTV.Application/Images/ImageViewModel.cs

@ -0,0 +1,5 @@ @@ -0,0 +1,5 @@
namespace ErsatzTV.Application.Images
{
// ReSharper disable once SuggestBaseTypeForParameter
public record ImageViewModel(byte[] Contents, string MimeType);
}

8
ErsatzTV.Application/Images/Queries/GetImageContents.cs

@ -0,0 +1,8 @@ @@ -0,0 +1,8 @@
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.Images.Queries
{
public record GetImageContents(string FileName) : IRequest<Either<BaseError, ImageViewModel>>;
}

44
ErsatzTV.Application/Images/Queries/GetImageContentsHandler.cs

@ -0,0 +1,44 @@ @@ -0,0 +1,44 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
using Microsoft.Extensions.Caching.Memory;
using Winista.Mime;
namespace ErsatzTV.Application.Images.Queries
{
public class GetImageContentsHandler : IRequestHandler<GetImageContents, Either<BaseError, ImageViewModel>>
{
private static readonly MimeTypes MimeTypes = new();
private readonly IMemoryCache _memoryCache;
public GetImageContentsHandler(IMemoryCache memoryCache) => _memoryCache = memoryCache;
public async Task<Either<BaseError, ImageViewModel>> Handle(
GetImageContents request,
CancellationToken cancellationToken)
{
try
{
return await _memoryCache.GetOrCreateAsync(
request.FileName,
async entry =>
{
entry.SlidingExpiration = TimeSpan.FromHours(1);
string fileName = Path.Combine(FileSystemLayout.ImageCacheFolder, request.FileName);
byte[] contents = await File.ReadAllBytesAsync(fileName, cancellationToken);
MimeType mimeType = MimeTypes.GetMimeType(contents);
return new ImageViewModel(contents, mimeType.Name);
});
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
}
}
}
}

9
ErsatzTV.Application/MediaCollections/Commands/CreateSimpleMediaCollection.cs

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record CreateSimpleMediaCollection
(string Name) : IRequest<Either<BaseError, MediaCollectionViewModel>>;
}

49
ErsatzTV.Application/MediaCollections/Commands/CreateSimpleMediaCollectionHandler.cs

@ -0,0 +1,49 @@ @@ -0,0 +1,49 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.MediaCollections.Mapper;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public class CreateSimpleMediaCollectionHandler : IRequestHandler<CreateSimpleMediaCollection,
Either<BaseError, MediaCollectionViewModel>>
{
private readonly IMediaCollectionRepository _mediaCollectionRepository;
public CreateSimpleMediaCollectionHandler(IMediaCollectionRepository mediaCollectionRepository) =>
_mediaCollectionRepository = mediaCollectionRepository;
public Task<Either<BaseError, MediaCollectionViewModel>> Handle(
CreateSimpleMediaCollection request,
CancellationToken cancellationToken) =>
Validate(request).MapT(PersistCollection).Bind(v => v.ToEitherAsync());
private Task<MediaCollectionViewModel> PersistCollection(SimpleMediaCollection c) =>
_mediaCollectionRepository.Add(c).Map(ProjectToViewModel);
private Task<Validation<BaseError, SimpleMediaCollection>> Validate(CreateSimpleMediaCollection request) =>
ValidateName(request).MapT(name => new SimpleMediaCollection { Name = name });
private async Task<Validation<BaseError, string>> ValidateName(CreateSimpleMediaCollection createCollection)
{
List<string> allNames = await _mediaCollectionRepository.GetSimpleMediaCollections()
.Map(list => list.Map(c => c.Name).ToList());
Validation<BaseError, string> result1 = createCollection.NotEmpty(c => c.Name)
.Bind(_ => createCollection.NotLongerThan(50)(c => c.Name));
var result2 = Optional(createCollection.Name)
.Filter(name => !allNames.Contains(name))
.ToValidation<BaseError>("Media collection name must be unique");
return (result1, result2).Apply((_, _) => createCollection.Name);
}
}
}

9
ErsatzTV.Application/MediaCollections/Commands/DeleteSimpleMediaCollection.cs

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
using System.Threading.Tasks;
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record DeleteSimpleMediaCollection(int SimpleMediaCollectionId) : IRequest<Either<BaseError, Task>>;
}

34
ErsatzTV.Application/MediaCollections/Commands/DeleteSimpleMediaCollectionHandler.cs

@ -0,0 +1,34 @@ @@ -0,0 +1,34 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public class
DeleteSimpleMediaCollectionHandler : IRequestHandler<DeleteSimpleMediaCollection, Either<BaseError, Task>>
{
private readonly IMediaCollectionRepository _mediaCollectionRepository;
public DeleteSimpleMediaCollectionHandler(IMediaCollectionRepository mediaCollectionRepository) =>
_mediaCollectionRepository = mediaCollectionRepository;
public async Task<Either<BaseError, Task>> Handle(
DeleteSimpleMediaCollection request,
CancellationToken cancellationToken) =>
(await SimpleMediaCollectionMustExist(request))
.Map(DoDeletion)
.ToEither<Task>();
private Task DoDeletion(int mediaCollectionId) => _mediaCollectionRepository.Delete(mediaCollectionId);
private async Task<Validation<BaseError, int>> SimpleMediaCollectionMustExist(
DeleteSimpleMediaCollection deleteMediaCollection) =>
(await _mediaCollectionRepository.GetSimpleMediaCollection(deleteMediaCollection.SimpleMediaCollectionId))
.ToValidation<BaseError>(
$"SimpleMediaCollection {deleteMediaCollection.SimpleMediaCollectionId} does not exist.")
.Map(c => c.Id);
}
}

11
ErsatzTV.Application/MediaCollections/Commands/ReplaceSimpleMediaCollectionItems.cs

@ -0,0 +1,11 @@ @@ -0,0 +1,11 @@
using System.Collections.Generic;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record ReplaceSimpleMediaCollectionItems
(int MediaCollectionId, List<int> MediaItemIds) : IRequest<Either<BaseError, List<MediaItemViewModel>>>;
}

65
ErsatzTV.Application/MediaCollections/Commands/ReplaceSimpleMediaCollectionItemsHandler.cs

@ -0,0 +1,65 @@ @@ -0,0 +1,65 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using LanguageExt.UnsafeValueAccess;
using MediatR;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public class ReplaceSimpleMediaCollectionItemsHandler : IRequestHandler<ReplaceSimpleMediaCollectionItems,
Either<BaseError, List<MediaItemViewModel>>>
{
private readonly IMediaCollectionRepository _mediaCollectionRepository;
private readonly IMediaItemRepository _mediaItemRepository;
public ReplaceSimpleMediaCollectionItemsHandler(
IMediaCollectionRepository mediaCollectionRepository,
IMediaItemRepository mediaItemRepository)
{
_mediaCollectionRepository = mediaCollectionRepository;
_mediaItemRepository = mediaItemRepository;
}
public Task<Either<BaseError, List<MediaItemViewModel>>> Handle(
ReplaceSimpleMediaCollectionItems request,
CancellationToken cancellationToken) =>
Validate(request)
.MapT(mediaItems => PersistItems(request, mediaItems))
.Bind(v => v.ToEitherAsync());
private async Task<List<MediaItemViewModel>> PersistItems(
ReplaceSimpleMediaCollectionItems request,
List<MediaItem> mediaItems)
{
await _mediaCollectionRepository.ReplaceItems(request.MediaCollectionId, mediaItems);
return mediaItems.Map(MediaItems.Mapper.ProjectToViewModel).ToList();
}
private Task<Validation<BaseError, List<MediaItem>>> Validate(ReplaceSimpleMediaCollectionItems request) =>
MediaCollectionMustExist(request).BindT(_ => MediaItemsMustExist(request));
private async Task<Validation<BaseError, SimpleMediaCollection>> MediaCollectionMustExist(
ReplaceSimpleMediaCollectionItems request) =>
(await _mediaCollectionRepository.GetSimpleMediaCollection(request.MediaCollectionId))
.ToValidation<BaseError>("[MediaCollectionId] does not exist.");
private async Task<Validation<BaseError, List<MediaItem>>> MediaItemsMustExist(
ReplaceSimpleMediaCollectionItems replaceItems)
{
var allMediaItems = (await replaceItems.MediaItemIds.Map(i => _mediaItemRepository.Get(i)).Sequence())
.ToList();
if (allMediaItems.Any(x => x.IsNone))
{
return BaseError.New("[MediaItemId] does not exist");
}
return allMediaItems.Sequence().ValueUnsafe().ToList();
}
}
}

47
ErsatzTV.Application/MediaCollections/Commands/UpdateChannelHandler.cs

@ -0,0 +1,47 @@ @@ -0,0 +1,47 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public class
UpdateSimpleMediaCollectionHandler : MediatR.IRequestHandler<UpdateSimpleMediaCollection,
Either<BaseError, Unit>>
{
private readonly IMediaCollectionRepository _mediaCollectionRepository;
public UpdateSimpleMediaCollectionHandler(IMediaCollectionRepository mediaCollectionRepository) =>
_mediaCollectionRepository = mediaCollectionRepository;
public Task<Either<BaseError, Unit>> Handle(
UpdateSimpleMediaCollection request,
CancellationToken cancellationToken) =>
Validate(request)
.MapT(c => ApplyUpdateRequest(c, request))
.Bind(v => v.ToEitherAsync());
private async Task<Unit> ApplyUpdateRequest(SimpleMediaCollection c, UpdateSimpleMediaCollection update)
{
c.Name = update.Name;
await _mediaCollectionRepository.Update(c);
return Unit.Default;
}
private async Task<Validation<BaseError, SimpleMediaCollection>>
Validate(UpdateSimpleMediaCollection request) =>
(await SimpleMediaCollectionMustExist(request), ValidateName(request))
.Apply((simpleMediaCollectionToUpdate, _) => simpleMediaCollectionToUpdate);
private Task<Validation<BaseError, SimpleMediaCollection>> SimpleMediaCollectionMustExist(
UpdateSimpleMediaCollection updateSimpleMediaCollection) =>
_mediaCollectionRepository.GetSimpleMediaCollection(updateSimpleMediaCollection.MediaCollectionId)
.Map(v => v.ToValidation<BaseError>("SimpleMediaCollection does not exist."));
private Validation<BaseError, string> ValidateName(UpdateSimpleMediaCollection updateSimpleMediaCollection) =>
updateSimpleMediaCollection.NotEmpty(c => c.Name)
.Bind(_ => updateSimpleMediaCollection.NotLongerThan(50)(c => c.Name));
}
}

8
ErsatzTV.Application/MediaCollections/Commands/UpdateSimpleMediaCollection.cs

@ -0,0 +1,8 @@ @@ -0,0 +1,8 @@
using ErsatzTV.Core;
using LanguageExt;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record UpdateSimpleMediaCollection
(int MediaCollectionId, string Name) : MediatR.IRequest<Either<BaseError, Unit>>;
}

19
ErsatzTV.Application/MediaCollections/Mapper.cs

@ -0,0 +1,19 @@ @@ -0,0 +1,19 @@
using ErsatzTV.Core.AggregateModels;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCollections
{
internal static class Mapper
{
internal static MediaCollectionViewModel ProjectToViewModel(MediaCollection mediaCollection) =>
new(mediaCollection.Id, mediaCollection.Name);
internal static MediaCollectionSummaryViewModel ProjectToViewModel(
MediaCollectionSummary mediaCollectionSummary) =>
new(
mediaCollectionSummary.Id,
mediaCollectionSummary.Name,
mediaCollectionSummary.ItemCount,
mediaCollectionSummary.IsSimple);
}
}

4
ErsatzTV.Application/MediaCollections/MediaCollectionSummaryViewModel.cs

@ -0,0 +1,4 @@ @@ -0,0 +1,4 @@
namespace ErsatzTV.Application.MediaCollections
{
public record MediaCollectionSummaryViewModel(int Id, string Name, int ItemCount, bool IsSimple);
}

4
ErsatzTV.Application/MediaCollections/MediaCollectionViewModel.cs

@ -0,0 +1,4 @@ @@ -0,0 +1,4 @@
namespace ErsatzTV.Application.MediaCollections
{
public record MediaCollectionViewModel(int Id, string Name);
}

7
ErsatzTV.Application/MediaCollections/Queries/GetAllMediaCollections.cs

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
using System.Collections.Generic;
using MediatR;
namespace ErsatzTV.Application.MediaCollections.Queries
{
public record GetAllMediaCollections : IRequest<List<MediaCollectionViewModel>>;
}

24
ErsatzTV.Application/MediaCollections/Queries/GetAllMediaCollectionsHandler.cs

@ -0,0 +1,24 @@ @@ -0,0 +1,24 @@
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.MediaCollections.Mapper;
namespace ErsatzTV.Application.MediaCollections.Queries
{
public class GetAllMediaCollectionsHandler : IRequestHandler<GetAllMediaCollections, List<MediaCollectionViewModel>>
{
private readonly IMediaCollectionRepository _mediaCollectionRepository;
public GetAllMediaCollectionsHandler(IMediaCollectionRepository mediaCollectionRepository) =>
_mediaCollectionRepository = mediaCollectionRepository;
public Task<List<MediaCollectionViewModel>> Handle(
GetAllMediaCollections request,
CancellationToken cancellationToken) =>
_mediaCollectionRepository.GetAll().Map(list => list.Map(ProjectToViewModel).ToList());
}
}

7
ErsatzTV.Application/MediaCollections/Queries/GetAllSimpleMediaCollections.cs

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
using System.Collections.Generic;
using MediatR;
namespace ErsatzTV.Application.MediaCollections.Queries
{
public record GetAllSimpleMediaCollections : IRequest<List<MediaCollectionViewModel>>;
}

25
ErsatzTV.Application/MediaCollections/Queries/GetAllSimpleMediaCollectionsHandler.cs

@ -0,0 +1,25 @@ @@ -0,0 +1,25 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Interfaces.Repositories;
using MediatR;
using static ErsatzTV.Application.MediaCollections.Mapper;
namespace ErsatzTV.Application.MediaCollections.Queries
{
public class
GetAllSimpleMediaCollectionsHandler : IRequestHandler<GetAllSimpleMediaCollections,
List<MediaCollectionViewModel>>
{
private readonly IMediaCollectionRepository _mediaCollectionRepository;
public GetAllSimpleMediaCollectionsHandler(IMediaCollectionRepository mediaCollectionRepository) =>
_mediaCollectionRepository = mediaCollectionRepository;
public async Task<List<MediaCollectionViewModel>> Handle(
GetAllSimpleMediaCollections request,
CancellationToken cancellationToken) =>
(await _mediaCollectionRepository.GetSimpleMediaCollections()).Map(ProjectToViewModel).ToList();
}
}

7
ErsatzTV.Application/MediaCollections/Queries/GetMediaCollectionSummaries.cs

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
using System.Collections.Generic;
using MediatR;
namespace ErsatzTV.Application.MediaCollections.Queries
{
public record GetMediaCollectionSummaries(string SearchString) : IRequest<List<MediaCollectionSummaryViewModel>>;
}

27
ErsatzTV.Application/MediaCollections/Queries/GetMediaCollectionSummariesHandler.cs

@ -0,0 +1,27 @@ @@ -0,0 +1,27 @@
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.MediaCollections.Mapper;
namespace ErsatzTV.Application.MediaCollections.Queries
{
public class
GetMediaCollectionSummariesHandler : IRequestHandler<GetMediaCollectionSummaries,
List<MediaCollectionSummaryViewModel>>
{
private readonly IMediaCollectionRepository _mediaCollectionRepository;
public GetMediaCollectionSummariesHandler(IMediaCollectionRepository mediaCollectionRepository) =>
_mediaCollectionRepository = mediaCollectionRepository;
public Task<List<MediaCollectionSummaryViewModel>> Handle(
GetMediaCollectionSummaries request,
CancellationToken cancellationToken) =>
_mediaCollectionRepository.GetSummaries(request.SearchString)
.Map(list => list.Map(ProjectToViewModel).ToList());
}
}

7
ErsatzTV.Application/MediaCollections/Queries/GetSimpleMediaCollectionById.cs

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.MediaCollections.Queries
{
public record GetSimpleMediaCollectionById(int Id) : IRequest<Option<MediaCollectionViewModel>>;
}

25
ErsatzTV.Application/MediaCollections/Queries/GetSimpleMediaCollectionByIdHandler.cs

@ -0,0 +1,25 @@ @@ -0,0 +1,25 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.MediaCollections.Mapper;
namespace ErsatzTV.Application.MediaCollections.Queries
{
public class
GetSimpleMediaCollectionByIdHandler : IRequestHandler<GetSimpleMediaCollectionById,
Option<MediaCollectionViewModel>>
{
private readonly IMediaCollectionRepository _mediaCollectionRepository;
public GetSimpleMediaCollectionByIdHandler(IMediaCollectionRepository mediaCollectionRepository) =>
_mediaCollectionRepository = mediaCollectionRepository;
public Task<Option<MediaCollectionViewModel>> Handle(
GetSimpleMediaCollectionById request,
CancellationToken cancellationToken) =>
_mediaCollectionRepository.GetSimpleMediaCollection(request.Id)
.MapT(ProjectToViewModel);
}
}

9
ErsatzTV.Application/MediaCollections/Queries/GetSimpleMediaCollectionItems.cs

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
using System.Collections.Generic;
using ErsatzTV.Application.MediaItems;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.MediaCollections.Queries
{
public record GetSimpleMediaCollectionItems(int Id) : IRequest<Option<IEnumerable<MediaItemViewModel>>>;
}

26
ErsatzTV.Application/MediaCollections/Queries/GetSimpleMediaCollectionItemsHandler.cs

@ -0,0 +1,26 @@ @@ -0,0 +1,26 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.MediaItems.Mapper;
namespace ErsatzTV.Application.MediaCollections.Queries
{
public class GetSimpleMediaCollectionItemsHandler : IRequestHandler<GetSimpleMediaCollectionItems,
Option<IEnumerable<MediaItemViewModel>>>
{
private readonly IMediaCollectionRepository _mediaCollectionRepository;
public GetSimpleMediaCollectionItemsHandler(IMediaCollectionRepository mediaCollectionRepository) =>
_mediaCollectionRepository = mediaCollectionRepository;
public Task<Option<IEnumerable<MediaItemViewModel>>> Handle(
GetSimpleMediaCollectionItems request,
CancellationToken cancellationToken) =>
_mediaCollectionRepository.GetSimpleMediaCollectionItems(request.Id)
.MapT(mediaItems => mediaItems.Map(ProjectToViewModel));
}
}

4
ErsatzTV.Application/MediaItems/AggregateMediaItemViewModel.cs

@ -0,0 +1,4 @@ @@ -0,0 +1,4 @@
namespace ErsatzTV.Application.MediaItems
{
public record AggregateMediaItemViewModel(string Source, string Title, int Count, string Duration);
}

8
ErsatzTV.Application/MediaItems/Commands/CreateMediaItem.cs

@ -0,0 +1,8 @@ @@ -0,0 +1,8 @@
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.MediaItems.Commands
{
public record CreateMediaItem(int MediaSourceId, string Path) : IRequest<Either<BaseError, MediaItemViewModel>>;
}

96
ErsatzTV.Application/MediaItems/Commands/CreateMediaItemHandler.cs

@ -0,0 +1,96 @@ @@ -0,0 +1,96 @@
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 LanguageExt;
using MediatR;
using static LanguageExt.Prelude;
using static ErsatzTV.Application.MediaItems.Mapper;
namespace ErsatzTV.Application.MediaItems.Commands
{
public class CreateMediaItemHandler : IRequestHandler<CreateMediaItem, Either<BaseError, MediaItemViewModel>>
{
private readonly IConfigElementRepository _configElementRepository;
private readonly ILocalMetadataProvider _localMetadataProvider;
private readonly ILocalStatisticsProvider _localStatisticsProvider;
private readonly IMediaItemRepository _mediaItemRepository;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly ISmartCollectionBuilder _smartCollectionBuilder;
public CreateMediaItemHandler(
IMediaItemRepository mediaItemRepository,
IMediaSourceRepository mediaSourceRepository,
IConfigElementRepository configElementRepository,
ISmartCollectionBuilder smartCollectionBuilder,
ILocalMetadataProvider localMetadataProvider,
ILocalStatisticsProvider localStatisticsProvider)
{
_mediaItemRepository = mediaItemRepository;
_mediaSourceRepository = mediaSourceRepository;
_configElementRepository = configElementRepository;
_smartCollectionBuilder = smartCollectionBuilder;
_localMetadataProvider = localMetadataProvider;
_localStatisticsProvider = localStatisticsProvider;
}
public Task<Either<BaseError, MediaItemViewModel>> Handle(
CreateMediaItem request,
CancellationToken cancellationToken) =>
Validate(request)
.MapT(PersistMediaItem)
.Bind(v => v.ToEitherAsync());
private async Task<MediaItemViewModel> PersistMediaItem(RequestParameters parameters)
{
await _mediaItemRepository.Add(parameters.MediaItem);
await _localStatisticsProvider.RefreshStatistics(parameters.FFprobePath, parameters.MediaItem);
await _localMetadataProvider.RefreshMetadata(parameters.MediaItem);
await _smartCollectionBuilder.RefreshSmartCollections(parameters.MediaItem);
return ProjectToViewModel(parameters.MediaItem);
}
private async Task<Validation<BaseError, RequestParameters>> Validate(CreateMediaItem request) =>
(await ValidateMediaSource(request), PathMustExist(request), await ValidateFFprobePath())
.Apply(
(mediaSourceId, path, ffprobePath) => new RequestParameters(
ffprobePath,
new MediaItem
{
MediaSourceId = mediaSourceId,
Path = path
}));
private async Task<Validation<BaseError, int>> ValidateMediaSource(CreateMediaItem createMediaItem) =>
(await MediaSourceMustExist(createMediaItem)).Bind(MediaSourceMustBeLocal);
private async Task<Validation<BaseError, MediaSource>> MediaSourceMustExist(CreateMediaItem createMediaItem) =>
(await _mediaSourceRepository.Get(createMediaItem.MediaSourceId))
.ToValidation<BaseError>($"[MediaSource] {createMediaItem.MediaSourceId} does not exist.");
private Validation<BaseError, int> MediaSourceMustBeLocal(MediaSource mediaSource) =>
Some(mediaSource)
.Filter(ms => ms is LocalMediaSource)
.ToValidation<BaseError>($"[MediaSource] {mediaSource.Id} must be a local media source")
.Map(ms => ms.Id);
private Validation<BaseError, string> PathMustExist(CreateMediaItem createMediaItem) =>
Some(createMediaItem.Path)
.Filter(File.Exists)
.ToValidation<BaseError>("[Path] does not exist on the file system");
private Task<Validation<BaseError, string>> ValidateFFprobePath() =>
_configElementRepository.GetValue<string>(ConfigElementKey.FFprobePath)
.FilterT(File.Exists)
.Map(
ffprobePath =>
ffprobePath.ToValidation<BaseError>("FFprobe path does not exist on the file system"));
private record RequestParameters(string FFprobePath, MediaItem MediaItem);
}
}

9
ErsatzTV.Application/MediaItems/Commands/DeleteMediaItem.cs

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
using System.Threading.Tasks;
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.MediaItems.Commands
{
public record DeleteMediaItem(int MediaItemId) : IRequest<Either<BaseError, Task>>;
}

31
ErsatzTV.Application/MediaItems/Commands/DeleteMediaItemHandler.cs

@ -0,0 +1,31 @@ @@ -0,0 +1,31 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.MediaItems.Commands
{
public class DeleteMediaItemHandler : IRequestHandler<DeleteMediaItem, Either<BaseError, Task>>
{
private readonly IMediaItemRepository _mediaItemRepository;
public DeleteMediaItemHandler(IMediaItemRepository mediaItemRepository) =>
_mediaItemRepository = mediaItemRepository;
public async Task<Either<BaseError, Task>> Handle(
DeleteMediaItem request,
CancellationToken cancellationToken) =>
(await MediaItemMustExist(request))
.Map(DoDeletion)
.ToEither<Task>();
private Task DoDeletion(int mediaItemId) => _mediaItemRepository.Delete(mediaItemId);
private async Task<Validation<BaseError, int>> MediaItemMustExist(DeleteMediaItem deleteMediaItem) =>
(await _mediaItemRepository.Get(deleteMediaItem.MediaItemId))
.ToValidation<BaseError>($"MediaItem {deleteMediaItem.MediaItemId} does not exist.")
.Map(c => c.Id);
}
}

8
ErsatzTV.Application/MediaItems/Commands/RefreshMediaItem.cs

@ -0,0 +1,8 @@ @@ -0,0 +1,8 @@
using ErsatzTV.Core;
using LanguageExt;
namespace ErsatzTV.Application.MediaItems.Commands
{
public record RefreshMediaItem(int MediaItemId) : MediatR.IRequest<Either<BaseError, Unit>>,
IBackgroundServiceRequest;
}

9
ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemCollections.cs

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
namespace ErsatzTV.Application.MediaItems.Commands
{
public record RefreshMediaItemCollections : RefreshMediaItem
{
public RefreshMediaItemCollections(int mediaItemId) : base(mediaItemId)
{
}
}
}

46
ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemCollectionsHandler.cs

@ -0,0 +1,46 @@ @@ -0,0 +1,46 @@
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 LanguageExt;
namespace ErsatzTV.Application.MediaItems.Commands
{
public class
RefreshMediaItemCollectionsHandler : MediatR.IRequestHandler<RefreshMediaItemCollections,
Either<BaseError, Unit>>
{
private readonly IMediaItemRepository _mediaItemRepository;
private readonly ISmartCollectionBuilder _smartCollectionBuilder;
public RefreshMediaItemCollectionsHandler(
IMediaItemRepository mediaItemRepository,
ISmartCollectionBuilder smartCollectionBuilder)
{
_mediaItemRepository = mediaItemRepository;
_smartCollectionBuilder = smartCollectionBuilder;
}
public Task<Either<BaseError, Unit>> Handle(
RefreshMediaItemCollections request,
CancellationToken cancellationToken) =>
Validate(request)
.MapT(RefreshCollections)
.Bind(v => v.ToEitherAsync());
private Task<Validation<BaseError, MediaItem>> Validate(RefreshMediaItemCollections request) =>
MediaItemMustExist(request);
private Task<Validation<BaseError, MediaItem>> MediaItemMustExist(
RefreshMediaItemCollections refreshMediaItemCollections) =>
_mediaItemRepository.Get(refreshMediaItemCollections.MediaItemId)
.Map(
maybeItem => maybeItem.ToValidation<BaseError>(
$"[MediaItem] {refreshMediaItemCollections.MediaItemId} does not exist."));
private Task<Unit> RefreshCollections(MediaItem mediaItem) =>
_smartCollectionBuilder.RefreshSmartCollections(mediaItem).ToUnit();
}
}

9
ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemMetadata.cs

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
namespace ErsatzTV.Application.MediaItems.Commands
{
public record RefreshMediaItemMetadata : RefreshMediaItem
{
public RefreshMediaItemMetadata(int mediaItemId) : base(mediaItemId)
{
}
}
}

52
ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemMetadataHandler.cs

@ -0,0 +1,52 @@ @@ -0,0 +1,52 @@
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 LanguageExt;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.MediaItems.Commands
{
public class
RefreshMediaItemMetadataHandler : MediatR.IRequestHandler<RefreshMediaItemMetadata, Either<BaseError, Unit>>
{
private readonly ILocalMetadataProvider _localMetadataProvider;
private readonly IMediaItemRepository _mediaItemRepository;
public RefreshMediaItemMetadataHandler(
IMediaItemRepository mediaItemRepository,
ILocalMetadataProvider localMetadataProvider)
{
_mediaItemRepository = mediaItemRepository;
_localMetadataProvider = localMetadataProvider;
}
public Task<Either<BaseError, Unit>> Handle(
RefreshMediaItemMetadata request,
CancellationToken cancellationToken) =>
Validate(request)
.MapT(RefreshMetadata)
.Bind(v => v.ToEitherAsync());
private Task<Validation<BaseError, MediaItem>> Validate(RefreshMediaItemMetadata request) =>
MediaItemMustExist(request).BindT(PathMustExist);
private Task<Validation<BaseError, MediaItem>> MediaItemMustExist(
RefreshMediaItemMetadata refreshMediaItemMetadata) =>
_mediaItemRepository.Get(refreshMediaItemMetadata.MediaItemId)
.Map(
maybeItem => maybeItem.ToValidation<BaseError>(
$"[MediaItem] {refreshMediaItemMetadata.MediaItemId} does not exist."));
private Validation<BaseError, MediaItem> PathMustExist(MediaItem mediaItem) =>
Some(mediaItem)
.Filter(item => File.Exists(item.Path))
.ToValidation<BaseError>($"[Path] '{mediaItem.Path}' does not exist on the file system");
private Task<Unit> RefreshMetadata(MediaItem mediaItem) =>
_localMetadataProvider.RefreshMetadata(mediaItem).ToUnit();
}
}

9
ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemStatistics.cs

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
namespace ErsatzTV.Application.MediaItems.Commands
{
public record RefreshMediaItemStatistics : RefreshMediaItem
{
public RefreshMediaItemStatistics(int mediaItemId) : base(mediaItemId)
{
}
}
}

65
ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemStatisticsHandler.cs

@ -0,0 +1,65 @@ @@ -0,0 +1,65 @@
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 LanguageExt;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.MediaItems.Commands
{
public class
RefreshMediaItemStatisticsHandler : MediatR.IRequestHandler<RefreshMediaItemStatistics, Either<BaseError, Unit>>
{
private readonly IConfigElementRepository _configElementRepository;
private readonly ILocalStatisticsProvider _localStatisticsProvider;
private readonly IMediaItemRepository _mediaItemRepository;
public RefreshMediaItemStatisticsHandler(
IMediaItemRepository mediaItemRepository,
IConfigElementRepository configElementRepository,
ILocalStatisticsProvider localStatisticsProvider)
{
_mediaItemRepository = mediaItemRepository;
_configElementRepository = configElementRepository;
_localStatisticsProvider = localStatisticsProvider;
}
public Task<Either<BaseError, Unit>> Handle(
RefreshMediaItemStatistics request,
CancellationToken cancellationToken) =>
Validate(request)
.MapT(RefreshStatistics)
.Bind(v => v.ToEitherAsync());
private async Task<Validation<BaseError, RefreshParameters>> Validate(RefreshMediaItemStatistics request) =>
(await MediaItemMustExist(request).BindT(PathMustExist), await ValidateFFprobePath())
.Apply((mediaItem, ffprobePath) => new RefreshParameters(mediaItem, ffprobePath));
private Task<Validation<BaseError, MediaItem>> MediaItemMustExist(
RefreshMediaItemStatistics refreshMediaItemStatistics) =>
_mediaItemRepository.Get(refreshMediaItemStatistics.MediaItemId)
.Map(
maybeItem => maybeItem.ToValidation<BaseError>(
$"[MediaItem] {refreshMediaItemStatistics.MediaItemId} does not exist."));
private Validation<BaseError, MediaItem> PathMustExist(MediaItem mediaItem) =>
Some(mediaItem)
.Filter(item => File.Exists(item.Path))
.ToValidation<BaseError>($"[Path] '{mediaItem.Path}' does not exist on the file system");
private Task<Validation<BaseError, string>> ValidateFFprobePath() =>
_configElementRepository.GetValue<string>(ConfigElementKey.FFprobePath)
.FilterT(File.Exists)
.Map(
ffprobePath =>
ffprobePath.ToValidation<BaseError>("FFprobe path does not exist on the file system"));
private Task<Unit> RefreshStatistics(RefreshParameters parameters) =>
_localStatisticsProvider.RefreshStatistics(parameters.FFprobePath, parameters.MediaItem).ToUnit();
private record RefreshParameters(MediaItem MediaItem, string FFprobePath);
}
}

13
ErsatzTV.Application/MediaItems/Mapper.cs

@ -0,0 +1,13 @@ @@ -0,0 +1,13 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaItems
{
internal static class Mapper
{
internal static MediaItemViewModel ProjectToViewModel(MediaItem mediaItem) =>
new(
mediaItem.Id,
mediaItem.MediaSourceId,
mediaItem.Path);
}
}

4
ErsatzTV.Application/MediaItems/MediaItemViewModel.cs

@ -0,0 +1,4 @@ @@ -0,0 +1,4 @@
namespace ErsatzTV.Application.MediaItems
{
public record MediaItemViewModel(int Id, int MediaSourceId, string Path);
}

9
ErsatzTV.Application/MediaItems/Queries/GetAggregateMediaItems.cs

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
using System.Collections.Generic;
using ErsatzTV.Core.Domain;
using MediatR;
namespace ErsatzTV.Application.MediaItems.Queries
{
public record GetAggregateMediaItems
(MediaType MediaType, string SearchString) : IRequest<List<AggregateMediaItemViewModel>>;
}

43
ErsatzTV.Application/MediaItems/Queries/GetAggregateMediaItemsHandler.cs

@ -0,0 +1,43 @@ @@ -0,0 +1,43 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using MediatR;
namespace ErsatzTV.Application.MediaItems.Queries
{
public class
GetAggregateMediaItemsHandler : IRequestHandler<GetAggregateMediaItems, List<AggregateMediaItemViewModel>>
{
private readonly IMediaItemRepository _mediaItemRepository;
public GetAggregateMediaItemsHandler(IMediaItemRepository mediaItemRepository) =>
_mediaItemRepository = mediaItemRepository;
public async Task<List<AggregateMediaItemViewModel>> Handle(
GetAggregateMediaItems request,
CancellationToken cancellationToken)
{
IEnumerable<MediaItem> allItems = await _mediaItemRepository.GetAll(request.MediaType);
if (!string.IsNullOrEmpty(request.SearchString))
{
allItems = allItems.Filter(i => i.Metadata?.Title.Contains(request.SearchString) == true);
}
return allItems.GroupBy(c => new { c.Source.Name, c.Metadata.Title }).Map(
group => new AggregateMediaItemViewModel(
group.Key.Name,
group.Key.Title,
group.Count(),
group.Count() == 1 ? DisplayDuration(group.Head()) : string.Empty))
.ToList();
}
private static string DisplayDuration(MediaItem mediaItem) => string.Format(
mediaItem.Metadata?.Duration.TotalHours >= 1 ? @"{0:h\:mm\:ss}" : @"{0:mm\:ss}",
mediaItem.Metadata?.Duration);
}
}

7
ErsatzTV.Application/MediaItems/Queries/GetAllMediaItems.cs

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
using System.Collections.Generic;
using MediatR;
namespace ErsatzTV.Application.MediaItems.Queries
{
public record GetAllMediaItems : IRequest<List<MediaItemViewModel>>;
}

23
ErsatzTV.Application/MediaItems/Queries/GetAllMediaItemsHandler.cs

@ -0,0 +1,23 @@ @@ -0,0 +1,23 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Interfaces.Repositories;
using MediatR;
using static ErsatzTV.Application.MediaItems.Mapper;
namespace ErsatzTV.Application.MediaItems.Queries
{
public class GetAllMediaItemsHandler : IRequestHandler<GetAllMediaItems, List<MediaItemViewModel>>
{
private readonly IMediaItemRepository _mediaItemRepository;
public GetAllMediaItemsHandler(IMediaItemRepository mediaItemRepository) =>
_mediaItemRepository = mediaItemRepository;
public async Task<List<MediaItemViewModel>> Handle(
GetAllMediaItems request,
CancellationToken cancellationToken) =>
(await _mediaItemRepository.GetAll()).Map(ProjectToViewModel).ToList();
}
}

7
ErsatzTV.Application/MediaItems/Queries/GetMediaItemById.cs

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.MediaItems.Queries
{
public record GetMediaItemById(int Id) : IRequest<Option<MediaItemViewModel>>;
}

23
ErsatzTV.Application/MediaItems/Queries/GetMediaItemByIdHandler.cs

@ -0,0 +1,23 @@ @@ -0,0 +1,23 @@
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 GetMediaItemByIdHandler : IRequestHandler<GetMediaItemById, Option<MediaItemViewModel>>
{
private readonly IMediaItemRepository _mediaItemRepository;
public GetMediaItemByIdHandler(IMediaItemRepository mediaItemRepository) =>
_mediaItemRepository = mediaItemRepository;
public Task<Option<MediaItemViewModel>> Handle(
GetMediaItemById request,
CancellationToken cancellationToken) =>
_mediaItemRepository.Get(request.Id)
.MapT(ProjectToViewModel);
}
}

10
ErsatzTV.Application/MediaSources/Commands/CreateLocalMediaSource.cs

@ -0,0 +1,10 @@ @@ -0,0 +1,10 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.MediaSources.Commands
{
public record CreateLocalMediaSource
(string Name, MediaType MediaType, string Folder) : IRequest<Either<BaseError, MediaSourceViewModel>>;
}

78
ErsatzTV.Application/MediaSources/Commands/CreateLocalMediaSourceHandler.cs

@ -0,0 +1,78 @@ @@ -0,0 +1,78 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.MediaSources.Mapper;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.MediaSources.Commands
{
public class CreateLocalMediaSourceHandler : IRequestHandler<CreateLocalMediaSource,
Either<BaseError, MediaSourceViewModel>>
{
private readonly IMediaSourceRepository _mediaSourceRepository;
public CreateLocalMediaSourceHandler(IMediaSourceRepository mediaSourceRepository) =>
_mediaSourceRepository = mediaSourceRepository;
public Task<Either<BaseError, MediaSourceViewModel>> Handle(
CreateLocalMediaSource request,
CancellationToken cancellationToken) =>
Validate(request).MapT(PersistLocalMediaSource).Bind(v => v.ToEitherAsync());
private Task<MediaSourceViewModel> PersistLocalMediaSource(LocalMediaSource c) =>
_mediaSourceRepository.Add(c).Map(ProjectToViewModel);
private async Task<Validation<BaseError, LocalMediaSource>> Validate(CreateLocalMediaSource request) =>
(await ValidateName(request), await ValidateFolder(request))
.Apply(
(name, folder) =>
new LocalMediaSource
{
Name = name,
MediaType = request.MediaType,
Folder = folder
});
private async Task<Validation<BaseError, string>> ValidateName(CreateLocalMediaSource createCollection)
{
List<string> allNames = await _mediaSourceRepository.GetAll()
.Map(list => list.Map(c => c.Name).ToList());
Validation<BaseError, string> result1 = createCollection.NotEmpty(c => c.Name)
.Bind(_ => createCollection.NotLongerThan(50)(c => c.Name));
var result2 = Optional(createCollection.Name)
.Filter(name => !allNames.Contains(name))
.ToValidation<BaseError>("Media source name must be unique");
return (result1, result2).Apply((_, _) => createCollection.Name);
}
private async Task<Validation<BaseError, string>> ValidateFolder(CreateLocalMediaSource createCollection)
{
List<string> allFolders = await _mediaSourceRepository.GetAll()
.Map(list => list.OfType<LocalMediaSource>().Map(c => c.Folder).ToList());
return Optional(createCollection.Folder)
.Filter(folder => allFolders.ForAll(f => !AreSubPaths(f, folder)))
.ToValidation<BaseError>("Folder must not belong to another media source");
}
private static bool AreSubPaths(string path1, string path2)
{
string one = path1 + Path.DirectorySeparatorChar;
string two = path2 + Path.DirectorySeparatorChar;
return one == two || one.StartsWith(two, StringComparison.OrdinalIgnoreCase) ||
two.StartsWith(one, StringComparison.OrdinalIgnoreCase);
}
}
}

9
ErsatzTV.Application/MediaSources/Commands/DeleteLocalMediaSource.cs

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
using System.Threading.Tasks;
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.MediaSources.Commands
{
public record DeleteLocalMediaSource(int LocalMediaSourceId) : IRequest<Either<BaseError, Task>>;
}

50
ErsatzTV.Application/MediaSources/Commands/DeleteLocalMediaSourceHandler.cs

@ -0,0 +1,50 @@ @@ -0,0 +1,50 @@
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.MediaSources.Commands
{
public class
DeleteLocalMediaSourceHandler : IRequestHandler<DeleteLocalMediaSource, Either<BaseError, Task>>
{
private readonly IMediaCollectionRepository _mediaCollectionRepository;
private readonly IMediaSourceRepository _mediaSourceRepository;
public DeleteLocalMediaSourceHandler(
IMediaSourceRepository mediaSourceRepository,
IMediaCollectionRepository mediaCollectionRepository)
{
_mediaSourceRepository = mediaSourceRepository;
_mediaCollectionRepository = mediaCollectionRepository;
}
public async Task<Either<BaseError, Task>> Handle(
DeleteLocalMediaSource request,
CancellationToken cancellationToken) =>
(await MediaSourceMustExist(request))
.Map(DoDeletion)
.ToEither<Task>();
private async Task DoDeletion(LocalMediaSource mediaSource)
{
await _mediaSourceRepository.Delete(mediaSource.Id);
if (mediaSource.MediaType == MediaType.TvShow)
{
await _mediaCollectionRepository.DeleteEmptyTelevisionCollections();
}
}
private async Task<Validation<BaseError, LocalMediaSource>> MediaSourceMustExist(
DeleteLocalMediaSource deleteMediaSource) =>
(await _mediaSourceRepository.Get(deleteMediaSource.LocalMediaSourceId))
.OfType<LocalMediaSource>()
.HeadOrNone()
.ToValidation<BaseError>(
$"Local media source {deleteMediaSource.LocalMediaSourceId} does not exist.");
}
}

9
ErsatzTV.Application/MediaSources/Commands/ScanLocalMediaSource.cs

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.MediaSources.Commands
{
public record ScanLocalMediaSource(int MediaSourceId) : IRequest<Either<BaseError, string>>,
IBackgroundServiceRequest;
}

56
ErsatzTV.Application/MediaSources/Commands/ScanLocalMediaSourceHandler.cs

@ -0,0 +1,56 @@ @@ -0,0 +1,56 @@
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 LanguageExt;
using MediatR;
namespace ErsatzTV.Application.MediaSources.Commands
{
public class ScanLocalMediaSourceHandler : IRequestHandler<ScanLocalMediaSource, Either<BaseError, string>>
{
private readonly IConfigElementRepository _configElementRepository;
private readonly ILocalMediaScanner _localMediaScanner;
private readonly IMediaSourceRepository _mediaSourceRepository;
public ScanLocalMediaSourceHandler(
IMediaSourceRepository mediaSourceRepository,
IConfigElementRepository configElementRepository,
ILocalMediaScanner localMediaScanner)
{
_mediaSourceRepository = mediaSourceRepository;
_configElementRepository = configElementRepository;
_localMediaScanner = localMediaScanner;
}
public Task<Either<BaseError, string>>
Handle(ScanLocalMediaSource request, CancellationToken cancellationToken) =>
Validate(request)
.MapT(
p => _localMediaScanner.ScanLocalMediaSource(p.LocalMediaSource, p.FFprobePath)
.Map(_ => p.LocalMediaSource.Name))
.Bind(v => v.ToEitherAsync());
private async Task<Validation<BaseError, RequestParameters>> Validate(ScanLocalMediaSource request) =>
(await LocalMediaSourceMustExist(request), await ValidateFFprobePath())
.Apply((localMediaSource, ffprobePath) => new RequestParameters(localMediaSource, ffprobePath));
private Task<Validation<BaseError, LocalMediaSource>> LocalMediaSourceMustExist(
ScanLocalMediaSource request) =>
_mediaSourceRepository.Get(request.MediaSourceId)
.Map(maybeMediaSource => maybeMediaSource.Map(ms => ms as LocalMediaSource))
.Map(v => v.ToValidation<BaseError>($"Local media source {request.MediaSourceId} does not exist."));
private Task<Validation<BaseError, string>> ValidateFFprobePath() =>
_configElementRepository.GetValue<string>(ConfigElementKey.FFprobePath)
.FilterT(File.Exists)
.Map(
ffprobePath =>
ffprobePath.ToValidation<BaseError>("FFprobe path does not exist on the file system"));
private record RequestParameters(LocalMediaSource LocalMediaSource, string FFprobePath);
}
}

8
ErsatzTV.Application/MediaSources/Commands/StartPlexPinFlow.cs

@ -0,0 +1,8 @@ @@ -0,0 +1,8 @@
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.MediaSources.Commands
{
public record StartPlexPinFlow : IRequest<Either<BaseError, string>>;
}

38
ErsatzTV.Application/MediaSources/Commands/StartPlexPinFlowHandler.cs

@ -0,0 +1,38 @@ @@ -0,0 +1,38 @@
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Plex;
using LanguageExt;
using MediatR;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.MediaSources.Commands
{
public class StartPlexPinFlowHandler : IRequestHandler<StartPlexPinFlow, Either<BaseError, string>>
{
private readonly ChannelWriter<IPlexBackgroundServiceRequest> _channel;
private readonly IPlexTvApiClient _plexTvApiClient;
public StartPlexPinFlowHandler(
IPlexTvApiClient plexTvApiClient,
ChannelWriter<IPlexBackgroundServiceRequest> channel)
{
_plexTvApiClient = plexTvApiClient;
_channel = channel;
}
public Task<Either<BaseError, string>> Handle(
StartPlexPinFlow request,
CancellationToken cancellationToken) =>
_plexTvApiClient.StartPinFlow().Bind(
result => result.Match(
Left: error => Task.FromResult(Left<BaseError, string>(error)),
Right: async pin =>
{
await _channel.WriteAsync(new TryCompletePlexPinFlow(pin), cancellationToken);
return Right<BaseError, string>(pin.Url);
})
);
}
}

7
ErsatzTV.Application/MediaSources/Commands/SynchronizePlexLibraries.cs

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
using ErsatzTV.Core;
using LanguageExt;
namespace ErsatzTV.Application.MediaSources.Commands
{
public record SynchronizePlexLibraries(int PlexMediaSourceId) : MediatR.IRequest<Either<BaseError, Unit>>;
}

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

Loading…
Cancel
Save