mirror of https://github.com/ErsatzTV/ErsatzTV.git
Browse Source
* update dependencies * add playlists * add playlist support to schedules * playout builder (flood) supports playlists * update changelogpull/1691/head
79 changed files with 31168 additions and 49 deletions
@ -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>>; |
@ -0,0 +1,5 @@
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core; |
||||
|
||||
namespace ErsatzTV.Application.MediaCollections; |
||||
|
||||
public record CreatePlaylistGroup(string Name) : IRequest<Either<BaseError, PlaylistGroupViewModel>>; |
@ -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)); |
||||
} |
@ -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); |
||||
} |
||||
} |
@ -0,0 +1,5 @@
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core; |
||||
|
||||
namespace ErsatzTV.Application.MediaCollections; |
||||
|
||||
public record DeletePlaylist(int PlaylistId) : IRequest<Option<BaseError>>; |
@ -0,0 +1,5 @@
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core; |
||||
|
||||
namespace ErsatzTV.Application.MediaCollections; |
||||
|
||||
public record DeletePlaylistGroup(int PlaylistGroupId) : IRequest<Option<BaseError>>; |
@ -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.")); |
||||
} |
||||
} |
@ -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.")); |
||||
} |
||||
} |
@ -0,0 +1,5 @@
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Application.Scheduling; |
||||
|
||||
namespace ErsatzTV.Application.MediaCollections; |
||||
|
||||
public record PreviewPlaylistPlayout(ReplacePlaylistItems Data) : IRequest<List<PlayoutItemPreviewViewModel>>; |
@ -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 |
||||
}; |
||||
} |
@ -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); |
@ -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>>>; |
@ -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; |
||||
} |
||||
} |
@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.MediaCollections; |
||||
|
||||
public record PlaylistGroupViewModel(int Id, string Name, int PlaylistCount); |
@ -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); |
@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.MediaCollections; |
||||
|
||||
public record PlaylistViewModel(int Id, int PlaylistGroupId, string Name); |
@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.MediaCollections; |
||||
|
||||
public record GetAllPlaylistGroups : IRequest<List<PlaylistGroupViewModel>>; |
@ -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(); |
||||
} |
||||
} |
@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.MediaCollections; |
||||
|
||||
public record GetPlaylistById(int PlaylistId) : IRequest<Option<PlaylistViewModel>>; |
@ -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); |
||||
} |
||||
} |
@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.MediaCollections; |
||||
|
||||
public record GetPlaylistItems(int PlaylistId) : IRequest<List<PlaylistItemViewModel>>; |
@ -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(); |
||||
} |
||||
} |
@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.MediaCollections; |
||||
|
||||
public record GetPlaylistsByPlaylistGroupId(int PlaylistGroupId) : IRequest<List<PlaylistViewModel>>; |
@ -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()); |
||||
} |
||||
} |
@ -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; }
|
||||
} |
@ -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; } |
||||
} |
@ -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; } |
||||
} |
@ -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; |
||||
} |
||||
} |
||||
} |
File diff suppressed because it is too large
Load Diff
@ -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"); |
||||
} |
||||
} |
||||
} |
File diff suppressed because it is too large
Load Diff
@ -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"); |
||||
} |
||||
} |
||||
} |
File diff suppressed because it is too large
Load Diff
@ -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"); |
||||
} |
||||
} |
||||
} |
File diff suppressed because it is too large
Load Diff
@ -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"); |
||||
} |
||||
} |
||||
} |
File diff suppressed because it is too large
Load Diff
@ -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"); |
||||
} |
||||
} |
||||
} |
@ -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); |
||||
|
||||
} |
||||
} |
@ -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); |
||||
} |
||||
} |
@ -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); |
||||
} |
||||
} |
@ -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; |
||||
|
||||
} |
@ -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); |
||||
} |
||||
} |
||||
} |
||||
|
||||
} |
@ -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; |
||||
} |
||||
} |
@ -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; } |
||||
} |
@ -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; } |
||||
} |
Loading…
Reference in new issue