Browse Source

add playlists (#1690)

* update dependencies

* add playlists

* add playlist support to schedules

* playout builder (flood) supports playlists

* update changelog
pull/1691/head
Jason Dove 1 year ago committed by GitHub
parent
commit
706a2d14a2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 12
      CHANGELOG.md
  2. 5
      ErsatzTV.Application/MediaCollections/Commands/CreatePlaylist.cs
  3. 5
      ErsatzTV.Application/MediaCollections/Commands/CreatePlaylistGroup.cs
  4. 33
      ErsatzTV.Application/MediaCollections/Commands/CreatePlaylistGroupHandler.cs
  5. 52
      ErsatzTV.Application/MediaCollections/Commands/CreatePlaylistHandler.cs
  6. 5
      ErsatzTV.Application/MediaCollections/Commands/DeletePlaylist.cs
  7. 5
      ErsatzTV.Application/MediaCollections/Commands/DeletePlaylistGroup.cs
  8. 29
      ErsatzTV.Application/MediaCollections/Commands/DeletePlaylistGroupHandler.cs
  9. 29
      ErsatzTV.Application/MediaCollections/Commands/DeletePlaylistHandler.cs
  10. 5
      ErsatzTV.Application/MediaCollections/Commands/PreviewPlaylistPlayout.cs
  11. 119
      ErsatzTV.Application/MediaCollections/Commands/PreviewPlaylistPlayoutHandler.cs
  12. 13
      ErsatzTV.Application/MediaCollections/Commands/ReplacePlaylistItem.cs
  13. 6
      ErsatzTV.Application/MediaCollections/Commands/ReplacePlaylistItems.cs
  14. 118
      ErsatzTV.Application/MediaCollections/Commands/ReplacePlaylistItemsHandler.cs
  15. 29
      ErsatzTV.Application/MediaCollections/Mapper.cs
  16. 3
      ErsatzTV.Application/MediaCollections/PlaylistGroupViewModel.cs
  17. 15
      ErsatzTV.Application/MediaCollections/PlaylistItemViewModel.cs
  18. 3
      ErsatzTV.Application/MediaCollections/PlaylistViewModel.cs
  19. 3
      ErsatzTV.Application/MediaCollections/Queries/GetAllPlaylistGroups.cs
  20. 21
      ErsatzTV.Application/MediaCollections/Queries/GetAllPlaylistGroupsHandler.cs
  21. 3
      ErsatzTV.Application/MediaCollections/Queries/GetPlaylistById.cs
  22. 17
      ErsatzTV.Application/MediaCollections/Queries/GetPlaylistByIdHandler.cs
  23. 3
      ErsatzTV.Application/MediaCollections/Queries/GetPlaylistItems.cs
  24. 45
      ErsatzTV.Application/MediaCollections/Queries/GetPlaylistItemsHandler.cs
  25. 3
      ErsatzTV.Application/MediaCollections/Queries/GetPlaylistsByPlaylistGroupId.cs
  26. 21
      ErsatzTV.Application/MediaCollections/Queries/GetPlaylistsByPlaylistGroupIdHandler.cs
  27. 1
      ErsatzTV.Application/ProgramSchedules/Commands/AddProgramScheduleItem.cs
  28. 1
      ErsatzTV.Application/ProgramSchedules/Commands/IProgramScheduleItemRequest.cs
  29. 11
      ErsatzTV.Application/ProgramSchedules/Commands/ProgramScheduleItemCommandBase.cs
  30. 1
      ErsatzTV.Application/ProgramSchedules/Commands/ReplaceProgramScheduleItems.cs
  31. 6
      ErsatzTV.Application/ProgramSchedules/Commands/ReplaceProgramScheduleItemsHandler.cs
  32. 4
      ErsatzTV.Core.Tests/Fakes/FakeMediaCollectionRepository.cs
  33. 11
      ErsatzTV.Core/Domain/Collection/Playlist.cs
  34. 8
      ErsatzTV.Core/Domain/Collection/PlaylistGroup.cs
  35. 20
      ErsatzTV.Core/Domain/Collection/PlaylistItem.cs
  36. 2
      ErsatzTV.Core/Domain/PlayoutProgramScheduleAnchor.cs
  37. 2
      ErsatzTV.Core/Domain/ProgramScheduleItem.cs
  38. 8
      ErsatzTV.Core/Domain/ProgramScheduleItemCollectionType.cs
  39. 2
      ErsatzTV.Core/Interfaces/Repositories/IMediaCollectionRepository.cs
  40. 47
      ErsatzTV.Core/Scheduling/CollectionKey.cs
  41. 5
      ErsatzTV.Core/Scheduling/CustomOrderCollectionEnumerator.cs
  42. 4
      ErsatzTV.Core/Scheduling/MediaItemsForCollection.cs
  43. 136
      ErsatzTV.Core/Scheduling/PlaylistEnumerator.cs
  44. 41
      ErsatzTV.Core/Scheduling/PlayoutBuilder.cs
  45. 2
      ErsatzTV.Infrastructure.MySql/ErsatzTV.Infrastructure.MySql.csproj
  46. 5747
      ErsatzTV.Infrastructure.MySql/Migrations/20240427142504_Add_Playlist.Designer.cs
  47. 154
      ErsatzTV.Infrastructure.MySql/Migrations/20240427142504_Add_Playlist.cs
  48. 5771
      ErsatzTV.Infrastructure.MySql/Migrations/20240427174059_Add_ProgramScheduleItemPlaylist.Designer.cs
  49. 80
      ErsatzTV.Infrastructure.MySql/Migrations/20240427174059_Add_ProgramScheduleItemPlaylist.cs
  50. 177
      ErsatzTV.Infrastructure.MySql/Migrations/TvContextModelSnapshot.cs
  51. 4
      ErsatzTV.Infrastructure.Sqlite/ErsatzTV.Infrastructure.Sqlite.csproj
  52. 5580
      ErsatzTV.Infrastructure.Sqlite/Migrations/20240427104030_Add_Playlist.Designer.cs
  53. 146
      ErsatzTV.Infrastructure.Sqlite/Migrations/20240427104030_Add_Playlist.cs
  54. 5586
      ErsatzTV.Infrastructure.Sqlite/Migrations/20240427135444_Add_MorePlaylistProperties.Designer.cs
  55. 40
      ErsatzTV.Infrastructure.Sqlite/Migrations/20240427135444_Add_MorePlaylistProperties.cs
  56. 5610
      ErsatzTV.Infrastructure.Sqlite/Migrations/20240427163730_Add_ProgramScheduleItemPlaylist.Designer.cs
  57. 80
      ErsatzTV.Infrastructure.Sqlite/Migrations/20240427163730_Add_ProgramScheduleItemPlaylist.cs
  58. 171
      ErsatzTV.Infrastructure.Sqlite/Migrations/TvContextModelSnapshot.cs
  59. 22
      ErsatzTV.Infrastructure/Data/Configurations/Collection/PlaylistConfiguration.cs
  60. 21
      ErsatzTV.Infrastructure/Data/Configurations/Collection/PlaylistGroupConfiguration.cs
  61. 37
      ErsatzTV.Infrastructure/Data/Configurations/Collection/PlaylistItemConfiguration.cs
  62. 6
      ErsatzTV.Infrastructure/Data/Configurations/PlayoutProgramScheduleAnchorConfiguration.cs
  63. 6
      ErsatzTV.Infrastructure/Data/Configurations/ProgramScheduleItemConfiguration.cs
  64. 161
      ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs
  65. 3
      ErsatzTV.Infrastructure/Data/TvContext.cs
  66. 12
      ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj
  67. 20
      ErsatzTV.Infrastructure/Search/ElasticSearchIndex.cs
  68. 18
      ErsatzTV/ErsatzTV.csproj
  69. 2
      ErsatzTV/Pages/Artist.razor
  70. 465
      ErsatzTV/Pages/PlaylistEditor.razor
  71. 211
      ErsatzTV/Pages/Playlists.razor
  72. 41
      ErsatzTV/Pages/ScheduleItemsEditor.razor
  73. 2
      ErsatzTV/Pages/TelevisionEpisodeList.razor
  74. 2
      ErsatzTV/Pages/TelevisionSeasonList.razor
  75. 1
      ErsatzTV/Shared/MainLayout.razor
  76. 79
      ErsatzTV/ViewModels/PlaylistItemEditViewModel.cs
  77. 7
      ErsatzTV/ViewModels/PlaylistItemsEditViewModel.cs
  78. 40
      ErsatzTV/ViewModels/PlaylistTreeItemViewModel.cs
  79. 1
      ErsatzTV/ViewModels/ProgramScheduleItemEditViewModel.cs

12
CHANGELOG.md

@ -35,6 +35,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -35,6 +35,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- `Qsv` / `hevc`
- `None` / `h264`
- `None` / `hevc`
- Add *experimental* list type `Playlist`
- Playlists contain an ordered list of:
- Collections
- Multi-Collections
- Smart Collections
- TV Shows
- TV Seasons
- Artists
- 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
- Playlist items with fewer media items will be re-shuffled (if applicable) before those with more media items
### Fixed
- Fix some cases of 404s from Plex when files were replaced and scanning the library from ETV didn't help

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

@ -0,0 +1,5 @@ @@ -0,0 +1,5 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.MediaCollections;
public record CreatePlaylist(int PlaylistGroupId, string Name) : IRequest<Either<BaseError, PlaylistViewModel>>;

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

@ -0,0 +1,5 @@ @@ -0,0 +1,5 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.MediaCollections;
public record CreatePlaylistGroup(string Name) : IRequest<Either<BaseError, PlaylistGroupViewModel>>;

33
ErsatzTV.Application/MediaCollections/Commands/CreatePlaylistGroupHandler.cs

@ -0,0 +1,33 @@ @@ -0,0 +1,33 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.MediaCollections;
public class CreatePlaylistGroupHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<CreatePlaylistGroup, Either<BaseError, PlaylistGroupViewModel>>
{
public async Task<Either<BaseError, PlaylistGroupViewModel>> Handle(
CreatePlaylistGroup request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, PlaylistGroup> validation = await Validate(request);
return await validation.Apply(profile => PersistPlaylistGroup(dbContext, profile));
}
private static async Task<PlaylistGroupViewModel> PersistPlaylistGroup(TvContext dbContext, PlaylistGroup playlistGroup)
{
await dbContext.PlaylistGroups.AddAsync(playlistGroup);
await dbContext.SaveChangesAsync();
return Mapper.ProjectToViewModel(playlistGroup);
}
private static Task<Validation<BaseError, PlaylistGroup>> Validate(CreatePlaylistGroup request) =>
Task.FromResult(ValidateName(request).Map(name => new PlaylistGroup { Name = name, Playlists = [] }));
private static Validation<BaseError, string> ValidateName(CreatePlaylistGroup createPlaylistGroup) =>
createPlaylistGroup.NotEmpty(x => x.Name)
.Bind(_ => createPlaylistGroup.NotLongerThan(50)(x => x.Name));
}

52
ErsatzTV.Application/MediaCollections/Commands/CreatePlaylistHandler.cs

@ -0,0 +1,52 @@ @@ -0,0 +1,52 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.MediaCollections;
public class CreatePlaylistHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<CreatePlaylist, Either<BaseError, PlaylistViewModel>>
{
public async Task<Either<BaseError, PlaylistViewModel>> Handle(
CreatePlaylist request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Playlist> validation = await Validate(dbContext, request);
return await validation.Apply(profile => PersistPlaylist(dbContext, profile));
}
private static async Task<PlaylistViewModel> PersistPlaylist(TvContext dbContext, Playlist playlist)
{
await dbContext.Playlists.AddAsync(playlist);
await dbContext.SaveChangesAsync();
return Mapper.ProjectToViewModel(playlist);
}
private static async Task<Validation<BaseError, Playlist>> Validate(TvContext dbContext, CreatePlaylist request) =>
await ValidatePlaylistName(dbContext, request).MapT(
name => new Playlist
{
PlaylistGroupId = request.PlaylistGroupId,
Name = name
});
private static async Task<Validation<BaseError, string>> ValidatePlaylistName(
TvContext dbContext,
CreatePlaylist request)
{
if (request.Name.Length > 50)
{
return BaseError.New($"Playlist name \"{request.Name}\" is invalid");
}
Option<Playlist> maybeExisting = await dbContext.Playlists
.FirstOrDefaultAsync(r => r.PlaylistGroupId == request.PlaylistGroupId && r.Name == request.Name)
.Map(Optional);
return maybeExisting.IsSome
? BaseError.New($"A playlist named \"{request.Name}\" already exists in that playlist group")
: Success<BaseError, string>(request.Name);
}
}

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

@ -0,0 +1,5 @@ @@ -0,0 +1,5 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.MediaCollections;
public record DeletePlaylist(int PlaylistId) : IRequest<Option<BaseError>>;

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

@ -0,0 +1,5 @@ @@ -0,0 +1,5 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.MediaCollections;
public record DeletePlaylistGroup(int PlaylistGroupId) : IRequest<Option<BaseError>>;

29
ErsatzTV.Application/MediaCollections/Commands/DeletePlaylistGroupHandler.cs

@ -0,0 +1,29 @@ @@ -0,0 +1,29 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.MediaCollections;
public class DeletePlaylistGroupHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<DeletePlaylistGroup, Option<BaseError>>
{
public async Task<Option<BaseError>> Handle(DeletePlaylistGroup request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<PlaylistGroup> maybePlaylistGroup = await dbContext.PlaylistGroups
.SelectOneAsync(p => p.Id, p => p.Id == request.PlaylistGroupId);
foreach (PlaylistGroup playlistGroup in maybePlaylistGroup)
{
dbContext.PlaylistGroups.Remove(playlistGroup);
await dbContext.SaveChangesAsync(cancellationToken);
}
return maybePlaylistGroup.Match(
_ => Option<BaseError>.None,
() => BaseError.New($"PlaylistGroup {request.PlaylistGroupId} does not exist."));
}
}

29
ErsatzTV.Application/MediaCollections/Commands/DeletePlaylistHandler.cs

@ -0,0 +1,29 @@ @@ -0,0 +1,29 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.MediaCollections;
public class DeletePlaylistHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<DeletePlaylist, Option<BaseError>>
{
public async Task<Option<BaseError>> Handle(DeletePlaylist request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<Playlist> maybePlaylist = await dbContext.Playlists
.SelectOneAsync(p => p.Id, p => p.Id == request.PlaylistId);
foreach (Playlist playlist in maybePlaylist)
{
dbContext.Playlists.Remove(playlist);
await dbContext.SaveChangesAsync(cancellationToken);
}
return maybePlaylist.Match(
_ => Option<BaseError>.None,
() => BaseError.New($"Playlist {request.PlaylistId} does not exist."));
}
}

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

@ -0,0 +1,5 @@ @@ -0,0 +1,5 @@
using ErsatzTV.Application.Scheduling;
namespace ErsatzTV.Application.MediaCollections;
public record PreviewPlaylistPlayout(ReplacePlaylistItems Data) : IRequest<List<PlayoutItemPreviewViewModel>>;

119
ErsatzTV.Application/MediaCollections/Commands/PreviewPlaylistPlayoutHandler.cs

@ -0,0 +1,119 @@ @@ -0,0 +1,119 @@
using System.Diagnostics.CodeAnalysis;
using ErsatzTV.Application.Scheduling;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.Interfaces.Scheduling;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.MediaCollections;
[SuppressMessage("ReSharper", "SuggestBaseTypeForParameterInConstructor")]
public class PreviewPlaylistPlayoutHandler(
IDbContextFactory<TvContext> dbContextFactory,
IBlockPlayoutPreviewBuilder blockPlayoutBuilder)
: IRequestHandler<PreviewPlaylistPlayout, List<PlayoutItemPreviewViewModel>>
{
public async Task<List<PlayoutItemPreviewViewModel>> Handle(
PreviewPlaylistPlayout request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
// TODO: consider using flood playout instead
var template = new Template
{
Items = []
};
template.Items.Add(
new TemplateItem
{
Block = MapToBlock(request.Data),
StartTime = TimeSpan.Zero,
Template = template
});
var playout = new Playout
{
Channel = new Channel(Guid.NewGuid())
{
Number = "1",
Name = "Playlist Preview"
},
Items = [],
ProgramSchedulePlayoutType = ProgramSchedulePlayoutType.Block,
PlayoutHistory = [],
Templates =
[
new PlayoutTemplate
{
DaysOfWeek = PlayoutTemplate.AllDaysOfWeek(),
DaysOfMonth = PlayoutTemplate.AllDaysOfMonth(),
MonthsOfYear = PlayoutTemplate.AllMonthsOfYear(),
Template = template
}
]
};
await blockPlayoutBuilder.Build(playout, PlayoutBuildMode.Reset, cancellationToken);
// load playout item details for title
foreach (PlayoutItem playoutItem in playout.Items)
{
Option<MediaItem> maybeMediaItem = await dbContext.MediaItems
.AsNoTracking()
.Include(mi => (mi as Movie).MovieMetadata)
.Include(mi => (mi as Movie).MediaVersions)
.Include(mi => (mi as MusicVideo).MusicVideoMetadata)
.Include(mi => (mi as MusicVideo).MediaVersions)
.Include(mi => (mi as MusicVideo).Artist)
.ThenInclude(mm => mm.ArtistMetadata)
.Include(mi => (mi as Episode).EpisodeMetadata)
.Include(mi => (mi as Episode).MediaVersions)
.Include(mi => (mi as Episode).Season)
.ThenInclude(s => s.SeasonMetadata)
.Include(mi => (mi as Episode).Season.Show)
.ThenInclude(s => s.ShowMetadata)
.Include(mi => (mi as OtherVideo).OtherVideoMetadata)
.Include(mi => (mi as OtherVideo).MediaVersions)
.Include(mi => (mi as Song).SongMetadata)
.Include(mi => (mi as Song).MediaVersions)
.Include(mi => (mi as Image).ImageMetadata)
.Include(mi => (mi as Image).MediaVersions)
.SelectOneAsync(mi => mi.Id, mi => mi.Id == playoutItem.MediaItemId);
foreach (MediaItem mediaItem in maybeMediaItem)
{
playoutItem.MediaItem = mediaItem;
}
}
return playout.Items.Map(Scheduling.Mapper.ProjectToViewModel).ToList();
}
private static Block MapToBlock(ReplacePlaylistItems request) =>
new()
{
Name = request.Name,
Minutes = 6 * 60,
StopScheduling = BlockStopScheduling.AfterDurationEnd,
Items = request.Items.Map(MapToBlockItem).ToList()
};
private static BlockItem MapToBlockItem(int id, ReplacePlaylistItem request) =>
new()
{
Id = id,
Index = request.Index,
CollectionType = request.CollectionType,
CollectionId = request.CollectionId,
MultiCollectionId = request.MultiCollectionId,
SmartCollectionId = request.SmartCollectionId,
MediaItemId = request.MediaItemId,
PlaybackOrder = request.PlaybackOrder
};
}

13
ErsatzTV.Application/MediaCollections/Commands/ReplacePlaylistItem.cs

@ -0,0 +1,13 @@ @@ -0,0 +1,13 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCollections;
public record ReplacePlaylistItem(
int Index,
ProgramScheduleItemCollectionType CollectionType,
int? CollectionId,
int? MultiCollectionId,
int? SmartCollectionId,
int? MediaItemId,
PlaybackOrder PlaybackOrder,
bool IncludeInProgramGuide);

6
ErsatzTV.Application/MediaCollections/Commands/ReplacePlaylistItems.cs

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.MediaCollections;
public record ReplacePlaylistItems(int PlaylistId, string Name, List<ReplacePlaylistItem> Items)
: IRequest<Either<BaseError, List<PlaylistItemViewModel>>>;

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

@ -0,0 +1,118 @@ @@ -0,0 +1,118 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.MediaCollections;
public class ReplacePlaylistItemsHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<ReplacePlaylistItems, Either<BaseError, List<PlaylistItemViewModel>>>
{
public async Task<Either<BaseError, List<PlaylistItemViewModel>>> Handle(
ReplacePlaylistItems request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Playlist> validation = await Validate(dbContext, request);
return await validation.Apply(ps => Persist(dbContext, request, ps));
}
private static async Task<List<PlaylistItemViewModel>> Persist(
TvContext dbContext,
ReplacePlaylistItems request,
Playlist playlist)
{
playlist.Name = request.Name;
//playlist.DateUpdated = DateTime.UtcNow;
dbContext.RemoveRange(playlist.Items);
playlist.Items = request.Items.Map(i => BuildItem(playlist, i.Index, i)).ToList();
await dbContext.SaveChangesAsync();
return playlist.Items.Map(Mapper.ProjectToViewModel).ToList();
}
private static PlaylistItem BuildItem(Playlist playlist, int index, ReplacePlaylistItem item) =>
new()
{
PlaylistId = playlist.Id,
Index = index,
CollectionType = item.CollectionType,
CollectionId = item.CollectionId,
MultiCollectionId = item.MultiCollectionId,
SmartCollectionId = item.SmartCollectionId,
MediaItemId = item.MediaItemId,
PlaybackOrder = item.PlaybackOrder,
IncludeInProgramGuide = item.IncludeInProgramGuide
};
private static Task<Validation<BaseError, Playlist>> Validate(TvContext dbContext, ReplacePlaylistItems request) =>
PlaylistMustExist(dbContext, request.PlaylistId)
.BindT(playlist => CollectionTypesMustBeValid(request, playlist));
private static Task<Validation<BaseError, Playlist>> PlaylistMustExist(TvContext dbContext, int playlistId) =>
dbContext.Playlists
.Include(b => b.Items)
.SelectOneAsync(b => b.Id, b => b.Id == playlistId)
.Map(o => o.ToValidation<BaseError>("[PlaylistId] does not exist."));
private static Validation<BaseError, Playlist> CollectionTypesMustBeValid(ReplacePlaylistItems request, Playlist playlist) =>
request.Items.Map(item => CollectionTypeMustBeValid(item, playlist)).Sequence().Map(_ => playlist);
private static Validation<BaseError, Playlist> CollectionTypeMustBeValid(ReplacePlaylistItem item, Playlist playlist)
{
switch (item.CollectionType)
{
case ProgramScheduleItemCollectionType.Collection:
if (item.CollectionId is null)
{
return BaseError.New("[Collection] is required for collection type 'Collection'");
}
break;
case ProgramScheduleItemCollectionType.TelevisionShow:
if (item.MediaItemId is null)
{
return BaseError.New("[MediaItem] is required for collection type 'TelevisionShow'");
}
break;
case ProgramScheduleItemCollectionType.TelevisionSeason:
if (item.MediaItemId is null)
{
return BaseError.New("[MediaItem] is required for collection type 'TelevisionSeason'");
}
break;
case ProgramScheduleItemCollectionType.Artist:
if (item.MediaItemId is null)
{
return BaseError.New("[MediaItem] is required for collection type 'Artist'");
}
break;
case ProgramScheduleItemCollectionType.MultiCollection:
if (item.MultiCollectionId is null)
{
return BaseError.New("[MultiCollection] is required for collection type 'MultiCollection'");
}
break;
case ProgramScheduleItemCollectionType.SmartCollection:
if (item.SmartCollectionId is null)
{
return BaseError.New("[SmartCollection] is required for collection type 'SmartCollection'");
}
break;
case ProgramScheduleItemCollectionType.FakeCollection:
default:
return BaseError.New("[CollectionType] is invalid");
}
return playlist;
}
}

29
ErsatzTV.Application/MediaCollections/Mapper.cs

@ -45,4 +45,33 @@ internal static class Mapper @@ -45,4 +45,33 @@ internal static class Mapper
ProjectToViewModel(multiCollectionSmartItem.SmartCollection),
multiCollectionSmartItem.ScheduleAsGroup,
multiCollectionSmartItem.PlaybackOrder);
internal static PlaylistGroupViewModel ProjectToViewModel(PlaylistGroup playlistGroup) =>
new(playlistGroup.Id, playlistGroup.Name, playlistGroup.Playlists.Count);
internal static PlaylistViewModel ProjectToViewModel(Playlist playlist) =>
new(playlist.Id, playlist.PlaylistGroupId, playlist.Name);
internal static PlaylistItemViewModel ProjectToViewModel(PlaylistItem playlistItem) =>
new(
playlistItem.Id,
playlistItem.Index,
playlistItem.CollectionType,
playlistItem.Collection is not null ? ProjectToViewModel(playlistItem.Collection) : null,
playlistItem.MultiCollection is not null
? ProjectToViewModel(playlistItem.MultiCollection)
: null,
playlistItem.SmartCollection is not null
? ProjectToViewModel(playlistItem.SmartCollection)
: null,
playlistItem.MediaItem switch
{
Show show => MediaItems.Mapper.ProjectToViewModel(show),
Season season => MediaItems.Mapper.ProjectToViewModel(season),
Artist artist => MediaItems.Mapper.ProjectToViewModel(artist),
// TODO: other items?
_ => null
},
playlistItem.PlaybackOrder,
playlistItem.IncludeInProgramGuide);
}

3
ErsatzTV.Application/MediaCollections/PlaylistGroupViewModel.cs

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
namespace ErsatzTV.Application.MediaCollections;
public record PlaylistGroupViewModel(int Id, string Name, int PlaylistCount);

15
ErsatzTV.Application/MediaCollections/PlaylistItemViewModel.cs

@ -0,0 +1,15 @@ @@ -0,0 +1,15 @@
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCollections;
public record PlaylistItemViewModel(
int Id,
int Index,
ProgramScheduleItemCollectionType CollectionType,
MediaCollectionViewModel Collection,
MultiCollectionViewModel MultiCollection,
SmartCollectionViewModel SmartCollection,
NamedMediaItemViewModel MediaItem,
PlaybackOrder PlaybackOrder,
bool IncludeInProgramGuide);

3
ErsatzTV.Application/MediaCollections/PlaylistViewModel.cs

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
namespace ErsatzTV.Application.MediaCollections;
public record PlaylistViewModel(int Id, int PlaylistGroupId, string Name);

3
ErsatzTV.Application/MediaCollections/Queries/GetAllPlaylistGroups.cs

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
namespace ErsatzTV.Application.MediaCollections;
public record GetAllPlaylistGroups : IRequest<List<PlaylistGroupViewModel>>;

21
ErsatzTV.Application/MediaCollections/Queries/GetAllPlaylistGroupsHandler.cs

@ -0,0 +1,21 @@ @@ -0,0 +1,21 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.MediaCollections;
public class GetAllPlaylistGroupsHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetAllPlaylistGroups, List<PlaylistGroupViewModel>>
{
public async Task<List<PlaylistGroupViewModel>> Handle(GetAllPlaylistGroups request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
List<PlaylistGroup> playlistGroups = await dbContext.PlaylistGroups
.AsNoTracking()
.Include(g => g.Playlists)
.ToListAsync(cancellationToken);
return playlistGroups.Map(Mapper.ProjectToViewModel).ToList();
}
}

3
ErsatzTV.Application/MediaCollections/Queries/GetPlaylistById.cs

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
namespace ErsatzTV.Application.MediaCollections;
public record GetPlaylistById(int PlaylistId) : IRequest<Option<PlaylistViewModel>>;

17
ErsatzTV.Application/MediaCollections/Queries/GetPlaylistByIdHandler.cs

@ -0,0 +1,17 @@ @@ -0,0 +1,17 @@
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.MediaCollections;
public class GetPlaylistByIdHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetPlaylistById, Option<PlaylistViewModel>>
{
public async Task<Option<PlaylistViewModel>> Handle(GetPlaylistById request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.Playlists
.SelectOneAsync(b => b.Id, b => b.Id == request.PlaylistId)
.MapT(Mapper.ProjectToViewModel);
}
}

3
ErsatzTV.Application/MediaCollections/Queries/GetPlaylistItems.cs

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
namespace ErsatzTV.Application.MediaCollections;
public record GetPlaylistItems(int PlaylistId) : IRequest<List<PlaylistItemViewModel>>;

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

@ -0,0 +1,45 @@ @@ -0,0 +1,45 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.MediaCollections;
public class GetPlaylistItemsHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetPlaylistItems, List<PlaylistItemViewModel>>
{
public async Task<List<PlaylistItemViewModel>> Handle(GetPlaylistItems request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
List<PlaylistItem> allItems = await dbContext.PlaylistItems
.AsNoTracking()
.Filter(i => i.PlaylistId == request.PlaylistId)
.Include(i => i.Collection)
.Include(i => i.MultiCollection)
.Include(i => i.SmartCollection)
.Include(i => i.MediaItem)
.ThenInclude(i => (i as Season).SeasonMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(i => i.MediaItem)
.ThenInclude(i => (i as Season).Show)
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(i => i.MediaItem)
.ThenInclude(i => (i as Show).ShowMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(i => i.MediaItem)
.ThenInclude(i => (i as Artist).ArtistMetadata)
.ThenInclude(am => am.Artwork)
.ToListAsync(cancellationToken);
if (allItems.All(bi => bi.IncludeInProgramGuide == false))
{
foreach (PlaylistItem bi in allItems)
{
bi.IncludeInProgramGuide = true;
}
}
return allItems.Map(Mapper.ProjectToViewModel).ToList();
}
}

3
ErsatzTV.Application/MediaCollections/Queries/GetPlaylistsByPlaylistGroupId.cs

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
namespace ErsatzTV.Application.MediaCollections;
public record GetPlaylistsByPlaylistGroupId(int PlaylistGroupId) : IRequest<List<PlaylistViewModel>>;

21
ErsatzTV.Application/MediaCollections/Queries/GetPlaylistsByPlaylistGroupIdHandler.cs

@ -0,0 +1,21 @@ @@ -0,0 +1,21 @@
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.MediaCollections;
public class GetPlaylistsByPlaylistGroupIdHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetPlaylistsByPlaylistGroupId, List<PlaylistViewModel>>
{
public async Task<List<PlaylistViewModel>> Handle(
GetPlaylistsByPlaylistGroupId request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.Playlists
.AsNoTracking()
.Filter(p => p.PlaylistGroupId == request.PlaylistGroupId)
.ToListAsync(cancellationToken)
.Map(items => items.Map(Mapper.ProjectToViewModel).ToList());
}
}

1
ErsatzTV.Application/ProgramSchedules/Commands/AddProgramScheduleItem.cs

@ -13,6 +13,7 @@ public record AddProgramScheduleItem( @@ -13,6 +13,7 @@ public record AddProgramScheduleItem(
int? MultiCollectionId,
int? SmartCollectionId,
int? MediaItemId,
int? PlaylistId,
PlaybackOrder PlaybackOrder,
FillWithGroupMode FillWithGroupMode,
int? MultipleCount,

1
ErsatzTV.Application/ProgramSchedules/Commands/IProgramScheduleItemRequest.cs

@ -10,6 +10,7 @@ public interface IProgramScheduleItemRequest @@ -10,6 +10,7 @@ public interface IProgramScheduleItemRequest
int? MultiCollectionId { get; }
int? SmartCollectionId { get; }
int? MediaItemId { get; }
int? PlaylistId { get; }
PlayoutMode PlayoutMode { get; }
PlaybackOrder PlaybackOrder { get; }
FillWithGroupMode FillWithGroupMode { get; }

11
ErsatzTV.Application/ProgramSchedules/Commands/ProgramScheduleItemCommandBase.cs

@ -158,6 +158,13 @@ public abstract class ProgramScheduleItemCommandBase @@ -158,6 +158,13 @@ public abstract class ProgramScheduleItemCommandBase
return BaseError.New("[SmartCollection] is required for collection type 'SmartCollection'");
}
break;
case ProgramScheduleItemCollectionType.Playlist:
if (item.PlaylistId is null)
{
return BaseError.New("[Playlist] is required for collection type 'Playlist'");
}
break;
default:
return BaseError.New("[CollectionType] is invalid");
@ -182,6 +189,7 @@ public abstract class ProgramScheduleItemCommandBase @@ -182,6 +189,7 @@ public abstract class ProgramScheduleItemCommandBase
MultiCollectionId = item.MultiCollectionId,
SmartCollectionId = item.SmartCollectionId,
MediaItemId = item.MediaItemId,
PlaylistId = item.PlaylistId,
PlaybackOrder = item.PlaybackOrder,
FillWithGroupMode = FillWithGroupMode.None,
CustomTitle = item.CustomTitle,
@ -207,6 +215,7 @@ public abstract class ProgramScheduleItemCommandBase @@ -207,6 +215,7 @@ public abstract class ProgramScheduleItemCommandBase
MultiCollectionId = item.MultiCollectionId,
SmartCollectionId = item.SmartCollectionId,
MediaItemId = item.MediaItemId,
PlaylistId = item.PlaylistId,
PlaybackOrder = item.PlaybackOrder,
FillWithGroupMode = FillWithGroupMode.None,
CustomTitle = item.CustomTitle,
@ -232,6 +241,7 @@ public abstract class ProgramScheduleItemCommandBase @@ -232,6 +241,7 @@ public abstract class ProgramScheduleItemCommandBase
MultiCollectionId = item.MultiCollectionId,
SmartCollectionId = item.SmartCollectionId,
MediaItemId = item.MediaItemId,
PlaylistId = item.PlaylistId,
PlaybackOrder = item.PlaybackOrder,
FillWithGroupMode = item.FillWithGroupMode,
Count = item.MultipleCount.GetValueOrDefault(),
@ -258,6 +268,7 @@ public abstract class ProgramScheduleItemCommandBase @@ -258,6 +268,7 @@ public abstract class ProgramScheduleItemCommandBase
MultiCollectionId = item.MultiCollectionId,
SmartCollectionId = item.SmartCollectionId,
MediaItemId = item.MediaItemId,
PlaylistId = item.PlaylistId,
PlaybackOrder = item.PlaybackOrder,
FillWithGroupMode = item.FillWithGroupMode,
PlayoutDuration = item.PlayoutDuration.GetValueOrDefault(),

1
ErsatzTV.Application/ProgramSchedules/Commands/ReplaceProgramScheduleItems.cs

@ -13,6 +13,7 @@ public record ReplaceProgramScheduleItem( @@ -13,6 +13,7 @@ public record ReplaceProgramScheduleItem(
int? MultiCollectionId,
int? SmartCollectionId,
int? MediaItemId,
int? PlaylistId,
PlaybackOrder PlaybackOrder,
FillWithGroupMode FillWithGroupMode,
int? MultipleCount,

6
ErsatzTV.Application/ProgramSchedules/Commands/ReplaceProgramScheduleItemsHandler.cs

@ -104,7 +104,8 @@ public class ReplaceProgramScheduleItemsHandler : ProgramScheduleItemCommandBase @@ -104,7 +104,8 @@ public class ReplaceProgramScheduleItemsHandler : ProgramScheduleItemCommandBase
item.CollectionId,
item.MediaItemId,
item.MultiCollectionId,
item.SmartCollectionId);
item.SmartCollectionId,
item.PlaylistId);
if (keyOrders.TryGetValue(key, out System.Collections.Generic.HashSet<PlaybackOrder> playbackOrders))
{
@ -128,5 +129,6 @@ public class ReplaceProgramScheduleItemsHandler : ProgramScheduleItemCommandBase @@ -128,5 +129,6 @@ public class ReplaceProgramScheduleItemsHandler : ProgramScheduleItemCommandBase
int? CollectionId,
int? MediaItemId,
int? MultiCollectionId,
int? SmartCollectionId);
int? SmartCollectionId,
int? PlaylistId);
}

4
ErsatzTV.Core.Tests/Fakes/FakeMediaCollectionRepository.cs

@ -10,12 +10,16 @@ public class FakeMediaCollectionRepository : IMediaCollectionRepository @@ -10,12 +10,16 @@ public class FakeMediaCollectionRepository : IMediaCollectionRepository
public FakeMediaCollectionRepository(Map<int, List<MediaItem>> data) => _data = data;
public Task<Dictionary<PlaylistItem, List<MediaItem>>> GetPlaylistItemMap(int playlistId) =>
throw new NotSupportedException();
public Task<Option<Collection>> GetCollectionWithCollectionItemsUntracked(int id) =>
throw new NotSupportedException();
public Task<List<MediaItem>> GetItems(int id) => _data[id].ToList().AsTask();
public Task<List<MediaItem>> GetMultiCollectionItems(int id) => throw new NotSupportedException();
public Task<List<MediaItem>> GetSmartCollectionItems(int id) => _data[id].ToList().AsTask();
public Task<List<MediaItem>> GetPlaylistItems(int id) => throw new NotSupportedException();
public Task<List<CollectionWithItems>> GetMultiCollectionCollections(int id) =>
throw new NotSupportedException();

11
ErsatzTV.Core/Domain/Collection/Playlist.cs

@ -0,0 +1,11 @@ @@ -0,0 +1,11 @@
namespace ErsatzTV.Core.Domain;
public class Playlist
{
public int Id { get; set; }
public int PlaylistGroupId { get; set; }
public PlaylistGroup PlaylistGroup { get; set; }
public string Name { get; set; }
public ICollection<PlaylistItem> Items { get; set; }
//public DateTime DateUpdated { get; set; }
}

8
ErsatzTV.Core/Domain/Collection/PlaylistGroup.cs

@ -0,0 +1,8 @@ @@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Domain;
public class PlaylistGroup
{
public int Id { get; set; }
public string Name { get; set; }
public ICollection<Playlist> Playlists { get; set; }
}

20
ErsatzTV.Core/Domain/Collection/PlaylistItem.cs

@ -0,0 +1,20 @@ @@ -0,0 +1,20 @@
namespace ErsatzTV.Core.Domain;
public class PlaylistItem
{
public int Id { get; set; }
public int Index { get; set; }
public int PlaylistId { get; set; }
public Playlist Playlist { get; set; }
public ProgramScheduleItemCollectionType CollectionType { get; set; }
public int? CollectionId { get; set; }
public Collection Collection { get; set; }
public int? MediaItemId { get; set; }
public MediaItem MediaItem { get; set; }
public int? MultiCollectionId { get; set; }
public MultiCollection MultiCollection { get; set; }
public int? SmartCollectionId { get; set; }
public SmartCollection SmartCollection { get; set; }
public PlaybackOrder PlaybackOrder { get; set; }
public bool IncludeInProgramGuide { get; set; }
}

2
ErsatzTV.Core/Domain/PlayoutProgramScheduleAnchor.cs

@ -25,6 +25,8 @@ public class PlayoutProgramScheduleAnchor @@ -25,6 +25,8 @@ public class PlayoutProgramScheduleAnchor
public SmartCollection SmartCollection { get; set; }
public int? MediaItemId { get; set; }
public MediaItem MediaItem { get; set; }
public int? PlaylistId { get; set; }
public Playlist Playlist { get; set; }
public string FakeCollectionKey { get; set; }
public CollectionEnumeratorState EnumeratorState { get; set; }
}

2
ErsatzTV.Core/Domain/ProgramScheduleItem.cs

@ -25,6 +25,8 @@ public abstract class ProgramScheduleItem @@ -25,6 +25,8 @@ public abstract class ProgramScheduleItem
public MultiCollection MultiCollection { get; set; }
public int? SmartCollectionId { get; set; }
public SmartCollection SmartCollection { get; set; }
public int? PlaylistId { get; set; }
public Playlist Playlist { get; set; }
public string FakeCollectionKey { get; set; }
public PlaybackOrder PlaybackOrder { get; set; }
public int? PreRollFillerId { get; set; }

8
ErsatzTV.Core/Domain/ProgramScheduleItemCollectionType.cs

@ -8,6 +8,14 @@ public enum ProgramScheduleItemCollectionType @@ -8,6 +8,14 @@ public enum ProgramScheduleItemCollectionType
Artist = 3,
MultiCollection = 4,
SmartCollection = 5,
Playlist = 6,
Movie = 10,
Episode = 20,
MusicVideo = 30,
OtherVideo = 40,
Song = 50,
Image = 60,
FakeCollection = 100
}

2
ErsatzTV.Core/Interfaces/Repositories/IMediaCollectionRepository.cs

@ -5,10 +5,12 @@ namespace ErsatzTV.Core.Interfaces.Repositories; @@ -5,10 +5,12 @@ namespace ErsatzTV.Core.Interfaces.Repositories;
public interface IMediaCollectionRepository
{
Task<Dictionary<PlaylistItem, List<MediaItem>>> GetPlaylistItemMap(int playlistId);
Task<Option<Collection>> GetCollectionWithCollectionItemsUntracked(int id);
Task<List<MediaItem>> GetItems(int id);
Task<List<MediaItem>> GetMultiCollectionItems(int id);
Task<List<MediaItem>> GetSmartCollectionItems(int id);
Task<List<MediaItem>> GetPlaylistItems(int id);
Task<List<CollectionWithItems>> GetMultiCollectionCollections(int id);
Task<List<CollectionWithItems>> GetFakeMultiCollectionCollections(int? collectionId, int? smartCollectionId);
Task<List<int>> PlayoutIdsUsingCollection(int collectionId);

47
ErsatzTV.Core/Scheduling/CollectionKey.cs

@ -11,7 +11,48 @@ public class CollectionKey : Record<CollectionKey> @@ -11,7 +11,48 @@ public class CollectionKey : Record<CollectionKey>
public int? MultiCollectionId { get; set; }
public int? SmartCollectionId { get; set; }
public int? MediaItemId { get; set; }
public int? PlaylistId { get; set; }
public string FakeCollectionKey { get; set; }
public static CollectionKey ForPlaylistItem(PlaylistItem item) =>
item.CollectionType switch
{
ProgramScheduleItemCollectionType.Collection => new CollectionKey
{
CollectionType = item.CollectionType,
CollectionId = item.CollectionId
},
ProgramScheduleItemCollectionType.TelevisionShow => new CollectionKey
{
CollectionType = item.CollectionType,
MediaItemId = item.MediaItemId
},
ProgramScheduleItemCollectionType.TelevisionSeason => new CollectionKey
{
CollectionType = item.CollectionType,
MediaItemId = item.MediaItemId
},
ProgramScheduleItemCollectionType.Artist => new CollectionKey
{
CollectionType = item.CollectionType,
MediaItemId = item.MediaItemId
},
ProgramScheduleItemCollectionType.MultiCollection => new CollectionKey
{
CollectionType = item.CollectionType,
MultiCollectionId = item.MultiCollectionId
},
ProgramScheduleItemCollectionType.SmartCollection => new CollectionKey
{
CollectionType = item.CollectionType,
SmartCollectionId = item.SmartCollectionId
},
ProgramScheduleItemCollectionType.FakeCollection => new CollectionKey
{
CollectionType = item.CollectionType
},
_ => throw new ArgumentOutOfRangeException(nameof(item))
};
public static CollectionKey ForBlockItem(BlockItem item) =>
item.CollectionType switch
@ -92,6 +133,12 @@ public class CollectionKey : Record<CollectionKey> @@ -92,6 +133,12 @@ public class CollectionKey : Record<CollectionKey>
SmartCollectionId = item.SmartCollectionId,
FakeCollectionKey = item.FakeCollectionKey
},
ProgramScheduleItemCollectionType.Playlist => new CollectionKey
{
CollectionType = item.CollectionType,
PlaylistId = item.PlaylistId,
FakeCollectionKey = item.FakeCollectionKey
},
ProgramScheduleItemCollectionType.FakeCollection => new CollectionKey
{
CollectionType = item.CollectionType,

5
ErsatzTV.Core/Scheduling/CustomOrderCollectionEnumerator.cs

@ -6,9 +6,7 @@ namespace ErsatzTV.Core.Scheduling; @@ -6,9 +6,7 @@ namespace ErsatzTV.Core.Scheduling;
public class CustomOrderCollectionEnumerator : IMediaCollectionEnumerator
{
private readonly Collection _collection;
private readonly Lazy<Option<TimeSpan>> _lazyMinimumDuration;
private readonly IList<MediaItem> _mediaItems;
private readonly IList<MediaItem> _sortedMediaItems;
@ -17,9 +15,6 @@ public class CustomOrderCollectionEnumerator : IMediaCollectionEnumerator @@ -17,9 +15,6 @@ public class CustomOrderCollectionEnumerator : IMediaCollectionEnumerator
IList<MediaItem> mediaItems,
CollectionEnumeratorState state)
{
_collection = collection;
_mediaItems = mediaItems;
// TODO: this will break if we allow shows and seasons
_sortedMediaItems = collection.CollectionItems
.OrderBy(ci => ci.CustomIndex)

4
ErsatzTV.Core/Scheduling/MediaItemsForCollection.cs

@ -37,6 +37,10 @@ public static class MediaItemsForCollection @@ -37,6 +37,10 @@ public static class MediaItemsForCollection
result.AddRange(
await mediaCollectionRepository.GetSmartCollectionItems(collectionKey.SmartCollectionId ?? 0));
break;
case ProgramScheduleItemCollectionType.Playlist:
result.AddRange(
await mediaCollectionRepository.GetPlaylistItems(collectionKey.PlaylistId ?? 0));
break;
default:
throw new ArgumentOutOfRangeException(nameof(collectionKey));
}

136
ErsatzTV.Core/Scheduling/PlaylistEnumerator.cs

@ -0,0 +1,136 @@ @@ -0,0 +1,136 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Scheduling;
namespace ErsatzTV.Core.Scheduling;
public class PlaylistEnumerator : IMediaCollectionEnumerator
{
private IList<IMediaCollectionEnumerator> _sortedEnumerators;
private int _enumeratorIndex;
public static async Task<PlaylistEnumerator> Create(
IMediaCollectionRepository mediaCollectionRepository,
Dictionary<PlaylistItem, List<MediaItem>> playlistItemMap,
CollectionEnumeratorState state,
CancellationToken cancellationToken)
{
var result = new PlaylistEnumerator
{
_sortedEnumerators = [],
Count = LCM(playlistItemMap.Values.Map(v => v.Count)) * playlistItemMap.Count
};
// collections should share enumerators
var enumeratorMap = new Dictionary<CollectionKey, IMediaCollectionEnumerator>();
foreach (PlaylistItem playlistItem in playlistItemMap.Keys.OrderBy(i => i.Index))
{
List<MediaItem> items = playlistItemMap[playlistItem];
var collectionKey = CollectionKey.ForPlaylistItem(playlistItem);
if (enumeratorMap.TryGetValue(collectionKey, out IMediaCollectionEnumerator enumerator))
{
result._sortedEnumerators.Add(enumerator);
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)
{
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);
}
result.MinimumDuration = playlistItemMap.Values
.Flatten()
.Bind(i => i.GetNonZeroDuration())
.OrderBy(identity)
.HeadOrNone();
result.State = new CollectionEnumeratorState { Seed = state.Seed };
result._enumeratorIndex = 0;
while (result.State.Index < state.Index)
{
result.MoveNext();
}
return result;
}
private PlaylistEnumerator()
{
}
public void ResetState(CollectionEnumeratorState state) =>
// seed doesn't matter here
State.Index = state.Index;
public CollectionEnumeratorState State { get; private set; }
public Option<MediaItem> Current => _sortedEnumerators[_enumeratorIndex].Current;
public int Count { get; private set; }
public Option<TimeSpan> MinimumDuration { get; private set; }
public void MoveNext()
{
_sortedEnumerators[_enumeratorIndex].MoveNext();
_enumeratorIndex = (_enumeratorIndex + 1) % _sortedEnumerators.Count;
State.Index = (State.Index + 1) % Count;
}
private static int LCM(IEnumerable<int> numbers)
{
return numbers.Aggregate(lcm);
}
private static int lcm(int a, int b)
{
return Math.Abs(a * b) / GCD(a, b);
}
private static int GCD(int a, int b)
{
while (true)
{
if (b == 0) return a;
int a1 = a;
a = b;
b = a1 % b;
}
}
}

41
ErsatzTV.Core/Scheduling/PlayoutBuilder.cs

@ -866,6 +866,7 @@ public class PlayoutBuilder : IPlayoutBuilder @@ -866,6 +866,7 @@ public class PlayoutBuilder : IPlayoutBuilder
&& a.FakeCollectionKey == collectionKey.FakeCollectionKey
&& a.SmartCollectionId == collectionKey.SmartCollectionId
&& a.MultiCollectionId == collectionKey.MultiCollectionId
&& a.PlaylistId == collectionKey.PlaylistId
&& a.AnchorDate is null);
var maybeEnumeratorState = collectionEnumerators.ToDictionary(e => e.Key, e => e.Value.State);
@ -885,6 +886,7 @@ public class PlayoutBuilder : IPlayoutBuilder @@ -885,6 +886,7 @@ public class PlayoutBuilder : IPlayoutBuilder
MultiCollectionId = collectionKey.MultiCollectionId,
SmartCollectionId = collectionKey.SmartCollectionId,
MediaItemId = collectionKey.MediaItemId,
PlaylistId = collectionKey.PlaylistId,
FakeCollectionKey = collectionKey.FakeCollectionKey,
EnumeratorState = maybeEnumeratorState[collectionKey]
});
@ -922,7 +924,8 @@ public class PlayoutBuilder : IPlayoutBuilder @@ -922,7 +924,8 @@ public class PlayoutBuilder : IPlayoutBuilder
&& a.CollectionId == collectionKey.CollectionId
&& a.MultiCollectionId == collectionKey.MultiCollectionId
&& a.SmartCollectionId == collectionKey.SmartCollectionId
&& a.MediaItemId == collectionKey.MediaItemId);
&& a.MediaItemId == collectionKey.MediaItemId
&& a.PlaylistId == collectionKey.PlaylistId);
CollectionEnumeratorState state = null;
@ -936,6 +939,21 @@ public class PlayoutBuilder : IPlayoutBuilder @@ -936,6 +939,21 @@ public class PlayoutBuilder : IPlayoutBuilder
}
state ??= new CollectionEnumeratorState { Seed = Random.Next(), Index = 0 };
if (collectionKey.CollectionType is ProgramScheduleItemCollectionType.Playlist)
{
foreach (int playlistId in Optional(collectionKey.PlaylistId))
{
Dictionary<PlaylistItem, List<MediaItem>> playlistItemMap =
await _mediaCollectionRepository.GetPlaylistItemMap(playlistId);
return await PlaylistEnumerator.Create(
_mediaCollectionRepository,
playlistItemMap,
state,
cancellationToken);
}
}
int collectionId = collectionKey.CollectionId ?? 0;
@ -985,7 +1003,7 @@ public class PlayoutBuilder : IPlayoutBuilder @@ -985,7 +1003,7 @@ public class PlayoutBuilder : IPlayoutBuilder
return new RandomizedMediaCollectionEnumerator(mediaItems, state);
case PlaybackOrder.ShuffleInOrder:
return new ShuffleInOrderCollectionEnumerator(
await GetCollectionItemsForShuffleInOrder(collectionKey),
await GetCollectionItemsForShuffleInOrder(_mediaCollectionRepository, collectionKey),
state,
activeSchedule.RandomStartPoint,
cancellationToken);
@ -1026,7 +1044,11 @@ public class PlayoutBuilder : IPlayoutBuilder @@ -1026,7 +1044,11 @@ public class PlayoutBuilder : IPlayoutBuilder
case PlaybackOrder.MultiEpisodeShuffle:
case PlaybackOrder.Shuffle:
return new ShuffledMediaCollectionEnumerator(
await GetGroupedMediaItemsForShuffle(activeSchedule, mediaItems, collectionKey),
await GetGroupedMediaItemsForShuffle(
_mediaCollectionRepository,
activeSchedule,
mediaItems,
collectionKey),
state,
cancellationToken);
default:
@ -1035,14 +1057,15 @@ public class PlayoutBuilder : IPlayoutBuilder @@ -1035,14 +1057,15 @@ public class PlayoutBuilder : IPlayoutBuilder
}
}
private async Task<List<GroupedMediaItem>> GetGroupedMediaItemsForShuffle(
internal static async Task<List<GroupedMediaItem>> GetGroupedMediaItemsForShuffle(
IMediaCollectionRepository mediaCollectionRepository,
ProgramSchedule activeSchedule,
List<MediaItem> mediaItems,
CollectionKey collectionKey)
{
if (collectionKey.MultiCollectionId != null)
{
List<CollectionWithItems> collections = await _mediaCollectionRepository
List<CollectionWithItems> collections = await mediaCollectionRepository
.GetMultiCollectionCollections(collectionKey.MultiCollectionId.Value);
return MultiCollectionGrouper.GroupMediaItems(collections);
@ -1053,18 +1076,20 @@ public class PlayoutBuilder : IPlayoutBuilder @@ -1053,18 +1076,20 @@ public class PlayoutBuilder : IPlayoutBuilder
: mediaItems.Map(mi => new GroupedMediaItem(mi, null)).ToList();
}
private async Task<List<CollectionWithItems>> GetCollectionItemsForShuffleInOrder(CollectionKey collectionKey)
internal static async Task<List<CollectionWithItems>> GetCollectionItemsForShuffleInOrder(
IMediaCollectionRepository mediaCollectionRepository,
CollectionKey collectionKey)
{
List<CollectionWithItems> result;
if (collectionKey.MultiCollectionId != null)
{
result = await _mediaCollectionRepository.GetMultiCollectionCollections(
result = await mediaCollectionRepository.GetMultiCollectionCollections(
collectionKey.MultiCollectionId.Value);
}
else
{
result = await _mediaCollectionRepository.GetFakeMultiCollectionCollections(
result = await mediaCollectionRepository.GetFakeMultiCollectionCollections(
collectionKey.CollectionId,
collectionKey.SmartCollectionId);
}

2
ErsatzTV.Infrastructure.MySql/ErsatzTV.Infrastructure.MySql.csproj

@ -16,7 +16,7 @@ @@ -16,7 +16,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.4" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.2" />
</ItemGroup>

5747
ErsatzTV.Infrastructure.MySql/Migrations/20240427142504_Add_Playlist.Designer.cs generated

File diff suppressed because it is too large Load Diff

154
ErsatzTV.Infrastructure.MySql/Migrations/20240427142504_Add_Playlist.cs

@ -0,0 +1,154 @@ @@ -0,0 +1,154 @@
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class Add_Playlist : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "PlaylistGroup",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
Name = table.Column<string>(type: "varchar(255)", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4")
},
constraints: table =>
{
table.PrimaryKey("PK_PlaylistGroup", x => x.Id);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "Playlist",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
PlaylistGroupId = table.Column<int>(type: "int", nullable: false),
Name = table.Column<string>(type: "varchar(255)", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4")
},
constraints: table =>
{
table.PrimaryKey("PK_Playlist", x => x.Id);
table.ForeignKey(
name: "FK_Playlist_PlaylistGroup_PlaylistGroupId",
column: x => x.PlaylistGroupId,
principalTable: "PlaylistGroup",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "PlaylistItem",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
Index = table.Column<int>(type: "int", nullable: false),
PlaylistId = table.Column<int>(type: "int", nullable: false),
CollectionType = table.Column<int>(type: "int", nullable: false),
CollectionId = table.Column<int>(type: "int", nullable: true),
MediaItemId = table.Column<int>(type: "int", nullable: true),
MultiCollectionId = table.Column<int>(type: "int", nullable: true),
SmartCollectionId = table.Column<int>(type: "int", nullable: true),
PlaybackOrder = table.Column<int>(type: "int", nullable: false),
IncludeInProgramGuide = table.Column<bool>(type: "tinyint(1)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PlaylistItem", x => x.Id);
table.ForeignKey(
name: "FK_PlaylistItem_Collection_CollectionId",
column: x => x.CollectionId,
principalTable: "Collection",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_PlaylistItem_MediaItem_MediaItemId",
column: x => x.MediaItemId,
principalTable: "MediaItem",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_PlaylistItem_MultiCollection_MultiCollectionId",
column: x => x.MultiCollectionId,
principalTable: "MultiCollection",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_PlaylistItem_Playlist_PlaylistId",
column: x => x.PlaylistId,
principalTable: "Playlist",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_PlaylistItem_SmartCollection_SmartCollectionId",
column: x => x.SmartCollectionId,
principalTable: "SmartCollection",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_Playlist_PlaylistGroupId_Name",
table: "Playlist",
columns: new[] { "PlaylistGroupId", "Name" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_PlaylistGroup_Name",
table: "PlaylistGroup",
column: "Name",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_PlaylistItem_CollectionId",
table: "PlaylistItem",
column: "CollectionId");
migrationBuilder.CreateIndex(
name: "IX_PlaylistItem_MediaItemId",
table: "PlaylistItem",
column: "MediaItemId");
migrationBuilder.CreateIndex(
name: "IX_PlaylistItem_MultiCollectionId",
table: "PlaylistItem",
column: "MultiCollectionId");
migrationBuilder.CreateIndex(
name: "IX_PlaylistItem_PlaylistId",
table: "PlaylistItem",
column: "PlaylistId");
migrationBuilder.CreateIndex(
name: "IX_PlaylistItem_SmartCollectionId",
table: "PlaylistItem",
column: "SmartCollectionId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PlaylistItem");
migrationBuilder.DropTable(
name: "Playlist");
migrationBuilder.DropTable(
name: "PlaylistGroup");
}
}
}

5771
ErsatzTV.Infrastructure.MySql/Migrations/20240427174059_Add_ProgramScheduleItemPlaylist.Designer.cs generated

File diff suppressed because it is too large Load Diff

80
ErsatzTV.Infrastructure.MySql/Migrations/20240427174059_Add_ProgramScheduleItemPlaylist.cs

@ -0,0 +1,80 @@ @@ -0,0 +1,80 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class Add_ProgramScheduleItemPlaylist : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "PlaylistId",
table: "ProgramScheduleItem",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "PlaylistId",
table: "PlayoutProgramScheduleAnchor",
type: "int",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_ProgramScheduleItem_PlaylistId",
table: "ProgramScheduleItem",
column: "PlaylistId");
migrationBuilder.CreateIndex(
name: "IX_PlayoutProgramScheduleAnchor_PlaylistId",
table: "PlayoutProgramScheduleAnchor",
column: "PlaylistId");
migrationBuilder.AddForeignKey(
name: "FK_PlayoutProgramScheduleAnchor_Playlist_PlaylistId",
table: "PlayoutProgramScheduleAnchor",
column: "PlaylistId",
principalTable: "Playlist",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_ProgramScheduleItem_Playlist_PlaylistId",
table: "ProgramScheduleItem",
column: "PlaylistId",
principalTable: "Playlist",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_PlayoutProgramScheduleAnchor_Playlist_PlaylistId",
table: "PlayoutProgramScheduleAnchor");
migrationBuilder.DropForeignKey(
name: "FK_ProgramScheduleItem_Playlist_PlaylistId",
table: "ProgramScheduleItem");
migrationBuilder.DropIndex(
name: "IX_ProgramScheduleItem_PlaylistId",
table: "ProgramScheduleItem");
migrationBuilder.DropIndex(
name: "IX_PlayoutProgramScheduleAnchor_PlaylistId",
table: "PlayoutProgramScheduleAnchor");
migrationBuilder.DropColumn(
name: "PlaylistId",
table: "ProgramScheduleItem");
migrationBuilder.DropColumn(
name: "PlaylistId",
table: "PlayoutProgramScheduleAnchor");
}
}
}

177
ErsatzTV.Infrastructure.MySql/Migrations/TvContextModelSnapshot.cs

@ -17,7 +17,7 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -17,7 +17,7 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.3")
.HasAnnotation("ProductVersion", "8.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
@ -1582,6 +1582,97 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -1582,6 +1582,97 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.ToTable("OtherVideoMetadata", (string)null);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Playlist", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.HasColumnType("varchar(255)");
b.Property<int>("PlaylistGroupId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("PlaylistGroupId", "Name")
.IsUnique();
b.ToTable("Playlist", (string)null);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlaylistGroup", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.HasColumnType("varchar(255)");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("PlaylistGroup", (string)null);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlaylistItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<int?>("CollectionId")
.HasColumnType("int");
b.Property<int>("CollectionType")
.HasColumnType("int");
b.Property<bool>("IncludeInProgramGuide")
.HasColumnType("tinyint(1)");
b.Property<int>("Index")
.HasColumnType("int");
b.Property<int?>("MediaItemId")
.HasColumnType("int");
b.Property<int?>("MultiCollectionId")
.HasColumnType("int");
b.Property<int>("PlaybackOrder")
.HasColumnType("int");
b.Property<int>("PlaylistId")
.HasColumnType("int");
b.Property<int?>("SmartCollectionId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("CollectionId");
b.HasIndex("MediaItemId");
b.HasIndex("MultiCollectionId");
b.HasIndex("PlaylistId");
b.HasIndex("SmartCollectionId");
b.ToTable("PlaylistItem", (string)null);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Playout", b =>
{
b.Property<int>("Id")
@ -1730,6 +1821,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -1730,6 +1821,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<int?>("MultiCollectionId")
.HasColumnType("int");
b.Property<int?>("PlaylistId")
.HasColumnType("int");
b.Property<int>("PlayoutId")
.HasColumnType("int");
@ -1744,6 +1838,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -1744,6 +1838,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.HasIndex("MultiCollectionId");
b.HasIndex("PlaylistId");
b.HasIndex("PlayoutId");
b.HasIndex("SmartCollectionId");
@ -1954,6 +2050,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -1954,6 +2050,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<int>("PlaybackOrder")
.HasColumnType("int");
b.Property<int?>("PlaylistId")
.HasColumnType("int");
b.Property<int?>("PostRollFillerId")
.HasColumnType("int");
@ -1999,6 +2098,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -1999,6 +2098,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.HasIndex("MultiCollectionId");
b.HasIndex("PlaylistId");
b.HasIndex("PostRollFillerId");
b.HasIndex("PreRollFillerId");
@ -4066,6 +4167,56 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -4066,6 +4167,56 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Navigation("OtherVideo");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Playlist", b =>
{
b.HasOne("ErsatzTV.Core.Domain.PlaylistGroup", "PlaylistGroup")
.WithMany("Playlists")
.HasForeignKey("PlaylistGroupId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("PlaylistGroup");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlaylistItem", b =>
{
b.HasOne("ErsatzTV.Core.Domain.Collection", "Collection")
.WithMany()
.HasForeignKey("CollectionId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.MediaItem", "MediaItem")
.WithMany()
.HasForeignKey("MediaItemId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.MultiCollection", "MultiCollection")
.WithMany()
.HasForeignKey("MultiCollectionId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.Playlist", "Playlist")
.WithMany("Items")
.HasForeignKey("PlaylistId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.SmartCollection", "SmartCollection")
.WithMany()
.HasForeignKey("SmartCollectionId")
.OnDelete(DeleteBehavior.Cascade);
b.Navigation("Collection");
b.Navigation("MediaItem");
b.Navigation("MultiCollection");
b.Navigation("Playlist");
b.Navigation("SmartCollection");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Playout", b =>
{
b.HasOne("ErsatzTV.Core.Domain.Channel", "Channel")
@ -4188,6 +4339,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -4188,6 +4339,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
.HasForeignKey("MultiCollectionId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.Playlist", "Playlist")
.WithMany()
.HasForeignKey("PlaylistId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.Playout", "Playout")
.WithMany("ProgramScheduleAnchors")
.HasForeignKey("PlayoutId")
@ -4226,6 +4382,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -4226,6 +4382,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Navigation("MultiCollection");
b.Navigation("Playlist");
b.Navigation("Playout");
b.Navigation("SmartCollection");
@ -4339,6 +4497,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -4339,6 +4497,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
.HasForeignKey("MultiCollectionId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.Playlist", "Playlist")
.WithMany()
.HasForeignKey("PlaylistId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.Filler.FillerPreset", "PostRollFiller")
.WithMany()
.HasForeignKey("PostRollFillerId")
@ -4379,6 +4542,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -4379,6 +4542,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Navigation("MultiCollection");
b.Navigation("Playlist");
b.Navigation("PostRollFiller");
b.Navigation("PreRollFiller");
@ -5356,6 +5521,16 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -5356,6 +5521,16 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Navigation("Writers");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Playlist", b =>
{
b.Navigation("Items");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlaylistGroup", b =>
{
b.Navigation("Playlists");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Playout", b =>
{
b.Navigation("FillGroupIndices");

4
ErsatzTV.Infrastructure.Sqlite/ErsatzTV.Infrastructure.Sqlite.csproj

@ -13,8 +13,8 @@ @@ -13,8 +13,8 @@
<ItemGroup>
<PackageReference Include="Dapper" Version="2.1.35" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.4" />
</ItemGroup>

5580
ErsatzTV.Infrastructure.Sqlite/Migrations/20240427104030_Add_Playlist.Designer.cs generated

File diff suppressed because it is too large Load Diff

146
ErsatzTV.Infrastructure.Sqlite/Migrations/20240427104030_Add_Playlist.cs

@ -0,0 +1,146 @@ @@ -0,0 +1,146 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class Add_Playlist : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "PlaylistGroup",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_PlaylistGroup", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Playlist",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
PlaylistGroupId = table.Column<int>(type: "INTEGER", nullable: false),
Name = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Playlist", x => x.Id);
table.ForeignKey(
name: "FK_Playlist_PlaylistGroup_PlaylistGroupId",
column: x => x.PlaylistGroupId,
principalTable: "PlaylistGroup",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "PlaylistItem",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Index = table.Column<int>(type: "INTEGER", nullable: false),
PlaylistId = table.Column<int>(type: "INTEGER", nullable: false),
CollectionType = table.Column<int>(type: "INTEGER", nullable: false),
CollectionId = table.Column<int>(type: "INTEGER", nullable: true),
MediaItemId = table.Column<int>(type: "INTEGER", nullable: true),
MultiCollectionId = table.Column<int>(type: "INTEGER", nullable: true),
SmartCollectionId = table.Column<int>(type: "INTEGER", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_PlaylistItem", x => x.Id);
table.ForeignKey(
name: "FK_PlaylistItem_Collection_CollectionId",
column: x => x.CollectionId,
principalTable: "Collection",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_PlaylistItem_MediaItem_MediaItemId",
column: x => x.MediaItemId,
principalTable: "MediaItem",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_PlaylistItem_MultiCollection_MultiCollectionId",
column: x => x.MultiCollectionId,
principalTable: "MultiCollection",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_PlaylistItem_Playlist_PlaylistId",
column: x => x.PlaylistId,
principalTable: "Playlist",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_PlaylistItem_SmartCollection_SmartCollectionId",
column: x => x.SmartCollectionId,
principalTable: "SmartCollection",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Playlist_PlaylistGroupId_Name",
table: "Playlist",
columns: new[] { "PlaylistGroupId", "Name" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_PlaylistGroup_Name",
table: "PlaylistGroup",
column: "Name",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_PlaylistItem_CollectionId",
table: "PlaylistItem",
column: "CollectionId");
migrationBuilder.CreateIndex(
name: "IX_PlaylistItem_MediaItemId",
table: "PlaylistItem",
column: "MediaItemId");
migrationBuilder.CreateIndex(
name: "IX_PlaylistItem_MultiCollectionId",
table: "PlaylistItem",
column: "MultiCollectionId");
migrationBuilder.CreateIndex(
name: "IX_PlaylistItem_PlaylistId",
table: "PlaylistItem",
column: "PlaylistId");
migrationBuilder.CreateIndex(
name: "IX_PlaylistItem_SmartCollectionId",
table: "PlaylistItem",
column: "SmartCollectionId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PlaylistItem");
migrationBuilder.DropTable(
name: "Playlist");
migrationBuilder.DropTable(
name: "PlaylistGroup");
}
}
}

5586
ErsatzTV.Infrastructure.Sqlite/Migrations/20240427135444_Add_MorePlaylistProperties.Designer.cs generated

File diff suppressed because it is too large Load Diff

40
ErsatzTV.Infrastructure.Sqlite/Migrations/20240427135444_Add_MorePlaylistProperties.cs

@ -0,0 +1,40 @@ @@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class Add_MorePlaylistProperties : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IncludeInProgramGuide",
table: "PlaylistItem",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<int>(
name: "PlaybackOrder",
table: "PlaylistItem",
type: "INTEGER",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IncludeInProgramGuide",
table: "PlaylistItem");
migrationBuilder.DropColumn(
name: "PlaybackOrder",
table: "PlaylistItem");
}
}
}

5610
ErsatzTV.Infrastructure.Sqlite/Migrations/20240427163730_Add_ProgramScheduleItemPlaylist.Designer.cs generated

File diff suppressed because it is too large Load Diff

80
ErsatzTV.Infrastructure.Sqlite/Migrations/20240427163730_Add_ProgramScheduleItemPlaylist.cs

@ -0,0 +1,80 @@ @@ -0,0 +1,80 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class Add_ProgramScheduleItemPlaylist : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "PlaylistId",
table: "ProgramScheduleItem",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "PlaylistId",
table: "PlayoutProgramScheduleAnchor",
type: "INTEGER",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_ProgramScheduleItem_PlaylistId",
table: "ProgramScheduleItem",
column: "PlaylistId");
migrationBuilder.CreateIndex(
name: "IX_PlayoutProgramScheduleAnchor_PlaylistId",
table: "PlayoutProgramScheduleAnchor",
column: "PlaylistId");
migrationBuilder.AddForeignKey(
name: "FK_PlayoutProgramScheduleAnchor_Playlist_PlaylistId",
table: "PlayoutProgramScheduleAnchor",
column: "PlaylistId",
principalTable: "Playlist",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_ProgramScheduleItem_Playlist_PlaylistId",
table: "ProgramScheduleItem",
column: "PlaylistId",
principalTable: "Playlist",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_PlayoutProgramScheduleAnchor_Playlist_PlaylistId",
table: "PlayoutProgramScheduleAnchor");
migrationBuilder.DropForeignKey(
name: "FK_ProgramScheduleItem_Playlist_PlaylistId",
table: "ProgramScheduleItem");
migrationBuilder.DropIndex(
name: "IX_ProgramScheduleItem_PlaylistId",
table: "ProgramScheduleItem");
migrationBuilder.DropIndex(
name: "IX_PlayoutProgramScheduleAnchor_PlaylistId",
table: "PlayoutProgramScheduleAnchor");
migrationBuilder.DropColumn(
name: "PlaylistId",
table: "ProgramScheduleItem");
migrationBuilder.DropColumn(
name: "PlaylistId",
table: "PlayoutProgramScheduleAnchor");
}
}
}

171
ErsatzTV.Infrastructure.Sqlite/Migrations/TvContextModelSnapshot.cs

@ -15,7 +15,7 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -15,7 +15,7 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.3");
modelBuilder.HasAnnotation("ProductVersion", "8.0.4");
modelBuilder.Entity("ErsatzTV.Core.Domain.Actor", b =>
{
@ -1503,6 +1503,91 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -1503,6 +1503,91 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.ToTable("OtherVideoMetadata", (string)null);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Playlist", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int>("PlaylistGroupId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlaylistGroupId", "Name")
.IsUnique();
b.ToTable("Playlist", (string)null);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlaylistGroup", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("PlaylistGroup", (string)null);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlaylistItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("CollectionId")
.HasColumnType("INTEGER");
b.Property<int>("CollectionType")
.HasColumnType("INTEGER");
b.Property<bool>("IncludeInProgramGuide")
.HasColumnType("INTEGER");
b.Property<int>("Index")
.HasColumnType("INTEGER");
b.Property<int?>("MediaItemId")
.HasColumnType("INTEGER");
b.Property<int?>("MultiCollectionId")
.HasColumnType("INTEGER");
b.Property<int>("PlaybackOrder")
.HasColumnType("INTEGER");
b.Property<int>("PlaylistId")
.HasColumnType("INTEGER");
b.Property<int?>("SmartCollectionId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CollectionId");
b.HasIndex("MediaItemId");
b.HasIndex("MultiCollectionId");
b.HasIndex("PlaylistId");
b.HasIndex("SmartCollectionId");
b.ToTable("PlaylistItem", (string)null);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Playout", b =>
{
b.Property<int>("Id")
@ -1645,6 +1730,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -1645,6 +1730,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<int?>("MultiCollectionId")
.HasColumnType("INTEGER");
b.Property<int?>("PlaylistId")
.HasColumnType("INTEGER");
b.Property<int>("PlayoutId")
.HasColumnType("INTEGER");
@ -1659,6 +1747,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -1659,6 +1747,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.HasIndex("MultiCollectionId");
b.HasIndex("PlaylistId");
b.HasIndex("PlayoutId");
b.HasIndex("SmartCollectionId");
@ -1855,6 +1945,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -1855,6 +1945,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<int>("PlaybackOrder")
.HasColumnType("INTEGER");
b.Property<int?>("PlaylistId")
.HasColumnType("INTEGER");
b.Property<int?>("PostRollFillerId")
.HasColumnType("INTEGER");
@ -1900,6 +1993,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -1900,6 +1993,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.HasIndex("MultiCollectionId");
b.HasIndex("PlaylistId");
b.HasIndex("PostRollFillerId");
b.HasIndex("PreRollFillerId");
@ -3911,6 +4006,56 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -3911,6 +4006,56 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Navigation("OtherVideo");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Playlist", b =>
{
b.HasOne("ErsatzTV.Core.Domain.PlaylistGroup", "PlaylistGroup")
.WithMany("Playlists")
.HasForeignKey("PlaylistGroupId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("PlaylistGroup");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlaylistItem", b =>
{
b.HasOne("ErsatzTV.Core.Domain.Collection", "Collection")
.WithMany()
.HasForeignKey("CollectionId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.MediaItem", "MediaItem")
.WithMany()
.HasForeignKey("MediaItemId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.MultiCollection", "MultiCollection")
.WithMany()
.HasForeignKey("MultiCollectionId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.Playlist", "Playlist")
.WithMany("Items")
.HasForeignKey("PlaylistId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.SmartCollection", "SmartCollection")
.WithMany()
.HasForeignKey("SmartCollectionId")
.OnDelete(DeleteBehavior.Cascade);
b.Navigation("Collection");
b.Navigation("MediaItem");
b.Navigation("MultiCollection");
b.Navigation("Playlist");
b.Navigation("SmartCollection");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Playout", b =>
{
b.HasOne("ErsatzTV.Core.Domain.Channel", "Channel")
@ -4033,6 +4178,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -4033,6 +4178,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
.HasForeignKey("MultiCollectionId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.Playlist", "Playlist")
.WithMany()
.HasForeignKey("PlaylistId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.Playout", "Playout")
.WithMany("ProgramScheduleAnchors")
.HasForeignKey("PlayoutId")
@ -4071,6 +4221,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -4071,6 +4221,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Navigation("MultiCollection");
b.Navigation("Playlist");
b.Navigation("Playout");
b.Navigation("SmartCollection");
@ -4184,6 +4336,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -4184,6 +4336,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
.HasForeignKey("MultiCollectionId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.Playlist", "Playlist")
.WithMany()
.HasForeignKey("PlaylistId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.Filler.FillerPreset", "PostRollFiller")
.WithMany()
.HasForeignKey("PostRollFillerId")
@ -4224,6 +4381,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -4224,6 +4381,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Navigation("MultiCollection");
b.Navigation("Playlist");
b.Navigation("PostRollFiller");
b.Navigation("PreRollFiller");
@ -5201,6 +5360,16 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -5201,6 +5360,16 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Navigation("Writers");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Playlist", b =>
{
b.Navigation("Items");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlaylistGroup", b =>
{
b.Navigation("Playlists");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Playout", b =>
{
b.Navigation("FillGroupIndices");

22
ErsatzTV.Infrastructure/Data/Configurations/Collection/PlaylistConfiguration.cs

@ -0,0 +1,22 @@ @@ -0,0 +1,22 @@
using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ErsatzTV.Infrastructure.Data.Configurations;
public class PlaylistConfiguration : IEntityTypeConfiguration<Playlist>
{
public void Configure(EntityTypeBuilder<Playlist> builder)
{
builder.ToTable("Playlist");
builder.HasIndex(d => new { d.PlaylistGroupId, d.Name })
.IsUnique();
builder.HasMany(p => p.Items)
.WithOne(pi => pi.Playlist)
.HasForeignKey(pi => pi.PlaylistId)
.OnDelete(DeleteBehavior.Cascade);
}
}

21
ErsatzTV.Infrastructure/Data/Configurations/Collection/PlaylistGroupConfiguration.cs

@ -0,0 +1,21 @@ @@ -0,0 +1,21 @@
using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ErsatzTV.Infrastructure.Data.Configurations;
public class PlaylistGroupConfiguration : IEntityTypeConfiguration<PlaylistGroup>
{
public void Configure(EntityTypeBuilder<PlaylistGroup> builder)
{
builder.ToTable("PlaylistGroup");
builder.HasIndex(pg => pg.Name)
.IsUnique();
builder.HasMany(pg => pg.Playlists)
.WithOne(p => p.PlaylistGroup)
.HasForeignKey(p => p.PlaylistGroupId)
.OnDelete(DeleteBehavior.Cascade);
}
}

37
ErsatzTV.Infrastructure/Data/Configurations/Collection/PlaylistItemConfiguration.cs

@ -0,0 +1,37 @@ @@ -0,0 +1,37 @@
using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ErsatzTV.Infrastructure.Data.Configurations;
public class PlaylistItemConfiguration : IEntityTypeConfiguration<PlaylistItem>
{
public void Configure(EntityTypeBuilder<PlaylistItem> builder)
{
builder.ToTable("PlaylistItem");
builder.HasOne(i => i.Collection)
.WithMany()
.HasForeignKey(i => i.CollectionId)
.OnDelete(DeleteBehavior.Cascade)
.IsRequired(false);
builder.HasOne(i => i.MediaItem)
.WithMany()
.HasForeignKey(i => i.MediaItemId)
.OnDelete(DeleteBehavior.Cascade)
.IsRequired(false);
builder.HasOne(i => i.MultiCollection)
.WithMany()
.HasForeignKey(i => i.MultiCollectionId)
.OnDelete(DeleteBehavior.Cascade)
.IsRequired(false);
builder.HasOne(i => i.SmartCollection)
.WithMany()
.HasForeignKey(i => i.SmartCollectionId)
.OnDelete(DeleteBehavior.Cascade)
.IsRequired(false);
}
}

6
ErsatzTV.Infrastructure/Data/Configurations/PlayoutProgramScheduleAnchorConfiguration.cs

@ -36,6 +36,12 @@ public class PlayoutProgramScheduleAnchorConfiguration : IEntityTypeConfiguratio @@ -36,6 +36,12 @@ public class PlayoutProgramScheduleAnchorConfiguration : IEntityTypeConfiguratio
.OnDelete(DeleteBehavior.Cascade)
.IsRequired(false);
builder.HasOne(i => i.Playlist)
.WithMany()
.HasForeignKey(i => i.PlaylistId)
.OnDelete(DeleteBehavior.Cascade)
.IsRequired(false);
builder.Property(i => i.AnchorDate)
.IsRequired(false);
}

6
ErsatzTV.Infrastructure/Data/Configurations/ProgramScheduleItemConfiguration.cs

@ -33,6 +33,12 @@ public class ProgramScheduleItemConfiguration : IEntityTypeConfiguration<Program @@ -33,6 +33,12 @@ public class ProgramScheduleItemConfiguration : IEntityTypeConfiguration<Program
.HasForeignKey(i => i.SmartCollectionId)
.OnDelete(DeleteBehavior.Cascade)
.IsRequired(false);
builder.HasOne(i => i.Playlist)
.WithMany()
.HasForeignKey(i => i.PlaylistId)
.OnDelete(DeleteBehavior.Cascade)
.IsRequired(false);
builder.HasOne(i => i.PreRollFiller)
.WithMany()

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

@ -28,6 +28,88 @@ public class MediaCollectionRepository : IMediaCollectionRepository @@ -28,6 +28,88 @@ public class MediaCollectionRepository : IMediaCollectionRepository
_dbContextFactory = dbContextFactory;
}
public async Task<Dictionary<PlaylistItem, List<MediaItem>>> GetPlaylistItemMap(int playlistId)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
var result = new Dictionary<PlaylistItem, List<MediaItem>>();
Option<Playlist> maybePlaylist = await dbContext.Playlists
.Include(p => p.Items)
.SelectOneAsync(p => p.Id, p => p.Id == playlistId);
foreach (PlaylistItem playlistItem in maybePlaylist.SelectMany(p => p.Items))
{
var mediaItems = new List<MediaItem>();
switch (playlistItem.CollectionType)
{
case ProgramScheduleItemCollectionType.Collection:
foreach (int collectionId in Optional(playlistItem.CollectionId))
{
mediaItems.AddRange(await GetMovieItems(dbContext, collectionId));
mediaItems.AddRange(await GetShowItems(dbContext, collectionId));
mediaItems.AddRange(await GetSeasonItems(dbContext, collectionId));
mediaItems.AddRange(await GetEpisodeItems(dbContext, collectionId));
mediaItems.AddRange(await GetArtistItems(dbContext, collectionId));
mediaItems.AddRange(await GetMusicVideoItems(dbContext, collectionId));
mediaItems.AddRange(await GetOtherVideoItems(dbContext, collectionId));
mediaItems.AddRange(await GetSongItems(dbContext, collectionId));
mediaItems.AddRange(await GetImageItems(dbContext, collectionId));
}
break;
case ProgramScheduleItemCollectionType.TelevisionShow:
foreach (int mediaItemId in Optional(playlistItem.MediaItemId))
{
mediaItems.AddRange(await GetShowItemsFromShowId(dbContext, mediaItemId));
}
break;
case ProgramScheduleItemCollectionType.TelevisionSeason:
foreach (int mediaItemId in Optional(playlistItem.MediaItemId))
{
mediaItems.AddRange(await GetSeasonItemsFromSeasonId(dbContext, mediaItemId));
}
break;
case ProgramScheduleItemCollectionType.Artist:
foreach (int mediaItemId in Optional(playlistItem.MediaItemId))
{
mediaItems.AddRange(await GetArtistItemsFromArtistId(dbContext, mediaItemId));
}
break;
case ProgramScheduleItemCollectionType.MultiCollection:
foreach (int multiCollectionId in Optional(playlistItem.MultiCollectionId))
{
mediaItems.AddRange(await GetMultiCollectionItems(multiCollectionId));
}
break;
case ProgramScheduleItemCollectionType.SmartCollection:
foreach (int smartCollectionId in Optional(playlistItem.SmartCollectionId))
{
mediaItems.AddRange(await GetSmartCollectionItems(smartCollectionId));
}
break;
// TODO: other single media item types
}
result.Add(playlistItem, mediaItems);
}
return result;
}
public async Task<Option<Collection>> GetCollectionWithCollectionItemsUntracked(int id)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
@ -249,6 +331,84 @@ public class MediaCollectionRepository : IMediaCollectionRepository @@ -249,6 +331,84 @@ public class MediaCollectionRepository : IMediaCollectionRepository
return result;
}
public async Task<List<MediaItem>> GetPlaylistItems(int id)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
var result = new List<MediaItem>();
Option<Playlist> maybePlaylist = await dbContext.Playlists
.Include(p => p.Items)
.SelectOneAsync(p => p.Id, p => p.Id == id);
foreach (PlaylistItem playlistItem in maybePlaylist.SelectMany(p => p.Items))
{
switch (playlistItem.CollectionType)
{
case ProgramScheduleItemCollectionType.Collection:
foreach (int collectionId in Optional(playlistItem.CollectionId))
{
result.AddRange(await GetMovieItems(dbContext, collectionId));
result.AddRange(await GetShowItems(dbContext, collectionId));
result.AddRange(await GetSeasonItems(dbContext, collectionId));
result.AddRange(await GetEpisodeItems(dbContext, collectionId));
result.AddRange(await GetArtistItems(dbContext, collectionId));
result.AddRange(await GetMusicVideoItems(dbContext, collectionId));
result.AddRange(await GetOtherVideoItems(dbContext, collectionId));
result.AddRange(await GetSongItems(dbContext, collectionId));
result.AddRange(await GetImageItems(dbContext, collectionId));
}
break;
case ProgramScheduleItemCollectionType.TelevisionShow:
foreach (int mediaItemId in Optional(playlistItem.MediaItemId))
{
result.AddRange(await GetShowItemsFromShowId(dbContext, mediaItemId));
}
break;
case ProgramScheduleItemCollectionType.TelevisionSeason:
foreach (int mediaItemId in Optional(playlistItem.MediaItemId))
{
result.AddRange(await GetSeasonItemsFromSeasonId(dbContext, mediaItemId));
}
break;
case ProgramScheduleItemCollectionType.Artist:
foreach (int mediaItemId in Optional(playlistItem.MediaItemId))
{
result.AddRange(await GetArtistItemsFromArtistId(dbContext, mediaItemId));
}
break;
case ProgramScheduleItemCollectionType.MultiCollection:
foreach (int multiCollectionId in Optional(playlistItem.MultiCollectionId))
{
result.AddRange(await GetMultiCollectionItems(multiCollectionId));
}
break;
case ProgramScheduleItemCollectionType.SmartCollection:
foreach (int smartCollectionId in Optional(playlistItem.SmartCollectionId))
{
result.AddRange(await GetSmartCollectionItems(smartCollectionId));
}
break;
// TODO: other single media item types
}
}
return result.DistinctBy(x => x.Id).ToList();
}
public async Task<List<CollectionWithItems>> GetFakeMultiCollectionCollections(
int? collectionId,
@ -337,6 +497,7 @@ public class MediaCollectionRepository : IMediaCollectionRepository @@ -337,6 +497,7 @@ public class MediaCollectionRepository : IMediaCollectionRepository
ProgramScheduleItemCollectionType.TelevisionShow => await dbContext.Shows.Include(s => s.ShowMetadata)
.SelectOneAsync(a => a.Id, a => a.Id == emptyCollection.MediaItemId.Value)
.MapT(s => s.ShowMetadata.Head().Title),
// TODO: get playlist name
_ => None
};
}

3
ErsatzTV.Infrastructure/Data/TvContext.cs

@ -99,6 +99,9 @@ public class TvContext : DbContext @@ -99,6 +99,9 @@ public class TvContext : DbContext
public DbSet<DecoTemplateGroup> DecoTemplateGroups { get; set; }
public DbSet<DecoTemplate> DecoTemplates { get; set; }
public DbSet<DecoTemplateItem> DecoTemplateItems { get; set; }
public DbSet<PlaylistGroup> PlaylistGroups { get; set; }
public DbSet<Playlist> Playlists { get; set; }
public DbSet<PlaylistItem> PlaylistItems { get; set; }
public DbSet<FFmpegProfile> FFmpegProfiles { get; set; }
public DbSet<Resolution> Resolutions { get; set; }
public DbSet<LanguageCode> LanguageCodes { get; set; }

12
ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj

@ -13,17 +13,17 @@ @@ -13,17 +13,17 @@
<PackageReference Include="Blurhash.ImageSharp" Version="3.0.0" />
<PackageReference Include="CliWrap" Version="3.6.6" />
<PackageReference Include="Dapper" Version="2.1.35" />
<PackageReference Include="Elastic.Clients.Elasticsearch" Version="8.12.1" />
<PackageReference Include="Jint" Version="3.0.1" />
<PackageReference Include="Elastic.Clients.Elasticsearch" Version="8.13.10" />
<PackageReference Include="Jint" Version="3.1.0" />
<PackageReference Include="Lucene.Net" Version="4.8.0-beta00016" />
<PackageReference Include="Lucene.Net.Analysis.Common" Version="4.8.0-beta00016" />
<PackageReference Include="Lucene.Net.QueryParser" Version="4.8.0-beta00016" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.3">
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.4">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.4" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.9.28">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@ -31,7 +31,7 @@ @@ -31,7 +31,7 @@
<PackageReference Include="Refit" Version="7.0.0" />
<PackageReference Include="Refit.Newtonsoft.Json" Version="7.0.0" />
<PackageReference Include="Refit.Xml" Version="7.0.0" />
<PackageReference Include="Scriban.Signed" Version="5.9.1" />
<PackageReference Include="Scriban.Signed" Version="5.10.0" />
<PackageReference Include="TagLibSharp" Version="2.3.0" />
</ItemGroup>

20
ErsatzTV.Infrastructure/Search/ElasticSearchIndex.cs

@ -324,7 +324,7 @@ public class ElasticSearchIndex : ISearchIndex @@ -324,7 +324,7 @@ public class ElasticSearchIndex : ISearchIndex
doc.AdditionalProperties.Add(key, value);
}
await _client.IndexAsync(doc, IndexName);
await _client.IndexAsync(doc, index: IndexName);
}
catch (Exception ex)
{
@ -375,7 +375,7 @@ public class ElasticSearchIndex : ISearchIndex @@ -375,7 +375,7 @@ public class ElasticSearchIndex : ISearchIndex
doc.AdditionalProperties.Add(key, value);
}
await _client.IndexAsync(doc, IndexName);
await _client.IndexAsync(doc, index: IndexName);
}
catch (Exception ex)
{
@ -436,7 +436,7 @@ public class ElasticSearchIndex : ISearchIndex @@ -436,7 +436,7 @@ public class ElasticSearchIndex : ISearchIndex
doc.AdditionalProperties.Add(key, value);
}
await _client.IndexAsync(doc, IndexName);
await _client.IndexAsync(doc, index: IndexName);
}
catch (Exception ex)
{
@ -483,7 +483,7 @@ public class ElasticSearchIndex : ISearchIndex @@ -483,7 +483,7 @@ public class ElasticSearchIndex : ISearchIndex
doc.AdditionalProperties.Add(key, value);
}
await _client.IndexAsync(doc, IndexName);
await _client.IndexAsync(doc, index: IndexName);
}
catch (Exception ex)
{
@ -548,7 +548,7 @@ public class ElasticSearchIndex : ISearchIndex @@ -548,7 +548,7 @@ public class ElasticSearchIndex : ISearchIndex
doc.AdditionalProperties.Add(key, value);
}
await _client.IndexAsync(doc, IndexName);
await _client.IndexAsync(doc, index: IndexName);
}
catch (Exception ex)
{
@ -626,7 +626,7 @@ public class ElasticSearchIndex : ISearchIndex @@ -626,7 +626,7 @@ public class ElasticSearchIndex : ISearchIndex
doc.AdditionalProperties.Add(key, value);
}
await _client.IndexAsync(doc, IndexName);
await _client.IndexAsync(doc, index: IndexName);
}
catch (Exception ex)
{
@ -677,7 +677,7 @@ public class ElasticSearchIndex : ISearchIndex @@ -677,7 +677,7 @@ public class ElasticSearchIndex : ISearchIndex
doc.AdditionalProperties.Add(key, value);
}
await _client.IndexAsync(doc, IndexName);
await _client.IndexAsync(doc, index: IndexName);
}
catch (Exception ex)
{
@ -724,7 +724,7 @@ public class ElasticSearchIndex : ISearchIndex @@ -724,7 +724,7 @@ public class ElasticSearchIndex : ISearchIndex
doc.AdditionalProperties.Add(key, value);
}
await _client.IndexAsync(doc, IndexName);
await _client.IndexAsync(doc, index: IndexName);
}
catch (Exception ex)
{
@ -777,7 +777,7 @@ public class ElasticSearchIndex : ISearchIndex @@ -777,7 +777,7 @@ public class ElasticSearchIndex : ISearchIndex
doc.AdditionalProperties.Add(key, value);
}
await _client.IndexAsync(doc, IndexName);
await _client.IndexAsync(doc, index: IndexName);
}
catch (Exception ex)
{
@ -946,7 +946,7 @@ public class ElasticSearchIndex : ISearchIndex @@ -946,7 +946,7 @@ public class ElasticSearchIndex : ISearchIndex
s => s.Index(IndexName)
.Size(0)
.Sort(ss => ss.Field(f => f.SortTitle, fs => fs.Order(SortOrder.Asc)))
.Aggregations(a => a.Terms("count", v => v.Field(i => i.JumpLetter).Size(30)))
.Aggregations(a => a.Add("count", agg => agg.Terms(v => v.Field(i => i.JumpLetter).Size(30))))
.QueryLuceneSyntax(query));
if (!response.IsValidResponse)

18
ErsatzTV/ErsatzTV.csproj

@ -18,18 +18,18 @@ @@ -18,18 +18,18 @@
<ItemGroup>
<PackageReference Include="Blazored.FluentValidation" Version="2.1.0" />
<PackageReference Include="Bugsnag.AspNet.Core" Version="3.1.0" />
<PackageReference Include="FluentValidation" Version="11.9.0" />
<PackageReference Include="FluentValidation" Version="11.9.1" />
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
<PackageReference Include="Heron.MudCalendar" Version="1.1.1" />
<PackageReference Include="HtmlSanitizer" Version="8.0.843" />
<PackageReference Include="HtmlSanitizer" Version="8.0.865" />
<PackageReference Include="LanguageExt.Core" Version="4.4.8" />
<PackageReference Include="Markdig" Version="0.36.2" />
<PackageReference Include="Markdig" Version="0.37.0" />
<PackageReference Include="MediatR.Courier.DependencyInjection" Version="5.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.3" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="8.0.3" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.3" />
<PackageReference Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="8.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.3">
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="8.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.4" />
<PackageReference Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="8.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.4">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
@ -38,7 +38,7 @@ @@ -38,7 +38,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="MudBlazor" Version="6.19.1" />
<PackageReference Include="NaturalSort.Extension" Version="4.2.0" />
<PackageReference Include="NaturalSort.Extension" Version="4.3.0" />
<PackageReference Include="Refit.HttpClientFactory" Version="7.0.0" />
<PackageReference Include="Serilog" Version="3.1.1" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />

2
ErsatzTV/Pages/Artist.razor

@ -259,7 +259,7 @@ @@ -259,7 +259,7 @@
DialogResult result = await dialog.Result;
if (!result.Canceled && result.Data is ProgramScheduleViewModel schedule)
{
await _mediator.Send(new AddProgramScheduleItem(schedule.Id, StartType.Dynamic, null, PlayoutMode.One, ProgramScheduleItemCollectionType.Artist, null, null, null, ArtistId, PlaybackOrder.Shuffle, FillWithGroupMode.None, null, null, TailMode.None, null, null, GuideMode.Normal, null, null, null, null, null, null, null, null, null, null), _cts.Token);
await _mediator.Send(new AddProgramScheduleItem(schedule.Id, StartType.Dynamic, null, PlayoutMode.One, ProgramScheduleItemCollectionType.Artist, null, null, null, ArtistId, null, PlaybackOrder.Shuffle, FillWithGroupMode.None, null, null, TailMode.None, null, null, GuideMode.Normal, null, null, null, null, null, null, null, null, null, null), _cts.Token);
_navigationManager.NavigateTo($"schedules/{schedule.Id}/items");
}
}

465
ErsatzTV/Pages/PlaylistEditor.razor

@ -0,0 +1,465 @@ @@ -0,0 +1,465 @@
@page "/media/playlists/{Id:int}"
@using ErsatzTV.Application.Scheduling
@using ErsatzTV.Application.Search
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.MediaItems
@implements IDisposable
@inject NavigationManager NavigationManager
@inject ILogger<PlaylistEditor> Logger
@inject ISnackbar Snackbar
@inject IMediator Mediator
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudText Typo="Typo.h4" Class="mb-4">Edit Playlist</MudText>
<div style="max-width: 400px">
<MudCard>
<MudCardContent>
<MudTextField Label="Name" @bind-Value="_playlist.Name" For="@(() => _playlist.Name)"/>
</MudCardContent>
</MudCard>
</div>
<MudButton Variant="Variant.Filled" Color="Color.Default" OnClick="@(_ => AddPlaylistItem())" Class="mt-4">
Add Playlist Item
</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@(_ => SaveChanges())" Class="mt-4 ml-4">
Save Changes
</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Secondary" OnClick="@(_ => PreviewPlayout())" Class="mt-4 ml-4">
Preview Playlist Playout
</MudButton>
<MudGrid>
<MudItem xs="8">
<MudTable Class="mt-6" Hover="true" Items="_playlist.Items.OrderBy(i => i.Index)" Dense="true" @bind-SelectedItem="_selectedItem">
<ColGroup>
<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>Playback Order</MudTh>
<MudTh>Show In EPG</MudTh>
<MudTh/>
<MudTh/>
<MudTh/>
<MudTh/>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Collection">
<MudText Typo="@(context == _selectedItem ? Typo.subtitle2 : Typo.body2)">
@context.CollectionName
</MudText>
</MudTd>
<MudTd DataLabel="Playback Order">
<MudText Typo="@(context == _selectedItem ? Typo.subtitle2 : Typo.body2)">
@context.PlaybackOrder
</MudText>
</MudTd>
<MudTd>
<MudCheckBox T="bool" Value="@context.IncludeInProgramGuide" ValueChanged="@(e => UpdateEPG(context, e))"/>
</MudTd>
<MudTd>
<MudIconButton Icon="@Icons.Material.Filled.ContentCopy"
OnClick="@(_ => CopyItem(context))">
</MudIconButton>
</MudTd>
<MudTd>
<MudIconButton Icon="@Icons.Material.Filled.ArrowUpward"
OnClick="@(_ => MoveItemUp(context))"
Disabled="@(_playlist.Items.All(x => x.Index >= context.Index))">
</MudIconButton>
</MudTd>
<MudTd>
<MudIconButton Icon="@Icons.Material.Filled.ArrowDownward"
OnClick="@(_ => MoveItemDown(context))"
Disabled="@(_playlist.Items.All(x => x.Index <= context.Index))">
</MudIconButton>
</MudTd>
<MudTd>
<MudIconButton Icon="@Icons.Material.Filled.Delete"
OnClick="@(_ => RemovePlaylistItem(context))">
</MudIconButton>
</MudTd>
</RowTemplate>
</MudTable>
</MudItem>
</MudGrid>
<div class="mt-4">
@if (_selectedItem is not null)
{
<EditForm Model="_selectedItem">
<FluentValidationValidator/>
<div style="max-width: 400px;" class="mr-6">
<MudCard>
<MudCardContent>
<MudSelect Class="mt-3" Label="Collection Type" @bind-Value="_selectedItem.CollectionType" For="@(() => _selectedItem.CollectionType)">
<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.SmartCollection">Smart Collection</MudSelectItem>
</MudSelect>
@if (_selectedItem.CollectionType == ProgramScheduleItemCollectionType.Collection)
{
<MudAutocomplete Class="mt-3" T="MediaCollectionViewModel" Label="Collection"
@bind-Value="_selectedItem.Collection" SearchFunc="@SearchCollections"
ToStringFunc="@(c => c?.Name)" Placeholder="Type to search...">
<MoreItemsTemplate>
<MudText Align="Align.Center" Class="px-4 py-1">
Only the first 10 items are shown
</MudText>
</MoreItemsTemplate>
</MudAutocomplete>
}
@if (_selectedItem.CollectionType == ProgramScheduleItemCollectionType.MultiCollection)
{
<MudAutocomplete Class="mt-3" T="MultiCollectionViewModel" Label="Multi Collection"
@bind-Value="_selectedItem.MultiCollection" SearchFunc="@SearchMultiCollections"
ToStringFunc="@(c => c?.Name)" Placeholder="Type to search...">
<MoreItemsTemplate>
<MudText Align="Align.Center" Class="px-4 py-1">
Only the first 10 items are shown
</MudText>
</MoreItemsTemplate>
</MudAutocomplete>
}
@if (_selectedItem.CollectionType == ProgramScheduleItemCollectionType.SmartCollection)
{
<MudAutocomplete Class="mt-3" T="SmartCollectionViewModel" Label="Smart Collection"
@bind-Value="_selectedItem.SmartCollection" SearchFunc="@SearchSmartCollections"
ToStringFunc="@(c => c?.Name)" Placeholder="Type to search...">
<MoreItemsTemplate>
<MudText Align="Align.Center" Class="px-4 py-1">
Only the first 10 items are shown
</MudText>
</MoreItemsTemplate>
</MudAutocomplete>
}
@if (_selectedItem.CollectionType == ProgramScheduleItemCollectionType.TelevisionShow)
{
<MudAutocomplete Class="mt-3" T="NamedMediaItemViewModel" Label="Television Show"
@bind-Value="_selectedItem.MediaItem" SearchFunc="@SearchTelevisionShows"
ToStringFunc="@(c => c?.Name)" Placeholder="Type to search...">
<MoreItemsTemplate>
<MudText Align="Align.Center" Class="px-4 py-1">
Only the first 10 items are shown
</MudText>
</MoreItemsTemplate>
</MudAutocomplete>
}
@if (_selectedItem.CollectionType == ProgramScheduleItemCollectionType.TelevisionSeason)
{
<MudAutocomplete Class="mt-3" T="NamedMediaItemViewModel" Label="Television Season"
@bind-Value="_selectedItem.MediaItem" SearchFunc="@SearchTelevisionSeasons"
ToStringFunc="@(c => c?.Name)" Placeholder="Type to search..."
MaxItems="20">
<MoreItemsTemplate>
<MudText Align="Align.Center" Class="px-4 py-1">
Only the first 20 items are shown
</MudText>
</MoreItemsTemplate>
</MudAutocomplete>
}
@if (_selectedItem.CollectionType == ProgramScheduleItemCollectionType.Artist)
{
<MudAutocomplete Class="mt-3" T="NamedMediaItemViewModel" Label="Artist"
@bind-Value="_selectedItem.MediaItem" SearchFunc="@SearchArtists"
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)">
@switch (_selectedItem.CollectionType)
{
case ProgramScheduleItemCollectionType.MultiCollection:
<MudSelectItem Value="PlaybackOrder.Shuffle">Shuffle</MudSelectItem>
@* <MudSelectItem Value="PlaybackOrder.ShuffleInOrder">Shuffle In Order</MudSelectItem> *@
break;
case ProgramScheduleItemCollectionType.Collection:
case ProgramScheduleItemCollectionType.SmartCollection:
<MudSelectItem Value="PlaybackOrder.Chronological">Chronological</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Shuffle">Shuffle</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Random">Random</MudSelectItem>
@* <MudSelectItem Value="PlaybackOrder.ShuffleInOrder">Shuffle In Order</MudSelectItem> *@
break;
case ProgramScheduleItemCollectionType.TelevisionShow:
<MudSelectItem Value="PlaybackOrder.SeasonEpisode">Season, Episode</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Chronological">Chronological</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Shuffle">Shuffle</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Random">Random</MudSelectItem>
@* <MudSelectItem Value="PlaybackOrder.MultiEpisodeShuffle">Multi-Episode Shuffle</MudSelectItem> *@
break;
case ProgramScheduleItemCollectionType.TelevisionSeason:
case ProgramScheduleItemCollectionType.Artist:
case ProgramScheduleItemCollectionType.FakeCollection:
default:
<MudSelectItem Value="PlaybackOrder.Chronological">Chronological</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Shuffle">Shuffle</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Random">Random</MudSelectItem>
break;
}
</MudSelect>
</MudCardContent>
</MudCard>
</div>
</EditForm>
}
</div>
@if (_previewItems != null)
{
<MudTable Class="mt-8"
Hover="true"
Dense="true"
Items="_previewItems">
<ToolBarContent>
<MudText Typo="Typo.h6">Playlist Playout Preview</MudText>
</ToolBarContent>
<HeaderContent>
<MudTh>Start</MudTh>
<MudTh>Finish</MudTh>
<MudTh>Media Item</MudTh>
<MudTh>Duration</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Start">@context.Start.ToString(@"hh\:mm\:ss")</MudTd>
<MudTd DataLabel="Finish">@context.Finish.ToString(@"hh\:mm\:ss")</MudTd>
<MudTd DataLabel="Media Item">@context.Title</MudTd>
<MudTd DataLabel="Duration">@context.Duration</MudTd>
</RowTemplate>
</MudTable>
}
</MudContainer>
@code {
private readonly CancellationTokenSource _cts = new();
[Parameter]
public int Id { get; set; }
private PlaylistItemsEditViewModel _playlist = new() { Items = [] };
private PlaylistItemEditViewModel _selectedItem;
private List<PlayoutItemPreviewViewModel> _previewItems;
public void Dispose()
{
_cts.Cancel();
_cts.Dispose();
}
protected override async Task OnParametersSetAsync() => await LoadPlaylistItems();
private async Task LoadPlaylistItems()
{
Option<PlaylistViewModel> maybePlaylist = await Mediator.Send(new GetPlaylistById(Id), _cts.Token);
if (maybePlaylist.IsNone)
{
NavigationManager.NavigateTo("/media/playlists");
return;
}
foreach (PlaylistViewModel playlist in maybePlaylist)
{
_playlist = new PlaylistItemsEditViewModel
{
Name = playlist.Name,
Items = []
};
}
Option<IEnumerable<PlaylistItemViewModel>> maybeResults = await Mediator.Send(new GetPlaylistItems(Id), _cts.Token);
foreach (IEnumerable<PlaylistItemViewModel> items in maybeResults)
{
_playlist.Items.AddRange(items.Map(ProjectToEditViewModel));
if (_playlist.Items.Count == 1)
{
_selectedItem = _playlist.Items.Head();
}
}
}
private async Task<IEnumerable<MediaCollectionViewModel>> SearchCollections(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return new List<MediaCollectionViewModel>();
}
return await Mediator.Send(new SearchCollections(value), _cts.Token);
}
private async Task<IEnumerable<MultiCollectionViewModel>> SearchMultiCollections(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return new List<MultiCollectionViewModel>();
}
return await Mediator.Send(new SearchMultiCollections(value), _cts.Token);
}
private async Task<IEnumerable<SmartCollectionViewModel>> SearchSmartCollections(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return new List<SmartCollectionViewModel>();
}
return await Mediator.Send(new SearchSmartCollections(value), _cts.Token);
}
private async Task<IEnumerable<NamedMediaItemViewModel>> SearchTelevisionShows(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return new List<NamedMediaItemViewModel>();
}
return await Mediator.Send(new SearchTelevisionShows(value), _cts.Token);
}
private async Task<IEnumerable<NamedMediaItemViewModel>> SearchTelevisionSeasons(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return new List<NamedMediaItemViewModel>();
}
return await Mediator.Send(new SearchTelevisionSeasons(value), _cts.Token);
}
private async Task<IEnumerable<NamedMediaItemViewModel>> SearchArtists(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return new List<NamedMediaItemViewModel>();
}
return await Mediator.Send(new SearchArtists(value), _cts.Token);
}
private static PlaylistItemEditViewModel ProjectToEditViewModel(PlaylistItemViewModel item) =>
new()
{
Id = item.Id,
Index = item.Index,
CollectionType = item.CollectionType,
Collection = item.Collection,
MultiCollection = item.MultiCollection,
SmartCollection = item.SmartCollection,
MediaItem = item.MediaItem,
PlaybackOrder = item.PlaybackOrder,
IncludeInProgramGuide = item.IncludeInProgramGuide
};
private void AddPlaylistItem()
{
var item = new PlaylistItemEditViewModel
{
Index = _playlist.Items.Map(i => i.Index).DefaultIfEmpty().Max() + 1,
PlaybackOrder = PlaybackOrder.Chronological,
CollectionType = ProgramScheduleItemCollectionType.Collection
};
_playlist.Items.Add(item);
_selectedItem = item;
}
private void CopyItem(PlaylistItemEditViewModel item)
{
var newItem = new PlaylistItemEditViewModel
{
Index = item.Index + 1,
PlaybackOrder = item.PlaybackOrder,
CollectionType = item.CollectionType,
Collection = item.Collection,
MultiCollection = item.MultiCollection,
SmartCollection = item.SmartCollection,
MediaItem = item.MediaItem,
IncludeInProgramGuide = item.IncludeInProgramGuide
};
foreach (PlaylistItemEditViewModel i in _playlist.Items.Filter(bi => bi.Index >= newItem.Index))
{
i.Index += 1;
}
_playlist.Items.Add(newItem);
_selectedItem = newItem;
}
private void RemovePlaylistItem(PlaylistItemEditViewModel item)
{
_selectedItem = null;
_playlist.Items.Remove(item);
}
private void MoveItemUp(PlaylistItemEditViewModel item)
{
// swap with lower index
PlaylistItemEditViewModel toSwap = _playlist.Items.OrderByDescending(x => x.Index).First(x => x.Index < item.Index);
(toSwap.Index, item.Index) = (item.Index, toSwap.Index);
}
private void MoveItemDown(PlaylistItemEditViewModel item)
{
// swap with higher index
PlaylistItemEditViewModel toSwap = _playlist.Items.OrderBy(x => x.Index).First(x => x.Index > item.Index);
(toSwap.Index, item.Index) = (item.Index, toSwap.Index);
}
private async Task SaveChanges()
{
Seq<BaseError> errorMessages = await Mediator
.Send(GenerateReplaceRequest(), _cts.Token)
.Map(e => e.LeftToSeq());
errorMessages.HeadOrNone().Match(
error =>
{
Snackbar.Add($"Unexpected error saving playlist: {error.Value}", Severity.Error);
Logger.LogError("Unexpected error saving playlist: {Error}", error.Value);
},
() => NavigationManager.NavigateTo("/media/playlists"));
}
private ReplacePlaylistItems GenerateReplaceRequest()
{
var items = _playlist.Items.Map(
item => new ReplacePlaylistItem(
item.Index,
item.CollectionType,
item.Collection?.Id,
item.MultiCollection?.Id,
item.SmartCollection?.Id,
item.MediaItem?.MediaItemId,
item.PlaybackOrder,
item.IncludeInProgramGuide)).ToList();
return new ReplacePlaylistItems(Id, _playlist.Name, items);
}
private async Task PreviewPlayout()
{
_selectedItem = null;
_previewItems = await Mediator.Send(new PreviewPlaylistPlayout(GenerateReplaceRequest()), _cts.Token);
}
private static void UpdateEPG(PlaylistItemEditViewModel context, bool includeInProgramGuide) => context.IncludeInProgramGuide = includeInProgramGuide;
}

211
ErsatzTV/Pages/Playlists.razor

@ -0,0 +1,211 @@ @@ -0,0 +1,211 @@
@page "/media/playlists"
@using S = System.Collections.Generic
@using ErsatzTV.Application.MediaCollections
@implements IDisposable
@inject ILogger<Playlists> Logger
@inject ISnackbar Snackbar
@inject IMediator Mediator
@inject IDialogService Dialog
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudText Typo="Typo.h4" Class="mb-4">Playlists</MudText>
<MudGrid>
<MudItem xs="4">
<div style="max-width: 400px;" class="mr-4">
<MudCard>
<MudCardContent>
<MudTextField Class="mt-3 mx-3" Label="Playlist Group Name" @bind-Value="_playlistGroupName" For="@(() => _playlistGroupName)"/>
</MudCardContent>
<MudCardActions>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@(_ => AddPlaylistGroup())" Class="ml-4 mb-4">
Add Playlist Group
</MudButton>
</MudCardActions>
</MudCard>
</div>
</MudItem>
<MudItem xs="4">
<div style="max-width: 400px;" class="mb-6">
<MudCard>
<MudCardContent>
<div class="mx-4">
<MudSelect Label="Playlist Group" @bind-Value="_selectedPlaylistGroup" Class="mt-3">
@foreach (PlaylistGroupViewModel playlistGroup in _playlistGroups)
{
<MudSelectItem Value="@playlistGroup">@playlistGroup.Name</MudSelectItem>
}
</MudSelect>
<MudTextField Class="mt-3" Label="Playlist Name" @bind-Value="_playlistName" For="@(() => _playlistName)"/>
</div>
</MudCardContent>
<MudCardActions>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@(_ => AddPlaylist())" Class="ml-4 mb-4">
Add Playlist
</MudButton>
</MudCardActions>
</MudCard>
</div>
</MudItem>
<MudItem xs="8">
<MudCard>
<MudTreeView ServerData="LoadServerData" Items="@TreeItems" Hover="true" ExpandOnClick="true">
<ItemTemplate Context="item">
<MudTreeViewItem Items="@item.TreeItems" Icon="@item.Icon" CanExpand="@item.CanExpand" Value="@item">
<BodyContent>
<div style="display: grid; grid-template-columns: 1fr auto; align-items: center; width: 100%">
<MudGrid Justify="Justify.FlexStart">
<MudItem xs="5">
<MudText>@item.Text</MudText>
</MudItem>
@if (!string.IsNullOrWhiteSpace(item.EndText))
{
<MudItem xs="6">
<MudText>@item.EndText</MudText>
</MudItem>
}
</MudGrid>
<div style="justify-self: end;">
@foreach (int playlistId in Optional(item.PlaylistId))
{
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Medium" Color="Color.Inherit" Href="@($"media/playlists/{playlistId}")"/>
}
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Medium" Color="Color.Inherit" OnClick="@(_ => DeleteItem(item))"/>
</div>
</div>
</BodyContent>
</MudTreeViewItem>
</ItemTemplate>
</MudTreeView>
</MudCard>
</MudItem>
</MudGrid>
</MudContainer>
@code {
private readonly CancellationTokenSource _cts = new();
private S.HashSet<PlaylistTreeItemViewModel> TreeItems { get; set; } = [];
private List<PlaylistGroupViewModel> _playlistGroups = [];
private PlaylistGroupViewModel _selectedPlaylistGroup;
private string _playlistGroupName;
private string _playlistName;
public void Dispose()
{
_cts.Cancel();
_cts.Dispose();
}
protected override async Task OnParametersSetAsync()
{
await ReloadPlaylistGroups();
await InvokeAsync(StateHasChanged);
}
private async Task ReloadPlaylistGroups()
{
_playlistGroups = await Mediator.Send(new GetAllPlaylistGroups(), _cts.Token);
TreeItems = _playlistGroups.Map(g => new PlaylistTreeItemViewModel(g)).ToHashSet();
}
private async Task AddPlaylistGroup()
{
if (!string.IsNullOrWhiteSpace(_playlistGroupName))
{
Either<BaseError, PlaylistGroupViewModel> result = await Mediator.Send(new CreatePlaylistGroup(_playlistGroupName), _cts.Token);
foreach (BaseError error in result.LeftToSeq())
{
Snackbar.Add(error.Value, Severity.Error);
Logger.LogError("Unexpected error adding playlist group: {Error}", error.Value);
}
foreach (PlaylistGroupViewModel playlistGroup in result.RightToSeq())
{
TreeItems.Add(new PlaylistTreeItemViewModel(playlistGroup));
_playlistGroupName = null;
_playlistGroups = await Mediator.Send(new GetAllPlaylistGroups(), _cts.Token);
await InvokeAsync(StateHasChanged);
}
}
}
private async Task AddPlaylist()
{
if (_selectedPlaylistGroup is not null && !string.IsNullOrWhiteSpace(_playlistName))
{
Either<BaseError, PlaylistViewModel> result = await Mediator.Send(new CreatePlaylist(_selectedPlaylistGroup.Id, _playlistName), _cts.Token);
foreach (BaseError error in result.LeftToSeq())
{
Snackbar.Add(error.Value, Severity.Error);
Logger.LogError("Unexpected error adding playlist: {Error}", error.Value);
}
foreach (PlaylistViewModel playlist in result.RightToSeq())
{
foreach (PlaylistTreeItemViewModel item in TreeItems.Where(item => item.PlaylistGroupId == _selectedPlaylistGroup.Id))
{
item.TreeItems.Add(new PlaylistTreeItemViewModel(playlist));
}
_playlistName = null;
await InvokeAsync(StateHasChanged);
}
}
}
private async Task<S.HashSet<PlaylistTreeItemViewModel>> LoadServerData(PlaylistTreeItemViewModel parentNode)
{
foreach (int playlistGroupId in Optional(parentNode.PlaylistGroupId))
{
List<PlaylistViewModel> result = await Mediator.Send(new GetPlaylistsByPlaylistGroupId(playlistGroupId), _cts.Token);
foreach (PlaylistViewModel playlist in result)
{
parentNode.TreeItems.Add(new PlaylistTreeItemViewModel(playlist));
}
}
return parentNode.TreeItems;
}
private async Task DeleteItem(PlaylistTreeItemViewModel treeItem)
{
foreach (int playlistGroupId in Optional(treeItem.PlaylistGroupId))
{
var parameters = new DialogParameters { { "EntityType", "playlist group" }, { "EntityName", treeItem.Text } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<DeleteDialog>("Delete Playlist Group", parameters, options);
DialogResult result = await dialog.Result;
if (!result.Canceled)
{
await Mediator.Send(new DeletePlaylistGroup(playlistGroupId), _cts.Token);
TreeItems.RemoveWhere(i => i.PlaylistGroupId == playlistGroupId);
_playlistGroups = await Mediator.Send(new GetAllPlaylistGroups(), _cts.Token);
await InvokeAsync(StateHasChanged);
}
}
foreach (int playlistId in Optional(treeItem.PlaylistId))
{
var parameters = new DialogParameters { { "EntityType", "playlist" }, { "EntityName", treeItem.Text } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<DeleteDialog>("Delete Playlist", parameters, options);
DialogResult result = await dialog.Result;
if (!result.Canceled)
{
await Mediator.Send(new DeletePlaylist(playlistId), _cts.Token);
foreach (PlaylistTreeItemViewModel parent in TreeItems)
{
parent.TreeItems.Remove(treeItem);
}
await InvokeAsync(StateHasChanged);
}
}
}
}

41
ErsatzTV/Pages/ScheduleItemsEditor.razor

@ -108,6 +108,7 @@ @@ -108,6 +108,7 @@
<MudSelectItem Value="ProgramScheduleItemCollectionType.Artist">Artist</MudSelectItem>
<MudSelectItem Value="ProgramScheduleItemCollectionType.MultiCollection">Multi Collection</MudSelectItem>
<MudSelectItem Value="ProgramScheduleItemCollectionType.SmartCollection">Smart Collection</MudSelectItem>
<MudSelectItem Value="ProgramScheduleItemCollectionType.Playlist">Playlist</MudSelectItem>
</MudSelect>
@if (_selectedItem.CollectionType == ProgramScheduleItemCollectionType.Collection)
{
@ -183,7 +184,33 @@ @@ -183,7 +184,33 @@
</MoreItemsTemplate>
</MudAutocomplete>
}
<MudSelect Class="mt-3" Label="Playback Order" @bind-Value="@_selectedItem.PlaybackOrder" For="@(() => _selectedItem.PlaybackOrder)">
@if (_selectedItem.CollectionType == ProgramScheduleItemCollectionType.Playlist)
{
<MudSelect Class="mt-3"
T="PlaylistGroupViewModel"
Label="Playlist Group"
ValueChanged="@(vm => UpdatePlaylistGroupItems(vm))">
@foreach (PlaylistGroupViewModel playlistGroup in _playlistGroups)
{
<MudSelectItem Value="@playlistGroup">@playlistGroup.Name</MudSelectItem>
}
</MudSelect>
<MudSelect Class="mt-3"
T="PlaylistViewModel"
Label="Playlist"
@bind-value="_selectedItem.Playlist">
@foreach (PlaylistViewModel playlist in _playlists)
{
<MudSelectItem Value="@playlist">@playlist.Name</MudSelectItem>
}
</MudSelect>
}
<MudSelect Class="mt-3"
Label="Playback Order"
@bind-Value="@_selectedItem.PlaybackOrder"
For="@(() => _selectedItem.PlaybackOrder)"
Disabled="@(_selectedItem.CollectionType is ProgramScheduleItemCollectionType.Playlist)">
@switch (_selectedItem.CollectionType)
{
case ProgramScheduleItemCollectionType.MultiCollection:
@ -204,6 +231,8 @@ @@ -204,6 +231,8 @@
<MudSelectItem Value="PlaybackOrder.Shuffle">Shuffle</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.MultiEpisodeShuffle">Multi-Episode Shuffle</MudSelectItem>
break;
case ProgramScheduleItemCollectionType.Playlist:
break;
default:
<MudSelectItem Value="PlaybackOrder.Chronological">Chronological</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Random">Random</MudSelectItem>
@ -366,6 +395,8 @@ @@ -366,6 +395,8 @@
private List<FillerPresetViewModel> _fillerPresets = new();
private List<WatermarkViewModel> _watermarks = new();
private List<LanguageCodeViewModel> _availableCultures = new();
private readonly List<PlaylistGroupViewModel> _playlistGroups = [];
private readonly List<PlaylistViewModel> _playlists = [];
private ProgramScheduleItemEditViewModel _selectedItem;
@ -385,6 +416,7 @@ @@ -385,6 +416,7 @@
_watermarks = await Mediator.Send(new GetAllWatermarks(), _cts.Token)
.Map(list => list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase).ToList());
_availableCultures = await Mediator.Send(new GetAllLanguageCodes(), _cts.Token);
_playlistGroups.AddRange(await Mediator.Send(new GetAllPlaylistGroups(), _cts.Token));
string name = string.Empty;
var shuffleScheduleItems = false;
@ -473,6 +505,12 @@ @@ -473,6 +505,12 @@
return await Mediator.Send(new SearchArtists(value), _cts.Token);
}
private async Task UpdatePlaylistGroupItems(PlaylistGroupViewModel playlistGroup)
{
_playlists.Clear();
_playlists.AddRange(await Mediator.Send(new GetPlaylistsByPlaylistGroupId(playlistGroup.Id), _cts.Token));
}
private ProgramScheduleItemEditViewModel ProjectToEditViewModel(ProgramScheduleItemViewModel item)
{
var result = new ProgramScheduleItemEditViewModel
@ -566,6 +604,7 @@ @@ -566,6 +604,7 @@
item.MultiCollection?.Id,
item.SmartCollection?.Id,
item.MediaItem?.MediaItemId,
item.Playlist?.Id,
item.PlaybackOrder,
item.FillWithGroupMode,
item.MultipleCount,

2
ErsatzTV/Pages/TelevisionEpisodeList.razor

@ -241,7 +241,7 @@ @@ -241,7 +241,7 @@
DialogResult result = await dialog.Result;
if (!result.Canceled && result.Data is ProgramScheduleViewModel schedule)
{
await _mediator.Send(new AddProgramScheduleItem(schedule.Id, StartType.Dynamic, null, PlayoutMode.One, ProgramScheduleItemCollectionType.TelevisionSeason, null, null, null, SeasonId, PlaybackOrder.Shuffle, FillWithGroupMode.None, null, null, TailMode.None, null, null, GuideMode.Normal, null, null, null, null, null, null, null, null, null, null), _cts.Token);
await _mediator.Send(new AddProgramScheduleItem(schedule.Id, StartType.Dynamic, null, PlayoutMode.One, ProgramScheduleItemCollectionType.TelevisionSeason, null, null, null, SeasonId, null, PlaybackOrder.Shuffle, FillWithGroupMode.None, null, null, TailMode.None, null, null, GuideMode.Normal, null, null, null, null, null, null, null, null, null, null), _cts.Token);
_navigationManager.NavigateTo($"schedules/{schedule.Id}/items");
}
}

2
ErsatzTV/Pages/TelevisionSeasonList.razor

@ -229,7 +229,7 @@ @@ -229,7 +229,7 @@
DialogResult result = await dialog.Result;
if (!result.Canceled && result.Data is ProgramScheduleViewModel schedule)
{
await _mediator.Send(new AddProgramScheduleItem(schedule.Id, StartType.Dynamic, null, PlayoutMode.One, ProgramScheduleItemCollectionType.TelevisionShow, null, null, null, ShowId, PlaybackOrder.Shuffle, FillWithGroupMode.None, null, null, TailMode.None, null, null, GuideMode.Normal, null, null, null, null, null, null, null, null, null, null), _cts.Token);
await _mediator.Send(new AddProgramScheduleItem(schedule.Id, StartType.Dynamic, null, PlayoutMode.One, ProgramScheduleItemCollectionType.TelevisionShow, null, null, null, ShowId, null, PlaybackOrder.Shuffle, FillWithGroupMode.None, null, null, TailMode.None, null, null, GuideMode.Normal, null, null, null, null, null, null, null, null, null, null), _cts.Token);
_navigationManager.NavigateTo($"schedules/{schedule.Id}/items");
}
}

1
ErsatzTV/Shared/MainLayout.razor

@ -118,6 +118,7 @@ @@ -118,6 +118,7 @@
</MudNavGroup>
<MudNavGroup Title="Lists">
<MudNavLink Href="media/collections">Collections</MudNavLink>
<MudNavLink Href="media/playlists">Playlists</MudNavLink>
<MudNavLink Href="media/trakt/lists">Trakt Lists</MudNavLink>
<MudNavLink Href="media/filler/presets">Filler Presets</MudNavLink>
</MudNavGroup>

79
ErsatzTV/ViewModels/PlaylistItemEditViewModel.cs

@ -0,0 +1,79 @@ @@ -0,0 +1,79 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.ViewModels;
public class PlaylistItemEditViewModel : INotifyPropertyChanged
{
private ProgramScheduleItemCollectionType _collectionType;
public int Id { get; set; }
public int Index { get; set; }
public ProgramScheduleItemCollectionType CollectionType
{
get => _collectionType;
set
{
if (_collectionType != value)
{
_collectionType = value;
Collection = null;
MultiCollection = null;
MediaItem = null;
SmartCollection = null;
OnPropertyChanged(nameof(Collection));
OnPropertyChanged(nameof(MultiCollection));
OnPropertyChanged(nameof(MediaItem));
OnPropertyChanged(nameof(SmartCollection));
}
if (_collectionType == ProgramScheduleItemCollectionType.MultiCollection)
{
PlaybackOrder = PlaybackOrder.Shuffle;
}
}
}
public MediaCollectionViewModel Collection { get; set; }
public MultiCollectionViewModel MultiCollection { get; set; }
public SmartCollectionViewModel SmartCollection { get; set; }
public NamedMediaItemViewModel MediaItem { get; set; }
public string CollectionName => CollectionType switch
{
ProgramScheduleItemCollectionType.Collection => Collection?.Name,
ProgramScheduleItemCollectionType.TelevisionShow => MediaItem?.Name,
ProgramScheduleItemCollectionType.TelevisionSeason => MediaItem?.Name,
ProgramScheduleItemCollectionType.Artist => MediaItem?.Name,
ProgramScheduleItemCollectionType.MultiCollection => MultiCollection?.Name,
ProgramScheduleItemCollectionType.SmartCollection => SmartCollection?.Name,
_ => string.Empty
};
public PlaybackOrder PlaybackOrder { get; set; }
public bool IncludeInProgramGuide { get; set; }
public event PropertyChangedEventHandler PropertyChanged;
protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null) =>
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
protected bool SetField<T>(ref T field, T value, [CallerMemberName] string propertyName = null)
{
if (EqualityComparer<T>.Default.Equals(field, value))
{
return false;
}
field = value;
OnPropertyChanged(propertyName);
return true;
}
}

7
ErsatzTV/ViewModels/PlaylistItemsEditViewModel.cs

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
namespace ErsatzTV.ViewModels;
public class PlaylistItemsEditViewModel
{
public string Name { get; set; }
public List<PlaylistItemEditViewModel> Items { get; set; }
}

40
ErsatzTV/ViewModels/PlaylistTreeItemViewModel.cs

@ -0,0 +1,40 @@ @@ -0,0 +1,40 @@
using ErsatzTV.Application.MediaCollections;
using MudBlazor;
using S = System.Collections.Generic;
namespace ErsatzTV.ViewModels;
public class PlaylistTreeItemViewModel
{
public PlaylistTreeItemViewModel(PlaylistGroupViewModel playlistGroup)
{
Text = playlistGroup.Name;
EndText = string.Empty;
TreeItems = [];
CanExpand = playlistGroup.PlaylistCount > 0;
PlaylistGroupId = playlistGroup.Id;
Icon = Icons.Material.Filled.Folder;
}
public PlaylistTreeItemViewModel(PlaylistViewModel playlist)
{
Text = playlist.Name;
TreeItems = [];
CanExpand = false;
PlaylistId = playlist.Id;
}
public string Text { get; }
public string EndText { get; }
public string Icon { get; }
public bool CanExpand { get; }
public int? PlaylistId { get; }
public int? PlaylistGroupId { get; }
public S.HashSet<PlaylistTreeItemViewModel> TreeItems { get; }
}

1
ErsatzTV/ViewModels/ProgramScheduleItemEditViewModel.cs

@ -69,6 +69,7 @@ public class ProgramScheduleItemEditViewModel : INotifyPropertyChanged @@ -69,6 +69,7 @@ public class ProgramScheduleItemEditViewModel : INotifyPropertyChanged
public MultiCollectionViewModel MultiCollection { get; set; }
public SmartCollectionViewModel SmartCollection { get; set; }
public NamedMediaItemViewModel MediaItem { get; set; }
public PlaylistViewModel Playlist { get; set; }
public FillerPresetViewModel PreRollFiller { get; set; }
public FillerPresetViewModel MidRollFiller { get; set; }
public FillerPresetViewModel PostRollFiller { get; set; }

Loading…
Cancel
Save