Browse Source

support multiple local libraries (#317)

* allow multiple local libraries

* add "move library path" function
pull/318/head
Jason Dove 5 years ago committed by GitHub
parent
commit
9fbe950e6e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 3
      CHANGELOG.md
  2. 11
      ErsatzTV.Application/Libraries/Commands/CreateLocalLibrary.cs
  3. 84
      ErsatzTV.Application/Libraries/Commands/CreateLocalLibraryHandler.cs
  4. 5
      ErsatzTV.Application/Libraries/Commands/CreateLocalLibraryPathHandler.cs
  5. 9
      ErsatzTV.Application/Libraries/Commands/DeleteLocalLibrary.cs
  6. 70
      ErsatzTV.Application/Libraries/Commands/DeleteLocalLibraryHandler.cs
  7. 7
      ErsatzTV.Application/Libraries/Commands/DeleteLocalLibraryPath.cs
  8. 46
      ErsatzTV.Application/Libraries/Commands/DeleteLocalLibraryPathHandler.cs
  9. 7
      ErsatzTV.Application/Libraries/Commands/ILocalLibraryRequest.cs
  10. 49
      ErsatzTV.Application/Libraries/Commands/LocalLibraryHandlerBase.cs
  11. 9
      ErsatzTV.Application/Libraries/Commands/MoveLocalLibraryPath.cs
  12. 121
      ErsatzTV.Application/Libraries/Commands/MoveLocalLibraryPathHandler.cs
  13. 12
      ErsatzTV.Application/Libraries/Commands/UpdateLocalLibrary.cs
  14. 110
      ErsatzTV.Application/Libraries/Commands/UpdateLocalLibraryHandler.cs
  15. 6
      ErsatzTV.Application/Libraries/Queries/CountMediaItemsByLibrary.cs
  16. 25
      ErsatzTV.Application/Libraries/Queries/CountMediaItemsByLibraryHandler.cs
  17. 7
      ErsatzTV.Application/Libraries/Queries/GetAllLocalLibraries.cs
  18. 30
      ErsatzTV.Application/Libraries/Queries/GetAllLocalLibrariesHandler.cs
  19. 2
      ErsatzTV.Application/MediaCollections/Commands/AddArtistToCollectionHandler.cs
  20. 2
      ErsatzTV/Pages/EmbyMediaSourceEditor.razor
  21. 2
      ErsatzTV/Pages/EmbyMediaSources.razor
  22. 2
      ErsatzTV/Pages/JellyfinMediaSourceEditor.razor
  23. 2
      ErsatzTV/Pages/JellyfinMediaSources.razor
  24. 11
      ErsatzTV/Pages/Libraries.razor
  25. 99
      ErsatzTV/Pages/LocalLibraries.razor
  26. 196
      ErsatzTV/Pages/LocalLibraryEditor.razor
  27. 4
      ErsatzTV/Pages/PlexMediaSources.razor
  28. 7
      ErsatzTV/Shared/MainLayout.razor
  29. 128
      ErsatzTV/Shared/MoveLocalLibraryPathDialog.razor
  30. 2
      ErsatzTV/Shared/RemoteMediaSourceLibrariesEditor.razor
  31. 2
      ErsatzTV/Shared/RemoteMediaSources.razor
  32. 10
      ErsatzTV/Validators/LocalLibraryEditViewModelValidator.cs
  33. 15
      ErsatzTV/ViewModels/LocalLibraryEditViewModel.cs
  34. 1
      ErsatzTV/ViewModels/LocalLibraryPathEditViewModel.cs

3
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/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased] ## [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 ## [0.0.51-alpha] - 2021-07-18
### Added ### Added

11
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<string> Paths)
: ILocalLibraryRequest, IRequest<Either<BaseError, LocalLibraryViewModel>>;
}

84
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<CreateLocalLibrary, Either<BaseError, LocalLibraryViewModel>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
private readonly IEntityLocker _entityLocker;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public CreateLocalLibraryHandler(
ChannelWriter<IBackgroundServiceRequest> workerChannel,
IEntityLocker entityLocker,
IDbContextFactory<TvContext> dbContextFactory)
{
_workerChannel = workerChannel;
_entityLocker = entityLocker;
_dbContextFactory = dbContextFactory;
}
public async Task<Either<BaseError, LocalLibraryViewModel>> Handle(
CreateLocalLibrary request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Validation<BaseError, LocalLibrary> validation = await Validate(dbContext, request);
return await validation.Apply(localLibrary => PersistLocalLibrary(dbContext, localLibrary));
}
private async Task<LocalLibraryViewModel> 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<Validation<BaseError, LocalLibrary>> Validate(
TvContext dbContext,
CreateLocalLibrary request) =>
MediaSourceMustExist(dbContext, request)
.BindT(localLibrary => NameMustBeValid(request, localLibrary))
.BindT(localLibrary => PathsMustBeValid(dbContext, localLibrary));
private static Task<Validation<BaseError, LocalLibrary>> 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<BaseError>("LocalMediaSource does not exist."));
}
}

5
ErsatzTV.Application/Libraries/Commands/CreateLocalLibraryPathHandler.cs

@ -19,8 +19,8 @@ namespace ErsatzTV.Application.Libraries.Commands
{ {
private readonly ILibraryRepository _libraryRepository; private readonly ILibraryRepository _libraryRepository;
public CreateLocalLibraryPathHandler(ILibraryRepository mediaSourceRepository) => public CreateLocalLibraryPathHandler(ILibraryRepository libraryRepository) =>
_libraryRepository = mediaSourceRepository; _libraryRepository = libraryRepository;
public Task<Either<BaseError, LocalLibraryPathViewModel>> Handle( public Task<Either<BaseError, LocalLibraryPathViewModel>> Handle(
CreateLocalLibraryPath request, CreateLocalLibraryPath request,
@ -45,7 +45,6 @@ namespace ErsatzTV.Application.Libraries.Commands
List<string> allPaths = await _libraryRepository.GetLocalPaths(request.LibraryId) List<string> allPaths = await _libraryRepository.GetLocalPaths(request.LibraryId)
.Map(list => list.Map(c => c.Path).ToList()); .Map(list => list.Map(c => c.Path).ToList());
return Optional(request.Path) return Optional(request.Path)
.Filter(folder => allPaths.ForAll(f => !AreSubPaths(f, folder))) .Filter(folder => allPaths.ForAll(f => !AreSubPaths(f, folder)))
.ToValidation<BaseError>("Path must not belong to another library path"); .ToValidation<BaseError>("Path must not belong to another library path");

9
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<Either<BaseError, Unit>>;
}

70
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<DeleteLocalLibrary, Either<BaseError, Unit>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IDbConnection _dbConnection;
private readonly ISearchIndex _searchIndex;
public DeleteLocalLibraryHandler(
IDbContextFactory<TvContext> dbContextFactory,
IDbConnection dbConnection,
ISearchIndex searchIndex)
{
_dbContextFactory = dbContextFactory;
_dbConnection = dbConnection;
_searchIndex = searchIndex;
}
public async Task<Either<BaseError, Unit>> Handle(
DeleteLocalLibrary request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Validation<BaseError, LocalLibrary> validation = await LocalLibraryMustExist(dbContext, request);
return await validation.Apply(localLibrary => DoDeletion(dbContext, localLibrary));
}
private async Task<Unit> DoDeletion(TvContext dbContext, LocalLibrary localLibrary)
{
List<int> ids = await _dbConnection.QueryAsync<int>(
@"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<Validation<BaseError, LocalLibrary>> LocalLibraryMustExist(
TvContext dbContext,
DeleteLocalLibrary request) =>
dbContext.LocalLibraries
.SelectOneAsync(ll => ll.Id, ll => ll.Id == request.LocalLibraryId)
.Map(o => o.ToValidation<BaseError>($"Local library {request.LocalLibraryId} does not exist."));
}
}

7
ErsatzTV.Application/Libraries/Commands/DeleteLocalLibraryPath.cs

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

46
ErsatzTV.Application/Libraries/Commands/DeleteLocalLibraryPathHandler.cs

@ -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<DeleteLocalLibraryPath, Either<BaseError, Unit>>
{
private readonly ILibraryRepository _libraryRepository;
private readonly ISearchIndex _searchIndex;
public DeleteLocalLibraryPathHandler(ILibraryRepository libraryRepository, ISearchIndex searchIndex)
{
_libraryRepository = libraryRepository;
_searchIndex = searchIndex;
}
public Task<Either<BaseError, Unit>> Handle(
DeleteLocalLibraryPath request,
CancellationToken cancellationToken) =>
MediaSourceMustExist(request)
.MapT(DoDeletion)
.Bind(t => t.ToEitherAsync());
private async Task<Unit> DoDeletion(LibraryPath libraryPath)
{
List<int> ids = await _libraryRepository.GetMediaIdsByLocalPath(libraryPath.Id);
await _searchIndex.RemoveItems(ids);
_searchIndex.Commit();
await _libraryRepository.DeleteLocalPath(libraryPath.Id);
return Unit.Default;
}
private async Task<Validation<BaseError, LibraryPath>> MediaSourceMustExist(DeleteLocalLibraryPath request) =>
(await _libraryRepository.GetPath(request.LocalLibraryPathId))
.HeadOrNone()
.ToValidation<BaseError>(
$"Local library path {request.LocalLibraryPathId} does not exist.");
}
}

7
ErsatzTV.Application/Libraries/Commands/ILocalLibraryRequest.cs

@ -0,0 +1,7 @@
namespace ErsatzTV.Application.Libraries.Commands
{
public interface ILocalLibraryRequest
{
public string Name { get; }
}
}

49
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<Validation<BaseError, LocalLibrary>> NameMustBeValid(
ILocalLibraryRequest request,
LocalLibrary localLibrary) =>
request.NotEmpty(c => c.Name)
.Bind(_ => request.NotLongerThan(50)(c => c.Name))
.Map(_ => localLibrary).AsTask();
protected static async Task<Validation<BaseError, LocalLibrary>> PathsMustBeValid(
TvContext dbContext,
LocalLibrary localLibrary,
int? existingLibraryId = null)
{
List<string> 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<BaseError>("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);
}
}
}

9
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<Either<BaseError, Unit>>;
}

121
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<MoveLocalLibraryPath, Either<BaseError, Unit>>
{
private readonly ISearchIndex _searchIndex;
private readonly ISearchRepository _searchRepository;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IDbConnection _dbConnection;
private readonly ILogger<MoveLocalLibraryPathHandler> _logger;
public MoveLocalLibraryPathHandler(
ISearchIndex searchIndex,
ISearchRepository searchRepository,
IDbContextFactory<TvContext> dbContextFactory,
IDbConnection dbConnection,
ILogger<MoveLocalLibraryPathHandler> logger)
{
_searchIndex = searchIndex;
_searchRepository = searchRepository;
_dbContextFactory = dbContextFactory;
_dbConnection = dbConnection;
_logger = logger;
}
public async Task<Either<BaseError, Unit>> Handle(
MoveLocalLibraryPath request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Validation<BaseError, Parameters> validation = await Validate(dbContext, request);
return await validation.Apply(parameters => MovePath(dbContext, parameters));
}
private async Task<Unit> MovePath(TvContext dbContext, Parameters parameters)
{
LibraryPath path = parameters.LibraryPath;
LocalLibrary newLibrary = parameters.Library;
path.LibraryId = newLibrary.Id;
if (await dbContext.SaveChangesAsync() > 0)
{
List<int> ids = await _dbConnection.QueryAsync<int>(
@"SELECT MediaItem.Id FROM MediaItem WHERE LibraryPathId = @LibraryPathId",
new { LibraryPathId = path.Id })
.Map(result => result.ToList());
foreach (int id in ids)
{
Option<MediaItem> 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> { mediaItem });
}
}
}
return Unit.Default;
}
private static async Task<Validation<BaseError, Parameters>> Validate(
TvContext dbContext,
MoveLocalLibraryPath request) =>
(await LibraryPathMustExist(dbContext, request), await LocalLibraryMustExist(dbContext, request))
.Apply((libraryPath, localLibrary) => new Parameters(libraryPath, localLibrary));
private static Task<Validation<BaseError, LibraryPath>> LibraryPathMustExist(
TvContext dbContext,
MoveLocalLibraryPath request) =>
dbContext.LibraryPaths
.Include(lp => lp.Library)
.SelectOneAsync(c => c.Id, c => c.Id == request.LibraryPathId)
.Map(o => o.ToValidation<BaseError>("LibraryPath does not exist."));
private static Task<Validation<BaseError, LocalLibrary>> LocalLibraryMustExist(
TvContext dbContext,
MoveLocalLibraryPath request) =>
dbContext.LocalLibraries
.Include(ll => ll.Paths)
.SelectOneAsync(a => a.Id, a => a.Id == request.TargetLibraryId)
.Map(o => o.ToValidation<BaseError>("LocalLibrary does not exist"));
private async Task<string> GetPath(MediaItem mediaItem) =>
mediaItem switch
{
Movie => await _dbConnection.QuerySingleAsync<string>(
@"SELECT Path FROM MediaFile
INNER JOIN MediaVersion MV on MediaFile.MediaVersionId = MV.Id
WHERE MV.MovieId = @Id", new { mediaItem.Id }),
Episode => await _dbConnection.QuerySingleAsync<string>(
@"SELECT Path FROM MediaFile
INNER JOIN MediaVersion MV on MediaFile.MediaVersionId = MV.Id
WHERE MV.EpisodeId = @Id", new { mediaItem.Id }),
MusicVideo => await _dbConnection.QuerySingleAsync<string>(
@"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);
}
}

12
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<UpdateLocalLibraryPath> Paths) : ILocalLibraryRequest,
IRequest<Either<BaseError, LocalLibraryViewModel>>;
}

110
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<UpdateLocalLibrary, Either<BaseError, LocalLibraryViewModel>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
private readonly IEntityLocker _entityLocker;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public UpdateLocalLibraryHandler(
ChannelWriter<IBackgroundServiceRequest> workerChannel,
IEntityLocker entityLocker,
IDbContextFactory<TvContext> dbContextFactory)
{
_workerChannel = workerChannel;
_entityLocker = entityLocker;
_dbContextFactory = dbContextFactory;
}
public async Task<Either<BaseError, LocalLibraryViewModel>> Handle(
UpdateLocalLibrary request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Validation<BaseError, Parameters> validation = await Validate(dbContext, request);
return await validation.Apply(parameters => UpdateLocalLibrary(dbContext, parameters));
}
private async Task<LocalLibraryViewModel> 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<Validation<BaseError, Parameters>> 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<Validation<BaseError, Parameters>> 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<BaseError>("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();
}
}
}

6
ErsatzTV.Application/Libraries/Queries/CountMediaItemsByLibrary.cs

@ -0,0 +1,6 @@
using MediatR;
namespace ErsatzTV.Application.Libraries.Queries
{
public record CountMediaItemsByLibrary(int LibraryId) : IRequest<int>;
}

25
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<CountMediaItemsByLibrary, int>
{
private readonly IDbConnection _dbConnection;
public CountMediaItemsByLibraryHandler(IDbConnection dbConnection)
{
_dbConnection = dbConnection;
}
public Task<int> Handle(CountMediaItemsByLibrary request, CancellationToken cancellationToken) =>
_dbConnection.QuerySingleAsync<int>(
@"SELECT COUNT(*) FROM MediaItem
INNER JOIN LibraryPath LP on MediaItem.LibraryPathId = LP.Id
WHERE LP.LibraryId = @LibraryId",
new { request.LibraryId });
}
}

7
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<List<LocalLibraryViewModel>>;
}

30
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<GetAllLocalLibraries, List<LocalLibraryViewModel>>
{
private readonly ILibraryRepository _libraryRepository;
public GetAllLocalLibrariesHandler(ILibraryRepository libraryRepository) => _libraryRepository = libraryRepository;
public Task<List<LocalLibraryViewModel>> Handle(
GetAllLocalLibraries request,
CancellationToken cancellationToken) =>
_libraryRepository.GetAll()
.Map(
list => list
.OfType<LocalLibrary>()
.OrderBy(l => l.MediaKind)
.Map(ProjectToViewModel)
.ToList());
}
}

2
ErsatzTV.Application/MediaCollections/Commands/AddArtistToCollectionHandler.cs

@ -73,7 +73,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
AddArtistToCollection request) => AddArtistToCollection request) =>
dbContext.Artists dbContext.Artists
.SelectOneAsync(a => a.Id, a => a.Id == request.ArtistId) .SelectOneAsync(a => a.Id, a => a.Id == request.ArtistId)
.Map(o => o.ToValidation<BaseError>("Music video does not exist")); .Map(o => o.ToValidation<BaseError>("Artist does not exist"));
private record Parameters(Collection Collection, Artist Artist); private record Parameters(Collection Collection, Artist Artist);
} }

2
ErsatzTV/Pages/EmbyMediaSourceEditor.razor

@ -1,4 +1,4 @@
@page "/media/emby/edit" @page "/media/sources/emby/edit"
@using Unit = LanguageExt.Unit @using Unit = LanguageExt.Unit
@using ErsatzTV.Core.Emby @using ErsatzTV.Core.Emby
@using ErsatzTV.Application.Emby.Queries @using ErsatzTV.Application.Emby.Queries

2
ErsatzTV/Pages/EmbyMediaSources.razor

@ -1,4 +1,4 @@
@page "/media/emby" @page "/media/sources/emby"
@using ErsatzTV.Core.Interfaces.Emby @using ErsatzTV.Core.Interfaces.Emby
@using ErsatzTV.Application.Emby.Commands @using ErsatzTV.Application.Emby.Commands
@using ErsatzTV.Application.Emby.Queries @using ErsatzTV.Application.Emby.Queries

2
ErsatzTV/Pages/JellyfinMediaSourceEditor.razor

@ -1,4 +1,4 @@
@page "/media/jellyfin/edit" @page "/media/sources/jellyfin/edit"
@using Unit = LanguageExt.Unit @using Unit = LanguageExt.Unit
@using ErsatzTV.Core.Jellyfin @using ErsatzTV.Core.Jellyfin
@using ErsatzTV.Application.Jellyfin.Queries @using ErsatzTV.Application.Jellyfin.Queries

2
ErsatzTV/Pages/JellyfinMediaSources.razor

@ -1,4 +1,4 @@
@page "/media/jellyfin" @page "/media/sources/jellyfin"
@using ErsatzTV.Core.Interfaces.Jellyfin @using ErsatzTV.Core.Interfaces.Jellyfin
@using ErsatzTV.Application.Jellyfin.Commands @using ErsatzTV.Application.Jellyfin.Commands
@using ErsatzTV.Application.Jellyfin.Queries @using ErsatzTV.Application.Jellyfin.Queries

11
ErsatzTV/Pages/Libraries.razor

@ -28,7 +28,7 @@
<col/> <col/>
<col/> <col/>
<col/> <col/>
<col style="width: 240px;"/> <col style="width: 180px;"/>
</ColGroup> </ColGroup>
<HeaderContent> <HeaderContent>
<MudTh>Library Kind</MudTh> <MudTh>Library Kind</MudTh>
@ -71,15 +71,6 @@
Link="@($"/search?query=library_id%3a{context.Id}")"> Link="@($"/search?query=library_id%3a{context.Id}")">
</MudIconButton> </MudIconButton>
</MudTooltip> </MudTooltip>
@if (context is LocalLibraryViewModel)
{
<MudTooltip Text="Edit Library">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Disabled="@_locker.IsLibraryLocked(context.Id)"
Link="@($"/media/libraries/local/{context.Id}")">
</MudIconButton>
</MudTooltip>
}
</div> </div>
</MudTd> </MudTd>
</RowTemplate> </RowTemplate>

99
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<IBackgroundServiceRequest> _workerChannel
@inject ChannelWriter<IPlexBackgroundServiceRequest> _plexWorkerChannel
@inject ChannelWriter<IJellyfinBackgroundServiceRequest> _jellyfinWorkerChannel
@inject ChannelWriter<IEmbyBackgroundServiceRequest> _embyWorkerChannel
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudTable Hover="true" Items="_libraries" Dense="true">
<ToolBarContent>
<MudText Typo="Typo.h6">Local Libraries</MudText>
</ToolBarContent>
<ColGroup>
<col/>
<col/>
<col style="width: 120px;"/>
</ColGroup>
<HeaderContent>
<MudTh>Name</MudTh>
<MudTh>Media Kind</MudTh>
<MudTh/>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Name">@context.Name</MudTd>
<MudTd DataLabel="Media Kind">@context.MediaKind</MudTd>
<MudTd>
<div style="align-items: center; display: flex;">
<MudTooltip Text="Edit Library">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Disabled="@_locker.IsLibraryLocked(context.Id)"
Link="@($"/media/sources/local/{context.Id}/edit")">
</MudIconButton>
</MudTooltip>
<MudTooltip Text="Delete Library">
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Disabled="@_locker.IsLibraryLocked(context.Id)"
OnClick="@(() => DeleteLibrary(context))">
</MudIconButton>
</MudTooltip>
</div>
</MudTd>
</RowTemplate>
</MudTable>
<MudButton Variant="Variant.Filled" Color="Color.Primary" Link="/media/sources/local/add" Class="mt-4">
Add Local Library
</MudButton>
</MudContainer>
@code {
private IList<LocalLibraryViewModel> _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<DeleteDialog>("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;
}
}

196
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
@using ErsatzTV.Application.Libraries.Commands @using ErsatzTV.Application.Libraries.Commands
@using ErsatzTV.Application.Libraries.Queries @using ErsatzTV.Application.Libraries.Queries
@using Unit = LanguageExt.Unit
@implements IDisposable
@inject IDialogService _dialog @inject IDialogService _dialog
@inject IEntityLocker _locker
@inject IMediator _mediator @inject IMediator _mediator
@inject ILogger<LocalLibraryEditor> _logger
@inject ISnackbar _snackbar
@inject NavigationManager _navigationManager
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8"> <MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudTable Hover="true" Items="_libraryPaths" Dense="true"> <div style="max-width: 400px;">
<MudText Typo="Typo.h4" Class="mb-4">@(IsEdit ? "Edit Local Library" : "Add Local Library")</MudText>
<EditForm EditContext="_editContext" OnSubmit="@SaveChangesAsync">
<FluentValidator/>
<MudCard>
<MudCardContent>
<MudTextField Class="mt-3" Label="Name" @bind-Value="_model.Name" For="@(() => _model.Name)"/>
<MudSelect Disabled="IsEdit" Label="MediaKind" @bind-Value="_model.MediaKind" For="@(() => _model.MediaKind)">
@foreach (LibraryMediaKind mediaKind in Enum.GetValues<LibraryMediaKind>())
{
<MudSelectItem Value="@mediaKind">@mediaKind</MudSelectItem>
}
</MudSelect>
<MudTextField Class="mt-3" Label="Path" @bind-Value="_newPath.Path" For="@(() => _newPath.Path)"/>
</MudCardContent>
<MudCardActions>
<MudButton Variant="Variant.Filled" Color="Color.Secondary" OnClick="@(_ => AddLibraryPath())" Class="ml-2">
Add Path
</MudButton>
<MudButton ButtonType="ButtonType.Submit" Variant="Variant.Filled" Color="Color.Primary" Class="mr-2 ml-auto">
Save Changes
</MudButton>
</MudCardActions>
</MudCard>
</EditForm>
</div>
<MudTable Hover="true" Items="_model.Paths" Dense="true" Class="mt-6">
<ToolBarContent> <ToolBarContent>
<MudText Typo="Typo.h6">Library Paths</MudText> <MudText Typo="Typo.h6">Library Paths</MudText>
</ToolBarContent> </ToolBarContent>
<ColGroup> <ColGroup>
<col/> <col/>
<col style="width: 60px;"/> <col style="width: 120px;"/>
</ColGroup> </ColGroup>
<HeaderContent> <HeaderContent>
<MudTh>Path</MudTh> <MudTh>Path</MudTh>
@ -22,6 +57,12 @@
<MudTd DataLabel="Path">@context.Path</MudTd> <MudTd DataLabel="Path">@context.Path</MudTd>
<MudTd> <MudTd>
<div style="align-items: center; display: flex;"> <div style="align-items: center; display: flex;">
<MudTooltip Text="Move Library Path">
<MudIconButton Icon="@Icons.Material.Filled.DriveFileMove"
Disabled="@(_model.Id == 0 || context.Id == 0 || _locker.IsLibraryLocked(_model.Id))"
OnClick="@(() => MoveLibraryPath(context))">
</MudIconButton>
</MudTooltip>
<MudTooltip Text="Delete Library Path"> <MudTooltip Text="Delete Library Path">
<MudIconButton Icon="@Icons.Material.Filled.Delete" <MudIconButton Icon="@Icons.Material.Filled.Delete"
OnClick="@(() => DeleteLibraryPath(context))"> OnClick="@(() => DeleteLibraryPath(context))">
@ -31,9 +72,6 @@
</MudTd> </MudTd>
</RowTemplate> </RowTemplate>
</MudTable> </MudTable>
<MudButton Variant="Variant.Filled" Color="Color.Primary" Link="@($"/media/libraries/local/{Id}/add")" Class="mt-4">
Add Library Path
</MudButton>
</MudContainer> </MudContainer>
@ -42,17 +80,94 @@
[Parameter] [Parameter]
public int Id { get; set; } public int Id { get; set; }
private IList<LocalLibraryPathViewModel> _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<LocalLibraryViewModel> 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() => await LoadLibraryPaths();
_libraryPaths = await _mediator.Send(new GetLocalLibraryPaths(Id)); },
() =>
{
_navigationManager.NavigateTo("404");
return Task.CompletedTask;
});
}
else
{
_model.HasChanges = true;
_model.Name = "New Local Library";
_model.MediaKind = LibraryMediaKind.Movies;
_model.Paths = new List<LocalLibraryPathEditViewModel>();
}
}
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<MoveLocalLibraryPathDialog>("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<BaseError, Unit> 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 var parameters = new DialogParameters
{ {
{ "EntityType", "library path" }, { "EntityType", "library path" },
@ -61,14 +176,65 @@
{ "DetailHighlight", count.ToString() } { "DetailHighlight", count.ToString() }
}; };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall }; var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = _dialog.Show<DeleteDialog>("Delete Library Path", parameters, options); IDialogReference dialog = _dialog.Show<DeleteDialog>("Delete Library Path", parameters, options);
DialogResult result = await dialog.Result; DialogResult result = await dialog.Result;
if (!result.Cancelled) if (!result.Cancelled)
{ {
await _mediator.Send(new DeleteLocalLibraryPath(libraryPath.Id)); _model.HasChanges = true;
await LoadLibraryPaths(); _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<BaseError, LocalLibraryViewModel> 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;
}
} }

4
ErsatzTV/Pages/PlexMediaSources.razor

@ -1,4 +1,4 @@
@page "/media/plex" @page "/media/sources/plex"
@using ErsatzTV.Core.Interfaces.Plex @using ErsatzTV.Core.Interfaces.Plex
@using ErsatzTV.Application.Plex @using ErsatzTV.Application.Plex
@using ErsatzTV.Application.Plex.Commands @using ErsatzTV.Application.Plex.Commands
@ -16,7 +16,7 @@
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8"> <MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudTable Hover="true" Dense="true" Items="_mediaSources"> <MudTable Hover="true" Dense="true" Items="_mediaSources">
<ToolBarContent> <ToolBarContent>
<MudText Typo="Typo.h6">Plex Media Source</MudText> <MudText Typo="Typo.h6">Plex Media Sources</MudText>
</ToolBarContent> </ToolBarContent>
<ColGroup> <ColGroup>
<col/> <col/>

7
ErsatzTV/Shared/MainLayout.razor

@ -43,9 +43,10 @@
<MudNavLink Href="/ffmpeg">FFmpeg Profiles</MudNavLink> <MudNavLink Href="/ffmpeg">FFmpeg Profiles</MudNavLink>
<MudNavLink Href="/watermarks">Watermarks</MudNavLink> <MudNavLink Href="/watermarks">Watermarks</MudNavLink>
<MudNavGroup Title="Media Sources" Expanded="true"> <MudNavGroup Title="Media Sources" Expanded="true">
<MudNavLink Href="/media/emby">Emby</MudNavLink> <MudNavLink Href="/media/sources/local">Local</MudNavLink>
<MudNavLink Href="/media/jellyfin">Jellyfin</MudNavLink> <MudNavLink Href="/media/sources/emby">Emby</MudNavLink>
<MudNavLink Href="/media/plex">Plex</MudNavLink> <MudNavLink Href="/media/sources/jellyfin">Jellyfin</MudNavLink>
<MudNavLink Href="/media/sources/plex">Plex</MudNavLink>
</MudNavGroup> </MudNavGroup>
<MudNavGroup Title="Media" Expanded="true"> <MudNavGroup Title="Media" Expanded="true">
<MudNavLink Href="/media/libraries">Libraries</MudNavLink> <MudNavLink Href="/media/libraries">Libraries</MudNavLink>

128
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<MoveLocalLibraryPathDialog> _logger
<MudDialog>
<DialogContent>
<EditForm Model="@_dummyModel" OnSubmit="@(_ => Submit())">
<MudContainer Class="mb-6">
<MudText>
Select the destination library
</MudText>
</MudContainer>
<MudSelect Label="Library" @bind-Value="_selectedLibrary" Class="mb-6 mx-4">
@foreach (LocalLibraryViewModel library in _libraries)
{
<MudSelectItem Value="@library">@library.Name</MudSelectItem>
}
</MudSelect>
<MudTextFieldString Label="New Library Name"
Disabled="@(_selectedLibrary != _newLibrary)"
@bind-Text="@_newLibraryName"
Class="mb-6 mx-4">
</MudTextFieldString>
</EditForm>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel" ButtonType="ButtonType.Reset">Cancel</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="Submit">
Move To Library
</MudButton>
</DialogActions>
</MudDialog>
@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<LocalLibraryViewModel> _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<BaseError, LocalLibraryViewModel> maybeResult =
await _mediator.Send(new CreateLocalLibrary(_newLibraryName, MediaKind, new List<string>()));
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();
}
}
}

2
ErsatzTV/Shared/RemoteMediaSourceLibrariesEditor.razor

@ -105,7 +105,7 @@
} }
} }
_navigationManager.NavigateTo($"/media/{Name.ToLowerInvariant()}"); _navigationManager.NavigateTo($"/media/sources/{Name.ToLowerInvariant()}");
}); });
} }

2
ErsatzTV/Shared/RemoteMediaSources.razor

@ -10,7 +10,7 @@
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8"> <MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudTable Hover="true" Dense="true" Items="_mediaSources"> <MudTable Hover="true" Dense="true" Items="_mediaSources">
<ToolBarContent> <ToolBarContent>
<MudText Typo="Typo.h6">@Name Media Source</MudText> <MudText Typo="Typo.h6">@Name Media Sources</MudText>
</ToolBarContent> </ToolBarContent>
<ColGroup> <ColGroup>
<col/> <col/>

10
ErsatzTV/Validators/LocalLibraryEditViewModelValidator.cs

@ -0,0 +1,10 @@
using ErsatzTV.ViewModels;
using FluentValidation;
namespace ErsatzTV.Validators
{
public class LocalLibraryEditViewModelValidator : AbstractValidator<LocalLibraryEditViewModel>
{
public LocalLibraryEditViewModelValidator() => RuleFor(c => c.Name).NotEmpty();
}
}

15
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<LocalLibraryPathEditViewModel> Paths { get; set; }
public bool HasChanges { get; set; }
}
}

1
ErsatzTV/ViewModels/LocalLibraryPathEditViewModel.cs

@ -2,6 +2,7 @@
{ {
public class LocalLibraryPathEditViewModel public class LocalLibraryPathEditViewModel
{ {
public int Id { get; set; }
public string Path { get; set; } public string Path { get; set; }
} }
} }

Loading…
Cancel
Save