mirror of https://github.com/ErsatzTV/ErsatzTV.git
Browse Source
* add movies to playlists * add search results to playlist * update changelogpull/1693/head
30 changed files with 932 additions and 48 deletions
@ -0,0 +1,15 @@ |
|||||||
|
using ErsatzTV.Core; |
||||||
|
|
||||||
|
namespace ErsatzTV.Application.MediaCollections; |
||||||
|
|
||||||
|
public record AddItemsToPlaylist( |
||||||
|
int PlaylistId, |
||||||
|
List<int> MovieIds, |
||||||
|
List<int> ShowIds, |
||||||
|
List<int> SeasonIds, |
||||||
|
List<int> EpisodeIds, |
||||||
|
List<int> ArtistIds, |
||||||
|
List<int> MusicVideoIds, |
||||||
|
List<int> OtherVideoIds, |
||||||
|
List<int> SongIds, |
||||||
|
List<int> ImageIds) : IRequest<Either<BaseError, Unit>>; |
||||||
@ -0,0 +1,120 @@ |
|||||||
|
using ErsatzTV.Core; |
||||||
|
using ErsatzTV.Core.Domain; |
||||||
|
using ErsatzTV.Core.Interfaces.Repositories; |
||||||
|
using ErsatzTV.Infrastructure.Data; |
||||||
|
using ErsatzTV.Infrastructure.Extensions; |
||||||
|
using Microsoft.EntityFrameworkCore; |
||||||
|
|
||||||
|
namespace ErsatzTV.Application.MediaCollections; |
||||||
|
|
||||||
|
public class AddItemsToPlaylistHandler : IRequestHandler<AddItemsToPlaylist, Either<BaseError, Unit>> |
||||||
|
{ |
||||||
|
private readonly IDbContextFactory<TvContext> _dbContextFactory; |
||||||
|
private readonly IMovieRepository _movieRepository; |
||||||
|
private readonly ITelevisionRepository _televisionRepository; |
||||||
|
|
||||||
|
public AddItemsToPlaylistHandler( |
||||||
|
IDbContextFactory<TvContext> dbContextFactory, |
||||||
|
IMovieRepository movieRepository, |
||||||
|
ITelevisionRepository televisionRepository) |
||||||
|
{ |
||||||
|
_dbContextFactory = dbContextFactory; |
||||||
|
_movieRepository = movieRepository; |
||||||
|
_televisionRepository = televisionRepository; |
||||||
|
} |
||||||
|
|
||||||
|
public async Task<Either<BaseError, Unit>> Handle(AddItemsToPlaylist request, CancellationToken cancellationToken) |
||||||
|
{ |
||||||
|
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); |
||||||
|
Validation<BaseError, Playlist> validation = await Validate(dbContext, request); |
||||||
|
return await validation.Apply(c => ApplyAddItemsRequest(dbContext, c, request)); |
||||||
|
} |
||||||
|
|
||||||
|
private static async Task<Unit> ApplyAddItemsRequest( |
||||||
|
TvContext dbContext, |
||||||
|
Playlist playlist, |
||||||
|
AddItemsToPlaylist request) |
||||||
|
{ |
||||||
|
var allItems = new Dictionary<ProgramScheduleItemCollectionType, List<int>> |
||||||
|
{ |
||||||
|
{ ProgramScheduleItemCollectionType.Movie, request.MovieIds }, |
||||||
|
{ ProgramScheduleItemCollectionType.TelevisionShow, request.ShowIds }, |
||||||
|
{ ProgramScheduleItemCollectionType.TelevisionSeason, request.SeasonIds }, |
||||||
|
{ ProgramScheduleItemCollectionType.Episode, request.EpisodeIds }, |
||||||
|
{ ProgramScheduleItemCollectionType.Artist, request.ArtistIds }, |
||||||
|
{ ProgramScheduleItemCollectionType.MusicVideo, request.MusicVideoIds }, |
||||||
|
{ ProgramScheduleItemCollectionType.OtherVideo, request.OtherVideoIds }, |
||||||
|
{ ProgramScheduleItemCollectionType.Song, request.SongIds }, |
||||||
|
{ ProgramScheduleItemCollectionType.Image, request.ImageIds } |
||||||
|
}; |
||||||
|
|
||||||
|
int index = playlist.Items.Max(i => i.Index) + 1; |
||||||
|
|
||||||
|
foreach ((ProgramScheduleItemCollectionType collectionType, List<int> ids) in allItems) |
||||||
|
{ |
||||||
|
foreach (int id in ids) |
||||||
|
{ |
||||||
|
var item = new PlaylistItem |
||||||
|
{ |
||||||
|
Index = index++, |
||||||
|
CollectionType = collectionType, |
||||||
|
MediaItemId = id, |
||||||
|
PlaybackOrder = PlaybackOrder.Shuffle, |
||||||
|
IncludeInProgramGuide = true |
||||||
|
}; |
||||||
|
|
||||||
|
playlist.Items.Add(item); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
await dbContext.SaveChangesAsync(); |
||||||
|
|
||||||
|
return Unit.Default; |
||||||
|
} |
||||||
|
|
||||||
|
private async Task<Validation<BaseError, Playlist>> Validate( |
||||||
|
TvContext dbContext, |
||||||
|
AddItemsToPlaylist request) => |
||||||
|
(await PlaylistMustExist(dbContext, request), |
||||||
|
await ValidateMovies(request), |
||||||
|
await ValidateShows(request), |
||||||
|
await ValidateSeasons(request), |
||||||
|
await ValidateEpisodes(request)) |
||||||
|
.Apply((collection, _, _, _, _) => collection); |
||||||
|
|
||||||
|
private static Task<Validation<BaseError, Playlist>> PlaylistMustExist( |
||||||
|
TvContext dbContext, |
||||||
|
AddItemsToPlaylist request) => |
||||||
|
dbContext.Playlists |
||||||
|
.Include(c => c.Items) |
||||||
|
.SelectOneAsync(c => c.Id, c => c.Id == request.PlaylistId) |
||||||
|
.Map(o => o.ToValidation<BaseError>("Playlist does not exist.")); |
||||||
|
|
||||||
|
private Task<Validation<BaseError, Unit>> ValidateMovies(AddItemsToPlaylist 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(AddItemsToPlaylist request) => |
||||||
|
_televisionRepository.AllShowsExist(request.ShowIds) |
||||||
|
.Map(Optional) |
||||||
|
.Filter(v => v == true) |
||||||
|
.MapT(_ => Unit.Default) |
||||||
|
.Map(v => v.ToValidation<BaseError>("Show does not exist")); |
||||||
|
|
||||||
|
private Task<Validation<BaseError, Unit>> ValidateSeasons(AddItemsToPlaylist request) => |
||||||
|
_televisionRepository.AllSeasonsExist(request.SeasonIds) |
||||||
|
.Map(Optional) |
||||||
|
.Filter(v => v == true) |
||||||
|
.MapT(_ => Unit.Default) |
||||||
|
.Map(v => v.ToValidation<BaseError>("Season does not exist")); |
||||||
|
|
||||||
|
private Task<Validation<BaseError, Unit>> ValidateEpisodes(AddItemsToPlaylist request) => |
||||||
|
_televisionRepository.AllEpisodesExist(request.EpisodeIds) |
||||||
|
.Map(Optional) |
||||||
|
.Filter(v => v == true) |
||||||
|
.MapT(_ => Unit.Default) |
||||||
|
.Map(v => v.ToValidation<BaseError>("Episode does not exist")); |
||||||
|
} |
||||||
@ -0,0 +1,5 @@ |
|||||||
|
using ErsatzTV.Core; |
||||||
|
|
||||||
|
namespace ErsatzTV.Application.MediaCollections; |
||||||
|
|
||||||
|
public record AddMovieToPlaylist(int PlaylistId, int MovieId) : IRequest<Either<BaseError, Unit>>; |
||||||
@ -0,0 +1,63 @@ |
|||||||
|
using System.Threading.Channels; |
||||||
|
using ErsatzTV.Application.Playouts; |
||||||
|
using ErsatzTV.Core; |
||||||
|
using ErsatzTV.Core.Domain; |
||||||
|
using ErsatzTV.Core.Interfaces.Repositories; |
||||||
|
using ErsatzTV.Core.Scheduling; |
||||||
|
using ErsatzTV.Infrastructure.Data; |
||||||
|
using ErsatzTV.Infrastructure.Extensions; |
||||||
|
using Microsoft.EntityFrameworkCore; |
||||||
|
|
||||||
|
namespace ErsatzTV.Application.MediaCollections; |
||||||
|
|
||||||
|
public class AddMovieToPlaylistHandler(IDbContextFactory<TvContext> dbContextFactory) |
||||||
|
: IRequestHandler<AddMovieToPlaylist, Either<BaseError, Unit>> |
||||||
|
{ |
||||||
|
public async Task<Either<BaseError, Unit>> Handle( |
||||||
|
AddMovieToPlaylist request, |
||||||
|
CancellationToken cancellationToken) |
||||||
|
{ |
||||||
|
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); |
||||||
|
Validation<BaseError, Parameters> validation = await Validate(dbContext, request); |
||||||
|
return await validation.Apply(parameters => ApplyAddMovieRequest(dbContext, parameters)); |
||||||
|
} |
||||||
|
|
||||||
|
private static async Task<Unit> ApplyAddMovieRequest(TvContext dbContext, Parameters parameters) |
||||||
|
{ |
||||||
|
var playlistItem = new PlaylistItem |
||||||
|
{ |
||||||
|
Index = parameters.Playlist.Items.Max(i => i.Index) + 1, |
||||||
|
CollectionType = ProgramScheduleItemCollectionType.Movie, |
||||||
|
MediaItemId = parameters.Movie.Id, |
||||||
|
PlaybackOrder = PlaybackOrder.Shuffle, |
||||||
|
IncludeInProgramGuide = true |
||||||
|
}; |
||||||
|
|
||||||
|
parameters.Playlist.Items.Add(playlistItem); |
||||||
|
await dbContext.SaveChangesAsync(); |
||||||
|
return Unit.Default; |
||||||
|
} |
||||||
|
|
||||||
|
private static async Task<Validation<BaseError, Parameters>> Validate( |
||||||
|
TvContext dbContext, |
||||||
|
AddMovieToPlaylist request) => |
||||||
|
(await PlaylistMustExist(dbContext, request), await ValidateMovie(dbContext, request)) |
||||||
|
.Apply((collection, episode) => new Parameters(collection, episode)); |
||||||
|
|
||||||
|
private static Task<Validation<BaseError, Playlist>> PlaylistMustExist( |
||||||
|
TvContext dbContext, |
||||||
|
AddMovieToPlaylist request) => |
||||||
|
dbContext.Playlists |
||||||
|
.Include(c => c.Items) |
||||||
|
.SelectOneAsync(c => c.Id, c => c.Id == request.PlaylistId) |
||||||
|
.Map(o => o.ToValidation<BaseError>("Playlist does not exist.")); |
||||||
|
|
||||||
|
private static Task<Validation<BaseError, Movie>> ValidateMovie( |
||||||
|
TvContext dbContext, |
||||||
|
AddMovieToPlaylist request) => |
||||||
|
dbContext.Movies |
||||||
|
.SelectOneAsync(m => m.Id, e => e.Id == request.MovieId) |
||||||
|
.Map(o => o.ToValidation<BaseError>("Movie does not exist")); |
||||||
|
|
||||||
|
private sealed record Parameters(Playlist Playlist, Movie Movie); |
||||||
|
} |
||||||
@ -0,0 +1,5 @@ |
|||||||
|
using ErsatzTV.Application.MediaItems; |
||||||
|
|
||||||
|
namespace ErsatzTV.Application.Search; |
||||||
|
|
||||||
|
public record SearchMovies(string Query) : IRequest<List<NamedMediaItemViewModel>>; |
||||||
@ -0,0 +1,32 @@ |
|||||||
|
using System.Globalization; |
||||||
|
using ErsatzTV.Application.MediaItems; |
||||||
|
using ErsatzTV.Core.Domain; |
||||||
|
using ErsatzTV.Infrastructure.Data; |
||||||
|
using Microsoft.EntityFrameworkCore; |
||||||
|
|
||||||
|
namespace ErsatzTV.Application.Search; |
||||||
|
|
||||||
|
public class SearchMoviesHandler(IDbContextFactory<TvContext> dbContextFactory) |
||||||
|
: IRequestHandler<SearchMovies, List<NamedMediaItemViewModel>> |
||||||
|
{ |
||||||
|
public async Task<List<NamedMediaItemViewModel>> Handle(SearchMovies request, CancellationToken cancellationToken) |
||||||
|
{ |
||||||
|
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); |
||||||
|
return await dbContext.MovieMetadata |
||||||
|
.AsNoTracking() |
||||||
|
.Where( |
||||||
|
s => EF.Functions.Like( |
||||||
|
EF.Functions.Collate(s.Title + " " + s.Year, TvContext.CaseInsensitiveCollation), |
||||||
|
$"%{request.Query}%")) |
||||||
|
.OrderBy(a => EF.Functions.Collate(a.Title, TvContext.CaseInsensitiveCollation)) |
||||||
|
.ThenBy(s => s.Year) |
||||||
|
.Take(10) |
||||||
|
.ToListAsync(cancellationToken) |
||||||
|
.Map(list => list.Map(ToNamedMediaItem).ToList()); |
||||||
|
} |
||||||
|
|
||||||
|
private static NamedMediaItemViewModel ToNamedMediaItem(MovieMetadata movie) => |
||||||
|
new( |
||||||
|
movie.MovieId, |
||||||
|
$"{movie.Title} ({(movie.Year.HasValue ? movie.Year.Value.ToString(CultureInfo.InvariantCulture) : "???")})"); |
||||||
|
} |
||||||
@ -0,0 +1,25 @@ |
|||||||
|
using ErsatzTV.Core.Domain; |
||||||
|
using ErsatzTV.Core.Extensions; |
||||||
|
using ErsatzTV.Core.Interfaces.Scheduling; |
||||||
|
|
||||||
|
namespace ErsatzTV.Core.Scheduling; |
||||||
|
|
||||||
|
public class SingleMediaItemEnumerator(MediaItem mediaItem) : IMediaCollectionEnumerator |
||||||
|
{ |
||||||
|
public CollectionEnumeratorState State { get; } = new(); |
||||||
|
public Option<MediaItem> Current => mediaItem; |
||||||
|
public Option<bool> CurrentIncludeInProgramGuide => Option<bool>.None; |
||||||
|
|
||||||
|
public int Count => 1; |
||||||
|
public Option<TimeSpan> MinimumDuration => mediaItem.GetNonZeroDuration(); |
||||||
|
|
||||||
|
public void ResetState(CollectionEnumeratorState state) |
||||||
|
{ |
||||||
|
// do nothing
|
||||||
|
} |
||||||
|
|
||||||
|
public void MoveNext() |
||||||
|
{ |
||||||
|
// do nothing
|
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,137 @@ |
|||||||
|
@using Microsoft.Extensions.Caching.Memory |
||||||
|
@using ErsatzTV.Application.MediaCollections |
||||||
|
@implements IDisposable |
||||||
|
@inject IMediator Mediator |
||||||
|
@inject IMemoryCache MemoryCache |
||||||
|
|
||||||
|
<MudDialog> |
||||||
|
<DialogContent> |
||||||
|
<EditForm Model="@_dummyModel" OnSubmit="@(_ => Submit())"> |
||||||
|
<MudContainer Class="mb-6"> |
||||||
|
<MudHighlighter Class="mud-primary-text" |
||||||
|
Style="background-color: transparent; font-weight: bold" |
||||||
|
Text="@FormatText()" |
||||||
|
HighlightedText="@EntityName"/> |
||||||
|
</MudContainer> |
||||||
|
<MudSelect Class="mb-3 mx-4" |
||||||
|
T="PlaylistGroupViewModel" |
||||||
|
Label="Playlist Group" |
||||||
|
Value="_selectedPlaylistGroup" |
||||||
|
ValueChanged="@(vm => UpdatePlaylistGroupItems(vm))"> |
||||||
|
@foreach (PlaylistGroupViewModel playlistGroup in _playlistGroups) |
||||||
|
{ |
||||||
|
<MudSelectItem Value="@playlistGroup">@playlistGroup.Name</MudSelectItem> |
||||||
|
} |
||||||
|
</MudSelect> |
||||||
|
<MudSelect Class="mb-6 mx-4" |
||||||
|
T="PlaylistViewModel" |
||||||
|
Label="Playlist" |
||||||
|
@bind-value="_selectedPlaylist"> |
||||||
|
@foreach (PlaylistViewModel playlist in _playlists) |
||||||
|
{ |
||||||
|
<MudSelectItem Value="@playlist">@playlist.Name</MudSelectItem> |
||||||
|
} |
||||||
|
</MudSelect> |
||||||
|
</EditForm> |
||||||
|
</DialogContent> |
||||||
|
<DialogActions> |
||||||
|
<MudButton OnClick="Cancel" ButtonType="ButtonType.Reset">Cancel</MudButton> |
||||||
|
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="Submit"> |
||||||
|
Add To Playlist |
||||||
|
</MudButton> |
||||||
|
</DialogActions> |
||||||
|
</MudDialog> |
||||||
|
|
||||||
|
|
||||||
|
@code { |
||||||
|
private readonly CancellationTokenSource _cts = new(); |
||||||
|
|
||||||
|
[CascadingParameter] |
||||||
|
MudDialogInstance MudDialog { get; set; } |
||||||
|
|
||||||
|
[Parameter] |
||||||
|
public string EntityType { get; set; } |
||||||
|
|
||||||
|
[Parameter] |
||||||
|
public string EntityName { get; set; } |
||||||
|
|
||||||
|
[Parameter] |
||||||
|
public string DetailText { get; set; } |
||||||
|
|
||||||
|
[Parameter] |
||||||
|
public string DetailHighlight { get; set; } |
||||||
|
|
||||||
|
private readonly List<PlaylistGroupViewModel> _playlistGroups = []; |
||||||
|
private readonly List<PlaylistViewModel> _playlists = []; |
||||||
|
|
||||||
|
private PlaylistGroupViewModel _selectedPlaylistGroup; |
||||||
|
private PlaylistViewModel _selectedPlaylist; |
||||||
|
|
||||||
|
private record DummyModel; |
||||||
|
|
||||||
|
private readonly DummyModel _dummyModel = new(); |
||||||
|
|
||||||
|
public void Dispose() |
||||||
|
{ |
||||||
|
_cts.Cancel(); |
||||||
|
_cts.Dispose(); |
||||||
|
} |
||||||
|
|
||||||
|
private bool CanSubmit() => _selectedPlaylist != null; |
||||||
|
|
||||||
|
protected override async Task OnParametersSetAsync() |
||||||
|
{ |
||||||
|
_playlistGroups.AddRange(await Mediator.Send(new GetAllPlaylistGroups(), _cts.Token)); |
||||||
|
|
||||||
|
if (MemoryCache.TryGetValue("AddToPlaylistDialog.SelectedPlaylistGroupId", out int groupId)) |
||||||
|
{ |
||||||
|
_selectedPlaylistGroup = _playlistGroups.SingleOrDefault(pg => pg.Id == groupId); |
||||||
|
if (_selectedPlaylistGroup is not null) |
||||||
|
{ |
||||||
|
await UpdatePlaylistGroupItems(_selectedPlaylistGroup); |
||||||
|
if (MemoryCache.TryGetValue("AddToPlaylistDialog.SelectedPlaylistId", out int id)) |
||||||
|
{ |
||||||
|
_selectedPlaylist = _playlists.SingleOrDefault(c => c.Id == id); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private string FormatText() => $"Select the playlist to add the {EntityType} {EntityName}"; |
||||||
|
|
||||||
|
private async Task UpdatePlaylistGroupItems(PlaylistGroupViewModel playlistGroup) |
||||||
|
{ |
||||||
|
_selectedPlaylistGroup = playlistGroup; |
||||||
|
|
||||||
|
_playlists.Clear(); |
||||||
|
_playlists.AddRange(await Mediator.Send(new GetPlaylistsByPlaylistGroupId(playlistGroup.Id), _cts.Token)); |
||||||
|
} |
||||||
|
|
||||||
|
private async Task Submit() |
||||||
|
{ |
||||||
|
if (!CanSubmit()) |
||||||
|
{ |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
await Task.CompletedTask; |
||||||
|
|
||||||
|
MemoryCache.Set("AddToPlaylistDialog.SelectedPlaylistGroupId", _selectedPlaylistGroup.Id); |
||||||
|
MemoryCache.Set("AddToPlaylistDialog.SelectedPlaylistId", _selectedPlaylist.Id); |
||||||
|
MudDialog.Close(DialogResult.Ok(_selectedPlaylist)); |
||||||
|
} |
||||||
|
|
||||||
|
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(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
Loading…
Reference in new issue