Browse Source

add individual media items to playlists (#1692)

* add movies to playlists

* add search results to playlist

* update changelog
pull/1693/head
Jason Dove 1 year ago committed by GitHub
parent
commit
202ae33e37
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 6
      CHANGELOG.md
  2. 15
      ErsatzTV.Application/MediaCollections/Commands/AddItemsToPlaylist.cs
  3. 120
      ErsatzTV.Application/MediaCollections/Commands/AddItemsToPlaylistHandler.cs
  4. 5
      ErsatzTV.Application/MediaCollections/Commands/AddMovieToPlaylist.cs
  5. 63
      ErsatzTV.Application/MediaCollections/Commands/AddMovieToPlaylistHandler.cs
  6. 12
      ErsatzTV.Application/MediaCollections/Commands/ReplacePlaylistItemsHandler.cs
  7. 7
      ErsatzTV.Application/MediaCollections/Mapper.cs
  8. 25
      ErsatzTV.Application/MediaCollections/Queries/GetPlaylistItemsHandler.cs
  9. 71
      ErsatzTV.Application/MediaItems/Mapper.cs
  10. 12
      ErsatzTV.Application/ProgramSchedules/Mapper.cs
  11. 2
      ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemDurationViewModel.cs
  12. 2
      ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemFloodViewModel.cs
  13. 2
      ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemMultipleViewModel.cs
  14. 2
      ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemOneViewModel.cs
  15. 3
      ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemViewModel.cs
  16. 1
      ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleItemsHandler.cs
  17. 5
      ErsatzTV.Application/Search/Queries/SearchMovies.cs
  18. 32
      ErsatzTV.Application/Search/Queries/SearchMoviesHandler.cs
  19. 30
      ErsatzTV.Core/Scheduling/CollectionKey.cs
  20. 77
      ErsatzTV.Core/Scheduling/PlaylistEnumerator.cs
  21. 25
      ErsatzTV.Core/Scheduling/SingleMediaItemEnumerator.cs
  22. 96
      ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs
  23. 23
      ErsatzTV/Pages/Movie.razor
  24. 66
      ErsatzTV/Pages/MultiSelectBase.cs
  25. 57
      ErsatzTV/Pages/PlaylistEditor.razor
  26. 26
      ErsatzTV/Pages/ScheduleItemsEditor.razor
  27. 32
      ErsatzTV/Pages/Search.razor
  28. 137
      ErsatzTV/Shared/AddToPlaylistDialog.razor
  29. 25
      ErsatzTV/ViewModels/PlaylistItemEditViewModel.cs
  30. 1
      ErsatzTV/ViewModels/ProgramScheduleItemEditViewModel.cs

6
CHANGELOG.md

@ -43,6 +43,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -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

15
ErsatzTV.Application/MediaCollections/Commands/AddItemsToPlaylist.cs

@ -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>>;

120
ErsatzTV.Application/MediaCollections/Commands/AddItemsToPlaylistHandler.cs

@ -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"));
}

5
ErsatzTV.Application/MediaCollections/Commands/AddMovieToPlaylist.cs

@ -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>>;

63
ErsatzTV.Application/MediaCollections/Commands/AddMovieToPlaylistHandler.cs

@ -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);
}

12
ErsatzTV.Application/MediaCollections/Commands/ReplacePlaylistItemsHandler.cs

@ -107,6 +107,18 @@ public class ReplacePlaylistItemsHandler(IDbContextFactory<TvContext> dbContextF @@ -107,6 +107,18 @@ public class ReplacePlaylistItemsHandler(IDbContextFactory<TvContext> 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:

7
ErsatzTV.Application/MediaCollections/Mapper.cs

@ -69,7 +69,12 @@ internal static class Mapper @@ -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,

25
ErsatzTV.Application/MediaCollections/Queries/GetPlaylistItemsHandler.cs

@ -30,6 +30,31 @@ public class GetPlaylistItemsHandler(IDbContextFactory<TvContext> dbContextFacto @@ -30,6 +30,31 @@ public class GetPlaylistItemsHandler(IDbContextFactory<TvContext> 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))

71
ErsatzTV.Application/MediaItems/Mapper.cs

@ -14,6 +14,41 @@ internal static class Mapper @@ -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 @@ -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]");
}
}

12
ErsatzTV.Application/ProgramSchedules/Mapper.cs

@ -32,6 +32,9 @@ internal static class Mapper @@ -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 @@ -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 @@ -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 @@ -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),

2
ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemDurationViewModel.cs

@ -17,6 +17,7 @@ public record ProgramScheduleItemDurationViewModel : ProgramScheduleItemViewMode @@ -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 @@ -44,6 +45,7 @@ public record ProgramScheduleItemDurationViewModel : ProgramScheduleItemViewMode
collection,
multiCollection,
smartCollection,
playlist,
mediaItem,
playbackOrder,
fillWithGroupMode,

2
ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemFloodViewModel.cs

@ -17,6 +17,7 @@ public record ProgramScheduleItemFloodViewModel : ProgramScheduleItemViewModel @@ -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 @@ -41,6 +42,7 @@ public record ProgramScheduleItemFloodViewModel : ProgramScheduleItemViewModel
collection,
multiCollection,
smartCollection,
playlist,
mediaItem,
playbackOrder,
fillWithGroupMode,

2
ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemMultipleViewModel.cs

@ -17,6 +17,7 @@ public record ProgramScheduleItemMultipleViewModel : ProgramScheduleItemViewMode @@ -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 @@ -42,6 +43,7 @@ public record ProgramScheduleItemMultipleViewModel : ProgramScheduleItemViewMode
collection,
multiCollection,
smartCollection,
playlist,
mediaItem,
playbackOrder,
fillWithGroupMode,

2
ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemOneViewModel.cs

@ -17,6 +17,7 @@ public record ProgramScheduleItemOneViewModel : ProgramScheduleItemViewModel @@ -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 @@ -41,6 +42,7 @@ public record ProgramScheduleItemOneViewModel : ProgramScheduleItemViewModel
collection,
multiCollection,
smartCollection,
playlist,
mediaItem,
playbackOrder,
fillWithGroupMode,

3
ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemViewModel.cs

@ -16,6 +16,7 @@ public abstract record ProgramScheduleItemViewModel( @@ -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( @@ -45,6 +46,8 @@ public abstract record ProgramScheduleItemViewModel(
MultiCollection?.Name,
ProgramScheduleItemCollectionType.SmartCollection =>
SmartCollection?.Name,
ProgramScheduleItemCollectionType.Playlist =>
Playlist?.Name,
_ => string.Empty
};
}

1
ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleItemsHandler.cs

@ -28,6 +28,7 @@ public class GetProgramScheduleItemsHandler : @@ -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)

5
ErsatzTV.Application/Search/Queries/SearchMovies.cs

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

32
ErsatzTV.Application/Search/Queries/SearchMoviesHandler.cs

@ -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) : "???")})");
}

30
ErsatzTV.Core/Scheduling/CollectionKey.cs

@ -51,6 +51,36 @@ public class CollectionKey : Record<CollectionKey> @@ -51,6 +51,36 @@ public class CollectionKey : Record<CollectionKey>
{
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))
};

77
ErsatzTV.Core/Scheduling/PlaylistEnumerator.cs

@ -45,43 +45,54 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator @@ -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<GroupedMediaItem> 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<GroupedMediaItem> 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

25
ErsatzTV.Core/Scheduling/SingleMediaItemEnumerator.cs

@ -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
}
}

96
ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs

@ -100,8 +100,54 @@ public class MediaCollectionRepository : IMediaCollectionRepository @@ -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]));
}
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;
// TODO: other single media item types
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 @@ -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;
}
}

23
ErsatzTV/Pages/Movie.razor

@ -78,6 +78,15 @@ @@ -78,6 +78,15 @@
Add To Collection
</MudButton>
</div>
<div>
<MudButton Class="mb-6"
Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add"
OnClick="@AddToPlaylist">
Add To Playlist
</MudButton>
</div>
<div>
<MudButton Variant="Variant.Filled"
Color="Color.Secondary"
@ -294,6 +303,20 @@ @@ -294,6 +303,20 @@
}
}
private async Task AddToPlaylist()
{
var parameters = new DialogParameters { { "EntityType", "movie" }, { "EntityName", _movie.Title } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<AddToPlaylistDialog>("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<BaseError, MediaItemInfo> maybeInfo = await Mediator.Send(new GetMediaItemInfo(MovieId));

66
ErsatzTV/Pages/MultiSelectBase.cs

@ -91,6 +91,17 @@ public class MultiSelectBase<T> : FragmentNavigationBase @@ -91,6 +91,17 @@ public class MultiSelectBase<T> : FragmentNavigationBase
SelectedItems.OfType<OtherVideoCardViewModel>().Map(ov => ov.OtherVideoId).ToList(),
SelectedItems.OfType<SongCardViewModel>().Map(s => s.SongId).ToList(),
SelectedItems.OfType<ImageCardViewModel>().Map(i => i.ImageId).ToList());
protected Task AddSelectionToPlaylist() => AddItemsToPlaylist(
SelectedItems.OfType<MovieCardViewModel>().Map(m => m.MovieId).ToList(),
SelectedItems.OfType<TelevisionShowCardViewModel>().Map(s => s.TelevisionShowId).ToList(),
SelectedItems.OfType<TelevisionSeasonCardViewModel>().Map(s => s.TelevisionSeasonId).ToList(),
SelectedItems.OfType<TelevisionEpisodeCardViewModel>().Map(e => e.EpisodeId).ToList(),
SelectedItems.OfType<ArtistCardViewModel>().Map(a => a.ArtistId).ToList(),
SelectedItems.OfType<MusicVideoCardViewModel>().Map(mv => mv.MusicVideoId).ToList(),
SelectedItems.OfType<OtherVideoCardViewModel>().Map(ov => ov.OtherVideoId).ToList(),
SelectedItems.OfType<SongCardViewModel>().Map(s => s.SongId).ToList(),
SelectedItems.OfType<ImageCardViewModel>().Map(i => i.ImageId).ToList());
protected async Task AddItemsToCollection(
List<int> movieIds,
@ -105,7 +116,7 @@ public class MultiSelectBase<T> : FragmentNavigationBase @@ -105,7 +116,7 @@ public class MultiSelectBase<T> : 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<T> : FragmentNavigationBase @@ -174,4 +185,57 @@ public class MultiSelectBase<T> : FragmentNavigationBase
ClearSelection();
}
}
protected async Task AddItemsToPlaylist(
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,
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<AddToPlaylistDialog>("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<BaseError, Unit> 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();
});
}
}
}

57
ErsatzTV/Pages/PlaylistEditor.razor

@ -28,19 +28,21 @@ @@ -28,19 +28,21 @@
Preview Playlist Playout
</MudButton>
<MudGrid>
<MudItem xs="8">
<MudItem xs="12">
<MudTable Class="mt-6" Hover="true" Items="_playlist.Items.OrderBy(i => i.Index)" Dense="true" @bind-SelectedItem="_selectedItem">
<ColGroup>
<col/>
<col/>
<col/>
<col/>
<col style="width: 60px;"/>
<col style="width: 60px;"/>
<col style="width: 60px;"/>
<col style="width: 60px;"/>
</ColGroup>
<HeaderContent>
<MudTh>Collection</MudTh>
<MudTh>Item Type</MudTh>
<MudTh>Item</MudTh>
<MudTh>Playback Order</MudTh>
<MudTh>Show In EPG</MudTh>
<MudTh/>
@ -49,14 +51,19 @@ @@ -49,14 +51,19 @@
<MudTh/>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Collection">
<MudTd DataLabel="Item Type">
<MudText Typo="@(context == _selectedItem ? Typo.subtitle2 : Typo.body2)">
@context.ItemType
</MudText>
</MudTd>
<MudTd DataLabel="Item">
<MudText Typo="@(context == _selectedItem ? Typo.subtitle2 : Typo.body2)">
@context.CollectionName
@context.ItemName
</MudText>
</MudTd>
<MudTd DataLabel="Playback Order">
<MudText Typo="@(context == _selectedItem ? Typo.subtitle2 : Typo.body2)">
@context.PlaybackOrder
@(context.PlaybackOrder > 0 ? context.PlaybackOrder : "")
</MudText>
</MudTd>
<MudTd>
@ -100,9 +107,15 @@ @@ -100,9 +107,15 @@
<MudSelectItem Value="ProgramScheduleItemCollectionType.Collection">Collection</MudSelectItem>
<MudSelectItem Value="ProgramScheduleItemCollectionType.TelevisionShow">Television Show</MudSelectItem>
<MudSelectItem Value="ProgramScheduleItemCollectionType.TelevisionSeason">Television Season</MudSelectItem>
@* <MudSelectItem Value="ProgramScheduleItemCollectionType.Artist">Artist</MudSelectItem> *@
@* <MudSelectItem Value="ProgramScheduleItemCollectionType.MultiCollection">Multi Collection</MudSelectItem> *@
<MudSelectItem Value="ProgramScheduleItemCollectionType.Artist">Artist</MudSelectItem>
<MudSelectItem Value="ProgramScheduleItemCollectionType.MultiCollection">Multi Collection</MudSelectItem>
<MudSelectItem Value="ProgramScheduleItemCollectionType.SmartCollection">Smart Collection</MudSelectItem>
<MudSelectItem Value="ProgramScheduleItemCollectionType.Movie">Movie</MudSelectItem>
<MudSelectItem Value="ProgramScheduleItemCollectionType.Episode">Episode</MudSelectItem>
<MudSelectItem Value="ProgramScheduleItemCollectionType.MusicVideo">Music Video</MudSelectItem>
<MudSelectItem Value="ProgramScheduleItemCollectionType.OtherVideo">Other Video</MudSelectItem>
<MudSelectItem Value="ProgramScheduleItemCollectionType.Song">Song</MudSelectItem>
<MudSelectItem Value="ProgramScheduleItemCollectionType.Image">Image</MudSelectItem>
</MudSelect>
@if (_selectedItem.CollectionType == ProgramScheduleItemCollectionType.Collection)
{
@ -184,7 +197,25 @@ @@ -184,7 +197,25 @@
</MudAutocomplete>
}
<MudSelect Class="mt-3" Label="Playback Order" @bind-Value="@_selectedItem.PlaybackOrder" For="@(() => _selectedItem.PlaybackOrder)">
@if (_selectedItem.CollectionType == ProgramScheduleItemCollectionType.Movie)
{
<MudAutocomplete Class="mt-3" T="NamedMediaItemViewModel" Label="Movie"
@bind-Value="_selectedItem.MediaItem" SearchFunc="@SearchMovies"
ToStringFunc="@(c => c?.Name)" Placeholder="Type to search..."
MaxItems="10">
<MoreItemsTemplate>
<MudText Align="Align.Center" Class="px-4 py-1">
Only the first 10 items are shown
</MudText>
</MoreItemsTemplate>
</MudAutocomplete>
}
<MudSelect Class="mt-3"
Label="Playback Order"
@bind-Value="@_selectedItem.PlaybackOrder"
For="@(() => _selectedItem.PlaybackOrder)"
Disabled="@(_selectedItem.CollectionType is ProgramScheduleItemCollectionType.Movie or ProgramScheduleItemCollectionType.Episode or ProgramScheduleItemCollectionType.MusicVideo or ProgramScheduleItemCollectionType.OtherVideo or ProgramScheduleItemCollectionType.Song or ProgramScheduleItemCollectionType.Image)">
@switch (_selectedItem.CollectionType)
{
case ProgramScheduleItemCollectionType.MultiCollection:
@ -353,6 +384,16 @@ @@ -353,6 +384,16 @@
return await Mediator.Send(new SearchArtists(value), _cts.Token);
}
private async Task<IEnumerable<NamedMediaItemViewModel>> SearchMovies(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return new List<NamedMediaItemViewModel>();
}
return await Mediator.Send(new SearchMovies(value), _cts.Token);
}
private static PlaylistItemEditViewModel ProjectToEditViewModel(PlaylistItemViewModel item) =>
new()
{

26
ErsatzTV/Pages/ScheduleItemsEditor.razor

@ -13,7 +13,11 @@ @@ -13,7 +13,11 @@
@inject IMediator Mediator
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudTable Hover="true" Items="_schedule?.Items?.OrderBy(i => i.Index)" Dense="true" @bind-SelectedItem="_selectedItem">
<MudTable T="ProgramScheduleItemEditViewModel"
Hover="true"
Items="_schedule?.Items?.OrderBy(i => i.Index)"
Dense="true"
SelectedItemChanged="@(vm => SelectedItemChanged(vm))">
<ToolBarContent>
<MudText Typo="Typo.h6">@_schedule.Name Items</MudText>
</ToolBarContent>
@ -188,6 +192,7 @@ @@ -188,6 +192,7 @@
{
<MudSelect Class="mt-3"
T="PlaylistGroupViewModel"
Value="@_selectedPlaylistGroup"
Label="Playlist Group"
ValueChanged="@(vm => UpdatePlaylistGroupItems(vm))">
@foreach (PlaylistGroupViewModel playlistGroup in _playlistGroups)
@ -398,6 +403,7 @@ @@ -398,6 +403,7 @@
private readonly List<PlaylistGroupViewModel> _playlistGroups = [];
private readonly List<PlaylistViewModel> _playlists = [];
private PlaylistGroupViewModel _selectedPlaylistGroup;
private ProgramScheduleItemEditViewModel _selectedItem;
public void Dispose()
@ -440,7 +446,7 @@ @@ -440,7 +446,7 @@
if (_schedule.Items.Count == 1)
{
_selectedItem = _schedule.Items.Head();
await SelectedItemChanged(_schedule.Items.Head());
}
}
}
@ -507,6 +513,8 @@ @@ -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 @@ @@ -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 @@ @@ -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);
}
}
}
}

32
ErsatzTV/Pages/Search.razor

@ -21,6 +21,13 @@ @@ -21,6 +21,13 @@
OnClick="@(_ => AddSelectionToCollection())">
Add To Collection
</MudButton>
<MudButton Class="ml-3"
Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.PlaylistAdd"
OnClick="@(_ => AddSelectionToPlaylist())">
Add To Playlist
</MudButton>
<MudButton Class="ml-3"
Variant="Variant.Filled"
Color="Color.Secondary"
@ -87,6 +94,15 @@ @@ -87,6 +94,15 @@
Add All
</MudButton>
</MudTooltip>
<MudTooltip Text="Add All To Playlist">
<MudButton Class="ml-3"
Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.PlaylistAdd"
OnClick="@AddAllToPlaylist">
Add All
</MudButton>
</MudTooltip>
<MudTooltip Text="Save As Smart Collection">
<MudButton Class="ml-3" Variant="Variant.Filled"
Color="Color.Secondary"
@ -765,6 +781,22 @@ @@ -765,6 +781,22 @@
"search results");
}
private async Task AddAllToPlaylist(MouseEventArgs _)
{
SearchResultAllItemsViewModel results = await Mediator.Send(new QuerySearchIndexAllItems(_query), CancellationToken);
await AddItemsToPlaylist(
results.MovieIds,
results.ShowIds,
results.SeasonIds,
results.EpisodeIds,
results.ArtistIds,
results.MusicVideoIds,
results.OtherVideoIds,
results.SongIds,
results.ImageIds,
"search results");
}
private async Task SaveAsSmartCollection(MouseEventArgs _)
{
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };

137
ErsatzTV/Shared/AddToPlaylistDialog.razor

@ -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();
}
}
}

25
ErsatzTV/ViewModels/PlaylistItemEditViewModel.cs

@ -45,7 +45,24 @@ public class PlaylistItemEditViewModel : INotifyPropertyChanged @@ -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 @@ -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
};

1
ErsatzTV/ViewModels/ProgramScheduleItemEditViewModel.cs

@ -89,6 +89,7 @@ public class ProgramScheduleItemEditViewModel : INotifyPropertyChanged @@ -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
};

Loading…
Cancel
Save