mirror of https://github.com/ErsatzTV/ErsatzTV.git
34 changed files with 1003 additions and 93 deletions
@ -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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
using ErsatzTV.Core; |
|
||||||
using LanguageExt; |
|
||||||
|
|
||||||
namespace ErsatzTV.Application.Libraries.Commands |
|
||||||
{ |
|
||||||
public record DeleteLocalLibraryPath(int LocalLibraryPathId) : MediatR.IRequest<Either<BaseError, Unit>>; |
|
||||||
} |
|
||||||
@ -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 @@ |
|||||||
|
namespace ErsatzTV.Application.Libraries.Commands |
||||||
|
{ |
||||||
|
public interface ILocalLibraryRequest |
||||||
|
{ |
||||||
|
public string Name { get; } |
||||||
|
} |
||||||
|
} |
||||||
@ -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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
using MediatR; |
||||||
|
|
||||||
|
namespace ErsatzTV.Application.Libraries.Queries |
||||||
|
{ |
||||||
|
public record CountMediaItemsByLibrary(int LibraryId) : IRequest<int>; |
||||||
|
} |
||||||
@ -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 @@ |
|||||||
|
using System.Collections.Generic; |
||||||
|
using MediatR; |
||||||
|
|
||||||
|
namespace ErsatzTV.Application.Libraries.Queries |
||||||
|
{ |
||||||
|
public record GetAllLocalLibraries : IRequest<List<LocalLibraryViewModel>>; |
||||||
|
} |
||||||
@ -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 @@ |
|||||||
|
@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 @@ |
|||||||
|
@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 @@ |
|||||||
|
using ErsatzTV.ViewModels; |
||||||
|
using FluentValidation; |
||||||
|
|
||||||
|
namespace ErsatzTV.Validators |
||||||
|
{ |
||||||
|
public class LocalLibraryEditViewModelValidator : AbstractValidator<LocalLibraryEditViewModel> |
||||||
|
{ |
||||||
|
public LocalLibraryEditViewModelValidator() => RuleFor(c => c.Name).NotEmpty(); |
||||||
|
} |
||||||
|
} |
||||||
@ -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