diff --git a/ErsatzTV.Application/Libraries/Commands/CreateLocalLibraryPath.cs b/ErsatzTV.Application/Libraries/Commands/CreateLocalLibraryPath.cs new file mode 100644 index 000000000..8970f67b9 --- /dev/null +++ b/ErsatzTV.Application/Libraries/Commands/CreateLocalLibraryPath.cs @@ -0,0 +1,9 @@ +using ErsatzTV.Core; +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.Libraries.Commands +{ + public record CreateLocalLibraryPath + (int LibraryId, string Path) : IRequest>; +} diff --git a/ErsatzTV.Application/Libraries/Commands/CreateLocalLibraryPathHandler.cs b/ErsatzTV.Application/Libraries/Commands/CreateLocalLibraryPathHandler.cs new file mode 100644 index 000000000..870da8c00 --- /dev/null +++ b/ErsatzTV.Application/Libraries/Commands/CreateLocalLibraryPathHandler.cs @@ -0,0 +1,62 @@ +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 LanguageExt.Prelude; +using static ErsatzTV.Application.Libraries.Mapper; + +namespace ErsatzTV.Application.Libraries.Commands +{ + public class CreateLocalLibraryPathHandler : IRequestHandler> + { + private readonly ILibraryRepository _libraryRepository; + + public CreateLocalLibraryPathHandler(ILibraryRepository mediaSourceRepository) => + _libraryRepository = mediaSourceRepository; + + public Task> Handle( + CreateLocalLibraryPath request, + CancellationToken cancellationToken) => + Validate(request).MapT(PersistLocalLibraryPath).Bind(v => v.ToEitherAsync()); + + private Task PersistLocalLibraryPath(LibraryPath p) => + _libraryRepository.Add(p).Map(ProjectToViewModel); + + private Task> Validate(CreateLocalLibraryPath request) => + ValidateFolder(request) + .MapT( + folder => + new LibraryPath + { + LibraryId = request.LibraryId, + Path = folder + }); + + private async Task> ValidateFolder(CreateLocalLibraryPath request) + { + List allPaths = await _libraryRepository.GetLocalPaths(request.LibraryId) + .Map(list => list.Map(c => c.Path).ToList()); + + + return Optional(request.Path) + .Filter(folder => allPaths.ForAll(f => !AreSubPaths(f, folder))) + .ToValidation("Path must not belong to another library path"); + } + + 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); + } + } +} diff --git a/ErsatzTV.Application/Libraries/LocalLibraryPathViewModel.cs b/ErsatzTV.Application/Libraries/LocalLibraryPathViewModel.cs index 4f67de281..9c8356bf8 100644 --- a/ErsatzTV.Application/Libraries/LocalLibraryPathViewModel.cs +++ b/ErsatzTV.Application/Libraries/LocalLibraryPathViewModel.cs @@ -1,4 +1,4 @@ namespace ErsatzTV.Application.Libraries { - public record LocalLibraryPathViewModel(int Id, string Path); + public record LocalLibraryPathViewModel(int Id, int LibraryId, string Path); } diff --git a/ErsatzTV.Application/Libraries/LocalLibraryViewModel.cs b/ErsatzTV.Application/Libraries/LocalLibraryViewModel.cs index 2cf7b205d..3e37ecfb1 100644 --- a/ErsatzTV.Application/Libraries/LocalLibraryViewModel.cs +++ b/ErsatzTV.Application/Libraries/LocalLibraryViewModel.cs @@ -1,4 +1,6 @@ -namespace ErsatzTV.Application.Libraries +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Application.Libraries { - public record LocalLibraryViewModel(int Id, string Name, string MediaKind); + public record LocalLibraryViewModel(int Id, string Name, LibraryMediaKind MediaKind); } diff --git a/ErsatzTV.Application/Libraries/Mapper.cs b/ErsatzTV.Application/Libraries/Mapper.cs index c868045d6..872a1a163 100644 --- a/ErsatzTV.Application/Libraries/Mapper.cs +++ b/ErsatzTV.Application/Libraries/Mapper.cs @@ -5,9 +5,9 @@ namespace ErsatzTV.Application.Libraries internal static class Mapper { public static LocalLibraryViewModel ProjectToViewModel(LocalLibrary library) => - new(library.Id, library.Name, library.MediaKind.ToString()); + new(library.Id, library.Name, library.MediaKind); public static LocalLibraryPathViewModel ProjectToViewModel(LibraryPath libraryPath) => - new(libraryPath.Id, libraryPath.Path); + new(libraryPath.Id, libraryPath.LibraryId, libraryPath.Path); } } diff --git a/ErsatzTV.Application/Libraries/Queries/GetLocalLibraryById.cs b/ErsatzTV.Application/Libraries/Queries/GetLocalLibraryById.cs new file mode 100644 index 000000000..becb8e12c --- /dev/null +++ b/ErsatzTV.Application/Libraries/Queries/GetLocalLibraryById.cs @@ -0,0 +1,7 @@ +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.Libraries.Queries +{ + public record GetLocalLibraryById(int LibraryId) : IRequest>; +} diff --git a/ErsatzTV.Application/Libraries/Queries/GetLocalLibraryByIdHandler.cs b/ErsatzTV.Application/Libraries/Queries/GetLocalLibraryByIdHandler.cs new file mode 100644 index 000000000..6e9f111af --- /dev/null +++ b/ErsatzTV.Application/Libraries/Queries/GetLocalLibraryByIdHandler.cs @@ -0,0 +1,22 @@ +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; +using static ErsatzTV.Application.Libraries.Mapper; + +namespace ErsatzTV.Application.Libraries.Queries +{ + public class GetLocalLibraryByIdHandler : IRequestHandler> + { + private readonly ILibraryRepository _libraryRepository; + + public GetLocalLibraryByIdHandler(ILibraryRepository libraryRepository) => + _libraryRepository = libraryRepository; + + public Task> Handle( + GetLocalLibraryById request, + CancellationToken cancellationToken) => + _libraryRepository.GetLocal(request.LibraryId).MapT(ProjectToViewModel); + } +} diff --git a/ErsatzTV.Application/MediaSources/Commands/CreateLocalMediaSource.cs b/ErsatzTV.Application/MediaSources/Commands/CreateLocalMediaSource.cs deleted file mode 100644 index 00bdabbb2..000000000 --- a/ErsatzTV.Application/MediaSources/Commands/CreateLocalMediaSource.cs +++ /dev/null @@ -1,12 +0,0 @@ -// 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>; -// } - - diff --git a/ErsatzTV.Application/MediaSources/Commands/CreateLocalMediaSourceHandler.cs b/ErsatzTV.Application/MediaSources/Commands/CreateLocalMediaSourceHandler.cs deleted file mode 100644 index 248563da7..000000000 --- a/ErsatzTV.Application/MediaSources/Commands/CreateLocalMediaSourceHandler.cs +++ /dev/null @@ -1,80 +0,0 @@ -// 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> -// { -// private readonly IMediaSourceRepository _mediaSourceRepository; -// -// public CreateLocalMediaSourceHandler(IMediaSourceRepository mediaSourceRepository) => -// _mediaSourceRepository = mediaSourceRepository; -// -// public Task> Handle( -// CreateLocalMediaSource request, -// CancellationToken cancellationToken) => -// Validate(request).MapT(PersistLocalMediaSource).Bind(v => v.ToEitherAsync()); -// -// private Task PersistLocalMediaSource(LocalMediaSource c) => -// _mediaSourceRepository.Add(c).Map(ProjectToViewModel); -// -// private async Task> Validate(CreateLocalMediaSource request) => -// (await ValidateName(request), await ValidateFolder(request)) -// .Apply( -// (name, folder) => -// new LocalMediaSource -// { -// Name = name, -// MediaType = request.MediaType, -// Folder = folder -// }); -// -// private async Task> ValidateName(CreateLocalMediaSource request) -// { -// List allNames = await _mediaSourceRepository.GetAll() -// .Map(list => list.Map(c => c.Name).ToList()); -// -// Validation result1 = request.NotEmpty(c => c.Name) -// .Bind(_ => request.NotLongerThan(50)(c => c.Name)); -// -// var result2 = Optional(request.Name) -// .Filter(name => !allNames.Contains(name)) -// .ToValidation("Media source name must be unique"); -// -// return (result1, result2).Apply((_, _) => request.Name); -// } -// -// private async Task> ValidateFolder(CreateLocalMediaSource request) -// { -// List allFolders = await _mediaSourceRepository.GetAll() -// .Map(list => list.OfType().Map(c => c.Folder).ToList()); -// -// -// return Optional(request.Folder) -// .Filter(folder => allFolders.ForAll(f => !AreSubPaths(f, folder))) -// .ToValidation("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); -// } -// } -// } - - diff --git a/ErsatzTV.Core/Interfaces/Repositories/ILibraryRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/ILibraryRepository.cs index a411d67a5..8ecca06fa 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/ILibraryRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/ILibraryRepository.cs @@ -7,7 +7,9 @@ namespace ErsatzTV.Core.Interfaces.Repositories { public interface ILibraryRepository { + Task Add(LibraryPath libraryPath); Task> Get(int libraryId); + Task> GetLocal(int libraryId); Task> GetAllLocal(); Task UpdateLastScan(Library library); Task> GetLocalPaths(int libraryId); diff --git a/ErsatzTV.Infrastructure/Data/Repositories/LibraryRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/LibraryRepository.cs index 747794c8a..340c88e97 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/LibraryRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/LibraryRepository.cs @@ -22,6 +22,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories _dbConnection = dbConnection; } + public async Task Add(LibraryPath libraryPath) + { + await using TvContext context = _dbContextFactory.CreateDbContext(); + await context.LibraryPaths.AddAsync(libraryPath); + await context.SaveChangesAsync(); + return libraryPath; + } + public Task> Get(int libraryId) { using TvContext context = _dbContextFactory.CreateDbContext(); @@ -32,6 +40,15 @@ namespace ErsatzTV.Infrastructure.Data.Repositories .Map(Optional); } + public Task> GetLocal(int libraryId) + { + using TvContext context = _dbContextFactory.CreateDbContext(); + return context.LocalLibraries + .OrderBy(l => l.Id) + .SingleOrDefaultAsync(l => l.Id == libraryId) + .Map(Optional); + } + public Task> GetAllLocal() { using TvContext context = _dbContextFactory.CreateDbContext(); diff --git a/ErsatzTV/Pages/LocalLibraryEditor.razor b/ErsatzTV/Pages/LocalLibraryEditor.razor index 7c430f87c..3c039cf38 100644 --- a/ErsatzTV/Pages/LocalLibraryEditor.razor +++ b/ErsatzTV/Pages/LocalLibraryEditor.razor @@ -31,8 +31,12 @@ + + Add Library Path + + @code { [Parameter] diff --git a/ErsatzTV/Pages/LocalLibraryPathEditor.razor b/ErsatzTV/Pages/LocalLibraryPathEditor.razor new file mode 100644 index 000000000..77941ba25 --- /dev/null +++ b/ErsatzTV/Pages/LocalLibraryPathEditor.razor @@ -0,0 +1,86 @@ +@page "/media/libraries/local/{Id:int}/add" +@using ErsatzTV.Application.Libraries +@using ErsatzTV.Application.Libraries.Commands +@using ErsatzTV.Application.Libraries.Queries +@using ErsatzTV.Application.MediaSources.Commands +@inject NavigationManager NavigationManager +@inject ILogger Logger +@inject ISnackbar Snackbar +@inject IMediator Mediator +@inject IEntityLocker Locker +@inject ChannelWriter Channel + + + @_library.Name - Add Local Library Path +
+ + + + + + @* TODO: replace this with a folder picker *@ + + + + + Add Local Library Path + + + + +
+
+ +@code { + + [Parameter] + public int Id { get; set; } + + private readonly LocalLibraryPathEditViewModel _model = new(); + private EditContext _editContext; + private ValidationMessageStore _messageStore; + + private LocalLibraryViewModel _library; + + protected override async Task OnParametersSetAsync() + { + Option maybeLibrary = await Mediator.Send(new GetLocalLibraryById(Id)); + maybeLibrary.Match( + library => _library = library, + () => NavigationManager.NavigateTo("404")); + } + + protected override void OnInitialized() + { + _editContext = new EditContext(_model); + _messageStore = new ValidationMessageStore(_editContext); + } + + private async Task HandleSubmitAsync() + { + _messageStore.Clear(); + if (_editContext.Validate()) + { + var command = new CreateLocalLibraryPath(_library.Id, _model.Path); + Either result = await Mediator.Send(command); + await result.Match( + Left: error => + { + Snackbar.Add(error.Value, Severity.Error); + Logger.LogError("Unexpected error saving local library path: {Error}", error.Value); + return Task.CompletedTask; + }, + Right: async vm => + { + if (Locker.LockLibrary(_library.Id)) + { + await Channel.WriteAsync(new ForceScanLocalLibrary(_library.Id)); + NavigationManager.NavigateTo("/media/libraries"); + } + }); + } + } + + + +} \ No newline at end of file diff --git a/ErsatzTV/Pages/LocalMediaSourceEditor.razor b/ErsatzTV/Pages/LocalMediaSourceEditor.razor deleted file mode 100644 index b1d62e739..000000000 --- a/ErsatzTV/Pages/LocalMediaSourceEditor.razor +++ /dev/null @@ -1,99 +0,0 @@ -@* @page "/media/sources/local/add" *@ -@* @using ErsatzTV.Application.MediaSources.Commands *@ -@* @using ErsatzTV.Application.MediaSources *@ -@* @inject NavigationManager NavigationManager *@ -@* @inject ILogger Logger *@ -@* @inject ISnackbar Snackbar *@ -@* @inject IMediator Mediator *@ -@* @inject IEntityLocker Locker *@ -@* @inject ChannelWriter Channel *@ -@* *@ -@*
*@ -@* Add Local Media Source *@ -@* *@ -@* *@ -@* *@ -@* *@ -@* *@ -@* *@ -@* @foreach (MediaType mediaType in new[] { MediaType.TvShow, MediaType.Movie }) *@ -@* { *@ -@* @mediaType *@ -@* } *@ -@* *@ -@* $1$ TODO: replace this with a folder picker #1# *@ -@* *@ -@* *@ -@* *@ -@* *@ -@* Add Local Media Source *@ -@* *@ -@* *@ -@* *@ -@* *@ -@*
*@ -@* *@ -@* @code { *@ -@* *@ -@* [Parameter] *@ -@* public int Id { get; set; } *@ -@* *@ -@* private readonly LocalMediaSourceEditViewModel _model = new(); *@ -@* private EditContext _editContext; *@ -@* private ValidationMessageStore _messageStore; *@ -@* *@ -@* private LocalMediaSource _mediaSource; *@ -@* *@ -@* protected override Task OnParametersSetAsync() *@ -@* { *@ -@* _mediaSource = new LocalMediaSource *@ -@* { *@ -@* MediaType = MediaType.TvShow *@ -@* }; *@ -@* *@ -@* _model.MediaType = _mediaSource.MediaType; *@ -@* _model.Folder = _mediaSource.Folder; *@ -@* *@ -@* return Task.CompletedTask; *@ -@* } *@ -@* *@ -@* protected override void OnInitialized() *@ -@* { *@ -@* _editContext = new EditContext(_model); *@ -@* _messageStore = new ValidationMessageStore(_editContext); *@ -@* } *@ -@* *@ -@* private async Task HandleSubmitAsync() *@ -@* { *@ -@* _messageStore.Clear(); *@ -@* if (_editContext.Validate()) *@ -@* { *@ -@* var command = new CreateLocalMediaSource( *@ -@* Convert.ToBase64String(Guid.NewGuid().ToByteArray()) *@ -@* .TrimEnd('=') *@ -@* .Replace("/", "_") *@ -@* .Replace("+", "-"), *@ -@* _model.MediaType, *@ -@* _model.Folder); *@ -@* Either result = await Mediator.Send(command); *@ -@* await result.Match( *@ -@* Left: error => *@ -@* { *@ -@* Snackbar.Add(error.Value, Severity.Error); *@ -@* Logger.LogError("Unexpected error saving collection: {Error}", error.Value); *@ -@* return Task.CompletedTask; *@ -@* }, *@ -@* Right: async vm => *@ -@* { *@ -@* if (Locker.LockLibrary(vm.Id)) *@ -@* { *@ -@* await Channel.WriteAsync(new ForceScanLocalLibrary(vm.Id)); *@ -@* NavigationManager.NavigateTo("/media/sources"); *@ -@* } *@ -@* }); *@ -@* } *@ -@* } *@ -@* *@ -@* *@ -@* *@ -@* } *@ \ No newline at end of file diff --git a/ErsatzTV/Pages/MediaSources.razor b/ErsatzTV/Pages/MediaSources.razor index dc1216f59..7a24157d8 100644 --- a/ErsatzTV/Pages/MediaSources.razor +++ b/ErsatzTV/Pages/MediaSources.razor @@ -1,25 +1,25 @@ -@page "/media/sources" -@page "/media/sources/{PanelIndex:int}" -@inject NavigationManager NavigationManager - - - - - - - - - - - - -@code { - - [Parameter] - public int PanelIndex { get; set; } - - private MudTabs _tabs; - - protected override void OnAfterRender(bool firstRender) => _tabs.ActivatePanel(PanelIndex); - -} \ No newline at end of file +@* @page "/media/sources" *@ +@* @page "/media/sources/{PanelIndex:int}" *@ +@* @inject NavigationManager NavigationManager *@ +@* *@ +@* *@ +@* *@ +@* *@ +@* *@ +@* *@ +@* *@ +@* *@ +@* *@ +@* *@ +@* *@ +@* *@ +@* @code { *@ +@* *@ +@* [Parameter] *@ +@* public int PanelIndex { get; set; } *@ +@* *@ +@* private MudTabs _tabs; *@ +@* *@ +@* protected override void OnAfterRender(bool firstRender) => _tabs.ActivatePanel(PanelIndex); *@ +@* *@ +@* } *@ \ No newline at end of file diff --git a/ErsatzTV/Validators/LocalLibraryPathEditViewModelValidator.cs b/ErsatzTV/Validators/LocalLibraryPathEditViewModelValidator.cs new file mode 100644 index 000000000..f9b7b9326 --- /dev/null +++ b/ErsatzTV/Validators/LocalLibraryPathEditViewModelValidator.cs @@ -0,0 +1,15 @@ +using System.IO; +using ErsatzTV.ViewModels; +using FluentValidation; + +namespace ErsatzTV.Validators +{ + public class LocalLibraryPathEditViewModelValidator : AbstractValidator + { + public LocalLibraryPathEditViewModelValidator() + { + RuleFor(x => x.Path).NotEmpty(); + RuleFor(x => x.Path).Must(Directory.Exists).WithMessage("Path must exist on filesystem"); + } + } +} diff --git a/ErsatzTV/Validators/LocalMediaSourceEditViewModelValidator.cs b/ErsatzTV/Validators/LocalMediaSourceEditViewModelValidator.cs deleted file mode 100644 index 8957bb5e9..000000000 --- a/ErsatzTV/Validators/LocalMediaSourceEditViewModelValidator.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.IO; -using ErsatzTV.ViewModels; -using FluentValidation; - -namespace ErsatzTV.Validators -{ - public class LocalMediaSourceEditViewModelValidator : AbstractValidator - { - public LocalMediaSourceEditViewModelValidator() - { - RuleFor(x => x.Folder).NotEmpty(); - RuleFor(x => x.Folder).Must(Directory.Exists).WithMessage("Folder must exist on filesystem"); - } - } -} diff --git a/ErsatzTV/ViewModels/LocalLibraryPathEditViewModel.cs b/ErsatzTV/ViewModels/LocalLibraryPathEditViewModel.cs new file mode 100644 index 000000000..acc130bbb --- /dev/null +++ b/ErsatzTV/ViewModels/LocalLibraryPathEditViewModel.cs @@ -0,0 +1,7 @@ +namespace ErsatzTV.ViewModels +{ + public class LocalLibraryPathEditViewModel + { + public string Path { get; set; } + } +} diff --git a/ErsatzTV/ViewModels/LocalMediaSourceEditViewModel.cs b/ErsatzTV/ViewModels/LocalMediaSourceEditViewModel.cs deleted file mode 100644 index e093179c9..000000000 --- a/ErsatzTV/ViewModels/LocalMediaSourceEditViewModel.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace ErsatzTV.ViewModels -{ - public class LocalMediaSourceEditViewModel - { - public string Folder { get; set; } - } -}