From 9fbe950e6efe1242c40eda5cadfc2dd7b93ffbe3 Mon Sep 17 00:00:00 2001 From: Jason Dove Date: Tue, 20 Jul 2021 07:47:12 -0500 Subject: [PATCH] support multiple local libraries (#317) * allow multiple local libraries * add "move library path" function --- CHANGELOG.md | 3 + .../Libraries/Commands/CreateLocalLibrary.cs | 11 + .../Commands/CreateLocalLibraryHandler.cs | 84 ++++++++ .../Commands/CreateLocalLibraryPathHandler.cs | 5 +- .../Libraries/Commands/DeleteLocalLibrary.cs | 9 + .../Commands/DeleteLocalLibraryHandler.cs | 70 +++++++ .../Commands/DeleteLocalLibraryPath.cs | 7 - .../Commands/DeleteLocalLibraryPathHandler.cs | 46 ---- .../Commands/ILocalLibraryRequest.cs | 7 + .../Commands/LocalLibraryHandlerBase.cs | 49 +++++ .../Commands/MoveLocalLibraryPath.cs | 9 + .../Commands/MoveLocalLibraryPathHandler.cs | 121 +++++++++++ .../Libraries/Commands/UpdateLocalLibrary.cs | 12 ++ .../Commands/UpdateLocalLibraryHandler.cs | 110 ++++++++++ .../Queries/CountMediaItemsByLibrary.cs | 6 + .../CountMediaItemsByLibraryHandler.cs | 25 +++ .../Libraries/Queries/GetAllLocalLibraries.cs | 7 + .../Queries/GetAllLocalLibrariesHandler.cs | 30 +++ .../Commands/AddArtistToCollectionHandler.cs | 2 +- ErsatzTV/Pages/EmbyMediaSourceEditor.razor | 2 +- ErsatzTV/Pages/EmbyMediaSources.razor | 2 +- .../Pages/JellyfinMediaSourceEditor.razor | 2 +- ErsatzTV/Pages/JellyfinMediaSources.razor | 2 +- ErsatzTV/Pages/Libraries.razor | 11 +- ErsatzTV/Pages/LocalLibraries.razor | 99 +++++++++ ErsatzTV/Pages/LocalLibraryEditor.razor | 196 ++++++++++++++++-- ErsatzTV/Pages/PlexMediaSources.razor | 4 +- ErsatzTV/Shared/MainLayout.razor | 7 +- .../Shared/MoveLocalLibraryPathDialog.razor | 128 ++++++++++++ .../RemoteMediaSourceLibrariesEditor.razor | 2 +- ErsatzTV/Shared/RemoteMediaSources.razor | 2 +- .../LocalLibraryEditViewModelValidator.cs | 10 + .../ViewModels/LocalLibraryEditViewModel.cs | 15 ++ .../LocalLibraryPathEditViewModel.cs | 1 + 34 files changed, 1003 insertions(+), 93 deletions(-) create mode 100644 ErsatzTV.Application/Libraries/Commands/CreateLocalLibrary.cs create mode 100644 ErsatzTV.Application/Libraries/Commands/CreateLocalLibraryHandler.cs create mode 100644 ErsatzTV.Application/Libraries/Commands/DeleteLocalLibrary.cs create mode 100644 ErsatzTV.Application/Libraries/Commands/DeleteLocalLibraryHandler.cs delete mode 100644 ErsatzTV.Application/Libraries/Commands/DeleteLocalLibraryPath.cs delete mode 100644 ErsatzTV.Application/Libraries/Commands/DeleteLocalLibraryPathHandler.cs create mode 100644 ErsatzTV.Application/Libraries/Commands/ILocalLibraryRequest.cs create mode 100644 ErsatzTV.Application/Libraries/Commands/LocalLibraryHandlerBase.cs create mode 100644 ErsatzTV.Application/Libraries/Commands/MoveLocalLibraryPath.cs create mode 100644 ErsatzTV.Application/Libraries/Commands/MoveLocalLibraryPathHandler.cs create mode 100644 ErsatzTV.Application/Libraries/Commands/UpdateLocalLibrary.cs create mode 100644 ErsatzTV.Application/Libraries/Commands/UpdateLocalLibraryHandler.cs create mode 100644 ErsatzTV.Application/Libraries/Queries/CountMediaItemsByLibrary.cs create mode 100644 ErsatzTV.Application/Libraries/Queries/CountMediaItemsByLibraryHandler.cs create mode 100644 ErsatzTV.Application/Libraries/Queries/GetAllLocalLibraries.cs create mode 100644 ErsatzTV.Application/Libraries/Queries/GetAllLocalLibrariesHandler.cs create mode 100644 ErsatzTV/Pages/LocalLibraries.razor create mode 100644 ErsatzTV/Shared/MoveLocalLibraryPathDialog.razor create mode 100644 ErsatzTV/Validators/LocalLibraryEditViewModelValidator.cs create mode 100644 ErsatzTV/ViewModels/LocalLibraryEditViewModel.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b801d58f..6307f1b79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] +### Added +- Add multiple local libraries to better organize your media +- Add `Move Library Path` function to support reorganizing existing local libraries ## [0.0.51-alpha] - 2021-07-18 ### Added diff --git a/ErsatzTV.Application/Libraries/Commands/CreateLocalLibrary.cs b/ErsatzTV.Application/Libraries/Commands/CreateLocalLibrary.cs new file mode 100644 index 000000000..01f856685 --- /dev/null +++ b/ErsatzTV.Application/Libraries/Commands/CreateLocalLibrary.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.Libraries.Commands +{ + public record CreateLocalLibrary(string Name, LibraryMediaKind MediaKind, List Paths) + : ILocalLibraryRequest, IRequest>; +} diff --git a/ErsatzTV.Application/Libraries/Commands/CreateLocalLibraryHandler.cs b/ErsatzTV.Application/Libraries/Commands/CreateLocalLibraryHandler.cs new file mode 100644 index 000000000..b6e289eae --- /dev/null +++ b/ErsatzTV.Application/Libraries/Commands/CreateLocalLibraryHandler.cs @@ -0,0 +1,84 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using ErsatzTV.Application.MediaSources.Commands; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Locking; +using ErsatzTV.Infrastructure.Data; +using LanguageExt; +using MediatR; +using Microsoft.EntityFrameworkCore; +using static ErsatzTV.Application.Libraries.Mapper; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Application.Libraries.Commands +{ + public class CreateLocalLibraryHandler : LocalLibraryHandlerBase, + IRequestHandler> + { + private readonly ChannelWriter _workerChannel; + private readonly IEntityLocker _entityLocker; + private readonly IDbContextFactory _dbContextFactory; + + public CreateLocalLibraryHandler( + ChannelWriter workerChannel, + IEntityLocker entityLocker, + IDbContextFactory dbContextFactory) + { + _workerChannel = workerChannel; + _entityLocker = entityLocker; + _dbContextFactory = dbContextFactory; + } + + public async Task> Handle( + CreateLocalLibrary request, + CancellationToken cancellationToken) + { + await using TvContext dbContext = _dbContextFactory.CreateDbContext(); + Validation validation = await Validate(dbContext, request); + return await validation.Apply(localLibrary => PersistLocalLibrary(dbContext, localLibrary)); + } + + private async Task PersistLocalLibrary( + TvContext dbContext, + LocalLibrary localLibrary) + { + await dbContext.LocalLibraries.AddAsync(localLibrary); + await dbContext.SaveChangesAsync(); + + if (_entityLocker.LockLibrary(localLibrary.Id)) + { + await _workerChannel.WriteAsync(new ForceScanLocalLibrary(localLibrary.Id)); + } + + return ProjectToViewModel(localLibrary); + } + + private static Task> Validate( + TvContext dbContext, + CreateLocalLibrary request) => + MediaSourceMustExist(dbContext, request) + .BindT(localLibrary => NameMustBeValid(request, localLibrary)) + .BindT(localLibrary => PathsMustBeValid(dbContext, localLibrary)); + + private static Task> MediaSourceMustExist( + TvContext dbContext, + CreateLocalLibrary request) => + dbContext.LocalMediaSources + .OrderBy(lms => lms.Id) + .FirstOrDefaultAsync() + .Map(Optional) + .MapT( + lms => new LocalLibrary + { + Name = request.Name, + Paths = request.Paths.Map(p => new LibraryPath { Path = p }).ToList(), + MediaKind = request.MediaKind, + MediaSourceId = lms.Id + }) + .Map(o => o.ToValidation("LocalMediaSource does not exist.")); + } +} diff --git a/ErsatzTV.Application/Libraries/Commands/CreateLocalLibraryPathHandler.cs b/ErsatzTV.Application/Libraries/Commands/CreateLocalLibraryPathHandler.cs index 870da8c00..08215769b 100644 --- a/ErsatzTV.Application/Libraries/Commands/CreateLocalLibraryPathHandler.cs +++ b/ErsatzTV.Application/Libraries/Commands/CreateLocalLibraryPathHandler.cs @@ -19,8 +19,8 @@ namespace ErsatzTV.Application.Libraries.Commands { private readonly ILibraryRepository _libraryRepository; - public CreateLocalLibraryPathHandler(ILibraryRepository mediaSourceRepository) => - _libraryRepository = mediaSourceRepository; + public CreateLocalLibraryPathHandler(ILibraryRepository libraryRepository) => + _libraryRepository = libraryRepository; public Task> Handle( CreateLocalLibraryPath request, @@ -45,7 +45,6 @@ namespace ErsatzTV.Application.Libraries.Commands 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"); diff --git a/ErsatzTV.Application/Libraries/Commands/DeleteLocalLibrary.cs b/ErsatzTV.Application/Libraries/Commands/DeleteLocalLibrary.cs new file mode 100644 index 000000000..6a425d305 --- /dev/null +++ b/ErsatzTV.Application/Libraries/Commands/DeleteLocalLibrary.cs @@ -0,0 +1,9 @@ +using ErsatzTV.Core; +using LanguageExt; +using MediatR; +using Unit = LanguageExt.Unit; + +namespace ErsatzTV.Application.Libraries.Commands +{ + public record DeleteLocalLibrary(int LocalLibraryId) : IRequest>; +} diff --git a/ErsatzTV.Application/Libraries/Commands/DeleteLocalLibraryHandler.cs b/ErsatzTV.Application/Libraries/Commands/DeleteLocalLibraryHandler.cs new file mode 100644 index 000000000..69fe3c933 --- /dev/null +++ b/ErsatzTV.Application/Libraries/Commands/DeleteLocalLibraryHandler.cs @@ -0,0 +1,70 @@ +using System.Collections.Generic; +using System.Data; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Dapper; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Search; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Infrastructure.Extensions; +using LanguageExt; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Unit = LanguageExt.Unit; + +namespace ErsatzTV.Application.Libraries.Commands +{ + public class DeleteLocalLibraryHandler : LocalLibraryHandlerBase, + IRequestHandler> + { + private readonly IDbContextFactory _dbContextFactory; + private readonly IDbConnection _dbConnection; + private readonly ISearchIndex _searchIndex; + + public DeleteLocalLibraryHandler( + IDbContextFactory dbContextFactory, + IDbConnection dbConnection, + ISearchIndex searchIndex) + { + _dbContextFactory = dbContextFactory; + _dbConnection = dbConnection; + _searchIndex = searchIndex; + } + + public async Task> Handle( + DeleteLocalLibrary request, + CancellationToken cancellationToken) + { + await using TvContext dbContext = _dbContextFactory.CreateDbContext(); + Validation validation = await LocalLibraryMustExist(dbContext, request); + return await validation.Apply(localLibrary => DoDeletion(dbContext, localLibrary)); + } + + private async Task DoDeletion(TvContext dbContext, LocalLibrary localLibrary) + { + List ids = await _dbConnection.QueryAsync( + @"SELECT MediaItem.Id FROM MediaItem + INNER JOIN LibraryPath LP on MediaItem.LibraryPathId = LP.Id + WHERE LP.LibraryId = @LibraryId", + new { LibraryId = localLibrary.Id }) + .Map(result => result.ToList()); + + await _searchIndex.RemoveItems(ids); + _searchIndex.Commit(); + + dbContext.LocalLibraries.Remove(localLibrary); + await dbContext.SaveChangesAsync(); + + return Unit.Default; + } + + private static Task> LocalLibraryMustExist( + TvContext dbContext, + DeleteLocalLibrary request) => + dbContext.LocalLibraries + .SelectOneAsync(ll => ll.Id, ll => ll.Id == request.LocalLibraryId) + .Map(o => o.ToValidation($"Local library {request.LocalLibraryId} does not exist.")); + } +} diff --git a/ErsatzTV.Application/Libraries/Commands/DeleteLocalLibraryPath.cs b/ErsatzTV.Application/Libraries/Commands/DeleteLocalLibraryPath.cs deleted file mode 100644 index d8c72e366..000000000 --- a/ErsatzTV.Application/Libraries/Commands/DeleteLocalLibraryPath.cs +++ /dev/null @@ -1,7 +0,0 @@ -using ErsatzTV.Core; -using LanguageExt; - -namespace ErsatzTV.Application.Libraries.Commands -{ - public record DeleteLocalLibraryPath(int LocalLibraryPathId) : MediatR.IRequest>; -} diff --git a/ErsatzTV.Application/Libraries/Commands/DeleteLocalLibraryPathHandler.cs b/ErsatzTV.Application/Libraries/Commands/DeleteLocalLibraryPathHandler.cs deleted file mode 100644 index 90e64d5cd..000000000 --- a/ErsatzTV.Application/Libraries/Commands/DeleteLocalLibraryPathHandler.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using ErsatzTV.Core; -using ErsatzTV.Core.Domain; -using ErsatzTV.Core.Interfaces.Repositories; -using ErsatzTV.Core.Interfaces.Search; -using LanguageExt; - -namespace ErsatzTV.Application.Libraries.Commands -{ - public class - DeleteLocalLibraryPathHandler : MediatR.IRequestHandler> - { - private readonly ILibraryRepository _libraryRepository; - private readonly ISearchIndex _searchIndex; - - public DeleteLocalLibraryPathHandler(ILibraryRepository libraryRepository, ISearchIndex searchIndex) - { - _libraryRepository = libraryRepository; - _searchIndex = searchIndex; - } - - public Task> Handle( - DeleteLocalLibraryPath request, - CancellationToken cancellationToken) => - MediaSourceMustExist(request) - .MapT(DoDeletion) - .Bind(t => t.ToEitherAsync()); - - private async Task DoDeletion(LibraryPath libraryPath) - { - List ids = await _libraryRepository.GetMediaIdsByLocalPath(libraryPath.Id); - await _searchIndex.RemoveItems(ids); - _searchIndex.Commit(); - await _libraryRepository.DeleteLocalPath(libraryPath.Id); - return Unit.Default; - } - - private async Task> MediaSourceMustExist(DeleteLocalLibraryPath request) => - (await _libraryRepository.GetPath(request.LocalLibraryPathId)) - .HeadOrNone() - .ToValidation( - $"Local library path {request.LocalLibraryPathId} does not exist."); - } -} diff --git a/ErsatzTV.Application/Libraries/Commands/ILocalLibraryRequest.cs b/ErsatzTV.Application/Libraries/Commands/ILocalLibraryRequest.cs new file mode 100644 index 000000000..59682c5c0 --- /dev/null +++ b/ErsatzTV.Application/Libraries/Commands/ILocalLibraryRequest.cs @@ -0,0 +1,7 @@ +namespace ErsatzTV.Application.Libraries.Commands +{ + public interface ILocalLibraryRequest + { + public string Name { get; } + } +} diff --git a/ErsatzTV.Application/Libraries/Commands/LocalLibraryHandlerBase.cs b/ErsatzTV.Application/Libraries/Commands/LocalLibraryHandlerBase.cs new file mode 100644 index 000000000..b6a343b07 --- /dev/null +++ b/ErsatzTV.Application/Libraries/Commands/LocalLibraryHandlerBase.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Infrastructure.Data; +using LanguageExt; +using Microsoft.EntityFrameworkCore; +using static LanguageExt.Prelude; + +namespace ErsatzTV.Application.Libraries.Commands +{ + public abstract class LocalLibraryHandlerBase + { + protected static Task> NameMustBeValid( + ILocalLibraryRequest request, + LocalLibrary localLibrary) => + request.NotEmpty(c => c.Name) + .Bind(_ => request.NotLongerThan(50)(c => c.Name)) + .Map(_ => localLibrary).AsTask(); + + protected static async Task> PathsMustBeValid( + TvContext dbContext, + LocalLibrary localLibrary, + int? existingLibraryId = null) + { + List allPaths = await dbContext.LocalLibraries + .Include(ll => ll.Paths) + .Filter(ll => existingLibraryId == null || ll.Id != existingLibraryId) + .ToListAsync() + .Map(list => list.SelectMany(ll => ll.Paths).Map(lp => lp.Path).ToList()); + + return Optional(localLibrary.Paths.Count(folder => allPaths.Any(f => AreSubPaths(f, folder.Path)))) + .Filter(length => length == 0) + .Map(_ => localLibrary) + .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/Commands/MoveLocalLibraryPath.cs b/ErsatzTV.Application/Libraries/Commands/MoveLocalLibraryPath.cs new file mode 100644 index 000000000..588008308 --- /dev/null +++ b/ErsatzTV.Application/Libraries/Commands/MoveLocalLibraryPath.cs @@ -0,0 +1,9 @@ +using ErsatzTV.Core; +using LanguageExt; +using MediatR; +using Unit = LanguageExt.Unit; + +namespace ErsatzTV.Application.Libraries.Commands +{ + public record MoveLocalLibraryPath(int LibraryPathId, int TargetLibraryId) : IRequest>; +} diff --git a/ErsatzTV.Application/Libraries/Commands/MoveLocalLibraryPathHandler.cs b/ErsatzTV.Application/Libraries/Commands/MoveLocalLibraryPathHandler.cs new file mode 100644 index 000000000..ab98e318c --- /dev/null +++ b/ErsatzTV.Application/Libraries/Commands/MoveLocalLibraryPathHandler.cs @@ -0,0 +1,121 @@ +using System.Collections.Generic; +using System.Data; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Dapper; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Interfaces.Search; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Infrastructure.Extensions; +using LanguageExt; +using MediatR; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Unit = LanguageExt.Unit; + +namespace ErsatzTV.Application.Libraries.Commands +{ + public class MoveLocalLibraryPathHandler : IRequestHandler> + { + private readonly ISearchIndex _searchIndex; + private readonly ISearchRepository _searchRepository; + private readonly IDbContextFactory _dbContextFactory; + private readonly IDbConnection _dbConnection; + private readonly ILogger _logger; + + public MoveLocalLibraryPathHandler( + ISearchIndex searchIndex, + ISearchRepository searchRepository, + IDbContextFactory dbContextFactory, + IDbConnection dbConnection, + ILogger logger) + { + _searchIndex = searchIndex; + _searchRepository = searchRepository; + _dbContextFactory = dbContextFactory; + _dbConnection = dbConnection; + _logger = logger; + } + + public async Task> Handle( + MoveLocalLibraryPath request, + CancellationToken cancellationToken) + { + await using TvContext dbContext = _dbContextFactory.CreateDbContext(); + Validation validation = await Validate(dbContext, request); + return await validation.Apply(parameters => MovePath(dbContext, parameters)); + } + + private async Task MovePath(TvContext dbContext, Parameters parameters) + { + LibraryPath path = parameters.LibraryPath; + LocalLibrary newLibrary = parameters.Library; + + path.LibraryId = newLibrary.Id; + if (await dbContext.SaveChangesAsync() > 0) + { + List ids = await _dbConnection.QueryAsync( + @"SELECT MediaItem.Id FROM MediaItem WHERE LibraryPathId = @LibraryPathId", + new { LibraryPathId = path.Id }) + .Map(result => result.ToList()); + + foreach (int id in ids) + { + Option maybeMediaItem = await _searchRepository.GetItemToIndex(id); + foreach (MediaItem mediaItem in maybeMediaItem) + { + _logger.LogInformation("Moving item at {Path}", await GetPath(mediaItem)); + await _searchIndex.UpdateItems(_searchRepository, new List { mediaItem }); + } + } + } + + return Unit.Default; + } + + private static async Task> Validate( + TvContext dbContext, + MoveLocalLibraryPath request) => + (await LibraryPathMustExist(dbContext, request), await LocalLibraryMustExist(dbContext, request)) + .Apply((libraryPath, localLibrary) => new Parameters(libraryPath, localLibrary)); + + private static Task> LibraryPathMustExist( + TvContext dbContext, + MoveLocalLibraryPath request) => + dbContext.LibraryPaths + .Include(lp => lp.Library) + .SelectOneAsync(c => c.Id, c => c.Id == request.LibraryPathId) + .Map(o => o.ToValidation("LibraryPath does not exist.")); + + private static Task> LocalLibraryMustExist( + TvContext dbContext, + MoveLocalLibraryPath request) => + dbContext.LocalLibraries + .Include(ll => ll.Paths) + .SelectOneAsync(a => a.Id, a => a.Id == request.TargetLibraryId) + .Map(o => o.ToValidation("LocalLibrary does not exist")); + + private async Task GetPath(MediaItem mediaItem) => + mediaItem switch + { + Movie => await _dbConnection.QuerySingleAsync( + @"SELECT Path FROM MediaFile + INNER JOIN MediaVersion MV on MediaFile.MediaVersionId = MV.Id + WHERE MV.MovieId = @Id", new { mediaItem.Id }), + Episode => await _dbConnection.QuerySingleAsync( + @"SELECT Path FROM MediaFile + INNER JOIN MediaVersion MV on MediaFile.MediaVersionId = MV.Id + WHERE MV.EpisodeId = @Id", new { mediaItem.Id }), + MusicVideo => await _dbConnection.QuerySingleAsync( + @"SELECT Path FROM MediaFile + INNER JOIN MediaVersion MV on MediaFile.MediaVersionId = MV.Id + WHERE MV.MusicVideoId = @Id", new { mediaItem.Id }), + _ => null + }; + + private record Parameters(LibraryPath LibraryPath, LocalLibrary Library); + } +} diff --git a/ErsatzTV.Application/Libraries/Commands/UpdateLocalLibrary.cs b/ErsatzTV.Application/Libraries/Commands/UpdateLocalLibrary.cs new file mode 100644 index 000000000..efc73b4d0 --- /dev/null +++ b/ErsatzTV.Application/Libraries/Commands/UpdateLocalLibrary.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; +using ErsatzTV.Core; +using LanguageExt; +using MediatR; + +namespace ErsatzTV.Application.Libraries.Commands +{ + public record UpdateLocalLibraryPath(int Id, string Path); + + public record UpdateLocalLibrary(int Id, string Name, List Paths) : ILocalLibraryRequest, + IRequest>; +} diff --git a/ErsatzTV.Application/Libraries/Commands/UpdateLocalLibraryHandler.cs b/ErsatzTV.Application/Libraries/Commands/UpdateLocalLibraryHandler.cs new file mode 100644 index 000000000..29a5e9ee1 --- /dev/null +++ b/ErsatzTV.Application/Libraries/Commands/UpdateLocalLibraryHandler.cs @@ -0,0 +1,110 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using ErsatzTV.Application.MediaSources.Commands; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Locking; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Infrastructure.Extensions; +using LanguageExt; +using MediatR; +using Microsoft.EntityFrameworkCore; +using static ErsatzTV.Application.Libraries.Mapper; + +namespace ErsatzTV.Application.Libraries.Commands +{ + public class UpdateLocalLibraryHandler : LocalLibraryHandlerBase, + IRequestHandler> + { + private readonly ChannelWriter _workerChannel; + private readonly IEntityLocker _entityLocker; + private readonly IDbContextFactory _dbContextFactory; + + public UpdateLocalLibraryHandler( + ChannelWriter workerChannel, + IEntityLocker entityLocker, + IDbContextFactory dbContextFactory) + { + _workerChannel = workerChannel; + _entityLocker = entityLocker; + _dbContextFactory = dbContextFactory; + } + + public async Task> Handle( + UpdateLocalLibrary request, + CancellationToken cancellationToken) + { + await using TvContext dbContext = _dbContextFactory.CreateDbContext(); + Validation validation = await Validate(dbContext, request); + return await validation.Apply(parameters => UpdateLocalLibrary(dbContext, parameters)); + } + + private async Task UpdateLocalLibrary(TvContext dbContext, Parameters parameters) + { + (LocalLibrary existing, LocalLibrary incoming) = parameters; + existing.Name = incoming.Name; + + // toAdd + var toAdd = incoming.Paths + .Filter(p => existing.Paths.All(ep => NormalizePath(ep.Path) != NormalizePath(p.Path))) + .ToList(); + var toRemove = existing.Paths + .Filter(ep => incoming.Paths.All(p => NormalizePath(p.Path) != NormalizePath(ep.Path))) + .ToList(); + + existing.Paths.RemoveAll(toRemove.Contains); + existing.Paths.AddRange(toAdd); + + await dbContext.SaveChangesAsync(); + + if (toAdd.Count > 0 || toRemove.Count > 0 && _entityLocker.LockLibrary(existing.Id)) + { + await _workerChannel.WriteAsync(new ForceScanLocalLibrary(existing.Id)); + } + + return ProjectToViewModel(existing); + } + + private static Task> Validate( + TvContext dbContext, + UpdateLocalLibrary request) => + LocalLibraryMustExist(dbContext, request) + .BindT(parameters => NameMustBeValid(request, parameters.Incoming).MapT(_ => parameters)) + .BindT( + parameters => PathsMustBeValid(dbContext, parameters.Incoming, parameters.Existing.Id) + .MapT(_ => parameters)); + + private static Task> LocalLibraryMustExist( + TvContext dbContext, + UpdateLocalLibrary request) => + dbContext.LocalLibraries + .Include(ll => ll.Paths) + .SelectOneAsync(ll => ll.Id, ll => ll.Id == request.Id) + .MapT( + existing => + { + var incoming = new LocalLibrary + { + Name = request.Name, + Paths = request.Paths.Map(p => new LibraryPath { Id = p.Id, Path = p.Path }).ToList(), + MediaSourceId = existing.Id + }; + + return new Parameters(existing, incoming); + }) + .Map(o => o.ToValidation("LocalLibrary does not exist.")); + + private record Parameters(LocalLibrary Existing, LocalLibrary Incoming); + + private static string NormalizePath(string path) + { + return Path.GetFullPath(new Uri(path).LocalPath) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + .ToUpperInvariant(); + } + } +} diff --git a/ErsatzTV.Application/Libraries/Queries/CountMediaItemsByLibrary.cs b/ErsatzTV.Application/Libraries/Queries/CountMediaItemsByLibrary.cs new file mode 100644 index 000000000..51d0befa8 --- /dev/null +++ b/ErsatzTV.Application/Libraries/Queries/CountMediaItemsByLibrary.cs @@ -0,0 +1,6 @@ +using MediatR; + +namespace ErsatzTV.Application.Libraries.Queries +{ + public record CountMediaItemsByLibrary(int LibraryId) : IRequest; +} diff --git a/ErsatzTV.Application/Libraries/Queries/CountMediaItemsByLibraryHandler.cs b/ErsatzTV.Application/Libraries/Queries/CountMediaItemsByLibraryHandler.cs new file mode 100644 index 000000000..8247a2139 --- /dev/null +++ b/ErsatzTV.Application/Libraries/Queries/CountMediaItemsByLibraryHandler.cs @@ -0,0 +1,25 @@ +using System.Data; +using System.Threading; +using System.Threading.Tasks; +using Dapper; +using MediatR; + +namespace ErsatzTV.Application.Libraries.Queries +{ + public class CountMediaItemsByLibraryHandler : IRequestHandler + { + private readonly IDbConnection _dbConnection; + + public CountMediaItemsByLibraryHandler(IDbConnection dbConnection) + { + _dbConnection = dbConnection; + } + + public Task Handle(CountMediaItemsByLibrary request, CancellationToken cancellationToken) => + _dbConnection.QuerySingleAsync( + @"SELECT COUNT(*) FROM MediaItem + INNER JOIN LibraryPath LP on MediaItem.LibraryPathId = LP.Id + WHERE LP.LibraryId = @LibraryId", + new { request.LibraryId }); + } +} diff --git a/ErsatzTV.Application/Libraries/Queries/GetAllLocalLibraries.cs b/ErsatzTV.Application/Libraries/Queries/GetAllLocalLibraries.cs new file mode 100644 index 000000000..379b9f67d --- /dev/null +++ b/ErsatzTV.Application/Libraries/Queries/GetAllLocalLibraries.cs @@ -0,0 +1,7 @@ +using System.Collections.Generic; +using MediatR; + +namespace ErsatzTV.Application.Libraries.Queries +{ + public record GetAllLocalLibraries : IRequest>; +} diff --git a/ErsatzTV.Application/Libraries/Queries/GetAllLocalLibrariesHandler.cs b/ErsatzTV.Application/Libraries/Queries/GetAllLocalLibrariesHandler.cs new file mode 100644 index 000000000..b30eef61a --- /dev/null +++ b/ErsatzTV.Application/Libraries/Queries/GetAllLocalLibrariesHandler.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using MediatR; +using static ErsatzTV.Application.Libraries.Mapper; + +namespace ErsatzTV.Application.Libraries.Queries +{ + public class GetAllLocalLibrariesHandler : IRequestHandler> + { + private readonly ILibraryRepository _libraryRepository; + + public GetAllLocalLibrariesHandler(ILibraryRepository libraryRepository) => _libraryRepository = libraryRepository; + + public Task> Handle( + GetAllLocalLibraries request, + CancellationToken cancellationToken) => + _libraryRepository.GetAll() + .Map( + list => list + .OfType() + .OrderBy(l => l.MediaKind) + .Map(ProjectToViewModel) + .ToList()); + } +} diff --git a/ErsatzTV.Application/MediaCollections/Commands/AddArtistToCollectionHandler.cs b/ErsatzTV.Application/MediaCollections/Commands/AddArtistToCollectionHandler.cs index 8406a2328..99470520a 100644 --- a/ErsatzTV.Application/MediaCollections/Commands/AddArtistToCollectionHandler.cs +++ b/ErsatzTV.Application/MediaCollections/Commands/AddArtistToCollectionHandler.cs @@ -73,7 +73,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands AddArtistToCollection request) => dbContext.Artists .SelectOneAsync(a => a.Id, a => a.Id == request.ArtistId) - .Map(o => o.ToValidation("Music video does not exist")); + .Map(o => o.ToValidation("Artist does not exist")); private record Parameters(Collection Collection, Artist Artist); } diff --git a/ErsatzTV/Pages/EmbyMediaSourceEditor.razor b/ErsatzTV/Pages/EmbyMediaSourceEditor.razor index 7d4bf84c7..5db2c04b9 100644 --- a/ErsatzTV/Pages/EmbyMediaSourceEditor.razor +++ b/ErsatzTV/Pages/EmbyMediaSourceEditor.razor @@ -1,4 +1,4 @@ -@page "/media/emby/edit" +@page "/media/sources/emby/edit" @using Unit = LanguageExt.Unit @using ErsatzTV.Core.Emby @using ErsatzTV.Application.Emby.Queries diff --git a/ErsatzTV/Pages/EmbyMediaSources.razor b/ErsatzTV/Pages/EmbyMediaSources.razor index d0ac3ad56..bc16471c2 100644 --- a/ErsatzTV/Pages/EmbyMediaSources.razor +++ b/ErsatzTV/Pages/EmbyMediaSources.razor @@ -1,4 +1,4 @@ -@page "/media/emby" +@page "/media/sources/emby" @using ErsatzTV.Core.Interfaces.Emby @using ErsatzTV.Application.Emby.Commands @using ErsatzTV.Application.Emby.Queries diff --git a/ErsatzTV/Pages/JellyfinMediaSourceEditor.razor b/ErsatzTV/Pages/JellyfinMediaSourceEditor.razor index 7eb79bb6a..a428bb12d 100644 --- a/ErsatzTV/Pages/JellyfinMediaSourceEditor.razor +++ b/ErsatzTV/Pages/JellyfinMediaSourceEditor.razor @@ -1,4 +1,4 @@ -@page "/media/jellyfin/edit" +@page "/media/sources/jellyfin/edit" @using Unit = LanguageExt.Unit @using ErsatzTV.Core.Jellyfin @using ErsatzTV.Application.Jellyfin.Queries diff --git a/ErsatzTV/Pages/JellyfinMediaSources.razor b/ErsatzTV/Pages/JellyfinMediaSources.razor index b360ed26e..786527621 100644 --- a/ErsatzTV/Pages/JellyfinMediaSources.razor +++ b/ErsatzTV/Pages/JellyfinMediaSources.razor @@ -1,4 +1,4 @@ -@page "/media/jellyfin" +@page "/media/sources/jellyfin" @using ErsatzTV.Core.Interfaces.Jellyfin @using ErsatzTV.Application.Jellyfin.Commands @using ErsatzTV.Application.Jellyfin.Queries diff --git a/ErsatzTV/Pages/Libraries.razor b/ErsatzTV/Pages/Libraries.razor index 6f6a6a710..04294d4b4 100644 --- a/ErsatzTV/Pages/Libraries.razor +++ b/ErsatzTV/Pages/Libraries.razor @@ -28,7 +28,7 @@ - + Library Kind @@ -71,15 +71,6 @@ Link="@($"/search?query=library_id%3a{context.Id}")"> - @if (context is LocalLibraryViewModel) - { - - - - - } diff --git a/ErsatzTV/Pages/LocalLibraries.razor b/ErsatzTV/Pages/LocalLibraries.razor new file mode 100644 index 000000000..c55447c5e --- /dev/null +++ b/ErsatzTV/Pages/LocalLibraries.razor @@ -0,0 +1,99 @@ +@page "/media/sources/local" +@using ErsatzTV.Application.Libraries +@using ErsatzTV.Application.Libraries.Commands +@using ErsatzTV.Application.Libraries.Queries +@implements IDisposable +@inject IDialogService _dialog +@inject IMediator _mediator +@inject IEntityLocker _locker +@inject ChannelWriter _workerChannel +@inject ChannelWriter _plexWorkerChannel +@inject ChannelWriter _jellyfinWorkerChannel +@inject ChannelWriter _embyWorkerChannel + + + + + Local Libraries + + + + + + + + Name + Media Kind + + + + @context.Name + @context.MediaKind + +
+ + + + + + + + +
+
+
+
+ + Add Local Library + +
+ +@code { + private IList _libraries; + + protected override void OnInitialized() + { + _locker.OnLibraryChanged += LockChanged; + } + + protected override async Task OnParametersSetAsync() => await LoadLibraries(); + + private async Task LoadLibraries() + { + _libraries = await _mediator.Send(new GetAllLocalLibraries()); + } + + private void LockChanged(object sender, EventArgs e) => + InvokeAsync(StateHasChanged); + + private async Task DeleteLibrary(LocalLibraryViewModel library) + { + int count = await _mediator.Send(new CountMediaItemsByLibrary(library.Id)); + var parameters = new DialogParameters + { + { "EntityType", "library" }, + { "EntityName", library.Name }, + { "DetailText", $"This library contains {count} media items." }, + { "DetailHighlight", count.ToString() } + }; + var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall }; + + IDialogReference dialog = _dialog.Show("Delete Library", parameters, options); + DialogResult result = await dialog.Result; + if (!result.Cancelled) + { + await _mediator.Send(new DeleteLocalLibrary(library.Id)); + await LoadLibraries(); + } + } + + void IDisposable.Dispose() + { + _locker.OnLibraryChanged -= LockChanged; + } + +} \ No newline at end of file diff --git a/ErsatzTV/Pages/LocalLibraryEditor.razor b/ErsatzTV/Pages/LocalLibraryEditor.razor index 58b0d2dee..8d94298b7 100644 --- a/ErsatzTV/Pages/LocalLibraryEditor.razor +++ b/ErsatzTV/Pages/LocalLibraryEditor.razor @@ -1,18 +1,53 @@ -@page "/media/libraries/local/{Id:int}" +@page "/media/sources/local/{Id:int}/edit" +@page "/media/sources/local/add" @using ErsatzTV.Application.Libraries @using ErsatzTV.Application.Libraries.Commands @using ErsatzTV.Application.Libraries.Queries +@using Unit = LanguageExt.Unit +@implements IDisposable @inject IDialogService _dialog +@inject IEntityLocker _locker @inject IMediator _mediator +@inject ILogger _logger +@inject ISnackbar _snackbar +@inject NavigationManager _navigationManager - +
+ @(IsEdit ? "Edit Local Library" : "Add Local Library") + + + + + + + + @foreach (LibraryMediaKind mediaKind in Enum.GetValues()) + { + @mediaKind + } + + + + + + Add Path + + + Save Changes + + + + +
+ + Library Paths - + Path @@ -22,6 +57,12 @@ @context.Path
+ + + + @@ -31,9 +72,6 @@ - - Add Library Path - @@ -42,17 +80,94 @@ [Parameter] public int Id { get; set; } - private IList _libraryPaths; + private readonly LocalLibraryEditViewModel _model = new(); + private LocalLibraryPathEditViewModel _newPath = new(); + private EditContext _editContext; + private ValidationMessageStore _messageStore; + + private bool IsEdit => Id != 0; - protected override async Task OnParametersSetAsync() => await LoadLibraryPaths(); + protected override async Task OnParametersSetAsync() + { + if (IsEdit) + { + Option maybeLibrary = await _mediator.Send(new GetLocalLibraryById(Id)); + await maybeLibrary.Match( + async library => + { + _model.Id = library.Id; + _model.Name = library.Name; + _model.MediaKind = library.MediaKind; - private async Task LoadLibraryPaths() => - _libraryPaths = await _mediator.Send(new GetLocalLibraryPaths(Id)); + await LoadLibraryPaths(); + }, + () => + { + _navigationManager.NavigateTo("404"); + return Task.CompletedTask; + }); + } + else + { + _model.HasChanges = true; + _model.Name = "New Local Library"; + _model.MediaKind = LibraryMediaKind.Movies; + _model.Paths = new List(); + } + } - private async Task DeleteLibraryPath(LocalLibraryPathViewModel libraryPath) + protected override void OnInitialized() { - int count = await _mediator.Send(new CountMediaItemsByLibraryPath(libraryPath.Id)); + _locker.OnLibraryChanged += LockChanged; + _editContext = new EditContext(_model); + _messageStore = new ValidationMessageStore(_editContext); + } + + private void LockChanged(object sender, EventArgs e) => + InvokeAsync(StateHasChanged); + + private async Task LoadLibraryPaths() + { + _model.HasChanges = false; + _model.Paths = await _mediator.Send(new GetLocalLibraryPaths(Id)) + .Map(list => list.Map(vm => new LocalLibraryPathEditViewModel + { + Id = vm.Id, + Path = vm.Path + }).ToList()); + } + + private async Task MoveLibraryPath(LocalLibraryPathEditViewModel libraryPath) + { + var parameters = new DialogParameters { { "MediaKind", _model.MediaKind }, { "SourceLibraryId", Id } }; + var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall }; + + IDialogReference dialog = _dialog.Show("Move Local Library Path", parameters, options); + DialogResult result = await dialog.Result; + if (!result.Cancelled && result.Data is LocalLibraryViewModel library) + { + var request = new MoveLocalLibraryPath(libraryPath.Id, library.Id); + Either moveResult = await _mediator.Send(request); + moveResult.Match( + _ => _navigationManager.NavigateTo($"/media/sources/local/{library.Id}/edit"), + error => + { + _snackbar.Add(error.Value, Severity.Error); + _logger.LogError("Unexpected error moving local library path: {Error}", error.Value); + }); + } + } + + private async Task DeleteLibraryPath(LocalLibraryPathEditViewModel libraryPath) + { + if (libraryPath.Id == 0) + { + _model.HasChanges = true; + _model.Paths.Remove(libraryPath); + } + + int count = await _mediator.Send(new CountMediaItemsByLibraryPath(libraryPath.Id)); var parameters = new DialogParameters { { "EntityType", "library path" }, @@ -61,14 +176,65 @@ { "DetailHighlight", count.ToString() } }; var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall }; - + IDialogReference dialog = _dialog.Show("Delete Library Path", parameters, options); DialogResult result = await dialog.Result; if (!result.Cancelled) { - await _mediator.Send(new DeleteLocalLibraryPath(libraryPath.Id)); - await LoadLibraryPaths(); + _model.HasChanges = true; + _model.Paths.Remove(libraryPath); } } + private void AddLibraryPath() + { + if (_model.Paths.All(p => NormalizePath(p.Path) != NormalizePath(_newPath.Path))) + { + _model.HasChanges = true; + _model.Paths.Add(new LocalLibraryPathEditViewModel + { + Path = _newPath.Path + }); + } + + _newPath.Path = null; + } + + private async Task SaveChangesAsync() + { + _messageStore.Clear(); + if (_editContext.Validate()) + { + Either result = IsEdit + ? await _mediator.Send(new UpdateLocalLibrary( + _model.Id, + _model.Name, + _model.Paths.Map(p => new UpdateLocalLibraryPath(p.Id, p.Path)).ToList())) + : await _mediator.Send(new CreateLocalLibrary( + _model.Name, + _model.MediaKind, + _model.Paths.Map(p => p.Path).ToList())); + + result.Match( + _ => _navigationManager.NavigateTo("/media/sources/local"), + error => + { + _snackbar.Add(error.Value, Severity.Error); + _logger.LogError("Unexpected error saving local library: {Error}", error.Value); + }); + } + } + + private string NormalizePath(string path) + { + return Path.GetFullPath(new Uri(path).LocalPath) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + .ToUpperInvariant(); + } + + void IDisposable.Dispose() + { + _locker.OnLibraryChanged -= LockChanged; + } + } \ No newline at end of file diff --git a/ErsatzTV/Pages/PlexMediaSources.razor b/ErsatzTV/Pages/PlexMediaSources.razor index b2ad06db4..8991464c5 100644 --- a/ErsatzTV/Pages/PlexMediaSources.razor +++ b/ErsatzTV/Pages/PlexMediaSources.razor @@ -1,4 +1,4 @@ -@page "/media/plex" +@page "/media/sources/plex" @using ErsatzTV.Core.Interfaces.Plex @using ErsatzTV.Application.Plex @using ErsatzTV.Application.Plex.Commands @@ -16,7 +16,7 @@ - Plex Media Source + Plex Media Sources diff --git a/ErsatzTV/Shared/MainLayout.razor b/ErsatzTV/Shared/MainLayout.razor index 1587c2548..a908e7e2a 100644 --- a/ErsatzTV/Shared/MainLayout.razor +++ b/ErsatzTV/Shared/MainLayout.razor @@ -43,9 +43,10 @@ FFmpeg Profiles Watermarks - Emby - Jellyfin - Plex + Local + Emby + Jellyfin + Plex Libraries diff --git a/ErsatzTV/Shared/MoveLocalLibraryPathDialog.razor b/ErsatzTV/Shared/MoveLocalLibraryPathDialog.razor new file mode 100644 index 000000000..baa8b1566 --- /dev/null +++ b/ErsatzTV/Shared/MoveLocalLibraryPathDialog.razor @@ -0,0 +1,128 @@ +@using Microsoft.Extensions.Caching.Memory +@using ErsatzTV.Application.Libraries +@using ErsatzTV.Application.Libraries.Commands +@using ErsatzTV.Application.Libraries.Queries +@inject IMediator _mediator +@inject IMemoryCache _memoryCache +@inject ISnackbar _snackbar +@inject ILogger _logger + + + + + + + Select the destination library + + + + @foreach (LocalLibraryViewModel library in _libraries) + { + @library.Name + } + + + + + + + Cancel + + Move To Library + + + + + +@code { + + [CascadingParameter] + MudDialogInstance MudDialog { get; set; } + + [Parameter] + public LibraryMediaKind MediaKind { get; set; } + + [Parameter] + public int SourceLibraryId { get; set; } + + private LocalLibraryViewModel _newLibrary; + private string _newLibraryName; + + private List _libraries; + + private LocalLibraryViewModel _selectedLibrary; + + private record DummyModel; + + private readonly DummyModel _dummyModel = new(); + + private bool CanSubmit() => + _selectedLibrary != null && (_selectedLibrary != _newLibrary || !string.IsNullOrWhiteSpace(_newLibraryName)); + + protected override async Task OnParametersSetAsync() + { + _newLibrary = new LocalLibraryViewModel(-1, "(New Library)", MediaKind); + + _libraries = await _mediator.Send(new GetAllLocalLibraries()) + .Map(list => list.Filter(ll => ll.MediaKind == MediaKind && ll.Id != SourceLibraryId)) + .Map(list => new[] { _newLibrary }.Append(list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase)).ToList()); + + if (_memoryCache.TryGetValue("MoveLocalLibraryPathDialog.SelectedLibraryId", out int id)) + { + _selectedLibrary = _libraries.SingleOrDefault(c => c.Id == id) ?? _newLibrary; + } + else + { + _selectedLibrary = _newLibrary; + } + } + + private async Task Submit() + { + if (!CanSubmit()) + { + return; + } + + if (_selectedLibrary == _newLibrary) + { + Either maybeResult = + await _mediator.Send(new CreateLocalLibrary(_newLibraryName, MediaKind, new List())); + + maybeResult.Match( + collection => + { + _memoryCache.Set("MoveLocalLibraryPathDialog.SelectedLibraryId", collection.Id); + MudDialog.Close(DialogResult.Ok(collection)); + }, + error => + { + _snackbar.Add(error.Value, Severity.Error); + _logger.LogError("Error creating new local library: {Error}", error.Value); + MudDialog.Close(DialogResult.Cancel()); + }); + } + else + { + _memoryCache.Set("MoveLocalLibraryPathDialog.SelectedLibraryId", _selectedLibrary.Id); + MudDialog.Close(DialogResult.Ok(_selectedLibrary)); + } + } + + private async Task Cancel(MouseEventArgs e) + { + // this is gross, but [enter] seems to sometimes trigger cancel instead of submit + if (e.Detail == 0) + { + await Submit(); + } + else + { + MudDialog.Cancel(); + } + } + +} \ No newline at end of file diff --git a/ErsatzTV/Shared/RemoteMediaSourceLibrariesEditor.razor b/ErsatzTV/Shared/RemoteMediaSourceLibrariesEditor.razor index 6c7c2dddd..bb78a3afc 100644 --- a/ErsatzTV/Shared/RemoteMediaSourceLibrariesEditor.razor +++ b/ErsatzTV/Shared/RemoteMediaSourceLibrariesEditor.razor @@ -105,7 +105,7 @@ } } - _navigationManager.NavigateTo($"/media/{Name.ToLowerInvariant()}"); + _navigationManager.NavigateTo($"/media/sources/{Name.ToLowerInvariant()}"); }); } diff --git a/ErsatzTV/Shared/RemoteMediaSources.razor b/ErsatzTV/Shared/RemoteMediaSources.razor index 3cf5676ad..cf3e9fb0b 100644 --- a/ErsatzTV/Shared/RemoteMediaSources.razor +++ b/ErsatzTV/Shared/RemoteMediaSources.razor @@ -10,7 +10,7 @@ - @Name Media Source + @Name Media Sources diff --git a/ErsatzTV/Validators/LocalLibraryEditViewModelValidator.cs b/ErsatzTV/Validators/LocalLibraryEditViewModelValidator.cs new file mode 100644 index 000000000..15ec20608 --- /dev/null +++ b/ErsatzTV/Validators/LocalLibraryEditViewModelValidator.cs @@ -0,0 +1,10 @@ +using ErsatzTV.ViewModels; +using FluentValidation; + +namespace ErsatzTV.Validators +{ + public class LocalLibraryEditViewModelValidator : AbstractValidator + { + public LocalLibraryEditViewModelValidator() => RuleFor(c => c.Name).NotEmpty(); + } +} diff --git a/ErsatzTV/ViewModels/LocalLibraryEditViewModel.cs b/ErsatzTV/ViewModels/LocalLibraryEditViewModel.cs new file mode 100644 index 000000000..f7d63bd79 --- /dev/null +++ b/ErsatzTV/ViewModels/LocalLibraryEditViewModel.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.ViewModels +{ + public class LocalLibraryEditViewModel + { + public int Id { get; set; } + public string Name { get; set; } + public LibraryMediaKind MediaKind { get; set; } + public string NewPath { get; set; } + public List Paths { get; set; } + public bool HasChanges { get; set; } + } +} diff --git a/ErsatzTV/ViewModels/LocalLibraryPathEditViewModel.cs b/ErsatzTV/ViewModels/LocalLibraryPathEditViewModel.cs index acc130bbb..c06dd3aba 100644 --- a/ErsatzTV/ViewModels/LocalLibraryPathEditViewModel.cs +++ b/ErsatzTV/ViewModels/LocalLibraryPathEditViewModel.cs @@ -2,6 +2,7 @@ { public class LocalLibraryPathEditViewModel { + public int Id { get; set; } public string Path { get; set; } } }