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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Application.MediaItems; |
||||
|
||||
namespace ErsatzTV.Application.Search; |
||||
|
||||
public record SearchMovies(string Query) : IRequest<List<NamedMediaItemViewModel>>; |
@ -0,0 +1,32 @@
@@ -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 @@
@@ -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 @@
@@ -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