From 202ae33e374e573e7b5f0f5f2a6854c98c4c32eb Mon Sep 17 00:00:00 2001 From: Jason Dove <1695733+jasongdove@users.noreply.github.com> Date: Sun, 28 Apr 2024 06:33:57 -0500 Subject: [PATCH] add individual media items to playlists (#1692) * add movies to playlists * add search results to playlist * update changelog --- CHANGELOG.md | 6 + .../Commands/AddItemsToPlaylist.cs | 15 ++ .../Commands/AddItemsToPlaylistHandler.cs | 120 +++++++++++++++ .../Commands/AddMovieToPlaylist.cs | 5 + .../Commands/AddMovieToPlaylistHandler.cs | 63 ++++++++ .../Commands/ReplacePlaylistItemsHandler.cs | 12 ++ .../MediaCollections/Mapper.cs | 7 +- .../Queries/GetPlaylistItemsHandler.cs | 25 ++++ ErsatzTV.Application/MediaItems/Mapper.cs | 71 +++++++++ .../ProgramSchedules/Mapper.cs | 12 ++ .../ProgramScheduleItemDurationViewModel.cs | 2 + .../ProgramScheduleItemFloodViewModel.cs | 2 + .../ProgramScheduleItemMultipleViewModel.cs | 2 + .../ProgramScheduleItemOneViewModel.cs | 2 + .../ProgramScheduleItemViewModel.cs | 3 + .../Queries/GetProgramScheduleItemsHandler.cs | 1 + .../Search/Queries/SearchMovies.cs | 5 + .../Search/Queries/SearchMoviesHandler.cs | 32 ++++ ErsatzTV.Core/Scheduling/CollectionKey.cs | 30 ++++ .../Scheduling/PlaylistEnumerator.cs | 77 +++++----- .../Scheduling/SingleMediaItemEnumerator.cs | 25 ++++ .../Repositories/MediaCollectionRepository.cs | 96 +++++++++++- ErsatzTV/Pages/Movie.razor | 23 +++ ErsatzTV/Pages/MultiSelectBase.cs | 66 ++++++++- ErsatzTV/Pages/PlaylistEditor.razor | 57 +++++++- ErsatzTV/Pages/ScheduleItemsEditor.razor | 26 +++- ErsatzTV/Pages/Search.razor | 32 ++++ ErsatzTV/Shared/AddToPlaylistDialog.razor | 137 ++++++++++++++++++ .../ViewModels/PlaylistItemEditViewModel.cs | 25 +++- .../ProgramScheduleItemEditViewModel.cs | 1 + 30 files changed, 932 insertions(+), 48 deletions(-) create mode 100644 ErsatzTV.Application/MediaCollections/Commands/AddItemsToPlaylist.cs create mode 100644 ErsatzTV.Application/MediaCollections/Commands/AddItemsToPlaylistHandler.cs create mode 100644 ErsatzTV.Application/MediaCollections/Commands/AddMovieToPlaylist.cs create mode 100644 ErsatzTV.Application/MediaCollections/Commands/AddMovieToPlaylistHandler.cs create mode 100644 ErsatzTV.Application/Search/Queries/SearchMovies.cs create mode 100644 ErsatzTV.Application/Search/Queries/SearchMoviesHandler.cs create mode 100644 ErsatzTV.Core/Scheduling/SingleMediaItemEnumerator.cs create mode 100644 ErsatzTV/Shared/AddToPlaylistDialog.razor diff --git a/CHANGELOG.md b/CHANGELOG.md index 287aa42db..233f1af27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - TV Shows - TV Seasons - Artists + - Movies + - Episodes + - Music Videos + - Other Videos + - Songs + - Images - Playlists can be added to schedules as a schedule item - Each time through the playlist, one item will be scheduled from each playlist item - NB: This does not mean every collection will always schedule one item; the normal flood playout restrictions like duration and fixed start times still apply here diff --git a/ErsatzTV.Application/MediaCollections/Commands/AddItemsToPlaylist.cs b/ErsatzTV.Application/MediaCollections/Commands/AddItemsToPlaylist.cs new file mode 100644 index 000000000..1f71fc0a9 --- /dev/null +++ b/ErsatzTV.Application/MediaCollections/Commands/AddItemsToPlaylist.cs @@ -0,0 +1,15 @@ +using ErsatzTV.Core; + +namespace ErsatzTV.Application.MediaCollections; + +public record AddItemsToPlaylist( + int PlaylistId, + List MovieIds, + List ShowIds, + List SeasonIds, + List EpisodeIds, + List ArtistIds, + List MusicVideoIds, + List OtherVideoIds, + List SongIds, + List ImageIds) : IRequest>; diff --git a/ErsatzTV.Application/MediaCollections/Commands/AddItemsToPlaylistHandler.cs b/ErsatzTV.Application/MediaCollections/Commands/AddItemsToPlaylistHandler.cs new file mode 100644 index 000000000..8f6b808c8 --- /dev/null +++ b/ErsatzTV.Application/MediaCollections/Commands/AddItemsToPlaylistHandler.cs @@ -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> +{ + private readonly IDbContextFactory _dbContextFactory; + private readonly IMovieRepository _movieRepository; + private readonly ITelevisionRepository _televisionRepository; + + public AddItemsToPlaylistHandler( + IDbContextFactory dbContextFactory, + IMovieRepository movieRepository, + ITelevisionRepository televisionRepository) + { + _dbContextFactory = dbContextFactory; + _movieRepository = movieRepository; + _televisionRepository = televisionRepository; + } + + public async Task> Handle(AddItemsToPlaylist request, CancellationToken cancellationToken) + { + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + Validation validation = await Validate(dbContext, request); + return await validation.Apply(c => ApplyAddItemsRequest(dbContext, c, request)); + } + + private static async Task ApplyAddItemsRequest( + TvContext dbContext, + Playlist playlist, + AddItemsToPlaylist request) + { + var allItems = new Dictionary> + { + { 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 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> 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> PlaylistMustExist( + TvContext dbContext, + AddItemsToPlaylist request) => + dbContext.Playlists + .Include(c => c.Items) + .SelectOneAsync(c => c.Id, c => c.Id == request.PlaylistId) + .Map(o => o.ToValidation("Playlist does not exist.")); + + private Task> ValidateMovies(AddItemsToPlaylist request) => + _movieRepository.AllMoviesExist(request.MovieIds) + .Map(Optional) + .Filter(v => v == true) + .MapT(_ => Unit.Default) + .Map(v => v.ToValidation("Movie does not exist")); + + private Task> ValidateShows(AddItemsToPlaylist request) => + _televisionRepository.AllShowsExist(request.ShowIds) + .Map(Optional) + .Filter(v => v == true) + .MapT(_ => Unit.Default) + .Map(v => v.ToValidation("Show does not exist")); + + private Task> ValidateSeasons(AddItemsToPlaylist request) => + _televisionRepository.AllSeasonsExist(request.SeasonIds) + .Map(Optional) + .Filter(v => v == true) + .MapT(_ => Unit.Default) + .Map(v => v.ToValidation("Season does not exist")); + + private Task> ValidateEpisodes(AddItemsToPlaylist request) => + _televisionRepository.AllEpisodesExist(request.EpisodeIds) + .Map(Optional) + .Filter(v => v == true) + .MapT(_ => Unit.Default) + .Map(v => v.ToValidation("Episode does not exist")); +} diff --git a/ErsatzTV.Application/MediaCollections/Commands/AddMovieToPlaylist.cs b/ErsatzTV.Application/MediaCollections/Commands/AddMovieToPlaylist.cs new file mode 100644 index 000000000..e55adbe61 --- /dev/null +++ b/ErsatzTV.Application/MediaCollections/Commands/AddMovieToPlaylist.cs @@ -0,0 +1,5 @@ +using ErsatzTV.Core; + +namespace ErsatzTV.Application.MediaCollections; + +public record AddMovieToPlaylist(int PlaylistId, int MovieId) : IRequest>; diff --git a/ErsatzTV.Application/MediaCollections/Commands/AddMovieToPlaylistHandler.cs b/ErsatzTV.Application/MediaCollections/Commands/AddMovieToPlaylistHandler.cs new file mode 100644 index 000000000..a4cf96d1e --- /dev/null +++ b/ErsatzTV.Application/MediaCollections/Commands/AddMovieToPlaylistHandler.cs @@ -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 dbContextFactory) + : IRequestHandler> +{ + public async Task> Handle( + AddMovieToPlaylist request, + CancellationToken cancellationToken) + { + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + Validation validation = await Validate(dbContext, request); + return await validation.Apply(parameters => ApplyAddMovieRequest(dbContext, parameters)); + } + + private static async Task 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> Validate( + TvContext dbContext, + AddMovieToPlaylist request) => + (await PlaylistMustExist(dbContext, request), await ValidateMovie(dbContext, request)) + .Apply((collection, episode) => new Parameters(collection, episode)); + + private static Task> PlaylistMustExist( + TvContext dbContext, + AddMovieToPlaylist request) => + dbContext.Playlists + .Include(c => c.Items) + .SelectOneAsync(c => c.Id, c => c.Id == request.PlaylistId) + .Map(o => o.ToValidation("Playlist does not exist.")); + + private static Task> ValidateMovie( + TvContext dbContext, + AddMovieToPlaylist request) => + dbContext.Movies + .SelectOneAsync(m => m.Id, e => e.Id == request.MovieId) + .Map(o => o.ToValidation("Movie does not exist")); + + private sealed record Parameters(Playlist Playlist, Movie Movie); +} diff --git a/ErsatzTV.Application/MediaCollections/Commands/ReplacePlaylistItemsHandler.cs b/ErsatzTV.Application/MediaCollections/Commands/ReplacePlaylistItemsHandler.cs index 9f5eacb54..75cb647bb 100644 --- a/ErsatzTV.Application/MediaCollections/Commands/ReplacePlaylistItemsHandler.cs +++ b/ErsatzTV.Application/MediaCollections/Commands/ReplacePlaylistItemsHandler.cs @@ -107,6 +107,18 @@ public class ReplacePlaylistItemsHandler(IDbContextFactory dbContextF return BaseError.New("[SmartCollection] is required for collection type 'SmartCollection'"); } + break; + case ProgramScheduleItemCollectionType.Movie: + case ProgramScheduleItemCollectionType.Episode: + case ProgramScheduleItemCollectionType.MusicVideo: + case ProgramScheduleItemCollectionType.OtherVideo: + case ProgramScheduleItemCollectionType.Song: + case ProgramScheduleItemCollectionType.Image: + if (item.MediaItemId is null) + { + return BaseError.New($"[MediaItem] is required for type '{item.CollectionType}'"); + } + break; case ProgramScheduleItemCollectionType.FakeCollection: default: diff --git a/ErsatzTV.Application/MediaCollections/Mapper.cs b/ErsatzTV.Application/MediaCollections/Mapper.cs index ff2d0e400..7072230fe 100644 --- a/ErsatzTV.Application/MediaCollections/Mapper.cs +++ b/ErsatzTV.Application/MediaCollections/Mapper.cs @@ -69,7 +69,12 @@ internal static class Mapper Show show => MediaItems.Mapper.ProjectToViewModel(show), Season season => MediaItems.Mapper.ProjectToViewModel(season), Artist artist => MediaItems.Mapper.ProjectToViewModel(artist), - // TODO: other items? + Movie movie => MediaItems.Mapper.ProjectToViewModel(movie), + Episode episode => MediaItems.Mapper.ProjectToViewModel(episode), + MusicVideo musicVideo => MediaItems.Mapper.ProjectToViewModel(musicVideo), + OtherVideo otherVideo => MediaItems.Mapper.ProjectToViewModel(otherVideo), + Song song => MediaItems.Mapper.ProjectToViewModel(song), + Image image => MediaItems.Mapper.ProjectToViewModel(image), _ => null }, playlistItem.PlaybackOrder, diff --git a/ErsatzTV.Application/MediaCollections/Queries/GetPlaylistItemsHandler.cs b/ErsatzTV.Application/MediaCollections/Queries/GetPlaylistItemsHandler.cs index 16793f955..a406bd5fb 100644 --- a/ErsatzTV.Application/MediaCollections/Queries/GetPlaylistItemsHandler.cs +++ b/ErsatzTV.Application/MediaCollections/Queries/GetPlaylistItemsHandler.cs @@ -30,6 +30,31 @@ public class GetPlaylistItemsHandler(IDbContextFactory dbContextFacto .Include(i => i.MediaItem) .ThenInclude(i => (i as Artist).ArtistMetadata) .ThenInclude(am => am.Artwork) + .Include(i => i.MediaItem) + .ThenInclude(i => (i as Movie).MovieMetadata) + .ThenInclude(mm => mm.Artwork) + .Include(i => i.MediaItem) + .ThenInclude(i => (i as Episode).EpisodeMetadata) + .ThenInclude(mm => mm.Artwork) + .Include(i => i.MediaItem) + .ThenInclude(i => (i as Episode).Season) + .ThenInclude(s => s.Show) + .ThenInclude(s => s.ShowMetadata) + .Include(i => i.MediaItem) + .ThenInclude(i => (i as MusicVideo).MusicVideoMetadata) + .ThenInclude(mm => mm.Artwork) + .Include(i => i.MediaItem) + .ThenInclude(i => (i as MusicVideo).Artist) + .ThenInclude(a => a.ArtistMetadata) + .Include(i => i.MediaItem) + .ThenInclude(i => (i as OtherVideo).OtherVideoMetadata) + .ThenInclude(mm => mm.Artwork) + .Include(i => i.MediaItem) + .ThenInclude(i => (i as Song).SongMetadata) + .ThenInclude(mm => mm.Artwork) + .Include(i => i.MediaItem) + .ThenInclude(i => (i as Image).ImageMetadata) + .ThenInclude(mm => mm.Artwork) .ToListAsync(cancellationToken); if (allItems.All(bi => bi.IncludeInProgramGuide == false)) diff --git a/ErsatzTV.Application/MediaItems/Mapper.cs b/ErsatzTV.Application/MediaItems/Mapper.cs index f087ce0f7..3528a43ac 100644 --- a/ErsatzTV.Application/MediaItems/Mapper.cs +++ b/ErsatzTV.Application/MediaItems/Mapper.cs @@ -14,6 +14,41 @@ internal static class Mapper internal static NamedMediaItemViewModel ProjectToViewModel(Artist artist) => new(artist.Id, artist.ArtistMetadata.HeadOrNone().Match(am => am.Title, () => "???")); + internal static NamedMediaItemViewModel ProjectToViewModel(Movie movie) => + new(movie.Id, MovieTitle(movie)); + + internal static NamedMediaItemViewModel ProjectToViewModel(Episode episode) => + new(episode.Id, EpisodeTitle(episode)); + + internal static NamedMediaItemViewModel ProjectToViewModel(MusicVideo musicVideo) => + new(musicVideo.Id, MusicVideoTitle(musicVideo)); + + internal static NamedMediaItemViewModel ProjectToViewModel(OtherVideo otherVideo) => + new(otherVideo.Id, otherVideo.OtherVideoMetadata.HeadOrNone().Match(ov => ov.Title, () => "???")); + + internal static NamedMediaItemViewModel ProjectToViewModel(Song song) => + new(song.Id, SongTitle(song)); + + internal static NamedMediaItemViewModel ProjectToViewModel(Image image) => + new(image.Id, image.ImageMetadata.HeadOrNone().Match(i => i.Title, () => "???")); + + private static string MovieTitle(Movie movie) + { + var title = "???"; + var year = "???"; + + foreach (MovieMetadata movieMetadata in movie.MovieMetadata.HeadOrNone()) + { + title = movieMetadata.Title; + foreach (int y in Optional(movieMetadata.Year)) + { + year = y.ToString(CultureInfo.InvariantCulture); + } + } + + return $"{title} ({year})"; + } + private static string ShowTitle(Season season) { var title = "???"; @@ -33,4 +68,40 @@ internal static class Mapper private static string SeasonDescription(Season season) => season.SeasonNumber == 0 ? "Specials" : $"Season {season.SeasonNumber}"; + + private static string EpisodeTitle(Episode e) + { + string showTitle = e.Season.Show.ShowMetadata.HeadOrNone() + .Map(sm => $"{sm.Title} - ").IfNone(string.Empty); + var episodeNumbers = e.EpisodeMetadata.Map(em => em.EpisodeNumber).ToList(); + var episodeTitles = e.EpisodeMetadata.Map(em => em.Title).ToList(); + if (episodeNumbers.Count == 0 || episodeTitles.Count == 0) + { + return "[unknown episode]"; + } + + var numbersString = $"e{string.Join('e', episodeNumbers.Map(n => $"{n:00}"))}"; + var titlesString = $"{string.Join('/', episodeTitles)}"; + + return $"{showTitle}s{e.Season.SeasonNumber:00}{numbersString} - {titlesString}"; + } + + private static string MusicVideoTitle(MusicVideo mv) + { + string artistName = mv.Artist.ArtistMetadata.HeadOrNone() + .Map(am => $"{am.Title} - ").IfNone(string.Empty); + return mv.MusicVideoMetadata.HeadOrNone() + .Map(mvm => $"{artistName}{mvm.Title}") + .IfNone("[unknown music video]"); + } + + private static string SongTitle(Song s) + { + string songArtist = s.SongMetadata.HeadOrNone() + .Map(sm => $"{string.Join(", ", sm.Artists)} - ") + .IfNone(string.Empty); + return s.SongMetadata.HeadOrNone() + .Map(sm => $"{songArtist}{sm.Title ?? string.Empty}") + .IfNone("[unknown song]"); + } } diff --git a/ErsatzTV.Application/ProgramSchedules/Mapper.cs b/ErsatzTV.Application/ProgramSchedules/Mapper.cs index bff19f543..b4735696c 100644 --- a/ErsatzTV.Application/ProgramSchedules/Mapper.cs +++ b/ErsatzTV.Application/ProgramSchedules/Mapper.cs @@ -32,6 +32,9 @@ internal static class Mapper duration.SmartCollection != null ? MediaCollections.Mapper.ProjectToViewModel(duration.SmartCollection) : null, + duration.Playlist != null + ? MediaCollections.Mapper.ProjectToViewModel(duration.Playlist) + : null, duration.MediaItem switch { Show show => MediaItems.Mapper.ProjectToViewModel(show), @@ -84,6 +87,9 @@ internal static class Mapper flood.SmartCollection != null ? MediaCollections.Mapper.ProjectToViewModel(flood.SmartCollection) : null, + flood.Playlist != null + ? MediaCollections.Mapper.ProjectToViewModel(flood.Playlist) + : null, flood.MediaItem switch { Show show => MediaItems.Mapper.ProjectToViewModel(show), @@ -133,6 +139,9 @@ internal static class Mapper multiple.SmartCollection != null ? MediaCollections.Mapper.ProjectToViewModel(multiple.SmartCollection) : null, + multiple.Playlist != null + ? MediaCollections.Mapper.ProjectToViewModel(multiple.Playlist) + : null, multiple.MediaItem switch { Show show => MediaItems.Mapper.ProjectToViewModel(show), @@ -183,6 +192,9 @@ internal static class Mapper one.SmartCollection != null ? MediaCollections.Mapper.ProjectToViewModel(one.SmartCollection) : null, + one.Playlist != null + ? MediaCollections.Mapper.ProjectToViewModel(one.Playlist) + : null, one.MediaItem switch { Show show => MediaItems.Mapper.ProjectToViewModel(show), diff --git a/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemDurationViewModel.cs b/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemDurationViewModel.cs index b8aff90d9..d5bde5dee 100644 --- a/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemDurationViewModel.cs +++ b/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemDurationViewModel.cs @@ -17,6 +17,7 @@ public record ProgramScheduleItemDurationViewModel : ProgramScheduleItemViewMode MediaCollectionViewModel collection, MultiCollectionViewModel multiCollection, SmartCollectionViewModel smartCollection, + PlaylistViewModel playlist, NamedMediaItemViewModel mediaItem, PlaybackOrder playbackOrder, FillWithGroupMode fillWithGroupMode, @@ -44,6 +45,7 @@ public record ProgramScheduleItemDurationViewModel : ProgramScheduleItemViewMode collection, multiCollection, smartCollection, + playlist, mediaItem, playbackOrder, fillWithGroupMode, diff --git a/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemFloodViewModel.cs b/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemFloodViewModel.cs index 8cb384d72..5ec0bfde8 100644 --- a/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemFloodViewModel.cs +++ b/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemFloodViewModel.cs @@ -17,6 +17,7 @@ public record ProgramScheduleItemFloodViewModel : ProgramScheduleItemViewModel MediaCollectionViewModel collection, MultiCollectionViewModel multiCollection, SmartCollectionViewModel smartCollection, + PlaylistViewModel playlist, NamedMediaItemViewModel mediaItem, PlaybackOrder playbackOrder, FillWithGroupMode fillWithGroupMode, @@ -41,6 +42,7 @@ public record ProgramScheduleItemFloodViewModel : ProgramScheduleItemViewModel collection, multiCollection, smartCollection, + playlist, mediaItem, playbackOrder, fillWithGroupMode, diff --git a/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemMultipleViewModel.cs b/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemMultipleViewModel.cs index 1d196a1a0..08006f4b3 100644 --- a/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemMultipleViewModel.cs +++ b/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemMultipleViewModel.cs @@ -17,6 +17,7 @@ public record ProgramScheduleItemMultipleViewModel : ProgramScheduleItemViewMode MediaCollectionViewModel collection, MultiCollectionViewModel multiCollection, SmartCollectionViewModel smartCollection, + PlaylistViewModel playlist, NamedMediaItemViewModel mediaItem, PlaybackOrder playbackOrder, FillWithGroupMode fillWithGroupMode, @@ -42,6 +43,7 @@ public record ProgramScheduleItemMultipleViewModel : ProgramScheduleItemViewMode collection, multiCollection, smartCollection, + playlist, mediaItem, playbackOrder, fillWithGroupMode, diff --git a/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemOneViewModel.cs b/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemOneViewModel.cs index 8ee706ceb..01947b4de 100644 --- a/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemOneViewModel.cs +++ b/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemOneViewModel.cs @@ -17,6 +17,7 @@ public record ProgramScheduleItemOneViewModel : ProgramScheduleItemViewModel MediaCollectionViewModel collection, MultiCollectionViewModel multiCollection, SmartCollectionViewModel smartCollection, + PlaylistViewModel playlist, NamedMediaItemViewModel mediaItem, PlaybackOrder playbackOrder, FillWithGroupMode fillWithGroupMode, @@ -41,6 +42,7 @@ public record ProgramScheduleItemOneViewModel : ProgramScheduleItemViewModel collection, multiCollection, smartCollection, + playlist, mediaItem, playbackOrder, fillWithGroupMode, diff --git a/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemViewModel.cs b/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemViewModel.cs index 8ea074b4e..fca6f13dd 100644 --- a/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemViewModel.cs +++ b/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemViewModel.cs @@ -16,6 +16,7 @@ public abstract record ProgramScheduleItemViewModel( MediaCollectionViewModel Collection, MultiCollectionViewModel MultiCollection, SmartCollectionViewModel SmartCollection, + PlaylistViewModel Playlist, NamedMediaItemViewModel MediaItem, PlaybackOrder PlaybackOrder, FillWithGroupMode FillWithGroupMode, @@ -45,6 +46,8 @@ public abstract record ProgramScheduleItemViewModel( MultiCollection?.Name, ProgramScheduleItemCollectionType.SmartCollection => SmartCollection?.Name, + ProgramScheduleItemCollectionType.Playlist => + Playlist?.Name, _ => string.Empty }; } diff --git a/ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleItemsHandler.cs b/ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleItemsHandler.cs index 3921f2b5e..dd98442ba 100644 --- a/ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleItemsHandler.cs +++ b/ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleItemsHandler.cs @@ -28,6 +28,7 @@ public class GetProgramScheduleItemsHandler : .Include(i => i.Collection) .Include(i => i.MultiCollection) .Include(i => i.SmartCollection) + .Include(i => i.Playlist) .Include(i => i.MediaItem) .ThenInclude(i => (i as Movie).MovieMetadata) .ThenInclude(mm => mm.Artwork) diff --git a/ErsatzTV.Application/Search/Queries/SearchMovies.cs b/ErsatzTV.Application/Search/Queries/SearchMovies.cs new file mode 100644 index 000000000..022af1cb3 --- /dev/null +++ b/ErsatzTV.Application/Search/Queries/SearchMovies.cs @@ -0,0 +1,5 @@ +using ErsatzTV.Application.MediaItems; + +namespace ErsatzTV.Application.Search; + +public record SearchMovies(string Query) : IRequest>; diff --git a/ErsatzTV.Application/Search/Queries/SearchMoviesHandler.cs b/ErsatzTV.Application/Search/Queries/SearchMoviesHandler.cs new file mode 100644 index 000000000..1736c42b5 --- /dev/null +++ b/ErsatzTV.Application/Search/Queries/SearchMoviesHandler.cs @@ -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 dbContextFactory) + : IRequestHandler> +{ + public async Task> 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) : "???")})"); +} diff --git a/ErsatzTV.Core/Scheduling/CollectionKey.cs b/ErsatzTV.Core/Scheduling/CollectionKey.cs index 475b6397b..711f356ca 100644 --- a/ErsatzTV.Core/Scheduling/CollectionKey.cs +++ b/ErsatzTV.Core/Scheduling/CollectionKey.cs @@ -51,6 +51,36 @@ public class CollectionKey : Record { CollectionType = item.CollectionType }, + ProgramScheduleItemCollectionType.Movie => new CollectionKey + { + CollectionType = item.CollectionType, + MediaItemId = item.MediaItemId + }, + ProgramScheduleItemCollectionType.Episode => new CollectionKey + { + CollectionType = item.CollectionType, + MediaItemId = item.MediaItemId + }, + ProgramScheduleItemCollectionType.MusicVideo => new CollectionKey + { + CollectionType = item.CollectionType, + MediaItemId = item.MediaItemId + }, + ProgramScheduleItemCollectionType.OtherVideo => new CollectionKey + { + CollectionType = item.CollectionType, + MediaItemId = item.MediaItemId + }, + ProgramScheduleItemCollectionType.Song => new CollectionKey + { + CollectionType = item.CollectionType, + MediaItemId = item.MediaItemId + }, + ProgramScheduleItemCollectionType.Image => new CollectionKey + { + CollectionType = item.CollectionType, + MediaItemId = item.MediaItemId + }, _ => throw new ArgumentOutOfRangeException(nameof(item)) }; diff --git a/ErsatzTV.Core/Scheduling/PlaylistEnumerator.cs b/ErsatzTV.Core/Scheduling/PlaylistEnumerator.cs index b639d0a35..b316b9312 100644 --- a/ErsatzTV.Core/Scheduling/PlaylistEnumerator.cs +++ b/ErsatzTV.Core/Scheduling/PlaylistEnumerator.cs @@ -45,43 +45,54 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator continue; } - // TODO: sort each of the item lists / or maybe pass into child enumerators? var initState = new CollectionEnumeratorState { Seed = state.Seed, Index = 0 }; - switch (playlistItem.PlaybackOrder) + if (items.Count == 1) { - case PlaybackOrder.Chronological: - enumerator = new ChronologicalMediaCollectionEnumerator(items, initState); - break; - // TODO: fix multi episode shuffle? - case PlaybackOrder.MultiEpisodeShuffle: - case PlaybackOrder.Shuffle: - List i = await PlayoutBuilder.GetGroupedMediaItemsForShuffle( - mediaCollectionRepository, - // TODO: fix this - new ProgramSchedule { KeepMultiPartEpisodesTogether = false }, - items, - CollectionKey.ForPlaylistItem(playlistItem)); - enumerator = new ShuffledMediaCollectionEnumerator(i, initState, cancellationToken); - break; - case PlaybackOrder.ShuffleInOrder: - enumerator = new ShuffleInOrderCollectionEnumerator( - await PlayoutBuilder.GetCollectionItemsForShuffleInOrder(mediaCollectionRepository, CollectionKey.ForPlaylistItem(playlistItem)), - initState, - // TODO: fix this - randomStartPoint: false, - cancellationToken); - break; - case PlaybackOrder.SeasonEpisode: - // TODO: check random start point? - enumerator = new SeasonEpisodeMediaCollectionEnumerator(items, initState); - break; - case PlaybackOrder.Random: - enumerator = new RandomizedMediaCollectionEnumerator(items, initState); - break; + enumerator = new SingleMediaItemEnumerator(items.Head()); + } + else + { + switch (playlistItem.PlaybackOrder) + { + case PlaybackOrder.Chronological: + enumerator = new ChronologicalMediaCollectionEnumerator(items, initState); + break; + // TODO: fix multi episode shuffle? + case PlaybackOrder.MultiEpisodeShuffle: + case PlaybackOrder.Shuffle: + List i = await PlayoutBuilder.GetGroupedMediaItemsForShuffle( + mediaCollectionRepository, + // TODO: fix this + new ProgramSchedule { KeepMultiPartEpisodesTogether = false }, + items, + CollectionKey.ForPlaylistItem(playlistItem)); + enumerator = new ShuffledMediaCollectionEnumerator(i, initState, cancellationToken); + break; + case PlaybackOrder.ShuffleInOrder: + enumerator = new ShuffleInOrderCollectionEnumerator( + await PlayoutBuilder.GetCollectionItemsForShuffleInOrder( + mediaCollectionRepository, + CollectionKey.ForPlaylistItem(playlistItem)), + initState, + // TODO: fix this + randomStartPoint: false, + cancellationToken); + break; + case PlaybackOrder.SeasonEpisode: + // TODO: check random start point? + enumerator = new SeasonEpisodeMediaCollectionEnumerator(items, initState); + break; + case PlaybackOrder.Random: + enumerator = new RandomizedMediaCollectionEnumerator(items, initState); + break; + } } - enumeratorMap.Add(collectionKey, enumerator); - result._sortedEnumerators.Add(enumerator); + if (enumerator is not null) + { + enumeratorMap.Add(collectionKey, enumerator); + result._sortedEnumerators.Add(enumerator); + } } result.MinimumDuration = playlistItemMap.Values diff --git a/ErsatzTV.Core/Scheduling/SingleMediaItemEnumerator.cs b/ErsatzTV.Core/Scheduling/SingleMediaItemEnumerator.cs new file mode 100644 index 000000000..eafb7ae68 --- /dev/null +++ b/ErsatzTV.Core/Scheduling/SingleMediaItemEnumerator.cs @@ -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 Current => mediaItem; + public Option CurrentIncludeInProgramGuide => Option.None; + + public int Count => 1; + public Option MinimumDuration => mediaItem.GetNonZeroDuration(); + + public void ResetState(CollectionEnumeratorState state) + { + // do nothing + } + + public void MoveNext() + { + // do nothing + } +} diff --git a/ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs index 3928f5209..5e09c2490 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs @@ -100,8 +100,54 @@ public class MediaCollectionRepository : IMediaCollectionRepository } break; + + case ProgramScheduleItemCollectionType.Movie: + foreach (int mediaItemId in Optional(playlistItem.MediaItemId)) + { + mediaItems.AddRange(await GetMovieItems(dbContext, [mediaItemId])); + } - // TODO: other single media item types + break; + + case ProgramScheduleItemCollectionType.Episode: + foreach (int mediaItemId in Optional(playlistItem.MediaItemId)) + { + mediaItems.AddRange(await GetEpisodeItems(dbContext, [mediaItemId])); + } + + break; + + case ProgramScheduleItemCollectionType.MusicVideo: + foreach (int mediaItemId in Optional(playlistItem.MediaItemId)) + { + mediaItems.AddRange(await GetMusicVideoItems(dbContext, [mediaItemId])); + } + + break; + + case ProgramScheduleItemCollectionType.OtherVideo: + foreach (int mediaItemId in Optional(playlistItem.MediaItemId)) + { + mediaItems.AddRange(await GetOtherVideoItems(dbContext, [mediaItemId])); + } + + break; + + case ProgramScheduleItemCollectionType.Song: + foreach (int mediaItemId in Optional(playlistItem.MediaItemId)) + { + mediaItems.AddRange(await GetSongItems(dbContext, [mediaItemId])); + } + + break; + + case ProgramScheduleItemCollectionType.Image: + foreach (int mediaItemId in Optional(playlistItem.MediaItemId)) + { + mediaItems.AddRange(await GetImageItems(dbContext, [mediaItemId])); + } + + break; } result.Add(playlistItem, mediaItems); @@ -402,7 +448,53 @@ public class MediaCollectionRepository : IMediaCollectionRepository break; - // TODO: other single media item types + case ProgramScheduleItemCollectionType.Movie: + foreach (int mediaItemId in Optional(playlistItem.MediaItemId)) + { + result.AddRange(await GetMovieItems(dbContext, [mediaItemId])); + } + + break; + + case ProgramScheduleItemCollectionType.Episode: + foreach (int mediaItemId in Optional(playlistItem.MediaItemId)) + { + result.AddRange(await GetEpisodeItems(dbContext, [mediaItemId])); + } + + break; + + case ProgramScheduleItemCollectionType.MusicVideo: + foreach (int mediaItemId in Optional(playlistItem.MediaItemId)) + { + result.AddRange(await GetMusicVideoItems(dbContext, [mediaItemId])); + } + + break; + + case ProgramScheduleItemCollectionType.OtherVideo: + foreach (int mediaItemId in Optional(playlistItem.MediaItemId)) + { + result.AddRange(await GetOtherVideoItems(dbContext, [mediaItemId])); + } + + break; + + case ProgramScheduleItemCollectionType.Song: + foreach (int mediaItemId in Optional(playlistItem.MediaItemId)) + { + result.AddRange(await GetSongItems(dbContext, [mediaItemId])); + } + + break; + + case ProgramScheduleItemCollectionType.Image: + foreach (int mediaItemId in Optional(playlistItem.MediaItemId)) + { + result.AddRange(await GetImageItems(dbContext, [mediaItemId])); + } + + break; } } diff --git a/ErsatzTV/Pages/Movie.razor b/ErsatzTV/Pages/Movie.razor index e42c30ce9..abc67553f 100644 --- a/ErsatzTV/Pages/Movie.razor +++ b/ErsatzTV/Pages/Movie.razor @@ -78,6 +78,15 @@ Add To Collection +
+ + Add To Playlist + +
("Add To Playlist", parameters, options); + DialogResult result = await dialog.Result; + if (!result.Canceled && result.Data is PlaylistViewModel playlist) + { + await Mediator.Send(new AddMovieToPlaylist(playlist.Id, MovieId), _cts.Token); + NavigationManager.NavigateTo($"media/playlists/{playlist.Id}"); + } + } + private async Task ShowInfo() { Either maybeInfo = await Mediator.Send(new GetMediaItemInfo(MovieId)); diff --git a/ErsatzTV/Pages/MultiSelectBase.cs b/ErsatzTV/Pages/MultiSelectBase.cs index 157d8dd4a..5daabe4d2 100644 --- a/ErsatzTV/Pages/MultiSelectBase.cs +++ b/ErsatzTV/Pages/MultiSelectBase.cs @@ -91,6 +91,17 @@ public class MultiSelectBase : FragmentNavigationBase SelectedItems.OfType().Map(ov => ov.OtherVideoId).ToList(), SelectedItems.OfType().Map(s => s.SongId).ToList(), SelectedItems.OfType().Map(i => i.ImageId).ToList()); + + protected Task AddSelectionToPlaylist() => AddItemsToPlaylist( + SelectedItems.OfType().Map(m => m.MovieId).ToList(), + SelectedItems.OfType().Map(s => s.TelevisionShowId).ToList(), + SelectedItems.OfType().Map(s => s.TelevisionSeasonId).ToList(), + SelectedItems.OfType().Map(e => e.EpisodeId).ToList(), + SelectedItems.OfType().Map(a => a.ArtistId).ToList(), + SelectedItems.OfType().Map(mv => mv.MusicVideoId).ToList(), + SelectedItems.OfType().Map(ov => ov.OtherVideoId).ToList(), + SelectedItems.OfType().Map(s => s.SongId).ToList(), + SelectedItems.OfType().Map(i => i.ImageId).ToList()); protected async Task AddItemsToCollection( List movieIds, @@ -105,7 +116,7 @@ public class MultiSelectBase : FragmentNavigationBase string entityName = "selected items") { int count = movieIds.Count + showIds.Count + seasonIds.Count + episodeIds.Count + artistIds.Count + - musicVideoIds.Count + otherVideoIds.Count + songIds.Count; + musicVideoIds.Count + otherVideoIds.Count + songIds.Count + imageIds.Count; var parameters = new DialogParameters { { "EntityType", count.ToString(CultureInfo.InvariantCulture) }, { "EntityName", entityName } }; @@ -174,4 +185,57 @@ public class MultiSelectBase : FragmentNavigationBase ClearSelection(); } } + + protected async Task AddItemsToPlaylist( + List movieIds, + List showIds, + List seasonIds, + List episodeIds, + List artistIds, + List musicVideoIds, + List otherVideoIds, + List songIds, + List imageIds, + string entityName = "selected items") + { + int count = movieIds.Count + showIds.Count + seasonIds.Count + episodeIds.Count + artistIds.Count + + musicVideoIds.Count + otherVideoIds.Count + songIds.Count + imageIds.Count; + + var parameters = new DialogParameters + { { "EntityType", count.ToString(CultureInfo.InvariantCulture) }, { "EntityName", entityName } }; + var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall }; + + IDialogReference dialog = + await Dialog.ShowAsync("Add To Playlist", parameters, options); + DialogResult result = await dialog.Result; + if (!result.Canceled && result.Data is PlaylistViewModel playlist) + { + var request = new AddItemsToPlaylist( + playlist.Id, + movieIds, + showIds, + seasonIds, + episodeIds, + artistIds, + musicVideoIds, + otherVideoIds, + songIds, + imageIds); + + Either addResult = await Mediator.Send(request, CancellationToken); + addResult.Match( + Left: error => + { + Snackbar.Add($"Unexpected error adding items to playlist: {error.Value}"); + Logger.LogError("Unexpected error adding items to playlist: {Error}", error.Value); + }, + Right: _ => + { + Snackbar.Add( + $"Added {count} items to playlist {playlist.Name}", + Severity.Success); + ClearSelection(); + }); + } + } } diff --git a/ErsatzTV/Pages/PlaylistEditor.razor b/ErsatzTV/Pages/PlaylistEditor.razor index 1c9e9bc0c..b848ab266 100644 --- a/ErsatzTV/Pages/PlaylistEditor.razor +++ b/ErsatzTV/Pages/PlaylistEditor.razor @@ -28,19 +28,21 @@ Preview Playlist Playout - + + - Collection + Item Type + Item Playback Order Show In EPG @@ -49,14 +51,19 @@ - + - @context.CollectionName + @context.ItemType + + + + + @context.ItemName - @context.PlaybackOrder + @(context.PlaybackOrder > 0 ? context.PlaybackOrder : "") @@ -100,9 +107,15 @@ Collection Television Show Television Season - @* Artist *@ - @* Multi Collection *@ + Artist + Multi Collection Smart Collection + Movie + Episode + Music Video + Other Video + Song + Image @if (_selectedItem.CollectionType == ProgramScheduleItemCollectionType.Collection) { @@ -184,7 +197,25 @@ } - + @if (_selectedItem.CollectionType == ProgramScheduleItemCollectionType.Movie) + { + + + + Only the first 10 items are shown + + + + } + + @switch (_selectedItem.CollectionType) { case ProgramScheduleItemCollectionType.MultiCollection: @@ -353,6 +384,16 @@ return await Mediator.Send(new SearchArtists(value), _cts.Token); } + private async Task> SearchMovies(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return new List(); + } + + return await Mediator.Send(new SearchMovies(value), _cts.Token); + } + private static PlaylistItemEditViewModel ProjectToEditViewModel(PlaylistItemViewModel item) => new() { diff --git a/ErsatzTV/Pages/ScheduleItemsEditor.razor b/ErsatzTV/Pages/ScheduleItemsEditor.razor index 8045d4faf..a31e02b4d 100644 --- a/ErsatzTV/Pages/ScheduleItemsEditor.razor +++ b/ErsatzTV/Pages/ScheduleItemsEditor.razor @@ -13,7 +13,11 @@ @inject IMediator Mediator - + @_schedule.Name Items @@ -188,6 +192,7 @@ { @foreach (PlaylistGroupViewModel playlistGroup in _playlistGroups) @@ -398,6 +403,7 @@ private readonly List _playlistGroups = []; private readonly List _playlists = []; + private PlaylistGroupViewModel _selectedPlaylistGroup; private ProgramScheduleItemEditViewModel _selectedItem; public void Dispose() @@ -440,7 +446,7 @@ if (_schedule.Items.Count == 1) { - _selectedItem = _schedule.Items.Head(); + await SelectedItemChanged(_schedule.Items.Head()); } } } @@ -507,6 +513,8 @@ private async Task UpdatePlaylistGroupItems(PlaylistGroupViewModel playlistGroup) { + _selectedPlaylistGroup = playlistGroup; + _playlists.Clear(); _playlists.AddRange(await Mediator.Send(new GetPlaylistsByPlaylistGroupId(playlistGroup.Id), _cts.Token)); } @@ -524,6 +532,7 @@ Collection = item.Collection, MultiCollection = item.MultiCollection, SmartCollection = item.SmartCollection, + Playlist = item.Playlist, MediaItem = item.MediaItem, PlaybackOrder = item.PlaybackOrder, FillWithGroupMode = item.FillWithGroupMode, @@ -635,4 +644,17 @@ () => NavigationManager.NavigateTo("/schedules")); } + private async Task SelectedItemChanged(ProgramScheduleItemEditViewModel vm) + { + _selectedItem = vm; + + foreach (int playlistGroupId in Optional(_selectedItem.Playlist?.Id)) + { + foreach (PlaylistGroupViewModel group in Optional(_playlistGroups.Find(g => g.Id == playlistGroupId))) + { + _selectedPlaylistGroup = group; + await UpdatePlaylistGroupItems(group); + } + } + } } \ No newline at end of file diff --git a/ErsatzTV/Pages/Search.razor b/ErsatzTV/Pages/Search.razor index b0705ced9..392c82173 100644 --- a/ErsatzTV/Pages/Search.razor +++ b/ErsatzTV/Pages/Search.razor @@ -21,6 +21,13 @@ OnClick="@(_ => AddSelectionToCollection())"> Add To Collection + + Add To Playlist + + + + Add All + + + + + + + + + @foreach (PlaylistGroupViewModel playlistGroup in _playlistGroups) + { + @playlistGroup.Name + } + + + @foreach (PlaylistViewModel playlist in _playlists) + { + @playlist.Name + } + + + + + Cancel + + Add To Playlist + + + + + +@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 _playlistGroups = []; + private readonly List _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(); + } + } + +} \ No newline at end of file diff --git a/ErsatzTV/ViewModels/PlaylistItemEditViewModel.cs b/ErsatzTV/ViewModels/PlaylistItemEditViewModel.cs index 84d9793f8..8441dac0d 100644 --- a/ErsatzTV/ViewModels/PlaylistItemEditViewModel.cs +++ b/ErsatzTV/ViewModels/PlaylistItemEditViewModel.cs @@ -45,7 +45,24 @@ public class PlaylistItemEditViewModel : INotifyPropertyChanged public SmartCollectionViewModel SmartCollection { get; set; } public NamedMediaItemViewModel MediaItem { get; set; } - public string CollectionName => CollectionType switch + public string ItemType => CollectionType switch + { + ProgramScheduleItemCollectionType.Collection => "Collection", + ProgramScheduleItemCollectionType.TelevisionShow => "Show", + ProgramScheduleItemCollectionType.TelevisionSeason => "Season", + ProgramScheduleItemCollectionType.Artist => "Artist", + ProgramScheduleItemCollectionType.MultiCollection => "Multi-Collection", + ProgramScheduleItemCollectionType.SmartCollection => "Smart Collection", + ProgramScheduleItemCollectionType.Movie => "Movie", + ProgramScheduleItemCollectionType.Episode => "Episode", + ProgramScheduleItemCollectionType.MusicVideo => "Music Video", + ProgramScheduleItemCollectionType.OtherVideo => "Other Video", + ProgramScheduleItemCollectionType.Song => "Song", + ProgramScheduleItemCollectionType.Image => "Image", + _ => string.Empty + }; + + public string ItemName => CollectionType switch { ProgramScheduleItemCollectionType.Collection => Collection?.Name, ProgramScheduleItemCollectionType.TelevisionShow => MediaItem?.Name, @@ -53,6 +70,12 @@ public class PlaylistItemEditViewModel : INotifyPropertyChanged ProgramScheduleItemCollectionType.Artist => MediaItem?.Name, ProgramScheduleItemCollectionType.MultiCollection => MultiCollection?.Name, ProgramScheduleItemCollectionType.SmartCollection => SmartCollection?.Name, + ProgramScheduleItemCollectionType.Movie => MediaItem?.Name, + ProgramScheduleItemCollectionType.Episode => MediaItem?.Name, + ProgramScheduleItemCollectionType.MusicVideo => MediaItem?.Name, + ProgramScheduleItemCollectionType.OtherVideo => MediaItem?.Name, + ProgramScheduleItemCollectionType.Song => MediaItem?.Name, + ProgramScheduleItemCollectionType.Image => MediaItem?.Name, _ => string.Empty }; diff --git a/ErsatzTV/ViewModels/ProgramScheduleItemEditViewModel.cs b/ErsatzTV/ViewModels/ProgramScheduleItemEditViewModel.cs index 957756d6c..d6ac38b20 100644 --- a/ErsatzTV/ViewModels/ProgramScheduleItemEditViewModel.cs +++ b/ErsatzTV/ViewModels/ProgramScheduleItemEditViewModel.cs @@ -89,6 +89,7 @@ public class ProgramScheduleItemEditViewModel : INotifyPropertyChanged ProgramScheduleItemCollectionType.Artist => MediaItem?.Name, ProgramScheduleItemCollectionType.MultiCollection => MultiCollection?.Name, ProgramScheduleItemCollectionType.SmartCollection => SmartCollection?.Name, + ProgramScheduleItemCollectionType.Playlist => Playlist?.Name, _ => string.Empty };