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/63/head v0.0.15-prealpha
39 changed files with 4210 additions and 108 deletions
@ -0,0 +1,9 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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