Browse Source

add deco break content (#2446)

* add initial models

* migrations

* edit break content mode

* add and remove break content from ui

* use autocompletes in deco editor

* save break content to db

* allow break content playlists

* refactor default filler build

* fix slow startup

* start to implement adding break content

* use clone; try to fix block breaks

* fix updating history

* use consistent removebefore values

* cleanup logging

* only allow playlist break content

* update changelog
pull/2447/head
Jason Dove 11 months ago committed by GitHub
parent
commit
788a1ecdc4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 9
      CHANGELOG.md
  2. 2
      ErsatzTV.Application/Playouts/Commands/BuildPlayoutHandler.cs
  3. 41
      ErsatzTV.Application/Playouts/Queries/CheckForOverlappingPlayoutItemsHandler.cs
  4. 12
      ErsatzTV.Application/Scheduling/Commands/UpdateDeco.cs
  5. 121
      ErsatzTV.Application/Scheduling/Commands/UpdateDecoHandler.cs
  6. 16
      ErsatzTV.Application/Scheduling/DecoBreakContentViewModel.cs
  7. 20
      ErsatzTV.Application/Scheduling/DecoViewModel.cs
  8. 67
      ErsatzTV.Application/Scheduling/Mapper.cs
  9. 62
      ErsatzTV.Application/Scheduling/Queries/GetDecoByIdHandler.cs
  10. 1
      ErsatzTV.Application/Scheduling/Queries/GetDecosByDecoGroupIdHandler.cs
  11. 44
      ErsatzTV.Core/Domain/PlayoutItem.cs
  12. 4
      ErsatzTV.Core/Domain/Scheduling/Deco.cs
  13. 20
      ErsatzTV.Core/Domain/Scheduling/DecoBreakContent.cs
  14. 9
      ErsatzTV.Core/Domain/Scheduling/DecoBreakPlacement.cs
  15. 15
      ErsatzTV.Core/Domain/Scheduling/PlayoutHistory.cs
  16. 48
      ErsatzTV.Core/Extensions/IEnumerableExtensions.cs
  17. 3
      ErsatzTV.Core/Scheduling/BlockScheduling/BlockPlayoutBuilder.cs
  18. 127
      ErsatzTV.Core/Scheduling/BlockScheduling/BlockPlayoutEnumerator.cs
  19. 342
      ErsatzTV.Core/Scheduling/BlockScheduling/BlockPlayoutFillerBuilder.cs
  20. 75
      ErsatzTV.Core/Scheduling/CollectionKey.cs
  21. 17
      ErsatzTV.Core/Scheduling/HistoryDetails.cs
  22. 6823
      ErsatzTV.Infrastructure.MySql/Migrations/20250918180547_Add_DecoBreakContent.Designer.cs
  23. 120
      ErsatzTV.Infrastructure.MySql/Migrations/20250918180547_Add_DecoBreakContent.cs
  24. 100
      ErsatzTV.Infrastructure.MySql/Migrations/TvContextModelSnapshot.cs
  25. 6650
      ErsatzTV.Infrastructure.Sqlite/Migrations/20250918180429_Add_DecoBreakContent.Designer.cs
  26. 118
      ErsatzTV.Infrastructure.Sqlite/Migrations/20250918180429_Add_DecoBreakContent.cs
  27. 98
      ErsatzTV.Infrastructure.Sqlite/Migrations/TvContextModelSnapshot.cs
  28. 43
      ErsatzTV.Infrastructure/Data/Configurations/Scheduling/DecoBreakContentConfiguration.cs
  29. 5
      ErsatzTV.Infrastructure/Data/Configurations/Scheduling/DecoConfiguration.cs
  30. 1
      ErsatzTV/Pages/BlockEditor.razor
  31. 641
      ErsatzTV/Pages/DecoEditor.razor
  32. 64
      ErsatzTV/ViewModels/DecoBreakContentEditViewModel.cs
  33. 3
      ErsatzTV/ViewModels/DecoEditViewModel.cs

9
CHANGELOG.md

@ -44,6 +44,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -44,6 +44,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- `text_fit: wrap` will wrap text to the given `width_percent`
- `text_fit: scale` will scale text *smaller* to fit the given `width_percent`
- Text that already fits with the configured style will not be adjusted
- Block schedules: add **experimental** `Break Content` to decos
- Break content is similar to filler from classic schedules
- Break content is currently limited to placement `Break Start` (play before anything else in the block)
- Future work will add other placement options
- Break content is currently limited to playlists (which do *not* pad - they simply play through the playlist one time)
- Future work will add other collection options which will pad to the full block duration
### Fixed
- Fix green output when libplacebo tonemapping is used with NVIDIA acceleration and 10-bit output in FFmpeg Profile
@ -57,9 +63,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -57,9 +63,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- This should resolve most playback issues with built-in channel preview
- Fix deco dead air fallback selection and duration on mirror channels
- Fix fallback filler duration on mirror channels
- Fix slow startup caused by check for overlapping playout items
### Changed
- Filler presets: use separate text fields for `hours`, `minutes` and `seconds` duration
- Use autocomplete fields for collection searching in deco editor
- This greatly improves the editor performance
## [25.6.0] - 2025-09-14
### Added

2
ErsatzTV.Application/Playouts/Commands/BuildPlayoutHandler.cs

@ -389,6 +389,7 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro @@ -389,6 +389,7 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
{
deco = await dbContext.Decos
.AsNoTracking()
.Include(d => d.BreakContent)
.Where(d => d.Playouts.Any(p => p.Id == playoutId))
.FirstOrDefaultAsync()
.Map(Optional);
@ -414,6 +415,7 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro @@ -414,6 +415,7 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
.Include(t => t.DecoTemplate)
.ThenInclude(t => t.Items)
.ThenInclude(i => i.Deco)
.ThenInclude(d => d.BreakContent)
.ToListAsync();
}

41
ErsatzTV.Application/Playouts/Queries/CheckForOverlappingPlayoutItemsHandler.cs

@ -14,16 +14,37 @@ public class CheckForOverlappingPlayoutItemsHandler( @@ -14,16 +14,37 @@ public class CheckForOverlappingPlayoutItemsHandler(
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
bool hasConflict = await dbContext.PlayoutItems
IAsyncEnumerable<PlayoutItemDto> items = dbContext.PlayoutItems
.AsNoTracking()
.Where(pi => pi.PlayoutId == request.PlayoutId)
.AnyAsync(
a => dbContext.PlayoutItems
.Where(b => b.PlayoutId == a.PlayoutId)
.Any(b =>
a.Id < b.Id &&
a.Start < b.Finish &&
a.Finish > b.Start),
cancellationToken);
.OrderBy(pi => pi.Start)
.Select(pi => new PlayoutItemDto(pi.Start, pi.Finish))
.AsAsyncEnumerable();
var hasConflict = false;
DateTime? maxFinish = null;
var isFirstItem = true;
await foreach (var item in items.WithCancellation(cancellationToken))
{
if (isFirstItem)
{
maxFinish = item.Finish;
isFirstItem = false;
continue;
}
if (item.Start < maxFinish)
{
hasConflict = true;
break;
}
if (item.Finish > maxFinish)
{
maxFinish = item.Finish;
}
}
if (hasConflict)
{
@ -41,4 +62,6 @@ public class CheckForOverlappingPlayoutItemsHandler( @@ -41,4 +62,6 @@ public class CheckForOverlappingPlayoutItemsHandler(
}
}
}
private sealed record PlayoutItemDto(DateTime Start, DateTime Finish);
}

12
ErsatzTV.Application/Scheduling/Commands/UpdateDeco.cs

@ -4,6 +4,16 @@ using ErsatzTV.Core.Domain.Scheduling; @@ -4,6 +4,16 @@ using ErsatzTV.Core.Domain.Scheduling;
namespace ErsatzTV.Application.Scheduling;
public record UpdateDecoBreakContent(
int Id,
CollectionType CollectionType,
int? CollectionId,
int? MediaItemId,
int? MultiCollectionId,
int? SmartCollectionId,
int? PlaylistId,
DecoBreakPlacement Placement);
public record UpdateDeco(
int DecoId,
int DecoGroupId,
@ -14,6 +24,8 @@ public record UpdateDeco( @@ -14,6 +24,8 @@ public record UpdateDeco(
DecoMode GraphicsElementsMode,
List<int> GraphicsElementIds,
bool UseGraphicsElementsDuringFiller,
DecoMode BreakContentMode,
List<UpdateDecoBreakContent> BreakContent,
DecoMode DefaultFillerMode,
CollectionType DefaultFillerCollectionType,
int? DefaultFillerCollectionId,

121
ErsatzTV.Application/Scheduling/Commands/UpdateDecoHandler.cs

@ -81,6 +81,44 @@ public class UpdateDecoHandler(IDbContextFactory<TvContext> dbContextFactory) @@ -81,6 +81,44 @@ public class UpdateDecoHandler(IDbContextFactory<TvContext> dbContextFactory)
existing.DecoGraphicsElements.Clear();
}
// break content
existing.BreakContentMode = request.BreakContentMode;
var breakContentToAdd =
request.BreakContent.Where(bc => existing.BreakContent.All(b => b.Id != bc.Id)).ToList();
IEnumerable<DecoBreakContent> breakContentToRemove =
existing.BreakContent.Where(bc => !request.BreakContent.Map(b => b.Id).Contains(bc.Id));
var breakContentToUpdate = request.BreakContent.Except(breakContentToAdd).ToList();
existing.BreakContent.RemoveAll(breakContentToRemove.Contains);
foreach (var toUpdate in breakContentToUpdate)
{
foreach (var ex in Optional(existing.BreakContent.FirstOrDefault(b => b.Id == toUpdate.Id)))
{
ex.CollectionType = toUpdate.CollectionType;
ex.CollectionId = toUpdate.CollectionId;
ex.MultiCollectionId = toUpdate.MultiCollectionId;
ex.SmartCollectionId = toUpdate.SmartCollectionId;
ex.PlaylistId = toUpdate.PlaylistId;
ex.Placement = toUpdate.Placement;
}
}
foreach (var add in breakContentToAdd)
{
existing.BreakContent.Add(new DecoBreakContent
{
DecoId = existing.Id,
CollectionType = add.CollectionType,
CollectionId = add.CollectionId,
MultiCollectionId = add.MultiCollectionId,
SmartCollectionId = add.SmartCollectionId,
PlaylistId = add.PlaylistId,
Placement = add.Placement
});
}
// default filler
existing.DefaultFillerMode = request.DefaultFillerMode;
existing.DefaultFillerCollectionType = request.DefaultFillerCollectionType;
@ -144,14 +182,16 @@ public class UpdateDecoHandler(IDbContextFactory<TvContext> dbContextFactory) @@ -144,14 +182,16 @@ public class UpdateDecoHandler(IDbContextFactory<TvContext> dbContextFactory)
TvContext dbContext,
UpdateDeco request,
CancellationToken cancellationToken) =>
(await DecoMustExist(dbContext, request, cancellationToken), await ValidateDecoName(dbContext, request))
.Apply((deco, _) => deco);
(await DecoMustExist(dbContext, request, cancellationToken), await ValidateDecoName(dbContext, request),
ValidateBreakContent(request))
.Apply((deco, _, _) => deco);
private static Task<Validation<BaseError, Deco>> DecoMustExist(
TvContext dbContext,
UpdateDeco request,
CancellationToken cancellationToken) =>
dbContext.Decos
.Include(d => d.BreakContent)
.Include(d => d.DecoWatermarks)
.Include(d => d.DecoGraphicsElements)
.SelectOneAsync(d => d.Id, d => d.Id == request.DecoId, cancellationToken)
@ -176,4 +216,81 @@ public class UpdateDecoHandler(IDbContextFactory<TvContext> dbContextFactory) @@ -176,4 +216,81 @@ public class UpdateDecoHandler(IDbContextFactory<TvContext> dbContextFactory)
? BaseError.New($"A deco named \"{request.Name}\" already exists in that deco group")
: Success<BaseError, string>(request.Name);
}
private static Validation<BaseError, Unit> ValidateBreakContent(UpdateDeco request)
{
int startCount = request.BreakContent.Count(bc => bc.Placement is DecoBreakPlacement.BlockStart);
if (startCount > 1)
{
return BaseError.New("Deco may only contain one [Block Start] break content");
}
int betweenCount = request.BreakContent.Count(bc => bc.Placement is DecoBreakPlacement.BetweenBlockItems);
if (betweenCount > 1)
{
return BaseError.New("Deco may only contain one [Between Block Items] break content");
}
int chapterCount = request.BreakContent.Count(bc => bc.Placement is DecoBreakPlacement.ChapterMarkers);
if (chapterCount > 1)
{
return BaseError.New("Deco may only contain one [At Chapter Markers] break content");
}
int finishCount = request.BreakContent.Count(bc => bc.Placement is DecoBreakPlacement.BlockFinish);
if (finishCount > 1)
{
return BaseError.New("Deco may only contain one [Block Finish] break content");
}
foreach (var breakContent in request.BreakContent)
{
switch (breakContent.CollectionType)
{
case CollectionType.Collection:
if (breakContent.CollectionId is null)
{
return BaseError.New("Break content must have valid collection");
}
break;
case CollectionType.MultiCollection:
if (breakContent.MultiCollectionId is null)
{
return BaseError.New("Break content must have valid multi collection");
}
break;
case CollectionType.SmartCollection:
if (breakContent.SmartCollectionId is null)
{
return BaseError.New("Break content must have valid smart collection");
}
break;
case CollectionType.TelevisionShow:
case CollectionType.TelevisionSeason:
case CollectionType.Artist:
if (breakContent.MediaItemId is null)
{
return BaseError.New("Break content must have valid media item");
}
break;
case CollectionType.Playlist:
if (breakContent.PlaylistId is null)
{
return BaseError.New("Break content must have valid playlist");
}
break;
}
}
return Unit.Default;
}
}

16
ErsatzTV.Application/Scheduling/DecoBreakContentViewModel.cs

@ -0,0 +1,16 @@ @@ -0,0 +1,16 @@
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Scheduling;
namespace ErsatzTV.Application.Scheduling;
public record DecoBreakContentViewModel(
int Id,
CollectionType CollectionType,
MediaCollectionViewModel Collection,
NamedMediaItemViewModel MediaItem,
MultiCollectionViewModel MultiCollection,
SmartCollectionViewModel SmartCollection,
PlaylistViewModel Playlist,
DecoBreakPlacement Placement);

20
ErsatzTV.Application/Scheduling/DecoViewModel.cs

@ -1,4 +1,6 @@ @@ -1,4 +1,6 @@
using ErsatzTV.Application.Graphics;
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Application.Watermarks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Scheduling;
@ -16,16 +18,18 @@ public record DecoViewModel( @@ -16,16 +18,18 @@ public record DecoViewModel(
DecoMode GraphicsElementsMode,
List<GraphicsElementViewModel> GraphicsElements,
bool UseGraphicsElementsDuringFiller,
DecoMode BreakContentMode,
List<DecoBreakContentViewModel> BreakContent,
DecoMode DefaultFillerMode,
CollectionType DefaultFillerCollectionType,
int? DefaultFillerCollectionId,
int? DefaultFillerMediaItemId,
int? DefaultFillerMultiCollectionId,
int? DefaultFillerSmartCollectionId,
MediaCollectionViewModel DefaultFillerCollection,
NamedMediaItemViewModel DefaultFillerMediaItem,
MultiCollectionViewModel DefaultFillerMultiCollection,
SmartCollectionViewModel DefaultFillerSmartCollection,
bool DefaultFillerTrimToFit,
DecoMode DeadAirFallbackMode,
CollectionType DeadAirFallbackCollectionType,
int? DeadAirFallbackCollectionId,
int? DeadAirFallbackMediaItemId,
int? DeadAirFallbackMultiCollectionId,
int? DeadAirFallbackSmartCollectionId);
MediaCollectionViewModel DeadAirFallbackCollection,
NamedMediaItemViewModel DeadAirFallbackMediaItem,
MultiCollectionViewModel DeadAirFallbackMultiCollection,
SmartCollectionViewModel DeadAirFallbackSmartCollection);

67
ErsatzTV.Application/Scheduling/Mapper.cs

@ -96,19 +96,70 @@ internal static class Mapper @@ -96,19 +96,70 @@ internal static class Mapper
deco.GraphicsElementsMode,
deco.DecoGraphicsElements.Map(ge => Graphics.Mapper.ProjectToViewModel(ge.GraphicsElement)).ToList(),
deco.UseGraphicsElementsDuringFiller,
deco.BreakContentMode,
deco.BreakContent.Map(ProjectToViewModel).ToList(),
deco.DefaultFillerMode,
deco.DefaultFillerCollectionType,
deco.DefaultFillerCollectionId,
deco.DefaultFillerMediaItemId,
deco.DefaultFillerMultiCollectionId,
deco.DefaultFillerSmartCollectionId,
deco.DefaultFillerCollection is not null
? MediaCollections.Mapper.ProjectToViewModel(deco.DefaultFillerCollection)
: null,
deco.DefaultFillerMediaItem switch
{
Show show => MediaItems.Mapper.ProjectToViewModel(show),
Season season => MediaItems.Mapper.ProjectToViewModel(season),
Artist artist => MediaItems.Mapper.ProjectToViewModel(artist),
_ => null
},
deco.DefaultFillerMultiCollection is not null
? MediaCollections.Mapper.ProjectToViewModel(deco.DefaultFillerMultiCollection)
: null,
deco.DefaultFillerSmartCollection is not null
? MediaCollections.Mapper.ProjectToViewModel(deco.DefaultFillerSmartCollection)
: null,
deco.DefaultFillerTrimToFit,
deco.DeadAirFallbackMode,
deco.DeadAirFallbackCollectionType,
deco.DeadAirFallbackCollectionId,
deco.DeadAirFallbackMediaItemId,
deco.DeadAirFallbackMultiCollectionId,
deco.DeadAirFallbackSmartCollectionId);
deco.DeadAirFallbackCollection is not null
? MediaCollections.Mapper.ProjectToViewModel(deco.DeadAirFallbackCollection)
: null,
deco.DeadAirFallbackMediaItem switch
{
Show show => MediaItems.Mapper.ProjectToViewModel(show),
Season season => MediaItems.Mapper.ProjectToViewModel(season),
Artist artist => MediaItems.Mapper.ProjectToViewModel(artist),
_ => null
},
deco.DeadAirFallbackMultiCollection is not null
? MediaCollections.Mapper.ProjectToViewModel(deco.DeadAirFallbackMultiCollection)
: null,
deco.DeadAirFallbackSmartCollection is not null
? MediaCollections.Mapper.ProjectToViewModel(deco.DeadAirFallbackSmartCollection)
: null);
internal static DecoBreakContentViewModel ProjectToViewModel(DecoBreakContent decoBreakContent) =>
new(
decoBreakContent.Id,
decoBreakContent.CollectionType,
decoBreakContent.Collection is not null
? MediaCollections.Mapper.ProjectToViewModel(decoBreakContent.Collection)
: null,
decoBreakContent.MediaItem switch
{
Show show => MediaItems.Mapper.ProjectToViewModel(show),
Season season => MediaItems.Mapper.ProjectToViewModel(season),
Artist artist => MediaItems.Mapper.ProjectToViewModel(artist),
_ => null
},
decoBreakContent.MultiCollection is not null
? MediaCollections.Mapper.ProjectToViewModel(decoBreakContent.MultiCollection)
: null,
decoBreakContent.SmartCollection is not null
? MediaCollections.Mapper.ProjectToViewModel(decoBreakContent.SmartCollection)
: null,
decoBreakContent.Playlist is not null
? MediaCollections.Mapper.ProjectToViewModel(decoBreakContent.Playlist)
: null,
decoBreakContent.Placement);
internal static DecoTemplateGroupViewModel ProjectToViewModel(DecoTemplateGroup decoTemplateGroup) =>
new(decoTemplateGroup.Id, decoTemplateGroup.Name, decoTemplateGroup.DecoTemplates.Count);

62
ErsatzTV.Application/Scheduling/Queries/GetDecoByIdHandler.cs

@ -1,3 +1,4 @@ @@ -1,3 +1,4 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
@ -17,6 +18,67 @@ public class GetDecoByIdHandler(IDbContextFactory<TvContext> dbContextFactory) @@ -17,6 +18,67 @@ public class GetDecoByIdHandler(IDbContextFactory<TvContext> dbContextFactory)
.ThenInclude(d => d.Watermark)
.Include(d => d.DecoGraphicsElements)
.ThenInclude(d => d.GraphicsElement)
.Include(d => d.BreakContent)
.ThenInclude(bc => bc.Collection)
.Include(d => d.BreakContent)
.ThenInclude(bc => bc.MediaItem)
.ThenInclude(i => (i as Season).SeasonMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(d => d.BreakContent)
.ThenInclude(bc => bc.MediaItem)
.ThenInclude(i => (i as Season).Show)
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(d => d.BreakContent)
.ThenInclude(bc => bc.MediaItem)
.ThenInclude(i => (i as Show).ShowMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(d => d.BreakContent)
.ThenInclude(bc => bc.MediaItem)
.ThenInclude(i => (i as Artist).ArtistMetadata)
.ThenInclude(am => am.Artwork)
.Include(d => d.BreakContent)
.ThenInclude(bc => bc.MultiCollection)
.Include(d => d.BreakContent)
.ThenInclude(bc => bc.SmartCollection)
.Include(d => d.BreakContent)
.ThenInclude(bc => bc.Playlist)
.Include(d => d.DefaultFillerCollection)
.Include(d => d.DefaultFillerMultiCollection)
.Include(d => d.DefaultFillerSmartCollection)
.Include(d => d.DefaultFillerMediaItem)
.ThenInclude(i => (i as Season).SeasonMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(i => i.DefaultFillerMediaItem)
.ThenInclude(i => (i as Season).Show)
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(i => i.DefaultFillerMediaItem)
.ThenInclude(i => (i as Show).ShowMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(i => i.DefaultFillerMediaItem)
.ThenInclude(i => (i as Artist).ArtistMetadata)
.ThenInclude(am => am.Artwork)
.Include(d => d.DeadAirFallbackCollection)
.Include(d => d.DeadAirFallbackMultiCollection)
.Include(d => d.DeadAirFallbackSmartCollection)
.Include(d => d.DeadAirFallbackMediaItem)
.ThenInclude(i => (i as Season).SeasonMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(i => i.DeadAirFallbackMediaItem)
.ThenInclude(i => (i as Season).Show)
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(i => i.DeadAirFallbackMediaItem)
.ThenInclude(i => (i as Show).ShowMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(i => i.DeadAirFallbackMediaItem)
.ThenInclude(i => (i as Artist).ArtistMetadata)
.ThenInclude(am => am.Artwork)
.SelectOneAsync(b => b.Id, b => b.Id == request.DecoId, cancellationToken)
.MapT(Mapper.ProjectToViewModel);
}

1
ErsatzTV.Application/Scheduling/Queries/GetDecosByDecoGroupIdHandler.cs

@ -13,6 +13,7 @@ public class GetDecosByDecoGroupIdHandler(IDbContextFactory<TvContext> dbContext @@ -13,6 +13,7 @@ public class GetDecosByDecoGroupIdHandler(IDbContextFactory<TvContext> dbContext
List<Deco> decos = await dbContext.Decos
.AsNoTracking()
.Include(d => d.BreakContent)
.Include(d => d.DecoGroup)
.Include(d => d.DecoWatermarks)
.ThenInclude(d => d.Watermark)

44
ErsatzTV.Core/Domain/PlayoutItem.cs

@ -41,6 +41,50 @@ public class PlayoutItem @@ -41,6 +41,50 @@ public class PlayoutItem
? new DateTimeOffset(GuideFinish.Value, TimeSpan.Zero).ToLocalTime()
: null;
public PlayoutItem Clone()
{
var watermarksCopy = new List<PlayoutItemWatermark>();
if (PlayoutItemWatermarks != null)
{
watermarksCopy.AddRange(
PlayoutItemWatermarks.Select(wm => new PlayoutItemWatermark { WatermarkId = wm.WatermarkId }));
}
var graphicsElementsCopy = new List<PlayoutItemGraphicsElement>();
if (PlayoutItemGraphicsElements != null)
{
graphicsElementsCopy.AddRange(
PlayoutItemGraphicsElements.Select(wm => new PlayoutItemGraphicsElement
{ GraphicsElementId = wm.GraphicsElementId }));
}
return new PlayoutItem
{
MediaItemId = MediaItemId,
Start = Start,
Finish = Finish,
GuideStart = GuideStart,
GuideFinish = GuideFinish,
CustomTitle = CustomTitle,
GuideGroup = GuideGroup,
FillerKind = FillerKind,
PlayoutId = PlayoutId,
InPoint = InPoint,
OutPoint = OutPoint,
ChapterTitle = ChapterTitle,
DisableWatermarks = DisableWatermarks,
PreferredAudioLanguageCode = PreferredAudioLanguageCode,
PreferredAudioTitle = PreferredAudioTitle,
PreferredSubtitleLanguageCode = PreferredSubtitleLanguageCode,
SubtitleMode = SubtitleMode,
BlockKey = BlockKey,
CollectionKey = CollectionKey,
CollectionEtag = CollectionEtag,
PlayoutItemWatermarks = watermarksCopy,
PlayoutItemGraphicsElements = graphicsElementsCopy
};
}
public PlayoutItem ForChapter(MediaChapter chapter)
{
var watermarksCopy = new List<PlayoutItemWatermark>();

4
ErsatzTV.Core/Domain/Scheduling/Deco.cs

@ -45,6 +45,10 @@ public class Deco @@ -45,6 +45,10 @@ public class Deco
public int? DeadAirFallbackSmartCollectionId { get; set; }
public SmartCollection DeadAirFallbackSmartCollection { get; set; }
// break content
public DecoMode BreakContentMode { get; set; }
public List<DecoBreakContent> BreakContent { get; set; }
// can be added directly to (block) playouts
public ICollection<Playout> Playouts { get; set; }
}

20
ErsatzTV.Core/Domain/Scheduling/DecoBreakContent.cs

@ -0,0 +1,20 @@ @@ -0,0 +1,20 @@
namespace ErsatzTV.Core.Domain.Scheduling;
public class DecoBreakContent
{
public int Id { get; set; }
public int DecoId { get; set; }
public Deco Deco { get; set; }
public CollectionType 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 int? PlaylistId { get; set; }
public Playlist Playlist { get; set; }
public DecoBreakPlacement Placement { get; set; }
}

9
ErsatzTV.Core/Domain/Scheduling/DecoBreakPlacement.cs

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
namespace ErsatzTV.Core.Domain.Scheduling;
public enum DecoBreakPlacement
{
BlockStart = 0,
BlockFinish = 1,
BetweenBlockItems = 2,
ChapterMarkers = 3
}

15
ErsatzTV.Core/Domain/Scheduling/PlayoutHistory.cs

@ -29,4 +29,19 @@ public class PlayoutHistory @@ -29,4 +29,19 @@ public class PlayoutHistory
// details about the item
public string Details { get; set; }
public PlayoutHistory Clone() =>
new()
{
PlayoutId = PlayoutId,
BlockId = BlockId,
PlaybackOrder = PlaybackOrder,
Index = Index,
Key = Key,
ChildKey = ChildKey,
IsCurrentChild = IsCurrentChild,
When = When,
Finish = Finish,
Details = Details
};
}

48
ErsatzTV.Core/Extensions/IEnumerableExtensions.cs

@ -0,0 +1,48 @@ @@ -0,0 +1,48 @@
using System.Collections;
namespace ErsatzTV.Core.Extensions;
public static class IEnumerableExtensions
{
public static IEnumerable<IGrouping<TKey, TSource>> GroupConsecutiveBy<TSource, TKey>(
this IEnumerable<TSource> source,
Func<TSource, TKey> keySelector)
{
EqualityComparer<TKey> comparer = EqualityComparer<TKey>.Default;
List<TSource> currentGroup = null;
foreach (TSource item in source)
{
TKey key = keySelector(item);
if (currentGroup == null)
{
currentGroup = [item];
continue;
}
if (comparer.Equals(keySelector(currentGroup[0]), key))
{
currentGroup.Add(item);
}
else
{
yield return new Grouping<TKey, TSource>(keySelector(currentGroup[0]), currentGroup);
currentGroup = [item];
}
}
if (currentGroup != null)
{
yield return new Grouping<TKey, TSource>(keySelector(currentGroup[0]), currentGroup);
}
}
private class Grouping<TKey, TElement>(TKey key, IEnumerable<TElement> elements) : IGrouping<TKey, TElement>
{
public TKey Key { get; } = key;
public IEnumerator<TElement> GetEnumerator() => elements.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
}

3
ErsatzTV.Core/Scheduling/BlockScheduling/BlockPlayoutBuilder.cs

@ -57,6 +57,9 @@ public class BlockPlayoutBuilder( @@ -57,6 +57,9 @@ public class BlockPlayoutBuilder(
List<EffectiveBlock> blocksToSchedule =
EffectiveBlock.GetEffectiveBlocks(referenceData.PlayoutTemplates, start, daysToBuild);
// always start at the beginning of the block
start = blocksToSchedule.Min(b => b.Start);
// get all collection items for the playout
Map<CollectionKey, List<MediaItem>> collectionMediaItems =
await GetCollectionMediaItems(blocksToSchedule, cancellationToken);

127
ErsatzTV.Core/Scheduling/BlockScheduling/BlockPlayoutEnumerator.cs

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Scheduling;
using Microsoft.Extensions.Logging;
@ -114,7 +115,7 @@ public static class BlockPlayoutEnumerator @@ -114,7 +115,7 @@ public static class BlockPlayoutEnumerator
DateTimeOffset currentTime,
int playoutSeed,
List<PlayoutHistory> playoutHistory,
Deco deco,
int seedOffset,
string historyKey)
{
DateTime historyTime = currentTime.UtcDateTime;
@ -124,7 +125,7 @@ public static class BlockPlayoutEnumerator @@ -124,7 +125,7 @@ public static class BlockPlayoutEnumerator
.OrderByDescending(h => h.When)
.HeadOrNone();
var state = new CollectionEnumeratorState { Seed = playoutSeed + deco.Id, Index = 0 };
var state = new CollectionEnumeratorState { Seed = playoutSeed + seedOffset, Index = 0 };
foreach (PlayoutHistory h in maybeHistory)
{
state.Index = h.Index + 1;
@ -140,6 +141,128 @@ public static class BlockPlayoutEnumerator @@ -140,6 +141,128 @@ public static class BlockPlayoutEnumerator
return new BlockPlayoutShuffledMediaCollectionEnumerator(mediaItems, state);
}
public static async Task<IMediaCollectionEnumerator> PlaylistForFiller(
IMediaCollectionRepository mediaCollectionRepository,
int playlistId,
DateTimeOffset currentTime,
int playoutSeed,
IReadOnlyCollection<PlayoutHistory> playoutHistory,
int seedOffset,
string historyKey,
CancellationToken cancellationToken)
{
Dictionary<PlaylistItem, List<MediaItem>> itemMap =
await mediaCollectionRepository.GetPlaylistItemMap(playlistId, cancellationToken);
var playlistMediaItems = new Dictionary<CollectionKey, List<MediaItem>>();
foreach ((PlaylistItem playlistItem, List<MediaItem> mediaItems) in itemMap)
{
playlistMediaItems.TryAdd(CollectionKey.ForPlaylistItem(playlistItem), mediaItems);
}
var state = new CollectionEnumeratorState { Seed = playoutSeed + seedOffset, Index = 0 };
var enumerator = await PlaylistEnumerator.Create(
mediaCollectionRepository,
itemMap,
state,
shufflePlaylistItems: false,
batchSize: Option<int>.None,
cancellationToken);
DateTime historyTime = currentTime.UtcDateTime;
Option<DateTime> maxWhen = await playoutHistory
.Filter(h => h.Key == historyKey)
.Filter(h => h.When < historyTime)
.Map(h => h.When)
.OrderByDescending(h => h)
.HeadOrNone()
.IfNoneAsync(DateTime.MinValue);
var maybeHistory = playoutHistory
.Filter(h => h.Key == historyKey)
.Filter(h => h.When == maxWhen)
.ToList();
Option<PlayoutHistory> maybePrimaryHistory = maybeHistory
.Filter(h => string.IsNullOrWhiteSpace(h.ChildKey))
.HeadOrNone();
foreach (PlayoutHistory primaryHistory in maybePrimaryHistory)
{
var hasSetEnumeratorIndex = false;
var childEnumeratorKeys = enumerator.ChildEnumerators.Map(x => x.CollectionKey).ToList();
foreach ((IMediaCollectionEnumerator childEnumerator, CollectionKey collectionKey) in
enumerator.ChildEnumerators)
{
PlaybackOrder itemPlaybackOrder = childEnumerator switch
{
ChronologicalMediaCollectionEnumerator => PlaybackOrder.Chronological,
RandomizedMediaCollectionEnumerator => PlaybackOrder.Random,
ShuffledMediaCollectionEnumerator => PlaybackOrder.Shuffle,
_ => PlaybackOrder.None
};
Option<PlayoutHistory> maybeApplicableHistory = maybeHistory
.Filter(h => h.ChildKey == HistoryDetails.KeyForCollectionKey(collectionKey))
.HeadOrNone();
List<MediaItem> collectionItems = playlistMediaItems[collectionKey];
if (collectionItems.Count == 0)
{
continue;
}
foreach (PlayoutHistory h in maybeApplicableHistory)
{
// logger.LogDebug(
// "History is applicable: {When}: {ChildKey} / {History} / {IsCurrentChild}",
// h.When,
// h.ChildKey,
// h.Details,
// h.IsCurrentChild);
enumerator.ResetState(
new CollectionEnumeratorState
{
Seed = enumerator.State.Seed,
Index = h.Index + (h.IsCurrentChild ? 1 : 0)
});
if (itemPlaybackOrder is PlaybackOrder.Chronological)
{
HistoryDetails.MoveToNextItem(
collectionItems,
h.Details,
childEnumerator,
itemPlaybackOrder,
true);
}
if (h.IsCurrentChild)
{
// try to find enumerator based on collection key
enumerator.SetEnumeratorIndex(childEnumeratorKeys.IndexOf(collectionKey));
hasSetEnumeratorIndex = true;
}
}
}
if (!hasSetEnumeratorIndex)
{
// falling back to enumerator based on index
enumerator.SetEnumeratorIndex(primaryHistory.Index);
}
// only move next at the end, because that may also move
// the enumerator index
enumerator.MoveNext(Option<DateTimeOffset>.None);
}
return enumerator;
}
public static IMediaCollectionEnumerator RandomRotation(
List<MediaItem> collectionItems,
DateTimeOffset currentTime,

342
ErsatzTV.Core/Scheduling/BlockScheduling/BlockPlayoutFillerBuilder.cs

@ -32,6 +32,7 @@ public class BlockPlayoutFillerBuilder( @@ -32,6 +32,7 @@ public class BlockPlayoutFillerBuilder(
.ToList();
var allItems = result.AddedItems.ToList();
var removeBefore = await result.RemoveBefore.IfNoneAsync(DateTimeOffset.MaxValue);
if (mode is PlayoutBuildMode.Reset)
{
@ -39,6 +40,11 @@ public class BlockPlayoutFillerBuilder( @@ -39,6 +40,11 @@ public class BlockPlayoutFillerBuilder(
// except block items that are hidden from the guide (guide mode)
foreach (PlayoutItem item in filteredExistingItems)
{
if (item.Finish < removeBefore)
{
continue;
}
if (item.FillerKind is FillerKind.None or FillerKind.GuideMode)
{
allItems.Add(item);
@ -59,6 +65,241 @@ public class BlockPlayoutFillerBuilder( @@ -59,6 +65,241 @@ public class BlockPlayoutFillerBuilder(
var collectionEnumerators = new Dictionary<CollectionKey, IMediaCollectionEnumerator>();
var breakContentResult = await AddBreakContent(
playout,
referenceData,
mode,
collectionEnumerators,
allItems,
filteredExistingHistory,
result.AddedHistory,
cancellationToken);
// merge break content result
result.AddedItems.AddRange(breakContentResult.AddedItems);
result.AddedHistory.AddRange(breakContentResult.AddedHistory);
foreach (int id in breakContentResult.ItemsToRemove)
{
result.ItemsToRemove.Add(id);
}
foreach (int id in breakContentResult.HistoryToRemove)
{
result.HistoryToRemove.Add(id);
}
allItems = referenceData.ExistingItems
.Where(i => !result.ItemsToRemove.Contains(i.Id))
.ToList();
allItems.AddRange(result.AddedItems);
result = await AddDefaultFiller(
playout,
referenceData,
result,
collectionEnumerators,
allItems,
filteredExistingHistory,
cancellationToken);
return result;
}
private async Task<PlayoutBuildResult> AddBreakContent(
Playout playout,
PlayoutReferenceData referenceData,
PlayoutBuildMode mode,
Dictionary<CollectionKey, IMediaCollectionEnumerator> collectionEnumerators,
IReadOnlyCollection<PlayoutItem> allItems,
IReadOnlyCollection<PlayoutHistory> filteredExistingHistory,
IReadOnlyCollection<PlayoutHistory> addedHistory,
CancellationToken cancellationToken)
{
var result = PlayoutBuildResult.Empty;
// TODO: support other modes
if (mode is not PlayoutBuildMode.Reset)
{
return result;
}
var allHistory = filteredExistingHistory.Append(addedHistory).ToList();
// guide group is template item id
// they are reused over multiple days, so we only want to group consecutive items
IEnumerable<IGrouping<int, PlayoutItem>> consecutiveBlocks = allItems.GroupConsecutiveBy(item => item.GuideGroup);
foreach (IGrouping<int, PlayoutItem> blockGroup in consecutiveBlocks)
{
var itemsInBlock = blockGroup.ToList();
// find all item and history pairs (to move together)
var itemsAndHistory = new List<ItemAndHistory>();
foreach (var item in itemsInBlock)
{
var history = allHistory.FirstOrDefault(h => h.When == item.Start);
if (history is null)
{
throw new InvalidOperationException($"Unable to locate history for playout item at {item.Start}");
}
itemsAndHistory.Add(new ItemAndHistory(item, history));
}
var head = itemsInBlock[0];
DateTimeOffset blockStart = new DateTimeOffset(head.GuideStart!.Value, TimeSpan.Zero).ToLocalTime();
DateTimeOffset blockFinish = new DateTimeOffset(head.GuideFinish!.Value, TimeSpan.Zero).ToLocalTime();
TimeSpan blockDuration = blockFinish - blockStart;
TimeSpan totalItemDuration = TimeSpan.FromTicks(itemsInBlock.Sum(i => (i.Finish - i.Start).Ticks));
TimeSpan remaining = blockDuration - totalItemDuration;
// find applicable deco
foreach (Deco deco in GetDecoFor(referenceData, blockStart))
{
if (deco.BreakContent.Count == 0)
{
continue;
}
// logger.LogDebug(
// "Block {Id} add break content from {Start} to {Finish} with {Remaining} remaining",
// blockGroup.Key,
// blockStart,
// blockFinish,
// remaining);
foreach (var blockStartContent in deco.BreakContent.Where(bc =>
bc.Placement is DecoBreakPlacement.BlockStart))
{
DateTimeOffset currentTime = blockStart;
var collectionKey = CollectionKey.ForBreakContent(blockStartContent);
string historyKey = HistoryDetails.ForBreakContent(blockStartContent);
var enumerator = await GetEnumerator(
collectionEnumerators,
collectionKey,
historyKey,
currentTime,
() => filteredExistingHistory.Append(addedHistory).Append(result.AddedHistory).ToList(),
playout.Seed,
deco.Id,
cancellationToken);
int count = enumerator switch
{
PlaylistEnumerator pe => pe.CountForFiller,
_ => enumerator.Count
};
if (count == 0)
{
break;
}
// TODO: support more than playlist enumerator
if (enumerator is PlaylistEnumerator playlistEnumerator)
{
List<ItemAndHistory> toInsert = [];
for (var i = 0; i < playlistEnumerator.CountForFiller; i++)
{
foreach (MediaItem mediaItem in enumerator.Current)
{
TimeSpan itemDuration = DurationForMediaItem(mediaItem);
var filler = new PlayoutItem
{
PlayoutId = playout.Id,
MediaItemId = mediaItem.Id,
Start = blockStart.UtcDateTime,
Finish = blockStart.UtcDateTime + itemDuration,
InPoint = TimeSpan.Zero,
OutPoint = itemDuration,
// FillerKind.Fallback will loop and avoid hw accel, so don't use that
FillerKind = FillerKind.DecoDefault,
CollectionKey = JsonConvert.SerializeObject(collectionKey, JsonSettings),
GuideStart = head.GuideStart,
GuideFinish = head.GuideFinish,
GuideGroup = head.GuideGroup
};
var nextHistory = new PlayoutHistory
{
PlayoutId = playout.Id,
PlaybackOrder = PlaybackOrder.None,
Index = enumerator.State.Index,
When = blockStart.UtcDateTime,
Finish = filler.FinishOffset.UtcDateTime,
Key = historyKey,
Details = HistoryDetails.ForMediaItem(mediaItem)
};
if (itemDuration > remaining)
{
logger.LogDebug(
"Block start item {Item} with duration {Duration} is too long for block with remaining time {Time}",
mediaItem.Id,
itemDuration,
remaining);
}
else
{
result.AddedItems.Add(filler);
result.AddedHistory.Add(nextHistory);
toInsert.Add(new ItemAndHistory(filler, nextHistory));
remaining -= itemDuration;
playlistEnumerator.MoveNext(currentTime);
currentTime += itemDuration;
}
}
}
itemsAndHistory.InsertRange(0, toInsert);
}
}
DateTimeOffset adjustedTime = blockStart;
foreach ((PlayoutItem playoutItem, PlayoutHistory playoutHistory) in itemsAndHistory)
{
bool changed = playoutItem.Start != adjustedTime;
TimeSpan duration = playoutItem.Finish - playoutItem.Start;
playoutItem.Start = adjustedTime.UtcDateTime;
playoutItem.Finish = (adjustedTime + duration).UtcDateTime;
playoutHistory.When = playoutItem.Start;
playoutHistory.Finish = playoutItem.Finish;
adjustedTime = playoutItem.FinishOffset;
if (changed && playoutHistory.Id > 0)
{
// change existing history
result.HistoryToRemove.Add(playoutHistory.Id);
result.AddedHistory.Add(playoutHistory.Clone());
// change existing item
result.ItemsToRemove.Add(playoutItem.Id);
result.AddedItems.Add(playoutItem.Clone());
}
}
}
}
return result;
}
private async Task<PlayoutBuildResult> AddDefaultFiller(
Playout playout,
PlayoutReferenceData referenceData,
PlayoutBuildResult result,
Dictionary<CollectionKey, IMediaCollectionEnumerator> collectionEnumerators,
List<PlayoutItem> allItems,
List<PlayoutHistory> filteredExistingHistory,
CancellationToken cancellationToken)
{
// find all unscheduled periods
var queue = new Queue<PlayoutItem>(allItems.OrderBy(i => i.Start));
while (queue.Count > 1)
@ -85,33 +326,15 @@ public class BlockPlayoutFillerBuilder( @@ -85,33 +326,15 @@ public class BlockPlayoutFillerBuilder(
var collectionKey = CollectionKey.ForDecoDefaultFiller(deco);
string historyKey = HistoryDetails.ForDefaultFiller(deco);
// load collection items from db on demand
if (!collectionEnumerators.TryGetValue(collectionKey, out IMediaCollectionEnumerator enumerator))
{
List<MediaItem> collectionItems = await MediaItemsForCollection.Collect(
mediaCollectionRepository,
televisionRepository,
artistRepository,
collectionKey,
cancellationToken);
enumerator = BlockPlayoutEnumerator.Shuffle(
collectionItems,
start,
playout.Seed,
filteredExistingHistory.Append(result.AddedHistory).ToList(),
deco,
historyKey);
if (enumerator.Count == 0)
{
logger.LogWarning(
"Block filler contains empty collection {@Key}; no filler will be scheduled",
collectionKey);
}
collectionEnumerators.Add(collectionKey, enumerator);
}
var enumerator = await GetEnumerator(
collectionEnumerators,
collectionKey,
historyKey,
start,
() => filteredExistingHistory.Append(result.AddedHistory).ToList(),
playout.Seed,
deco.Id,
cancellationToken);
// skip this deco if the collection has no items
if (enumerator.Count == 0)
@ -188,10 +411,71 @@ public class BlockPlayoutFillerBuilder( @@ -188,10 +411,71 @@ public class BlockPlayoutFillerBuilder(
}
}
return result;
}
private async Task<IMediaCollectionEnumerator> GetEnumerator(
Dictionary<CollectionKey, IMediaCollectionEnumerator> collectionEnumerators,
CollectionKey collectionKey,
string historyKey,
DateTimeOffset currentTime,
Func<List<PlayoutHistory>> allHistory,
int playoutSeed,
int seedOffset,
CancellationToken cancellationToken)
{
// load collection items from db on demand
if (!collectionEnumerators.TryGetValue(collectionKey, out IMediaCollectionEnumerator enumerator))
{
if (collectionKey.CollectionType is CollectionType.Playlist)
{
enumerator = await BlockPlayoutEnumerator.PlaylistForFiller(
mediaCollectionRepository,
collectionKey.PlaylistId!.Value,
currentTime,
playoutSeed,
allHistory(),
seedOffset,
historyKey,
cancellationToken);
}
else
{
List<MediaItem> collectionItems = await MediaItemsForCollection.Collect(
mediaCollectionRepository,
televisionRepository,
artistRepository,
collectionKey,
cancellationToken);
enumerator = BlockPlayoutEnumerator.Shuffle(
collectionItems,
currentTime,
playoutSeed,
allHistory(),
seedOffset,
historyKey);
}
int count = enumerator switch
{
PlaylistEnumerator pe => pe.CountForFiller,
_ => enumerator.Count
};
if (count == 0)
{
logger.LogWarning(
"Block filler contains empty collection {@Key}; no filler will be scheduled",
collectionKey);
}
collectionEnumerators.Add(collectionKey, enumerator);
}
return enumerator;
}
private static Option<Deco> GetDecoFor(PlayoutReferenceData referenceData, DateTimeOffset start)
{
Option<PlayoutTemplate> maybeTemplate =
@ -254,4 +538,6 @@ public class BlockPlayoutFillerBuilder( @@ -254,4 +538,6 @@ public class BlockPlayoutFillerBuilder(
MediaVersion version = mediaItem.GetHeadVersion();
return version.Duration;
}
private record ItemAndHistory(PlayoutItem PlayoutItem, PlayoutHistory History);
}

75
ErsatzTV.Core/Scheduling/CollectionKey.cs

@ -230,6 +230,81 @@ public class CollectionKey : Record<CollectionKey> @@ -230,6 +230,81 @@ public class CollectionKey : Record<CollectionKey>
_ => throw new ArgumentOutOfRangeException(nameof(deco))
};
public static CollectionKey ForBreakContent(DecoBreakContent breakContent) =>
breakContent.CollectionType switch
{
CollectionType.Collection => new CollectionKey
{
CollectionType = breakContent.CollectionType,
CollectionId = breakContent.CollectionId
},
CollectionType.TelevisionShow => new CollectionKey
{
CollectionType = breakContent.CollectionType,
MediaItemId = breakContent.MediaItemId
},
CollectionType.TelevisionSeason => new CollectionKey
{
CollectionType = breakContent.CollectionType,
MediaItemId = breakContent.MediaItemId
},
CollectionType.Artist => new CollectionKey
{
CollectionType = breakContent.CollectionType,
MediaItemId = breakContent.MediaItemId
},
CollectionType.MultiCollection => new CollectionKey
{
CollectionType = breakContent.CollectionType,
MultiCollectionId = breakContent.MultiCollectionId
},
CollectionType.SmartCollection => new CollectionKey
{
CollectionType = breakContent.CollectionType,
SmartCollectionId = breakContent.SmartCollectionId
},
CollectionType.Playlist => new CollectionKey
{
CollectionType = breakContent.CollectionType,
PlaylistId = breakContent.PlaylistId
},
CollectionType.FakeCollection => new CollectionKey
{
CollectionType = breakContent.CollectionType
},
CollectionType.Movie => new CollectionKey
{
CollectionType = breakContent.CollectionType,
MediaItemId = breakContent.MediaItemId
},
CollectionType.Episode => new CollectionKey
{
CollectionType = breakContent.CollectionType,
MediaItemId = breakContent.MediaItemId
},
CollectionType.MusicVideo => new CollectionKey
{
CollectionType = breakContent.CollectionType,
MediaItemId = breakContent.MediaItemId
},
CollectionType.OtherVideo => new CollectionKey
{
CollectionType = breakContent.CollectionType,
MediaItemId = breakContent.MediaItemId
},
CollectionType.Song => new CollectionKey
{
CollectionType = breakContent.CollectionType,
MediaItemId = breakContent.MediaItemId
},
CollectionType.Image => new CollectionKey
{
CollectionType = breakContent.CollectionType,
MediaItemId = breakContent.MediaItemId
},
_ => throw new ArgumentOutOfRangeException(nameof(breakContent))
};
public static CollectionKey ForScheduleItem(ProgramScheduleItem item) =>
item.CollectionType switch
{

17
ErsatzTV.Core/Scheduling/HistoryDetails.cs

@ -102,6 +102,23 @@ internal static class HistoryDetails @@ -102,6 +102,23 @@ internal static class HistoryDetails
return JsonConvert.SerializeObject(key, Formatting.None, JsonSettings);
}
public static string ForBreakContent(DecoBreakContent breakContent)
{
dynamic key = new
{
DecoBreakContentId = breakContent.Id,
PlaybackOrder = PlaybackOrder.Shuffle,
breakContent.CollectionType,
breakContent.CollectionId,
breakContent.MultiCollectionId,
breakContent.SmartCollectionId,
breakContent.MediaItemId,
breakContent.PlaylistId
};
return JsonConvert.SerializeObject(key, Formatting.None, JsonSettings);
}
public static string ForDefaultFiller(Deco deco)
{
dynamic key = new

6823
ErsatzTV.Infrastructure.MySql/Migrations/20250918180547_Add_DecoBreakContent.Designer.cs generated

File diff suppressed because it is too large Load Diff

120
ErsatzTV.Infrastructure.MySql/Migrations/20250918180547_Add_DecoBreakContent.cs

@ -0,0 +1,120 @@ @@ -0,0 +1,120 @@
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class Add_DecoBreakContent : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "BreakContentMode",
table: "Deco",
type: "int",
nullable: false,
defaultValue: 0);
migrationBuilder.CreateTable(
name: "DecoBreakContent",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
DecoId = 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),
PlaylistId = table.Column<int>(type: "int", nullable: true),
Placement = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_DecoBreakContent", x => x.Id);
table.ForeignKey(
name: "FK_DecoBreakContent_Collection_CollectionId",
column: x => x.CollectionId,
principalTable: "Collection",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_DecoBreakContent_Deco_DecoId",
column: x => x.DecoId,
principalTable: "Deco",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_DecoBreakContent_MediaItem_MediaItemId",
column: x => x.MediaItemId,
principalTable: "MediaItem",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_DecoBreakContent_MultiCollection_MultiCollectionId",
column: x => x.MultiCollectionId,
principalTable: "MultiCollection",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_DecoBreakContent_Playlist_PlaylistId",
column: x => x.PlaylistId,
principalTable: "Playlist",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_DecoBreakContent_SmartCollection_SmartCollectionId",
column: x => x.SmartCollectionId,
principalTable: "SmartCollection",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_DecoBreakContent_CollectionId",
table: "DecoBreakContent",
column: "CollectionId");
migrationBuilder.CreateIndex(
name: "IX_DecoBreakContent_DecoId",
table: "DecoBreakContent",
column: "DecoId");
migrationBuilder.CreateIndex(
name: "IX_DecoBreakContent_MediaItemId",
table: "DecoBreakContent",
column: "MediaItemId");
migrationBuilder.CreateIndex(
name: "IX_DecoBreakContent_MultiCollectionId",
table: "DecoBreakContent",
column: "MultiCollectionId");
migrationBuilder.CreateIndex(
name: "IX_DecoBreakContent_PlaylistId",
table: "DecoBreakContent",
column: "PlaylistId");
migrationBuilder.CreateIndex(
name: "IX_DecoBreakContent_SmartCollectionId",
table: "DecoBreakContent",
column: "SmartCollectionId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "DecoBreakContent");
migrationBuilder.DropColumn(
name: "BreakContentMode",
table: "Deco");
}
}
}

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

@ -2650,6 +2650,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -2650,6 +2650,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property<int>("Id"));
b.Property<int>("BreakContentMode")
.HasColumnType("int");
b.Property<int?>("DeadAirFallbackCollectionId")
.HasColumnType("int");
@ -2731,6 +2734,55 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -2731,6 +2734,55 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.ToTable("Deco", (string)null);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Scheduling.DecoBreakContent", 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<int>("DecoId")
.HasColumnType("int");
b.Property<int?>("MediaItemId")
.HasColumnType("int");
b.Property<int?>("MultiCollectionId")
.HasColumnType("int");
b.Property<int>("Placement")
.HasColumnType("int");
b.Property<int?>("PlaylistId")
.HasColumnType("int");
b.Property<int?>("SmartCollectionId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("CollectionId");
b.HasIndex("DecoId");
b.HasIndex("MediaItemId");
b.HasIndex("MultiCollectionId");
b.HasIndex("PlaylistId");
b.HasIndex("SmartCollectionId");
b.ToTable("DecoBreakContent", (string)null);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Scheduling.DecoGroup", b =>
{
b.Property<int>("Id")
@ -5463,6 +5515,52 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -5463,6 +5515,52 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Navigation("DefaultFillerSmartCollection");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Scheduling.DecoBreakContent", b =>
{
b.HasOne("ErsatzTV.Core.Domain.Collection", "Collection")
.WithMany()
.HasForeignKey("CollectionId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.Scheduling.Deco", "Deco")
.WithMany("BreakContent")
.HasForeignKey("DecoId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
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()
.HasForeignKey("PlaylistId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.SmartCollection", "SmartCollection")
.WithMany()
.HasForeignKey("SmartCollectionId")
.OnDelete(DeleteBehavior.Cascade);
b.Navigation("Collection");
b.Navigation("Deco");
b.Navigation("MediaItem");
b.Navigation("MultiCollection");
b.Navigation("Playlist");
b.Navigation("SmartCollection");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Scheduling.DecoTemplate", b =>
{
b.HasOne("ErsatzTV.Core.Domain.Scheduling.DecoTemplateGroup", "DecoTemplateGroup")
@ -6512,6 +6610,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -6512,6 +6610,8 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
modelBuilder.Entity("ErsatzTV.Core.Domain.Scheduling.Deco", b =>
{
b.Navigation("BreakContent");
b.Navigation("DecoGraphicsElements");
b.Navigation("DecoWatermarks");

6650
ErsatzTV.Infrastructure.Sqlite/Migrations/20250918180429_Add_DecoBreakContent.Designer.cs generated

File diff suppressed because it is too large Load Diff

118
ErsatzTV.Infrastructure.Sqlite/Migrations/20250918180429_Add_DecoBreakContent.cs

@ -0,0 +1,118 @@ @@ -0,0 +1,118 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class Add_DecoBreakContent : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "BreakContentMode",
table: "Deco",
type: "INTEGER",
nullable: false,
defaultValue: 0);
migrationBuilder.CreateTable(
name: "DecoBreakContent",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
DecoId = 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),
PlaylistId = table.Column<int>(type: "INTEGER", nullable: true),
Placement = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_DecoBreakContent", x => x.Id);
table.ForeignKey(
name: "FK_DecoBreakContent_Collection_CollectionId",
column: x => x.CollectionId,
principalTable: "Collection",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_DecoBreakContent_Deco_DecoId",
column: x => x.DecoId,
principalTable: "Deco",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_DecoBreakContent_MediaItem_MediaItemId",
column: x => x.MediaItemId,
principalTable: "MediaItem",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_DecoBreakContent_MultiCollection_MultiCollectionId",
column: x => x.MultiCollectionId,
principalTable: "MultiCollection",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_DecoBreakContent_Playlist_PlaylistId",
column: x => x.PlaylistId,
principalTable: "Playlist",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_DecoBreakContent_SmartCollection_SmartCollectionId",
column: x => x.SmartCollectionId,
principalTable: "SmartCollection",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_DecoBreakContent_CollectionId",
table: "DecoBreakContent",
column: "CollectionId");
migrationBuilder.CreateIndex(
name: "IX_DecoBreakContent_DecoId",
table: "DecoBreakContent",
column: "DecoId");
migrationBuilder.CreateIndex(
name: "IX_DecoBreakContent_MediaItemId",
table: "DecoBreakContent",
column: "MediaItemId");
migrationBuilder.CreateIndex(
name: "IX_DecoBreakContent_MultiCollectionId",
table: "DecoBreakContent",
column: "MultiCollectionId");
migrationBuilder.CreateIndex(
name: "IX_DecoBreakContent_PlaylistId",
table: "DecoBreakContent",
column: "PlaylistId");
migrationBuilder.CreateIndex(
name: "IX_DecoBreakContent_SmartCollectionId",
table: "DecoBreakContent",
column: "SmartCollectionId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "DecoBreakContent");
migrationBuilder.DropColumn(
name: "BreakContentMode",
table: "Deco");
}
}
}

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

@ -2527,6 +2527,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -2527,6 +2527,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("BreakContentMode")
.HasColumnType("INTEGER");
b.Property<int?>("DeadAirFallbackCollectionId")
.HasColumnType("INTEGER");
@ -2608,6 +2611,53 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -2608,6 +2611,53 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.ToTable("Deco", (string)null);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Scheduling.DecoBreakContent", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("CollectionId")
.HasColumnType("INTEGER");
b.Property<int>("CollectionType")
.HasColumnType("INTEGER");
b.Property<int>("DecoId")
.HasColumnType("INTEGER");
b.Property<int?>("MediaItemId")
.HasColumnType("INTEGER");
b.Property<int?>("MultiCollectionId")
.HasColumnType("INTEGER");
b.Property<int>("Placement")
.HasColumnType("INTEGER");
b.Property<int?>("PlaylistId")
.HasColumnType("INTEGER");
b.Property<int?>("SmartCollectionId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CollectionId");
b.HasIndex("DecoId");
b.HasIndex("MediaItemId");
b.HasIndex("MultiCollectionId");
b.HasIndex("PlaylistId");
b.HasIndex("SmartCollectionId");
b.ToTable("DecoBreakContent", (string)null);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Scheduling.DecoGroup", b =>
{
b.Property<int>("Id")
@ -5292,6 +5342,52 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -5292,6 +5342,52 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Navigation("DefaultFillerSmartCollection");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Scheduling.DecoBreakContent", b =>
{
b.HasOne("ErsatzTV.Core.Domain.Collection", "Collection")
.WithMany()
.HasForeignKey("CollectionId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.Scheduling.Deco", "Deco")
.WithMany("BreakContent")
.HasForeignKey("DecoId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
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()
.HasForeignKey("PlaylistId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.SmartCollection", "SmartCollection")
.WithMany()
.HasForeignKey("SmartCollectionId")
.OnDelete(DeleteBehavior.Cascade);
b.Navigation("Collection");
b.Navigation("Deco");
b.Navigation("MediaItem");
b.Navigation("MultiCollection");
b.Navigation("Playlist");
b.Navigation("SmartCollection");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Scheduling.DecoTemplate", b =>
{
b.HasOne("ErsatzTV.Core.Domain.Scheduling.DecoTemplateGroup", "DecoTemplateGroup")
@ -6341,6 +6437,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -6341,6 +6437,8 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
modelBuilder.Entity("ErsatzTV.Core.Domain.Scheduling.Deco", b =>
{
b.Navigation("BreakContent");
b.Navigation("DecoGraphicsElements");
b.Navigation("DecoWatermarks");

43
ErsatzTV.Infrastructure/Data/Configurations/Scheduling/DecoBreakContentConfiguration.cs

@ -0,0 +1,43 @@ @@ -0,0 +1,43 @@
using ErsatzTV.Core.Domain.Scheduling;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ErsatzTV.Infrastructure.Data.Configurations.Scheduling;
public class DecoBreakContentConfiguration : IEntityTypeConfiguration<DecoBreakContent>
{
public void Configure(EntityTypeBuilder<DecoBreakContent> builder)
{
builder.ToTable("DecoBreakContent");
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);
builder.HasOne(i => i.Playlist)
.WithMany()
.HasForeignKey(i => i.PlaylistId)
.OnDelete(DeleteBehavior.Cascade)
.IsRequired(false);
}
}

5
ErsatzTV.Infrastructure/Data/Configurations/Scheduling/DecoConfiguration.cs

@ -44,6 +44,11 @@ public class DecoConfiguration : IEntityTypeConfiguration<Deco> @@ -44,6 +44,11 @@ public class DecoConfiguration : IEntityTypeConfiguration<Deco>
.OnDelete(DeleteBehavior.Cascade)
.IsRequired(false);
builder.HasMany(d => d.BreakContent)
.WithOne(bc => bc.Deco)
.HasForeignKey(bc => bc.DecoId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(c => c.Watermarks)
.WithMany(m => m.Decos)
.UsingEntity<DecoWatermark>(

1
ErsatzTV/Pages/BlockEditor.razor

@ -97,6 +97,7 @@ @@ -97,6 +97,7 @@
<col/>
<col/>
<col/>
<col/>
<col style="width: 240px;"/>
</MudHidden>
</ColGroup>

641
ErsatzTV/Pages/DecoEditor.razor

@ -1,10 +1,9 @@ @@ -1,10 +1,9 @@
@page "/decos/{Id:int}"
@using ErsatzTV.Application.Artists
@using ErsatzTV.Application.Graphics
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.MediaItems
@using ErsatzTV.Application.Scheduling
@using ErsatzTV.Application.Television
@using ErsatzTV.Application.Search
@using ErsatzTV.Application.Watermarks
@using ErsatzTV.Core.Domain.Scheduling
@implements IDisposable
@ -111,6 +110,224 @@ @@ -111,6 +110,224 @@
@bind-Value="_deco.UseGraphicsElementsDuringFiller"
Dense="true"/>
</MudStack>
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Break Content</MudText>
<MudDivider Class="mb-6"/>
<MudText Typo="Typo.body2" Class="mb-6">Break content can be used to play specific items (using playlists), and to pad blocks to their full configured duration.</MudText>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Break Content Mode</MudText>
</div>
<MudSelect @bind-Value="_deco.BreakContentMode" For="@(() => _deco.BreakContentMode)">
<MudSelectItem Value="DecoMode.Inherit">Inherit</MudSelectItem>
<MudSelectItem Value="DecoMode.Disable">Disable</MudSelectItem>
<MudSelectItem Value="DecoMode.Override">Override</MudSelectItem>
</MudSelect>
</MudStack>
@if (_deco.BreakContentMode is DecoMode.Override)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex"></div>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@(_ => AddBreakContent())" StartIcon="@Icons.Material.Filled.Add">
Add Break Content
</MudButton>
</MudStack>
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Break Content Items</MudText>
<MudDivider Class="mb-6"/>
<MudTable T="DecoBreakContentEditViewModel" Class="mt-6" Hover="true" Items="_deco.BreakContent" Dense="true" SelectedItemChanged="@(vm => SelectedBreakContentChanged(vm))" RowClassFunc="@SelectedRowClassFunc">
<ColGroup>
<MudHidden Breakpoint="Breakpoint.Xs">
<col/>
<col/>
<col/>
<col style="width: 60px;"/>
</MudHidden>
</ColGroup>
<HeaderContent>
<MudTh>Type</MudTh>
<MudTh>Name</MudTh>
<MudTh>Placement</MudTh>
<MudTh/>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Type">@context.CollectionType.ToString()</MudTd>
<MudTd DataLabel="Name">@context.CollectionName</MudTd>
<MudTd DataLabel="Placement">
@(context.Placement switch {
DecoBreakPlacement.BlockStart => "Block Start",
DecoBreakPlacement.BlockFinish => "Block Finish",
DecoBreakPlacement.BetweenBlockItems => "Between Block Items",
DecoBreakPlacement.ChapterMarkers => "At Chapter Markers",
_ => string.Empty
})
</MudTd>
<MudTd>
<div class="d-flex">
@* <MudIconButton Icon="@Icons.Material.Filled.ContentCopy" *@
@* OnClick="@(_ => CopyItem(context))"> *@
@* </MudIconButton> *@
<MudIconButton Icon="@Icons.Material.Filled.Delete"
OnClick="@(_ => RemoveBreakContent(context))">
</MudIconButton>
</div>
</MudTd>
</RowTemplate>
</MudTable>
@if (_selectedBreakContent is not null)
{
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Break Content Item</MudText>
<MudDivider Class="mb-6"/>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Placement</MudText>
</div>
<MudSelect @bind-Value="_selectedBreakContent.Placement" For="@(() => _selectedBreakContent.Placement)" Disabled="true">
<MudSelectItem Value="DecoBreakPlacement.BlockStart">Block Start</MudSelectItem>
<MudSelectItem Value="DecoBreakPlacement.BetweenBlockItems">Between Block Items</MudSelectItem>
<MudSelectItem Value="DecoBreakPlacement.ChapterMarkers">At Chapter Markers</MudSelectItem>
<MudSelectItem Value="DecoBreakPlacement.BlockFinish">Block Finish</MudSelectItem>
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Collection Type</MudText>
</div>
<MudSelect @bind-Value="_selectedBreakContent.CollectionType" For="@(() => _selectedBreakContent.CollectionType)" Disabled="true">
@* <MudSelectItem Value="CollectionType.Collection">Collection</MudSelectItem> *@
@* <MudSelectItem Value="CollectionType.TelevisionShow">Television Show</MudSelectItem> *@
@* <MudSelectItem Value="CollectionType.TelevisionSeason">Television Season</MudSelectItem> *@
@* <MudSelectItem Value="CollectionType.Artist">Artist</MudSelectItem> *@
@* <MudSelectItem Value="CollectionType.MultiCollection">Multi Collection</MudSelectItem> *@
@* <MudSelectItem Value="CollectionType.SmartCollection">Smart Collection</MudSelectItem> *@
<MudSelectItem Value="CollectionType.Playlist">Playlist</MudSelectItem>
</MudSelect>
</MudStack>
@if (_selectedBreakContent.CollectionType == CollectionType.Collection)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Collection</MudText>
</div>
<MudAutocomplete T="MediaCollectionViewModel"
@bind-Value="_selectedBreakContent.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>
</MudStack>
}
@if (_selectedBreakContent.CollectionType == CollectionType.MultiCollection)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Multi Collection</MudText>
</div>
<MudAutocomplete T="MultiCollectionViewModel"
@bind-Value="_selectedBreakContent.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>
</MudStack>
}
@if (_selectedBreakContent.CollectionType == CollectionType.SmartCollection)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Smart Collection</MudText>
</div>
<MudAutocomplete T="SmartCollectionViewModel"
@bind-Value="_selectedBreakContent.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>
</MudStack>
}
@if (_selectedBreakContent.CollectionType == CollectionType.TelevisionShow)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Television Show</MudText>
</div>
<MudAutocomplete T="NamedMediaItemViewModel"
@bind-Value="_selectedBreakContent.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>
</MudStack>
}
@if (_selectedBreakContent.CollectionType == CollectionType.TelevisionSeason)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Television Season</MudText>
</div>
<MudAutocomplete T="NamedMediaItemViewModel"
@bind-Value="_selectedBreakContent.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>
</MudStack>
}
@if (_selectedBreakContent.CollectionType == CollectionType.Playlist)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Playlist Group</MudText>
</div>
<MudSelect T="PlaylistGroupViewModel"
Value="@_selectedPlaylistGroup"
ValueChanged="@(vm => UpdatePlaylistGroupItems(vm))">
@foreach (PlaylistGroupViewModel playlistGroup in _playlistGroups)
{
<MudSelectItem Value="@playlistGroup">@playlistGroup.Name</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Playlist</MudText>
</div>
<MudSelect T="PlaylistViewModel" @bind-value="_selectedBreakContent.Playlist">
@foreach (PlaylistViewModel playlist in _playlists)
{
<MudSelectItem Value="@playlist">@playlist.Name</MudSelectItem>
}
</MudSelect>
</MudStack>
}
}
}
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Default Filler</MudText>
<MudDivider Class="mb-6"/>
<MudText Typo="Typo.body2" Class="mb-6">After all blocks have been scheduled, a second pass will be made to fill unscheduled time using random items from this collection.</MudText>
@ -145,14 +362,17 @@ @@ -145,14 +362,17 @@
<div class="d-flex">
<MudText>Collection</MudText>
</div>
<MudSelect T="MediaCollectionViewModel"
Disabled="@(_deco.DefaultFillerMode != DecoMode.Override)"
@bind-value="_deco.DefaultFillerCollection">
@foreach (MediaCollectionViewModel collection in _mediaCollections)
{
<MudSelectItem Value="@collection">@collection.Name</MudSelectItem>
}
</MudSelect>
<MudAutocomplete T="MediaCollectionViewModel"
Disabled="@(_deco.DefaultFillerMode != DecoMode.Override)"
@bind-value="_deco.DefaultFillerCollection"
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>
</MudStack>
}
@if (_deco.DefaultFillerCollectionType == CollectionType.MultiCollection)
@ -161,14 +381,17 @@ @@ -161,14 +381,17 @@
<div class="d-flex">
<MudText>Multi Collection</MudText>
</div>
<MudSelect T="MultiCollectionViewModel"
Disabled="@(_deco.DefaultFillerMode != DecoMode.Override)"
@bind-value="_deco.DefaultFillerMultiCollection">
@foreach (MultiCollectionViewModel collection in _multiCollections)
{
<MudSelectItem Value="@collection">@collection.Name</MudSelectItem>
}
</MudSelect>
<MudAutocomplete T="MultiCollectionViewModel"
Disabled="@(_deco.DefaultFillerMode != DecoMode.Override)"
@bind-value="_deco.DefaultFillerMultiCollection"
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>
</MudStack>
}
@if (_deco.DefaultFillerCollectionType == CollectionType.SmartCollection)
@ -177,14 +400,17 @@ @@ -177,14 +400,17 @@
<div class="d-flex">
<MudText>Smart Collection</MudText>
</div>
<MudSelect T="SmartCollectionViewModel"
Disabled="@(_deco.DefaultFillerMode != DecoMode.Override)"
@bind-value="_deco.DefaultFillerSmartCollection">
@foreach (SmartCollectionViewModel collection in _smartCollections)
{
<MudSelectItem Value="@collection">@collection.Name</MudSelectItem>
}
</MudSelect>
<MudAutocomplete T="SmartCollectionViewModel"
Disabled="@(_deco.DefaultFillerMode != DecoMode.Override)"
@bind-value="_deco.DefaultFillerSmartCollection"
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>
</MudStack>
}
@if (_deco.DefaultFillerCollectionType == CollectionType.TelevisionShow)
@ -193,14 +419,17 @@ @@ -193,14 +419,17 @@
<div class="d-flex">
<MudText>Television Show</MudText>
</div>
<MudSelect T="NamedMediaItemViewModel"
Disabled="@(_deco.DefaultFillerMode != DecoMode.Override)"
@bind-value="_deco.DefaultFillerMediaItem">
@foreach (NamedMediaItemViewModel show in _televisionShows)
{
<MudSelectItem Value="@show">@show.Name</MudSelectItem>
}
</MudSelect>
<MudAutocomplete T="NamedMediaItemViewModel"
Disabled="@(_deco.DefaultFillerMode != DecoMode.Override)"
@bind-value="_deco.DefaultFillerMediaItem"
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>
</MudStack>
}
@if (_deco.DefaultFillerCollectionType == CollectionType.TelevisionSeason)
@ -209,14 +438,18 @@ @@ -209,14 +438,18 @@
<div class="d-flex">
<MudText>Television Season</MudText>
</div>
<MudSelect T="NamedMediaItemViewModel"
Disabled="@(_deco.DefaultFillerMode != DecoMode.Override)"
@bind-value="_deco.DefaultFillerMediaItem">
@foreach (NamedMediaItemViewModel season in _televisionSeasons)
{
<MudSelectItem Value="@season">@season.Name</MudSelectItem>
}
</MudSelect>
<MudAutocomplete T="NamedMediaItemViewModel"
Disabled="@(_deco.DefaultFillerMode != DecoMode.Override)"
@bind-Value="_deco.DefaultFillerMediaItem"
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>
</MudStack>
}
@if (_deco.DefaultFillerCollectionType == CollectionType.Artist)
@ -225,14 +458,18 @@ @@ -225,14 +458,18 @@
<div class="d-flex">
<MudText>Artist</MudText>
</div>
<MudSelect T="NamedMediaItemViewModel"
Disabled="@(_deco.DefaultFillerMode != DecoMode.Override)"
@bind-value="_deco.DefaultFillerMediaItem">
@foreach (NamedMediaItemViewModel artist in _artists)
{
<MudSelectItem Value="@artist">@artist.Name</MudSelectItem>
}
</MudSelect>
<MudAutocomplete T="NamedMediaItemViewModel"
Disabled="@(_deco.DefaultFillerMode != DecoMode.Override)"
@bind-Value="_deco.DefaultFillerMediaItem"
SearchFunc="@SearchArtists"
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>
</MudStack>
}
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
@ -280,14 +517,17 @@ @@ -280,14 +517,17 @@
<div class="d-flex">
<MudText>Collection</MudText>
</div>
<MudSelect T="MediaCollectionViewModel"
Disabled="@(_deco.DeadAirFallbackMode != DecoMode.Override)"
@bind-value="_deco.DeadAirFallbackCollection">
@foreach (MediaCollectionViewModel collection in _mediaCollections)
{
<MudSelectItem Value="@collection">@collection.Name</MudSelectItem>
}
</MudSelect>
<MudAutocomplete T="MediaCollectionViewModel"
Disabled="@(_deco.DeadAirFallbackMode != DecoMode.Override)"
@bind-value="_deco.DeadAirFallbackCollection"
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>
</MudStack>
}
@if (_deco.DeadAirFallbackCollectionType == CollectionType.MultiCollection)
@ -296,14 +536,17 @@ @@ -296,14 +536,17 @@
<div class="d-flex">
<MudText>Multi Collection</MudText>
</div>
<MudSelect T="MultiCollectionViewModel"
Disabled="@(_deco.DeadAirFallbackMode != DecoMode.Override)"
@bind-value="_deco.DeadAirFallbackMultiCollection">
@foreach (MultiCollectionViewModel collection in _multiCollections)
{
<MudSelectItem Value="@collection">@collection.Name</MudSelectItem>
}
</MudSelect>
<MudAutocomplete T="MultiCollectionViewModel"
Disabled="@(_deco.DeadAirFallbackMode != DecoMode.Override)"
@bind-value="_deco.DeadAirFallbackMultiCollection"
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>
</MudStack>
}
@if (_deco.DeadAirFallbackCollectionType == CollectionType.SmartCollection)
@ -312,14 +555,17 @@ @@ -312,14 +555,17 @@
<div class="d-flex">
<MudText>Smart Collection</MudText>
</div>
<MudSelect T="SmartCollectionViewModel"
Disabled="@(_deco.DeadAirFallbackMode != DecoMode.Override)"
@bind-value="_deco.DeadAirFallbackSmartCollection">
@foreach (SmartCollectionViewModel collection in _smartCollections)
{
<MudSelectItem Value="@collection">@collection.Name</MudSelectItem>
}
</MudSelect>
<MudAutocomplete T="SmartCollectionViewModel"
Disabled="@(_deco.DeadAirFallbackMode != DecoMode.Override)"
@bind-value="_deco.DeadAirFallbackSmartCollection"
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>
</MudStack>
}
@if (_deco.DeadAirFallbackCollectionType == CollectionType.TelevisionShow)
@ -328,14 +574,17 @@ @@ -328,14 +574,17 @@
<div class="d-flex">
<MudText>Television Show</MudText>
</div>
<MudSelect T="NamedMediaItemViewModel"
Disabled="@(_deco.DeadAirFallbackMode != DecoMode.Override)"
@bind-value="_deco.DeadAirFallbackMediaItem">
@foreach (NamedMediaItemViewModel show in _televisionShows)
{
<MudSelectItem Value="@show">@show.Name</MudSelectItem>
}
</MudSelect>
<MudAutocomplete T="NamedMediaItemViewModel"
Disabled="@(_deco.DeadAirFallbackMode != DecoMode.Override)"
@bind-value="_deco.DeadAirFallbackMediaItem"
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>
</MudStack>
}
@if (_deco.DeadAirFallbackCollectionType == CollectionType.TelevisionSeason)
@ -344,14 +593,18 @@ @@ -344,14 +593,18 @@
<div class="d-flex">
<MudText>Television Season</MudText>
</div>
<MudSelect T="NamedMediaItemViewModel"
Disabled="@(_deco.DeadAirFallbackMode != DecoMode.Override)"
@bind-value="_deco.DeadAirFallbackMediaItem">
@foreach (NamedMediaItemViewModel season in _televisionSeasons)
{
<MudSelectItem Value="@season">@season.Name</MudSelectItem>
}
</MudSelect>
<MudAutocomplete T="NamedMediaItemViewModel"
Disabled="@(_deco.DeadAirFallbackMode != DecoMode.Override)"
@bind-value="_deco.DeadAirFallbackMediaItem"
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>
</MudStack>
}
@if (_deco.DeadAirFallbackCollectionType == CollectionType.Artist)
@ -360,14 +613,18 @@ @@ -360,14 +613,18 @@
<div class="d-flex">
<MudText>Artist</MudText>
</div>
<MudSelect T="NamedMediaItemViewModel"
Disabled="@(_deco.DeadAirFallbackMode != DecoMode.Override)"
@bind-value="_deco.DeadAirFallbackMediaItem">
@foreach (NamedMediaItemViewModel artist in _artists)
{
<MudSelectItem Value="@artist">@artist.Name</MudSelectItem>
}
</MudSelect>
<MudAutocomplete T="NamedMediaItemViewModel"
Disabled="@(_deco.DeadAirFallbackMode != DecoMode.Override)"
@bind-value="_deco.DeadAirFallbackMediaItem"
SearchFunc="@SearchArtists"
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>
</MudStack>
}
</MudContainer>
@ -384,14 +641,11 @@ @@ -384,14 +641,11 @@
private List<WatermarkViewModel> _watermarks = [];
private List<GraphicsElementViewModel> _graphicsElements = [];
private readonly List<PlaylistGroupViewModel> _playlistGroups = [];
private readonly List<PlaylistViewModel> _playlists = [];
private List<MediaCollectionViewModel> _mediaCollections = [];
private List<MultiCollectionViewModel> _multiCollections = [];
private List<SmartCollectionViewModel> _smartCollections = [];
private List<NamedMediaItemViewModel> _televisionShows = [];
private List<NamedMediaItemViewModel> _televisionSeasons = [];
private List<NamedMediaItemViewModel> _artists = [];
private DecoBreakContentEditViewModel _selectedBreakContent;
private PlaylistGroupViewModel _selectedPlaylistGroup;
public void Dispose()
{
@ -410,19 +664,7 @@ @@ -410,19 +664,7 @@
{
_watermarks = await Mediator.Send(new GetAllWatermarks(), token);
_graphicsElements = await Mediator.Send(new GetAllGraphicsElements(), token);
_mediaCollections = await Mediator.Send(new GetAllCollections(), token)
.Map(list => list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase).ToList());
_multiCollections = await Mediator.Send(new GetAllMultiCollections(), token)
.Map(list => list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase).ToList());
_smartCollections = await Mediator.Send(new GetAllSmartCollections(), token)
.Map(list => list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase).ToList());
_televisionShows = await Mediator.Send(new GetAllTelevisionShows(), token)
.Map(list => list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase).ToList());
_televisionSeasons = await Mediator.Send(new GetAllTelevisionSeasons(), token)
.Map(list => list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase).ToList());
_artists = await Mediator.Send(new GetAllArtists(), token)
.Map(list => list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase).ToList());
_playlistGroups.AddRange(await Mediator.Send(new GetAllPlaylistGroups(), token));
Option<DecoViewModel> maybeDeco = await Mediator.Send(new GetDecoById(Id), token);
if (maybeDeco.IsNone)
@ -446,38 +688,23 @@ @@ -446,38 +688,23 @@
GraphicsElements = deco.GraphicsElements,
UseGraphicsElementsDuringFiller = deco.UseGraphicsElementsDuringFiller,
BreakContentMode = deco.BreakContentMode,
BreakContent = deco.BreakContent.Map(ProjectToEditViewModel).ToList(),
DefaultFillerMode = deco.DefaultFillerMode,
DefaultFillerCollectionType = deco.DefaultFillerCollectionType,
DefaultFillerCollection = deco.DefaultFillerCollectionId.HasValue
? _mediaCollections.Find(c => c.Id == deco.DefaultFillerCollectionId!.Value)
: null,
DefaultFillerMediaItem = deco.DefaultFillerMediaItemId.HasValue
? _televisionShows.Append(_televisionSeasons).Append(_artists).ToList()
.Find(vm => vm.MediaItemId == deco.DefaultFillerMediaItemId!.Value)
: null,
DefaultFillerMultiCollection = deco.DefaultFillerMultiCollectionId.HasValue
? _multiCollections.Find(c => c.Id == deco.DefaultFillerMultiCollectionId!.Value)
: null,
DefaultFillerSmartCollection = deco.DefaultFillerSmartCollectionId.HasValue
? _smartCollections.Find(c => c.Id == deco.DefaultFillerSmartCollectionId!.Value)
: null,
DefaultFillerCollection = deco.DefaultFillerCollection,
DefaultFillerMediaItem = deco.DefaultFillerMediaItem,
DefaultFillerMultiCollection = deco.DefaultFillerMultiCollection,
DefaultFillerSmartCollection = deco.DefaultFillerSmartCollection,
DefaultFillerTrimToFit = deco.DefaultFillerTrimToFit,
DeadAirFallbackMode = deco.DeadAirFallbackMode,
DeadAirFallbackCollectionType = deco.DeadAirFallbackCollectionType,
DeadAirFallbackCollection = deco.DeadAirFallbackCollectionId.HasValue
? _mediaCollections.Find(c => c.Id == deco.DeadAirFallbackCollectionId!.Value)
: null,
DeadAirFallbackMediaItem = deco.DeadAirFallbackMediaItemId.HasValue
? _televisionShows.Append(_televisionSeasons).Append(_artists).ToList()
.Find(vm => vm.MediaItemId == deco.DeadAirFallbackMediaItemId!.Value)
: null,
DeadAirFallbackMultiCollection = deco.DeadAirFallbackMultiCollectionId.HasValue
? _multiCollections.Find(c => c.Id == deco.DeadAirFallbackMultiCollectionId!.Value)
: null,
DeadAirFallbackSmartCollection = deco.DeadAirFallbackSmartCollectionId.HasValue
? _smartCollections.Find(c => c.Id == deco.DeadAirFallbackSmartCollectionId!.Value)
: null
DeadAirFallbackCollection = deco.DeadAirFallbackCollection,
DeadAirFallbackMediaItem = deco.DeadAirFallbackMediaItem,
DeadAirFallbackMultiCollection = deco.DeadAirFallbackMultiCollection,
DeadAirFallbackSmartCollection = deco.DeadAirFallbackSmartCollection
};
}
}
@ -487,8 +714,25 @@ @@ -487,8 +714,25 @@
}
}
private static DecoBreakContentEditViewModel ProjectToEditViewModel(DecoBreakContentViewModel vm) =>
new()
{
Id = vm.Id,
CollectionType = vm.CollectionType,
Collection = vm.Collection,
MediaItem = vm.MediaItem,
MultiCollection = vm.MultiCollection,
SmartCollection = vm.SmartCollection,
Playlist = vm.Playlist,
Placement = vm.Placement
};
private async Task SaveChanges()
{
var breakContent = _deco.BreakContent
.Map(vm => new UpdateDecoBreakContent(vm.Id, vm.CollectionType, vm.Collection?.Id, vm.MediaItem?.MediaItemId, vm.MultiCollection?.Id, vm.SmartCollection?.Id, vm.Playlist?.Id, vm.Placement))
.ToList();
var request = new UpdateDeco(
Id,
_deco.DecoGroupId,
@ -499,6 +743,8 @@ @@ -499,6 +743,8 @@
_deco.GraphicsElementsMode,
_deco.GraphicsElements.Map(ge => ge.Id).ToList(),
_deco.UseGraphicsElementsDuringFiller,
_deco.BreakContentMode,
breakContent,
_deco.DefaultFillerMode,
_deco.DefaultFillerCollectionType,
_deco.DefaultFillerCollection?.Id,
@ -526,4 +772,111 @@ @@ -526,4 +772,111 @@
() => NavigationManager.NavigateTo("decos"));
}
private async Task<IEnumerable<MediaCollectionViewModel>> SearchCollections(string value, CancellationToken cancellationToken)
{
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, CancellationToken cancellationToken)
{
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, CancellationToken cancellationToken)
{
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, CancellationToken cancellationToken)
{
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, CancellationToken cancellationToken)
{
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, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(value))
{
return new List<NamedMediaItemViewModel>();
}
return await Mediator.Send(new SearchArtists(value), _cts.Token);
}
private async Task SelectedBreakContentChanged(DecoBreakContentEditViewModel vm)
{
_selectedBreakContent = vm;
foreach (int playlistGroupId in Optional(_selectedBreakContent.Playlist?.PlaylistGroupId))
{
foreach (PlaylistGroupViewModel group in Optional(_playlistGroups.Find(g => g.Id == playlistGroupId)))
{
_selectedPlaylistGroup = group;
await UpdatePlaylistGroupItems(group);
}
}
}
private async Task UpdatePlaylistGroupItems(PlaylistGroupViewModel playlistGroup)
{
_selectedPlaylistGroup = playlistGroup;
_playlists.Clear();
_playlists.AddRange(await Mediator.Send(new GetPlaylistsByPlaylistGroupId(playlistGroup.Id), _cts.Token));
}
private void AddBreakContent()
{
var item = new DecoBreakContentEditViewModel
{
//CollectionType = CollectionType.Collection,
//Collection = null,
CollectionType = CollectionType.Playlist,
Playlist = null,
Placement = DecoBreakPlacement.BlockStart
};
_deco.BreakContent.Add(item);
}
private void RemoveBreakContent(DecoBreakContentEditViewModel item)
{
_deco.BreakContent.Remove(item);
if (_selectedBreakContent == item)
{
_selectedBreakContent = null;
}
}
private string SelectedRowClassFunc(DecoBreakContentEditViewModel element, int rowNumber) => _selectedBreakContent != null && _selectedBreakContent == element ? "selected" : string.Empty;
}

64
ErsatzTV/ViewModels/DecoBreakContentEditViewModel.cs

@ -0,0 +1,64 @@ @@ -0,0 +1,64 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
using ErsatzTV.Application.MediaCollections;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Scheduling;
namespace ErsatzTV.ViewModels;
public class DecoBreakContentEditViewModel : INotifyPropertyChanged
{
private CollectionType _collectionType;
public int Id { get; set; }
public CollectionType CollectionType
{
get => _collectionType;
set
{
if (_collectionType != value)
{
_collectionType = value;
Collection = null;
MultiCollection = null;
MediaItem = null;
SmartCollection = null;
Playlist = null;
OnPropertyChanged(nameof(Collection));
OnPropertyChanged(nameof(MultiCollection));
OnPropertyChanged(nameof(MediaItem));
OnPropertyChanged(nameof(SmartCollection));
OnPropertyChanged(nameof(Playlist));
}
}
}
public MediaCollectionViewModel Collection { get; set; }
public MultiCollectionViewModel MultiCollection { get; set; }
public SmartCollectionViewModel SmartCollection { get; set; }
public NamedMediaItemViewModel MediaItem { get; set; }
public PlaylistViewModel Playlist { get; set; }
public DecoBreakPlacement Placement { get; set; }
public string CollectionName => CollectionType switch
{
CollectionType.Collection => Collection?.Name,
CollectionType.TelevisionShow => MediaItem?.Name,
CollectionType.TelevisionSeason => MediaItem?.Name,
CollectionType.Artist => MediaItem?.Name,
CollectionType.MultiCollection => MultiCollection?.Name,
CollectionType.SmartCollection => SmartCollection?.Name,
CollectionType.Playlist => Playlist?.Name,
_ => string.Empty
};
public event PropertyChangedEventHandler PropertyChanged;
protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null) =>
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}

3
ErsatzTV/ViewModels/DecoEditViewModel.cs

@ -34,4 +34,7 @@ public class DecoEditViewModel @@ -34,4 +34,7 @@ public class DecoEditViewModel
public MultiCollectionViewModel DeadAirFallbackMultiCollection { get; set; }
public SmartCollectionViewModel DeadAirFallbackSmartCollection { get; set; }
public NamedMediaItemViewModel DeadAirFallbackMediaItem { get; set; }
public DecoMode BreakContentMode { get; set; }
public List<DecoBreakContentEditViewModel> BreakContent { get; set; } = [];
}

Loading…
Cancel
Save