mirror of https://github.com/ErsatzTV/ErsatzTV.git
Browse Source
* add basic selection behavior to search results * add search scrolling, selection actions * include shows in multiselect * multiselect movies, shows, collection items * add movie and show tags * code cleanup * update show screenshotpull/1213/head
39 changed files with 4210 additions and 108 deletions
@ -0,0 +1,9 @@ |
|||||||
|
using System.Collections.Generic; |
||||||
|
using ErsatzTV.Core; |
||||||
|
using LanguageExt; |
||||||
|
|
||||||
|
namespace ErsatzTV.Application.MediaCollections.Commands |
||||||
|
{ |
||||||
|
public record AddItemsToCollection |
||||||
|
(int CollectionId, List<int> MovieIds, List<int> ShowIds) : MediatR.IRequest<Either<BaseError, Unit>>; |
||||||
|
} |
||||||
@ -0,0 +1,80 @@ |
|||||||
|
using System.Linq; |
||||||
|
using System.Threading; |
||||||
|
using System.Threading.Channels; |
||||||
|
using System.Threading.Tasks; |
||||||
|
using ErsatzTV.Application.Playouts.Commands; |
||||||
|
using ErsatzTV.Core; |
||||||
|
using ErsatzTV.Core.Interfaces.Repositories; |
||||||
|
using LanguageExt; |
||||||
|
using static LanguageExt.Prelude; |
||||||
|
|
||||||
|
namespace ErsatzTV.Application.MediaCollections.Commands |
||||||
|
{ |
||||||
|
public class |
||||||
|
AddItemsToCollectionHandler : MediatR.IRequestHandler<AddItemsToCollection, Either<BaseError, Unit>> |
||||||
|
{ |
||||||
|
private readonly ChannelWriter<IBackgroundServiceRequest> _channel; |
||||||
|
private readonly IMediaCollectionRepository _mediaCollectionRepository; |
||||||
|
private readonly IMovieRepository _movieRepository; |
||||||
|
private readonly ITelevisionRepository _televisionRepository; |
||||||
|
|
||||||
|
public AddItemsToCollectionHandler( |
||||||
|
IMediaCollectionRepository mediaCollectionRepository, |
||||||
|
IMovieRepository movieRepository, |
||||||
|
ITelevisionRepository televisionRepository, |
||||||
|
ChannelWriter<IBackgroundServiceRequest> channel) |
||||||
|
{ |
||||||
|
_mediaCollectionRepository = mediaCollectionRepository; |
||||||
|
_movieRepository = movieRepository; |
||||||
|
_televisionRepository = televisionRepository; |
||||||
|
_channel = channel; |
||||||
|
} |
||||||
|
|
||||||
|
public Task<Either<BaseError, Unit>> Handle( |
||||||
|
AddItemsToCollection request, |
||||||
|
CancellationToken cancellationToken) => |
||||||
|
Validate(request) |
||||||
|
.MapT(_ => ApplyAddItemsRequest(request)) |
||||||
|
.Bind(v => v.ToEitherAsync()); |
||||||
|
|
||||||
|
private async Task<Unit> ApplyAddItemsRequest(AddItemsToCollection request) |
||||||
|
{ |
||||||
|
if (await _mediaCollectionRepository.AddMediaItems( |
||||||
|
request.CollectionId, |
||||||
|
request.MovieIds.Append(request.ShowIds).ToList())) |
||||||
|
{ |
||||||
|
// rebuild all playouts that use this collection
|
||||||
|
foreach (int playoutId in await _mediaCollectionRepository |
||||||
|
.PlayoutIdsUsingCollection(request.CollectionId)) |
||||||
|
{ |
||||||
|
await _channel.WriteAsync(new BuildPlayout(playoutId, true)); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return Unit.Default; |
||||||
|
} |
||||||
|
|
||||||
|
private async Task<Validation<BaseError, Unit>> Validate(AddItemsToCollection request) => |
||||||
|
(await CollectionMustExist(request), await ValidateMovies(request), await ValidateShows(request)) |
||||||
|
.Apply((_, _, _) => Unit.Default); |
||||||
|
|
||||||
|
private Task<Validation<BaseError, Unit>> CollectionMustExist(AddItemsToCollection request) => |
||||||
|
_mediaCollectionRepository.GetCollectionWithItems(request.CollectionId) |
||||||
|
.MapT(_ => Unit.Default) |
||||||
|
.Map(v => v.ToValidation<BaseError>("Collection does not exist.")); |
||||||
|
|
||||||
|
private Task<Validation<BaseError, Unit>> ValidateMovies(AddItemsToCollection request) => |
||||||
|
_movieRepository.AllMoviesExist(request.MovieIds) |
||||||
|
.Map(Optional) |
||||||
|
.Filter(v => v == true) |
||||||
|
.MapT(_ => Unit.Default) |
||||||
|
.Map(v => v.ToValidation<BaseError>("Movie does not exist")); |
||||||
|
|
||||||
|
private Task<Validation<BaseError, Unit>> ValidateShows(AddItemsToCollection request) => |
||||||
|
_televisionRepository.AllShowsExist(request.ShowIds) |
||||||
|
.Map(Optional) |
||||||
|
.Filter(v => v == true) |
||||||
|
.MapT(_ => Unit.Default) |
||||||
|
.Map(v => v.ToValidation<BaseError>("Show does not exist")); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,8 @@ |
|||||||
|
namespace ErsatzTV.Core.Domain |
||||||
|
{ |
||||||
|
public class Tag |
||||||
|
{ |
||||||
|
public int Id { get; set; } |
||||||
|
public string Name { get; set; } |
||||||
|
} |
||||||
|
} |
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,75 @@ |
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations; |
||||||
|
|
||||||
|
namespace ErsatzTV.Infrastructure.Migrations |
||||||
|
{ |
||||||
|
public partial class Add_MetadataTags : Migration |
||||||
|
{ |
||||||
|
protected override void Up(MigrationBuilder migrationBuilder) |
||||||
|
{ |
||||||
|
migrationBuilder.CreateTable( |
||||||
|
"Tag", |
||||||
|
table => new |
||||||
|
{ |
||||||
|
Id = table.Column<int>("INTEGER", nullable: false) |
||||||
|
.Annotation("Sqlite:Autoincrement", true), |
||||||
|
Name = table.Column<string>("TEXT", nullable: true), |
||||||
|
EpisodeMetadataId = table.Column<int>("INTEGER", nullable: true), |
||||||
|
MovieMetadataId = table.Column<int>("INTEGER", nullable: true), |
||||||
|
SeasonMetadataId = table.Column<int>("INTEGER", nullable: true), |
||||||
|
ShowMetadataId = table.Column<int>("INTEGER", nullable: true) |
||||||
|
}, |
||||||
|
constraints: table => |
||||||
|
{ |
||||||
|
table.PrimaryKey("PK_Tag", x => x.Id); |
||||||
|
table.ForeignKey( |
||||||
|
"FK_Tag_EpisodeMetadata_EpisodeMetadataId", |
||||||
|
x => x.EpisodeMetadataId, |
||||||
|
"EpisodeMetadata", |
||||||
|
"Id", |
||||||
|
onDelete: ReferentialAction.Restrict); |
||||||
|
table.ForeignKey( |
||||||
|
"FK_Tag_MovieMetadata_MovieMetadataId", |
||||||
|
x => x.MovieMetadataId, |
||||||
|
"MovieMetadata", |
||||||
|
"Id", |
||||||
|
onDelete: ReferentialAction.Cascade); |
||||||
|
table.ForeignKey( |
||||||
|
"FK_Tag_SeasonMetadata_SeasonMetadataId", |
||||||
|
x => x.SeasonMetadataId, |
||||||
|
"SeasonMetadata", |
||||||
|
"Id", |
||||||
|
onDelete: ReferentialAction.Restrict); |
||||||
|
table.ForeignKey( |
||||||
|
"FK_Tag_ShowMetadata_ShowMetadataId", |
||||||
|
x => x.ShowMetadataId, |
||||||
|
"ShowMetadata", |
||||||
|
"Id", |
||||||
|
onDelete: ReferentialAction.Cascade); |
||||||
|
}); |
||||||
|
|
||||||
|
migrationBuilder.CreateIndex( |
||||||
|
"IX_Tag_EpisodeMetadataId", |
||||||
|
"Tag", |
||||||
|
"EpisodeMetadataId"); |
||||||
|
|
||||||
|
migrationBuilder.CreateIndex( |
||||||
|
"IX_Tag_MovieMetadataId", |
||||||
|
"Tag", |
||||||
|
"MovieMetadataId"); |
||||||
|
|
||||||
|
migrationBuilder.CreateIndex( |
||||||
|
"IX_Tag_SeasonMetadataId", |
||||||
|
"Tag", |
||||||
|
"SeasonMetadataId"); |
||||||
|
|
||||||
|
migrationBuilder.CreateIndex( |
||||||
|
"IX_Tag_ShowMetadataId", |
||||||
|
"Tag", |
||||||
|
"ShowMetadataId"); |
||||||
|
} |
||||||
|
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder) => |
||||||
|
migrationBuilder.DropTable( |
||||||
|
"Tag"); |
||||||
|
} |
||||||
|
} |
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,18 @@ |
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations; |
||||||
|
|
||||||
|
namespace ErsatzTV.Infrastructure.Migrations |
||||||
|
{ |
||||||
|
public partial class Reset_MetadataDateUpdated_Tags : Migration |
||||||
|
{ |
||||||
|
protected override void Up(MigrationBuilder migrationBuilder) |
||||||
|
{ |
||||||
|
migrationBuilder.Sql(@"UPDATE MovieMetadata SET DateUpdated = '0001-01-01 00:00:00'"); |
||||||
|
migrationBuilder.Sql(@"UPDATE ShowMetadata SET DateUpdated = '0001-01-01 00:00:00'"); |
||||||
|
migrationBuilder.Sql(@"UPDATE Library SET LastScan = '0001-01-01 00:00:00'"); |
||||||
|
} |
||||||
|
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder) |
||||||
|
{ |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,33 @@ |
|||||||
|
using System; |
||||||
|
using System.Threading.Tasks; |
||||||
|
using ErsatzTV.Extensions; |
||||||
|
using Microsoft.AspNetCore.Components; |
||||||
|
using Microsoft.AspNetCore.Components.Routing; |
||||||
|
using Microsoft.JSInterop; |
||||||
|
|
||||||
|
namespace ErsatzTV.Pages |
||||||
|
{ |
||||||
|
public class FragmentNavigationBase : ComponentBase, IDisposable |
||||||
|
{ |
||||||
|
[Inject] |
||||||
|
private NavigationManager NavManager { get; set; } |
||||||
|
|
||||||
|
[Inject] |
||||||
|
private IJSRuntime JsRuntime { get; set; } |
||||||
|
|
||||||
|
public void Dispose() => NavManager.LocationChanged -= TryFragmentNavigation; |
||||||
|
|
||||||
|
protected override void OnInitialized() => NavManager.LocationChanged += TryFragmentNavigation; |
||||||
|
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender) |
||||||
|
{ |
||||||
|
if (firstRender) |
||||||
|
{ |
||||||
|
await NavManager.NavigateToFragmentAsync(JsRuntime); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private async void TryFragmentNavigation(object sender, LocationChangedEventArgs args) => |
||||||
|
await NavManager.NavigateToFragmentAsync(JsRuntime); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,159 @@ |
|||||||
|
using System; |
||||||
|
using System.Collections.Generic; |
||||||
|
using System.Linq; |
||||||
|
using System.Threading.Tasks; |
||||||
|
using ErsatzTV.Application.MediaCards; |
||||||
|
using ErsatzTV.Application.MediaCollections; |
||||||
|
using ErsatzTV.Application.MediaCollections.Commands; |
||||||
|
using ErsatzTV.Core; |
||||||
|
using ErsatzTV.Shared; |
||||||
|
using LanguageExt; |
||||||
|
using LanguageExt.UnsafeValueAccess; |
||||||
|
using MediatR; |
||||||
|
using Microsoft.AspNetCore.Components; |
||||||
|
using Microsoft.AspNetCore.Components.Web; |
||||||
|
using Microsoft.Extensions.Logging; |
||||||
|
using MudBlazor; |
||||||
|
using static LanguageExt.Prelude; |
||||||
|
using Unit = LanguageExt.Unit; |
||||||
|
|
||||||
|
namespace ErsatzTV.Pages |
||||||
|
{ |
||||||
|
public class MultiSelectBase<T> : FragmentNavigationBase |
||||||
|
{ |
||||||
|
private readonly System.Collections.Generic.HashSet<MediaCardViewModel> _selectedItems; |
||||||
|
private Option<MediaCardViewModel> _recentlySelected; |
||||||
|
|
||||||
|
public MultiSelectBase() |
||||||
|
{ |
||||||
|
_recentlySelected = None; |
||||||
|
_selectedItems = new System.Collections.Generic.HashSet<MediaCardViewModel>(); |
||||||
|
} |
||||||
|
|
||||||
|
[Inject] |
||||||
|
protected IDialogService Dialog { get; set; } |
||||||
|
|
||||||
|
[Inject] |
||||||
|
protected ISnackbar Snackbar { get; set; } |
||||||
|
|
||||||
|
[Inject] |
||||||
|
protected ILogger<T> Logger { get; set; } |
||||||
|
|
||||||
|
[Inject] |
||||||
|
protected IMediator Mediator { get; set; } |
||||||
|
|
||||||
|
protected bool IsSelected(MediaCardViewModel card) => |
||||||
|
_selectedItems.Contains(card); |
||||||
|
|
||||||
|
protected bool IsSelectMode() => |
||||||
|
_selectedItems.Any(); |
||||||
|
|
||||||
|
protected string SelectionLabel() => |
||||||
|
$"{_selectedItems.Count} {(_selectedItems.Count == 1 ? "Item" : "Items")} Selected"; |
||||||
|
|
||||||
|
protected void ClearSelection() |
||||||
|
{ |
||||||
|
_selectedItems.Clear(); |
||||||
|
_recentlySelected = None; |
||||||
|
} |
||||||
|
|
||||||
|
protected virtual Task RefreshData() => Task.CompletedTask; |
||||||
|
|
||||||
|
protected void SelectClicked( |
||||||
|
Func<List<MediaCardViewModel>> getSortedItems, |
||||||
|
MediaCardViewModel card, |
||||||
|
MouseEventArgs e) |
||||||
|
{ |
||||||
|
if (_selectedItems.Contains(card)) |
||||||
|
{ |
||||||
|
_selectedItems.Remove(card); |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
if (e.ShiftKey && _recentlySelected.IsSome) |
||||||
|
{ |
||||||
|
List<MediaCardViewModel> sorted = getSortedItems(); |
||||||
|
|
||||||
|
int start = sorted.IndexOf(_recentlySelected.ValueUnsafe()); |
||||||
|
int finish = sorted.IndexOf(card); |
||||||
|
if (start > finish) |
||||||
|
{ |
||||||
|
int temp = start; |
||||||
|
start = finish; |
||||||
|
finish = temp; |
||||||
|
} |
||||||
|
|
||||||
|
for (int i = start; i < finish; i++) |
||||||
|
{ |
||||||
|
_selectedItems.Add(sorted[i]); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
_recentlySelected = card; |
||||||
|
_selectedItems.Add(card); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
protected async Task AddSelectionToCollection() |
||||||
|
{ |
||||||
|
var parameters = new DialogParameters |
||||||
|
{ { "EntityType", _selectedItems.Count.ToString() }, { "EntityName", "selected items" } }; |
||||||
|
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall }; |
||||||
|
|
||||||
|
IDialogReference dialog = Dialog.Show<AddToCollectionDialog>("Add To Collection", parameters, options); |
||||||
|
DialogResult result = await dialog.Result; |
||||||
|
if (!result.Cancelled && result.Data is MediaCollectionViewModel collection) |
||||||
|
{ |
||||||
|
var request = new AddItemsToCollection( |
||||||
|
collection.Id, |
||||||
|
_selectedItems.OfType<MovieCardViewModel>().Map(m => m.MovieId).ToList(), |
||||||
|
_selectedItems.OfType<TelevisionShowCardViewModel>().Map(s => s.TelevisionShowId).ToList()); |
||||||
|
|
||||||
|
Either<BaseError, Unit> addResult = await Mediator.Send(request); |
||||||
|
addResult.Match( |
||||||
|
Left: error => |
||||||
|
{ |
||||||
|
Snackbar.Add($"Unexpected error adding items to collection: {error.Value}"); |
||||||
|
Logger.LogError("Unexpected error adding items to collection: {Error}", error.Value); |
||||||
|
}, |
||||||
|
Right: _ => |
||||||
|
{ |
||||||
|
Snackbar.Add( |
||||||
|
$"Added {_selectedItems.Count} items to collection {collection.Name}", |
||||||
|
Severity.Success); |
||||||
|
ClearSelection(); |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
protected async Task RemoveSelectionFromCollection(int collectionId) |
||||||
|
{ |
||||||
|
var parameters = new DialogParameters |
||||||
|
{ { "EntityType", _selectedItems.Count.ToString() }, { "EntityName", "selected items" } }; |
||||||
|
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall }; |
||||||
|
|
||||||
|
IDialogReference dialog = Dialog.Show<RemoveFromCollectionDialog>( |
||||||
|
"Remove From Collection", |
||||||
|
parameters, |
||||||
|
options); |
||||||
|
DialogResult result = await dialog.Result; |
||||||
|
if (!result.Cancelled) |
||||||
|
{ |
||||||
|
var itemIds = new List<int>(); |
||||||
|
itemIds.AddRange(_selectedItems.OfType<MovieCardViewModel>().Map(m => m.MovieId)); |
||||||
|
itemIds.AddRange(_selectedItems.OfType<TelevisionShowCardViewModel>().Map(s => s.TelevisionShowId)); |
||||||
|
itemIds.AddRange(_selectedItems.OfType<TelevisionSeasonCardViewModel>().Map(s => s.TelevisionSeasonId)); |
||||||
|
itemIds.AddRange(_selectedItems.OfType<TelevisionEpisodeCardViewModel>().Map(e => e.EpisodeId)); |
||||||
|
|
||||||
|
await Mediator.Send( |
||||||
|
new RemoveItemsFromCollection(collectionId) |
||||||
|
{ |
||||||
|
MediaItemIds = itemIds |
||||||
|
}); |
||||||
|
|
||||||
|
await RefreshData(); |
||||||
|
ClearSelection(); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
Before Width: | Height: | Size: 436 KiB After Width: | Height: | Size: 435 KiB |
Loading…
Reference in new issue