mirror of https://github.com/ErsatzTV/ErsatzTV.git
34 changed files with 1003 additions and 93 deletions
@ -0,0 +1,11 @@
@@ -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>>; |
||||
} |
||||
@ -0,0 +1,84 @@
@@ -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.")); |
||||
} |
||||
} |
||||
@ -0,0 +1,9 @@
@@ -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>>; |
||||
} |
||||
@ -0,0 +1,70 @@
@@ -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.")); |
||||
} |
||||
} |
||||
@ -1,7 +0,0 @@
@@ -1,7 +0,0 @@
|
||||
using ErsatzTV.Core; |
||||
using LanguageExt; |
||||
|
||||
namespace ErsatzTV.Application.Libraries.Commands |
||||
{ |
||||
public record DeleteLocalLibraryPath(int LocalLibraryPathId) : MediatR.IRequest<Either<BaseError, Unit>>; |
||||
} |
||||
@ -1,46 +0,0 @@
@@ -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."); |
||||
} |
||||
} |
||||
@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
|
||||
namespace ErsatzTV.Application.Libraries.Commands |
||||
{ |
||||
public interface ILocalLibraryRequest |
||||
{ |
||||
public string Name { get; } |
||||
} |
||||
} |
||||
@ -0,0 +1,49 @@
@@ -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); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,9 @@
@@ -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>>; |
||||
} |
||||
@ -0,0 +1,121 @@
@@ -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); |
||||
} |
||||
} |
||||
@ -0,0 +1,12 @@
@@ -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>>; |
||||
} |
||||
@ -0,0 +1,110 @@
@@ -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(); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
|
||||
using MediatR; |
||||
|
||||
namespace ErsatzTV.Application.Libraries.Queries |
||||
{ |
||||
public record CountMediaItemsByLibrary(int LibraryId) : IRequest<int>; |
||||
} |
||||
@ -0,0 +1,25 @@
@@ -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 }); |
||||
} |
||||
} |
||||
@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
|
||||
using System.Collections.Generic; |
||||
using MediatR; |
||||
|
||||
namespace ErsatzTV.Application.Libraries.Queries |
||||
{ |
||||
public record GetAllLocalLibraries : IRequest<List<LocalLibraryViewModel>>; |
||||
} |
||||
@ -0,0 +1,30 @@
@@ -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()); |
||||
} |
||||
} |
||||
@ -0,0 +1,99 @@
@@ -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; |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,128 @@
@@ -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(); |
||||
} |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
|
||||
using ErsatzTV.ViewModels; |
||||
using FluentValidation; |
||||
|
||||
namespace ErsatzTV.Validators |
||||
{ |
||||
public class LocalLibraryEditViewModelValidator : AbstractValidator<LocalLibraryEditViewModel> |
||||
{ |
||||
public LocalLibraryEditViewModelValidator() => RuleFor(c => c.Name).NotEmpty(); |
||||
} |
||||
} |
||||
@ -0,0 +1,15 @@
@@ -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; } |
||||
} |
||||
} |
||||
Loading…
Reference in new issue