Browse Source

add library paths from ui

pull/31/head
Jason Dove 6 years ago
parent
commit
c8f1099caa
  1. 9
      ErsatzTV.Application/Libraries/Commands/CreateLocalLibraryPath.cs
  2. 62
      ErsatzTV.Application/Libraries/Commands/CreateLocalLibraryPathHandler.cs
  3. 2
      ErsatzTV.Application/Libraries/LocalLibraryPathViewModel.cs
  4. 6
      ErsatzTV.Application/Libraries/LocalLibraryViewModel.cs
  5. 4
      ErsatzTV.Application/Libraries/Mapper.cs
  6. 7
      ErsatzTV.Application/Libraries/Queries/GetLocalLibraryById.cs
  7. 22
      ErsatzTV.Application/Libraries/Queries/GetLocalLibraryByIdHandler.cs
  8. 12
      ErsatzTV.Application/MediaSources/Commands/CreateLocalMediaSource.cs
  9. 80
      ErsatzTV.Application/MediaSources/Commands/CreateLocalMediaSourceHandler.cs
  10. 2
      ErsatzTV.Core/Interfaces/Repositories/ILibraryRepository.cs
  11. 17
      ErsatzTV.Infrastructure/Data/Repositories/LibraryRepository.cs
  12. 4
      ErsatzTV/Pages/LocalLibraryEditor.razor
  13. 86
      ErsatzTV/Pages/LocalLibraryPathEditor.razor
  14. 99
      ErsatzTV/Pages/LocalMediaSourceEditor.razor
  15. 50
      ErsatzTV/Pages/MediaSources.razor
  16. 15
      ErsatzTV/Validators/LocalLibraryPathEditViewModelValidator.cs
  17. 15
      ErsatzTV/Validators/LocalMediaSourceEditViewModelValidator.cs
  18. 7
      ErsatzTV/ViewModels/LocalLibraryPathEditViewModel.cs
  19. 7
      ErsatzTV/ViewModels/LocalMediaSourceEditViewModel.cs

9
ErsatzTV.Application/Libraries/Commands/CreateLocalLibraryPath.cs

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.Libraries.Commands
{
public record CreateLocalLibraryPath
(int LibraryId, string Path) : IRequest<Either<BaseError, LocalLibraryPathViewModel>>;
}

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

@ -0,0 +1,62 @@ @@ -0,0 +1,62 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using static LanguageExt.Prelude;
using static ErsatzTV.Application.Libraries.Mapper;
namespace ErsatzTV.Application.Libraries.Commands
{
public class CreateLocalLibraryPathHandler : IRequestHandler<CreateLocalLibraryPath,
Either<BaseError, LocalLibraryPathViewModel>>
{
private readonly ILibraryRepository _libraryRepository;
public CreateLocalLibraryPathHandler(ILibraryRepository mediaSourceRepository) =>
_libraryRepository = mediaSourceRepository;
public Task<Either<BaseError, LocalLibraryPathViewModel>> Handle(
CreateLocalLibraryPath request,
CancellationToken cancellationToken) =>
Validate(request).MapT(PersistLocalLibraryPath).Bind(v => v.ToEitherAsync());
private Task<LocalLibraryPathViewModel> PersistLocalLibraryPath(LibraryPath p) =>
_libraryRepository.Add(p).Map(ProjectToViewModel);
private Task<Validation<BaseError, LibraryPath>> Validate(CreateLocalLibraryPath request) =>
ValidateFolder(request)
.MapT(
folder =>
new LibraryPath
{
LibraryId = request.LibraryId,
Path = folder
});
private async Task<Validation<BaseError, string>> ValidateFolder(CreateLocalLibraryPath request)
{
List<string> allPaths = await _libraryRepository.GetLocalPaths(request.LibraryId)
.Map(list => list.Map(c => c.Path).ToList());
return Optional(request.Path)
.Filter(folder => allPaths.ForAll(f => !AreSubPaths(f, folder)))
.ToValidation<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);
}
}
}

2
ErsatzTV.Application/Libraries/LocalLibraryPathViewModel.cs

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
namespace ErsatzTV.Application.Libraries
{
public record LocalLibraryPathViewModel(int Id, string Path);
public record LocalLibraryPathViewModel(int Id, int LibraryId, string Path);
}

6
ErsatzTV.Application/Libraries/LocalLibraryViewModel.cs

@ -1,4 +1,6 @@ @@ -1,4 +1,6 @@
namespace ErsatzTV.Application.Libraries
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Libraries
{
public record LocalLibraryViewModel(int Id, string Name, string MediaKind);
public record LocalLibraryViewModel(int Id, string Name, LibraryMediaKind MediaKind);
}

4
ErsatzTV.Application/Libraries/Mapper.cs

@ -5,9 +5,9 @@ namespace ErsatzTV.Application.Libraries @@ -5,9 +5,9 @@ namespace ErsatzTV.Application.Libraries
internal static class Mapper
{
public static LocalLibraryViewModel ProjectToViewModel(LocalLibrary library) =>
new(library.Id, library.Name, library.MediaKind.ToString());
new(library.Id, library.Name, library.MediaKind);
public static LocalLibraryPathViewModel ProjectToViewModel(LibraryPath libraryPath) =>
new(libraryPath.Id, libraryPath.Path);
new(libraryPath.Id, libraryPath.LibraryId, libraryPath.Path);
}
}

7
ErsatzTV.Application/Libraries/Queries/GetLocalLibraryById.cs

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.Libraries.Queries
{
public record GetLocalLibraryById(int LibraryId) : IRequest<Option<LocalLibraryViewModel>>;
}

22
ErsatzTV.Application/Libraries/Queries/GetLocalLibraryByIdHandler.cs

@ -0,0 +1,22 @@ @@ -0,0 +1,22 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.Libraries.Mapper;
namespace ErsatzTV.Application.Libraries.Queries
{
public class GetLocalLibraryByIdHandler : IRequestHandler<GetLocalLibraryById, Option<LocalLibraryViewModel>>
{
private readonly ILibraryRepository _libraryRepository;
public GetLocalLibraryByIdHandler(ILibraryRepository libraryRepository) =>
_libraryRepository = libraryRepository;
public Task<Option<LocalLibraryViewModel>> Handle(
GetLocalLibraryById request,
CancellationToken cancellationToken) =>
_libraryRepository.GetLocal(request.LibraryId).MapT(ProjectToViewModel);
}
}

12
ErsatzTV.Application/MediaSources/Commands/CreateLocalMediaSource.cs

@ -1,12 +0,0 @@ @@ -1,12 +0,0 @@
// using ErsatzTV.Core;
// using ErsatzTV.Core.Domain;
// using LanguageExt;
// using MediatR;
//
// namespace ErsatzTV.Application.MediaSources.Commands
// {
// public record CreateLocalMediaSource
// (string Name, MediaType MediaType, string Folder) : IRequest<Either<BaseError, MediaSourceViewModel>>;
// }

80
ErsatzTV.Application/MediaSources/Commands/CreateLocalMediaSourceHandler.cs

@ -1,80 +0,0 @@ @@ -1,80 +0,0 @@
// using System;
// using System.Collections.Generic;
// using System.IO;
// using System.Linq;
// using System.Threading;
// using System.Threading.Tasks;
// using ErsatzTV.Core;
// using ErsatzTV.Core.Domain;
// using ErsatzTV.Core.Interfaces.Repositories;
// using LanguageExt;
// using MediatR;
// using static ErsatzTV.Application.MediaSources.Mapper;
// using static LanguageExt.Prelude;
//
// namespace ErsatzTV.Application.MediaSources.Commands
// {
// public class CreateLocalMediaSourceHandler : IRequestHandler<CreateLocalMediaSource,
// Either<BaseError, MediaSourceViewModel>>
// {
// private readonly IMediaSourceRepository _mediaSourceRepository;
//
// public CreateLocalMediaSourceHandler(IMediaSourceRepository mediaSourceRepository) =>
// _mediaSourceRepository = mediaSourceRepository;
//
// public Task<Either<BaseError, MediaSourceViewModel>> Handle(
// CreateLocalMediaSource request,
// CancellationToken cancellationToken) =>
// Validate(request).MapT(PersistLocalMediaSource).Bind(v => v.ToEitherAsync());
//
// private Task<MediaSourceViewModel> PersistLocalMediaSource(LocalMediaSource c) =>
// _mediaSourceRepository.Add(c).Map(ProjectToViewModel);
//
// private async Task<Validation<BaseError, LocalMediaSource>> Validate(CreateLocalMediaSource request) =>
// (await ValidateName(request), await ValidateFolder(request))
// .Apply(
// (name, folder) =>
// new LocalMediaSource
// {
// Name = name,
// MediaType = request.MediaType,
// Folder = folder
// });
//
// private async Task<Validation<BaseError, string>> ValidateName(CreateLocalMediaSource request)
// {
// List<string> allNames = await _mediaSourceRepository.GetAll()
// .Map(list => list.Map(c => c.Name).ToList());
//
// Validation<BaseError, string> result1 = request.NotEmpty(c => c.Name)
// .Bind(_ => request.NotLongerThan(50)(c => c.Name));
//
// var result2 = Optional(request.Name)
// .Filter(name => !allNames.Contains(name))
// .ToValidation<BaseError>("Media source name must be unique");
//
// return (result1, result2).Apply((_, _) => request.Name);
// }
//
// private async Task<Validation<BaseError, string>> ValidateFolder(CreateLocalMediaSource request)
// {
// List<string> allFolders = await _mediaSourceRepository.GetAll()
// .Map(list => list.OfType<LocalMediaSource>().Map(c => c.Folder).ToList());
//
//
// return Optional(request.Folder)
// .Filter(folder => allFolders.ForAll(f => !AreSubPaths(f, folder)))
// .ToValidation<BaseError>("Folder must not belong to another media source");
// }
//
// private static bool AreSubPaths(string path1, string path2)
// {
// string one = path1 + Path.DirectorySeparatorChar;
// string two = path2 + Path.DirectorySeparatorChar;
// return one == two || one.StartsWith(two, StringComparison.OrdinalIgnoreCase) ||
// two.StartsWith(one, StringComparison.OrdinalIgnoreCase);
// }
// }
// }

2
ErsatzTV.Core/Interfaces/Repositories/ILibraryRepository.cs

@ -7,7 +7,9 @@ namespace ErsatzTV.Core.Interfaces.Repositories @@ -7,7 +7,9 @@ namespace ErsatzTV.Core.Interfaces.Repositories
{
public interface ILibraryRepository
{
Task<LibraryPath> Add(LibraryPath libraryPath);
Task<Option<Library>> Get(int libraryId);
Task<Option<LocalLibrary>> GetLocal(int libraryId);
Task<List<LocalLibrary>> GetAllLocal();
Task<Unit> UpdateLastScan(Library library);
Task<List<LibraryPath>> GetLocalPaths(int libraryId);

17
ErsatzTV.Infrastructure/Data/Repositories/LibraryRepository.cs

@ -22,6 +22,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -22,6 +22,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
_dbConnection = dbConnection;
}
public async Task<LibraryPath> Add(LibraryPath libraryPath)
{
await using TvContext context = _dbContextFactory.CreateDbContext();
await context.LibraryPaths.AddAsync(libraryPath);
await context.SaveChangesAsync();
return libraryPath;
}
public Task<Option<Library>> Get(int libraryId)
{
using TvContext context = _dbContextFactory.CreateDbContext();
@ -32,6 +40,15 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -32,6 +40,15 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Map(Optional);
}
public Task<Option<LocalLibrary>> GetLocal(int libraryId)
{
using TvContext context = _dbContextFactory.CreateDbContext();
return context.LocalLibraries
.OrderBy(l => l.Id)
.SingleOrDefaultAsync(l => l.Id == libraryId)
.Map(Optional);
}
public Task<List<LocalLibrary>> GetAllLocal()
{
using TvContext context = _dbContextFactory.CreateDbContext();

4
ErsatzTV/Pages/LocalLibraryEditor.razor

@ -31,8 +31,12 @@ @@ -31,8 +31,12 @@
</MudTd>
</RowTemplate>
</MudTable>
<MudButton Variant="Variant.Filled" Color="Color.Primary" Link="@($"/media/libraries/local/{Id}/add")" Class="mt-4">
Add Library Path
</MudButton>
</MudContainer>
@code {
[Parameter]

86
ErsatzTV/Pages/LocalLibraryPathEditor.razor

@ -0,0 +1,86 @@ @@ -0,0 +1,86 @@
@page "/media/libraries/local/{Id:int}/add"
@using ErsatzTV.Application.Libraries
@using ErsatzTV.Application.Libraries.Commands
@using ErsatzTV.Application.Libraries.Queries
@using ErsatzTV.Application.MediaSources.Commands
@inject NavigationManager NavigationManager
@inject ILogger<LocalLibraryPathEditor> Logger
@inject ISnackbar Snackbar
@inject IMediator Mediator
@inject IEntityLocker Locker
@inject ChannelWriter<IBackgroundServiceRequest> Channel
<MudContainer MaxWidth="MaxWidth.ExtraLarge">
<MudText Typo="Typo.h4" Class="mb-4">@_library.Name - Add Local Library Path</MudText>
<div style="max-width: 400px;">
<EditForm EditContext="_editContext" OnSubmit="@HandleSubmitAsync">
<FluentValidator/>
<MudCard>
<MudCardContent>
<MudTextField T="string" Label="Media Kind" Disabled="true" Value="@(Enum.GetName(typeof(LibraryMediaKind), _library.MediaKind))"/>
@* TODO: replace this with a folder picker *@
<MudTextField Label="Path" @bind-Value="_model.Path" For="@(() => _model.Path)"/>
</MudCardContent>
<MudCardActions>
<MudButton ButtonType="ButtonType.Submit" Variant="Variant.Filled" Color="Color.Primary" Class="ml-auto">
Add Local Library Path
</MudButton>
</MudCardActions>
</MudCard>
</EditForm>
</div>
</MudContainer>
@code {
[Parameter]
public int Id { get; set; }
private readonly LocalLibraryPathEditViewModel _model = new();
private EditContext _editContext;
private ValidationMessageStore _messageStore;
private LocalLibraryViewModel _library;
protected override async Task OnParametersSetAsync()
{
Option<LocalLibraryViewModel> maybeLibrary = await Mediator.Send(new GetLocalLibraryById(Id));
maybeLibrary.Match(
library => _library = library,
() => NavigationManager.NavigateTo("404"));
}
protected override void OnInitialized()
{
_editContext = new EditContext(_model);
_messageStore = new ValidationMessageStore(_editContext);
}
private async Task HandleSubmitAsync()
{
_messageStore.Clear();
if (_editContext.Validate())
{
var command = new CreateLocalLibraryPath(_library.Id, _model.Path);
Either<BaseError, LocalLibraryPathViewModel> result = await Mediator.Send(command);
await result.Match(
Left: error =>
{
Snackbar.Add(error.Value, Severity.Error);
Logger.LogError("Unexpected error saving local library path: {Error}", error.Value);
return Task.CompletedTask;
},
Right: async vm =>
{
if (Locker.LockLibrary(_library.Id))
{
await Channel.WriteAsync(new ForceScanLocalLibrary(_library.Id));
NavigationManager.NavigateTo("/media/libraries");
}
});
}
}
}

99
ErsatzTV/Pages/LocalMediaSourceEditor.razor

@ -1,99 +0,0 @@ @@ -1,99 +0,0 @@
@* @page "/media/sources/local/add" *@
@* @using ErsatzTV.Application.MediaSources.Commands *@
@* @using ErsatzTV.Application.MediaSources *@
@* @inject NavigationManager NavigationManager *@
@* @inject ILogger<LocalMediaSourceEditor> Logger *@
@* @inject ISnackbar Snackbar *@
@* @inject IMediator Mediator *@
@* @inject IEntityLocker Locker *@
@* @inject ChannelWriter<IBackgroundServiceRequest> Channel *@
@* *@
@* <div style="max-width: 400px;"> *@
@* <MudText Typo="Typo.h4" Class="mb-4">Add Local Media Source</MudText> *@
@* *@
@* <EditForm EditContext="_editContext" OnSubmit="@HandleSubmitAsync"> *@
@* <FluentValidator/> *@
@* <MudCard> *@
@* <MudCardContent> *@
@* <MudSelect Class="mt-3" Label="Media Type" @bind-Value="_model.MediaType" For="@(() => _model.MediaType)"> *@
@* @foreach (MediaType mediaType in new[] { MediaType.TvShow, MediaType.Movie }) *@
@* { *@
@* <MudSelectItem Value="@mediaType">@mediaType</MudSelectItem> *@
@* } *@
@* </MudSelect> *@
@* $1$ TODO: replace this with a folder picker #1# *@
@* <MudTextField Label="Folder" @bind-Value="_model.Folder" For="@(() => _model.Folder)"/> *@
@* </MudCardContent> *@
@* <MudCardActions> *@
@* <MudButton ButtonType="ButtonType.Submit" Variant="Variant.Filled" Color="Color.Primary" Class="ml-auto"> *@
@* Add Local Media Source *@
@* </MudButton> *@
@* </MudCardActions> *@
@* </MudCard> *@
@* </EditForm> *@
@* </div> *@
@* *@
@* @code { *@
@* *@
@* [Parameter] *@
@* public int Id { get; set; } *@
@* *@
@* private readonly LocalMediaSourceEditViewModel _model = new(); *@
@* private EditContext _editContext; *@
@* private ValidationMessageStore _messageStore; *@
@* *@
@* private LocalMediaSource _mediaSource; *@
@* *@
@* protected override Task OnParametersSetAsync() *@
@* { *@
@* _mediaSource = new LocalMediaSource *@
@* { *@
@* MediaType = MediaType.TvShow *@
@* }; *@
@* *@
@* _model.MediaType = _mediaSource.MediaType; *@
@* _model.Folder = _mediaSource.Folder; *@
@* *@
@* return Task.CompletedTask; *@
@* } *@
@* *@
@* protected override void OnInitialized() *@
@* { *@
@* _editContext = new EditContext(_model); *@
@* _messageStore = new ValidationMessageStore(_editContext); *@
@* } *@
@* *@
@* private async Task HandleSubmitAsync() *@
@* { *@
@* _messageStore.Clear(); *@
@* if (_editContext.Validate()) *@
@* { *@
@* var command = new CreateLocalMediaSource( *@
@* Convert.ToBase64String(Guid.NewGuid().ToByteArray()) *@
@* .TrimEnd('=') *@
@* .Replace("/", "_") *@
@* .Replace("+", "-"), *@
@* _model.MediaType, *@
@* _model.Folder); *@
@* Either<BaseError, MediaSourceViewModel> result = await Mediator.Send(command); *@
@* await result.Match( *@
@* Left: error => *@
@* { *@
@* Snackbar.Add(error.Value, Severity.Error); *@
@* Logger.LogError("Unexpected error saving collection: {Error}", error.Value); *@
@* return Task.CompletedTask; *@
@* }, *@
@* Right: async vm => *@
@* { *@
@* if (Locker.LockLibrary(vm.Id)) *@
@* { *@
@* await Channel.WriteAsync(new ForceScanLocalLibrary(vm.Id)); *@
@* NavigationManager.NavigateTo("/media/sources"); *@
@* } *@
@* }); *@
@* } *@
@* } *@
@* *@
@* *@
@* *@
@* } *@

50
ErsatzTV/Pages/MediaSources.razor

@ -1,25 +1,25 @@ @@ -1,25 +1,25 @@
@page "/media/sources"
@page "/media/sources/{PanelIndex:int}"
@inject NavigationManager NavigationManager
<MudContainer MaxWidth="MaxWidth.ExtraLarge">
<MudTabs @ref="_tabs" Elevation="1">
<MudTabPanel Text="Local" OnClick="@(() => NavigationManager.NavigateTo("/media/sources/0"))">
<LocalMediaSources></LocalMediaSources>
</MudTabPanel>
<MudTabPanel Text="Plex" OnClick="@(() => NavigationManager.NavigateTo("/media/sources/1"))">
<PlexMediaSources></PlexMediaSources>
</MudTabPanel>
</MudTabs>
</MudContainer>
@code {
[Parameter]
public int PanelIndex { get; set; }
private MudTabs _tabs;
protected override void OnAfterRender(bool firstRender) => _tabs.ActivatePanel(PanelIndex);
}
@* @page "/media/sources" *@
@* @page "/media/sources/{PanelIndex:int}" *@
@* @inject NavigationManager NavigationManager *@
@* *@
@* <MudContainer MaxWidth="MaxWidth.ExtraLarge"> *@
@* <MudTabs @ref="_tabs" Elevation="1"> *@
@* <MudTabPanel Text="Local" OnClick="@(() => NavigationManager.NavigateTo("/media/sources/0"))"> *@
@* <LocalMediaSources></LocalMediaSources> *@
@* </MudTabPanel> *@
@* <MudTabPanel Text="Plex" OnClick="@(() => NavigationManager.NavigateTo("/media/sources/1"))"> *@
@* <PlexMediaSources></PlexMediaSources> *@
@* </MudTabPanel> *@
@* </MudTabs> *@
@* </MudContainer> *@
@* *@
@* @code { *@
@* *@
@* [Parameter] *@
@* public int PanelIndex { get; set; } *@
@* *@
@* private MudTabs _tabs; *@
@* *@
@* protected override void OnAfterRender(bool firstRender) => _tabs.ActivatePanel(PanelIndex); *@
@* *@
@* } *@

15
ErsatzTV/Validators/LocalLibraryPathEditViewModelValidator.cs

@ -0,0 +1,15 @@ @@ -0,0 +1,15 @@
using System.IO;
using ErsatzTV.ViewModels;
using FluentValidation;
namespace ErsatzTV.Validators
{
public class LocalLibraryPathEditViewModelValidator : AbstractValidator<LocalLibraryPathEditViewModel>
{
public LocalLibraryPathEditViewModelValidator()
{
RuleFor(x => x.Path).NotEmpty();
RuleFor(x => x.Path).Must(Directory.Exists).WithMessage("Path must exist on filesystem");
}
}
}

15
ErsatzTV/Validators/LocalMediaSourceEditViewModelValidator.cs

@ -1,15 +0,0 @@ @@ -1,15 +0,0 @@
using System.IO;
using ErsatzTV.ViewModels;
using FluentValidation;
namespace ErsatzTV.Validators
{
public class LocalMediaSourceEditViewModelValidator : AbstractValidator<LocalMediaSourceEditViewModel>
{
public LocalMediaSourceEditViewModelValidator()
{
RuleFor(x => x.Folder).NotEmpty();
RuleFor(x => x.Folder).Must(Directory.Exists).WithMessage("Folder must exist on filesystem");
}
}
}

7
ErsatzTV/ViewModels/LocalLibraryPathEditViewModel.cs

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
namespace ErsatzTV.ViewModels
{
public class LocalLibraryPathEditViewModel
{
public string Path { get; set; }
}
}

7
ErsatzTV/ViewModels/LocalMediaSourceEditViewModel.cs

@ -1,7 +0,0 @@ @@ -1,7 +0,0 @@
namespace ErsatzTV.ViewModels
{
public class LocalMediaSourceEditViewModel
{
public string Folder { get; set; }
}
}
Loading…
Cancel
Save