Browse Source

add global and channel fallback filler (#459)

* configure channel and global fallback filler

* play random item from configured channel/global fallback filler as needed
pull/460/head
Jason Dove 5 years ago committed by GitHub
parent
commit
0136de700c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 10
      CHANGELOG.md
  2. 3
      ErsatzTV.Application/Channels/ChannelViewModel.cs
  3. 3
      ErsatzTV.Application/Channels/Commands/CreateChannel.cs
  4. 31
      ErsatzTV.Application/Channels/Commands/CreateChannelHandler.cs
  5. 3
      ErsatzTV.Application/Channels/Commands/UpdateChannel.cs
  6. 1
      ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs
  7. 3
      ErsatzTV.Application/Channels/Mapper.cs
  8. 11
      ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegSettingsHandler.cs
  9. 1
      ErsatzTV.Application/FFmpegProfiles/FFmpegSettingsViewModel.cs
  10. 7
      ErsatzTV.Application/FFmpegProfiles/Queries/GetFFmpegSettingsHandler.cs
  11. 110
      ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs
  12. 71
      ErsatzTV.Core.Tests/Scheduling/PlayoutModeSchedulerOneTests.cs
  13. 3
      ErsatzTV.Core/Domain/Channel.cs
  14. 1
      ErsatzTV.Core/Domain/ConfigElementKey.cs
  15. 51
      ErsatzTV.Core/Scheduling/MediaItemsForCollection.cs
  16. 41
      ErsatzTV.Core/Scheduling/PlayoutBuilder.cs
  17. 12
      ErsatzTV.Infrastructure/Data/Configurations/ChannelConfiguration.cs
  18. 1
      ErsatzTV.Infrastructure/Data/TvContext.cs
  19. 3670
      ErsatzTV.Infrastructure/Migrations/20211025124549_Add_ChannelFallbackFiller.Designer.cs
  20. 68
      ErsatzTV.Infrastructure/Migrations/20211025124549_Add_ChannelFallbackFiller.cs
  21. 15
      ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs
  22. 30
      ErsatzTV/Pages/ChannelEditor.razor
  23. 23
      ErsatzTV/Pages/Settings.razor
  24. 7
      ErsatzTV/ViewModels/ChannelEditViewModel.cs

10
CHANGELOG.md

@ -4,7 +4,15 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased] ## [Unreleased]
- Fix EPG entries for Duration schedule items that play multiple items ### Fixed
- Fix EPG entries for Duration schedule items that play multiple items
### Added
- Add fallback filler settings to Channel and global FFmpeg Settings
- When streaming is attempted during an unscheduled gap, the resulting video will be determined using the following priority:
- Channel fallback filler
- Global fallback filler
- Generated `Channel Is Offline` error message video
## [0.2.1-alpha] - 2021-10-24 ## [0.2.1-alpha] - 2021-10-24
### Fixed ### Fixed

3
ErsatzTV.Application/Channels/ChannelViewModel.cs

@ -10,5 +10,6 @@ namespace ErsatzTV.Application.Channels
string Logo, string Logo,
string PreferredLanguageCode, string PreferredLanguageCode,
StreamingMode StreamingMode, StreamingMode StreamingMode,
int? WatermarkId); int? WatermarkId,
int? FallbackFillerId);
} }

3
ErsatzTV.Application/Channels/Commands/CreateChannel.cs

@ -13,5 +13,6 @@ namespace ErsatzTV.Application.Channels.Commands
string Logo, string Logo,
string PreferredLanguageCode, string PreferredLanguageCode,
StreamingMode StreamingMode, StreamingMode StreamingMode,
int? WatermarkId) : IRequest<Either<BaseError, CreateChannelResult>>; int? WatermarkId,
int? FallbackFillerId) : IRequest<Either<BaseError, CreateChannelResult>>;
} }

31
ErsatzTV.Application/Channels/Commands/CreateChannelHandler.cs

@ -7,6 +7,7 @@ using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
using LanguageExt; using LanguageExt;
@ -42,9 +43,10 @@ namespace ErsatzTV.Application.Channels.Commands
(ValidateName(request), await ValidateNumber(dbContext, request), (ValidateName(request), await ValidateNumber(dbContext, request),
await FFmpegProfileMustExist(dbContext, request), await FFmpegProfileMustExist(dbContext, request),
ValidatePreferredLanguage(request), ValidatePreferredLanguage(request),
await WatermarkMustExist(dbContext, request)) await WatermarkMustExist(dbContext, request),
await FillerPresetMustExist(dbContext, request))
.Apply( .Apply(
(name, number, ffmpegProfileId, preferredLanguageCode, watermarkId) => (name, number, ffmpegProfileId, preferredLanguageCode, watermarkId, fillerPresetId) =>
{ {
var artwork = new List<Artwork>(); var artwork = new List<Artwork>();
if (!string.IsNullOrWhiteSpace(request.Logo)) if (!string.IsNullOrWhiteSpace(request.Logo))
@ -74,6 +76,11 @@ namespace ErsatzTV.Application.Channels.Commands
channel.WatermarkId = id; channel.WatermarkId = id;
} }
foreach (int id in fillerPresetId)
{
channel.FallbackFillerId = id;
}
return channel; return channel;
}); });
@ -131,5 +138,25 @@ namespace ErsatzTV.Application.Channels.Commands
.MapT(_ => Optional(createChannel.WatermarkId)) .MapT(_ => Optional(createChannel.WatermarkId))
.Map(o => o.ToValidation<BaseError>($"Watermark {createChannel.WatermarkId} does not exist.")); .Map(o => o.ToValidation<BaseError>($"Watermark {createChannel.WatermarkId} does not exist."));
} }
private static async Task<Validation<BaseError, Option<int>>> FillerPresetMustExist(
TvContext dbContext,
CreateChannel createChannel)
{
if (createChannel.FallbackFillerId is null)
{
return Option<int>.None;
}
return await dbContext.FillerPresets
.Filter(fp => fp.FillerKind == FillerKind.Fallback)
.CountAsync(w => w.Id == createChannel.FallbackFillerId)
.Map(Optional)
.Filter(c => c > 0)
.MapT(_ => Optional(createChannel.FallbackFillerId))
.Map(
o => o.ToValidation<BaseError>(
$"Fallback filler {createChannel.FallbackFillerId} does not exist."));
}
} }
} }

3
ErsatzTV.Application/Channels/Commands/UpdateChannel.cs

@ -14,5 +14,6 @@ namespace ErsatzTV.Application.Channels.Commands
string Logo, string Logo,
string PreferredLanguageCode, string PreferredLanguageCode,
StreamingMode StreamingMode, StreamingMode StreamingMode,
int? WatermarkId) : IRequest<Either<BaseError, ChannelViewModel>>; int? WatermarkId,
int? FallbackFillerId) : IRequest<Either<BaseError, ChannelViewModel>>;
} }

1
ErsatzTV.Application/Channels/Commands/UpdateChannelHandler.cs

@ -67,6 +67,7 @@ namespace ErsatzTV.Application.Channels.Commands
c.StreamingMode = update.StreamingMode; c.StreamingMode = update.StreamingMode;
c.WatermarkId = update.WatermarkId; c.WatermarkId = update.WatermarkId;
c.FallbackFillerId = update.FallbackFillerId;
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
return ProjectToViewModel(c); return ProjectToViewModel(c);
} }

3
ErsatzTV.Application/Channels/Mapper.cs

@ -15,7 +15,8 @@ namespace ErsatzTV.Application.Channels
GetLogo(channel), GetLogo(channel),
channel.PreferredLanguageCode, channel.PreferredLanguageCode,
channel.StreamingMode, channel.StreamingMode,
channel.WatermarkId); channel.WatermarkId,
channel.FallbackFillerId);
private static string GetLogo(Channel channel) => private static string GetLogo(Channel channel) =>
Optional(channel.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Logo)) Optional(channel.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Logo))

11
ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegSettingsHandler.cs

@ -115,6 +115,17 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
await _configElementRepository.Delete(ConfigElementKey.FFmpegGlobalWatermarkId); await _configElementRepository.Delete(ConfigElementKey.FFmpegGlobalWatermarkId);
} }
if (request.Settings.GlobalFallbackFillerId is not null)
{
await _configElementRepository.Upsert(
ConfigElementKey.FFmpegGlobalFallbackFillerId,
request.Settings.GlobalFallbackFillerId.Value);
}
else
{
await _configElementRepository.Delete(ConfigElementKey.FFmpegGlobalFallbackFillerId);
}
await _configElementRepository.Upsert( await _configElementRepository.Upsert(
ConfigElementKey.FFmpegSegmenterTimeout, ConfigElementKey.FFmpegSegmenterTimeout,
request.Settings.HlsSegmenterIdleTimeout); request.Settings.HlsSegmenterIdleTimeout);

1
ErsatzTV.Application/FFmpegProfiles/FFmpegSettingsViewModel.cs

@ -8,6 +8,7 @@
public string PreferredLanguageCode { get; set; } public string PreferredLanguageCode { get; set; }
public bool SaveReports { get; set; } public bool SaveReports { get; set; }
public int? GlobalWatermarkId { get; set; } public int? GlobalWatermarkId { get; set; }
public int? GlobalFallbackFillerId { get; set; }
public int HlsSegmenterIdleTimeout { get; set; } public int HlsSegmenterIdleTimeout { get; set; }
public int WorkAheadSegmenterLimit { get; set; } public int WorkAheadSegmenterLimit { get; set; }
} }

7
ErsatzTV.Application/FFmpegProfiles/Queries/GetFFmpegSettingsHandler.cs

@ -28,6 +28,8 @@ namespace ErsatzTV.Application.FFmpegProfiles.Queries
await _configElementRepository.GetValue<string>(ConfigElementKey.FFmpegPreferredLanguageCode); await _configElementRepository.GetValue<string>(ConfigElementKey.FFmpegPreferredLanguageCode);
Option<int> watermark = Option<int> watermark =
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegGlobalWatermarkId); await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegGlobalWatermarkId);
Option<int> fallbackFiller =
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegGlobalFallbackFillerId);
Option<int> hlsSegmenterIdleTimeout = Option<int> hlsSegmenterIdleTimeout =
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegSegmenterTimeout); await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegSegmenterTimeout);
Option<int> workAheadSegmenterLimit = Option<int> workAheadSegmenterLimit =
@ -49,6 +51,11 @@ namespace ErsatzTV.Application.FFmpegProfiles.Queries
result.GlobalWatermarkId = watermarkId; result.GlobalWatermarkId = watermarkId;
} }
foreach (int fallbackFillerId in fallbackFiller)
{
result.GlobalFallbackFillerId = fallbackFillerId;
}
return result; return result;
} }
} }

110
ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs

@ -1,17 +1,21 @@
using System; using System;
using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.Linq; using System.Linq;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Threading.Tasks; using System.Threading.Tasks;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Errors; using ErsatzTV.Core.Errors;
using ErsatzTV.Core.FFmpeg; using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.Emby; using ErsatzTV.Core.Interfaces.Emby;
using ErsatzTV.Core.Interfaces.Jellyfin; using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Plex; using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Runtime; using ErsatzTV.Core.Interfaces.Runtime;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions; using ErsatzTV.Infrastructure.Extensions;
using LanguageExt; using LanguageExt;
@ -24,6 +28,9 @@ namespace ErsatzTV.Application.Streaming.Queries
FFmpegProcessHandler<GetPlayoutItemProcessByChannelNumber> FFmpegProcessHandler<GetPlayoutItemProcessByChannelNumber>
{ {
private readonly IEmbyPathReplacementService _embyPathReplacementService; private readonly IEmbyPathReplacementService _embyPathReplacementService;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
private readonly ITelevisionRepository _televisionRepository;
private readonly IArtistRepository _artistRepository;
private readonly FFmpegProcessService _ffmpegProcessService; private readonly FFmpegProcessService _ffmpegProcessService;
private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService; private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService;
private readonly ILocalFileSystem _localFileSystem; private readonly ILocalFileSystem _localFileSystem;
@ -37,6 +44,9 @@ namespace ErsatzTV.Application.Streaming.Queries
IPlexPathReplacementService plexPathReplacementService, IPlexPathReplacementService plexPathReplacementService,
IJellyfinPathReplacementService jellyfinPathReplacementService, IJellyfinPathReplacementService jellyfinPathReplacementService,
IEmbyPathReplacementService embyPathReplacementService, IEmbyPathReplacementService embyPathReplacementService,
IMediaCollectionRepository mediaCollectionRepository,
ITelevisionRepository televisionRepository,
IArtistRepository artistRepository,
IRuntimeInfo runtimeInfo) IRuntimeInfo runtimeInfo)
: base(dbContextFactory) : base(dbContextFactory)
{ {
@ -45,6 +55,9 @@ namespace ErsatzTV.Application.Streaming.Queries
_plexPathReplacementService = plexPathReplacementService; _plexPathReplacementService = plexPathReplacementService;
_jellyfinPathReplacementService = jellyfinPathReplacementService; _jellyfinPathReplacementService = jellyfinPathReplacementService;
_embyPathReplacementService = embyPathReplacementService; _embyPathReplacementService = embyPathReplacementService;
_mediaCollectionRepository = mediaCollectionRepository;
_televisionRepository = televisionRepository;
_artistRepository = artistRepository;
_runtimeInfo = runtimeInfo; _runtimeInfo = runtimeInfo;
} }
@ -85,6 +98,11 @@ namespace ErsatzTV.Application.Streaming.Queries
.Map(o => o.ToEither<BaseError>(new UnableToLocatePlayoutItem())) .Map(o => o.ToEither<BaseError>(new UnableToLocatePlayoutItem()))
.BindT(ValidatePlayoutItemPath); .BindT(ValidatePlayoutItemPath);
if (maybePlayoutItem.LeftAsEnumerable().Any(e => e is UnableToLocatePlayoutItem))
{
maybePlayoutItem = await CheckForFallbackFiller(dbContext, channel, now);
}
return await maybePlayoutItem.Match( return await maybePlayoutItem.Match(
async playoutItemWithPath => async playoutItemWithPath =>
{ {
@ -158,8 +176,7 @@ namespace ErsatzTV.Application.Streaming.Queries
maybeDuration, maybeDuration,
"Channel is Offline", "Channel is Offline",
request.HlsRealtime); request.HlsRealtime);
return new PlayoutItemProcessModel(errorProcess, finish); return new PlayoutItemProcessModel(errorProcess, finish);
} }
else else
@ -211,6 +228,95 @@ namespace ErsatzTV.Application.Streaming.Queries
}); });
} }
private async Task<Either<BaseError, PlayoutItemWithPath>> CheckForFallbackFiller(
TvContext dbContext,
Channel channel,
DateTimeOffset now)
{
// check for channel fallback
Option<FillerPreset> maybeFallback = await dbContext.FillerPresets
.SelectOneAsync(w => w.Id, w => w.Id == channel.FallbackFillerId);
// then check for global fallback
if (maybeFallback.IsNone)
{
maybeFallback = await dbContext.ConfigElements
.GetValue<int>(ConfigElementKey.FFmpegGlobalFallbackFillerId)
.BindT(fillerId => dbContext.FillerPresets.SelectOneAsync(w => w.Id, w => w.Id == fillerId));
}
foreach (FillerPreset fallbackPreset in maybeFallback)
{
// turn this into a playout item
var collectionKey = CollectionKey.ForFillerPreset(fallbackPreset);
List<MediaItem> items = await MediaItemsForCollection.Collect(
_mediaCollectionRepository,
_televisionRepository,
_artistRepository,
collectionKey);
// TODO: shuffle? does it really matter since we loop anyway
MediaItem item = items[new Random().Next(items.Count)];
Option<TimeSpan> maybeDuration = await Optional(channel.FFmpegProfile.Transcode)
.Filter(transcode => transcode)
.Match(
_ => dbContext.PlayoutItems
.Filter(pi => pi.Playout.ChannelId == channel.Id)
.Filter(pi => pi.Start > now.UtcDateTime)
.OrderBy(pi => pi.Start)
.FirstOrDefaultAsync()
.Map(Optional)
.MapT(pi => pi.StartOffset - now),
() => Option<TimeSpan>.None.AsTask());
MediaVersion version = item switch
{
Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(),
MusicVideo mv => mv.MediaVersions.Head(),
OtherVideo ov => ov.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(item))
};
version.MediaFiles = await dbContext.MediaFiles
.AsNoTracking()
.Filter(mf => mf.MediaVersionId == version.Id)
.ToListAsync();
version.Streams = await dbContext.MediaStreams
.AsNoTracking()
.Filter(ms => ms.MediaVersionId == version.Id)
.ToListAsync();
DateTimeOffset finish = maybeDuration.Match(
// next playout item exists
// loop until it starts
now.Add,
// no next playout item exists
// loop for 5 minutes if less than 30s, otherwise play full item
() => version.Duration < TimeSpan.FromSeconds(30)
? now.AddMinutes(5)
: now.Add(version.Duration));
var playoutItem = new PlayoutItem
{
MediaItem = item,
MediaItemId = item.Id,
Start = now.UtcDateTime,
Finish = finish.UtcDateTime,
FillerKind = FillerKind.Fallback,
InPoint = TimeSpan.Zero,
OutPoint = version.Duration
};
return await ValidatePlayoutItemPath(playoutItem);
}
return new UnableToLocatePlayoutItem();
}
private async Task<Either<BaseError, PlayoutItemWithPath>> ValidatePlayoutItemPath(PlayoutItem playoutItem) private async Task<Either<BaseError, PlayoutItemWithPath>> ValidatePlayoutItemPath(PlayoutItem playoutItem)
{ {
string path = await GetPlayoutItemPath(playoutItem); string path = await GetPlayoutItemPath(playoutItem);

71
ErsatzTV.Core.Tests/Scheduling/PlayoutModeSchedulerOneTests.cs

@ -63,6 +63,77 @@ namespace ErsatzTV.Core.Tests.Scheduling
playoutItems[0].FillerKind.Should().Be(FillerKind.None); playoutItems[0].FillerKind.Should().Be(FillerKind.None);
} }
[Test]
public void Should_Have_Gap_With_Empty_Tail_Empty_Fallback()
{
Collection collectionOne = TwoItemCollection(1, 2, TimeSpan.FromHours(1));
var collectionTwo = new Collection { Id = 2, Name = "Collection 2", MediaItems = new List<MediaItem>() };
var collectionThree = new Collection { Id = 3, Name = "Collection 3", MediaItems = new List<MediaItem>() };
var scheduleItem = new ProgramScheduleItemOne
{
Id = 1,
Index = 1,
Collection = collectionOne,
CollectionId = collectionOne.Id,
StartTime = null,
PlaybackOrder = PlaybackOrder.Chronological,
TailFiller = new FillerPreset
{
FillerKind = FillerKind.Tail,
CollectionId = collectionTwo.Id,
Collection = collectionTwo
},
FallbackFiller = new FillerPreset
{
FillerKind = FillerKind.Fallback,
CollectionId = collectionThree.Id,
Collection = collectionThree
}
};
var enumerator1 = new ChronologicalMediaCollectionEnumerator(
collectionOne.MediaItems,
new CollectionEnumeratorState());
var enumerator2 = new ChronologicalMediaCollectionEnumerator(
collectionTwo.MediaItems,
new CollectionEnumeratorState());
var enumerator3 = new ChronologicalMediaCollectionEnumerator(
collectionThree.MediaItems,
new CollectionEnumeratorState());
var scheduler = new PlayoutModeSchedulerOne(new Mock<ILogger>().Object);
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
StartState,
CollectionEnumerators(scheduleItem, enumerator1, scheduleItem.TailFiller, enumerator2, scheduleItem.FallbackFiller, enumerator3),
scheduleItem,
NextScheduleItem,
HardStop);
playoutBuilderState.CurrentTime.Should().Be(StartState.CurrentTime.AddHours(1));
playoutItems.Last().FinishOffset.Should().Be(playoutBuilderState.CurrentTime);
playoutBuilderState.NextGuideGroup.Should().Be(2);
playoutBuilderState.DurationFinish.IsNone.Should().BeTrue();
playoutBuilderState.InFlood.Should().BeFalse();
playoutBuilderState.MultipleRemaining.IsNone.Should().BeTrue();
playoutBuilderState.InDurationFiller.Should().BeFalse();
playoutBuilderState.ScheduleItemIndex.Should().Be(1);
enumerator1.State.Index.Should().Be(1);
enumerator2.State.Index.Should().Be(0);
enumerator3.State.Index.Should().Be(0);
playoutItems.Count.Should().Be(1);
playoutItems[0].MediaItemId.Should().Be(1);
playoutItems[0].StartOffset.Should().Be(StartState.CurrentTime);
playoutItems[0].GuideGroup.Should().Be(1);
playoutItems[0].FillerKind.Should().Be(FillerKind.None);
}
[Test] [Test]
public void Should_Not_Have_Gap_With_Exact_Tail() public void Should_Not_Have_Gap_With_Exact_Tail()
{ {

3
ErsatzTV.Core/Domain/Channel.cs

@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using ErsatzTV.Core.Domain.Filler;
namespace ErsatzTV.Core.Domain namespace ErsatzTV.Core.Domain
{ {
@ -16,6 +17,8 @@ namespace ErsatzTV.Core.Domain
public FFmpegProfile FFmpegProfile { get; set; } public FFmpegProfile FFmpegProfile { get; set; }
public int? WatermarkId { get; set; } public int? WatermarkId { get; set; }
public ChannelWatermark Watermark { get; set; } public ChannelWatermark Watermark { get; set; }
public int? FallbackFillerId { get; set; }
public FillerPreset FallbackFiller { get; set; }
public StreamingMode StreamingMode { get; set; } public StreamingMode StreamingMode { get; set; }
public List<Playout> Playouts { get; set; } public List<Playout> Playouts { get; set; }
public List<Artwork> Artwork { get; set; } public List<Artwork> Artwork { get; set; }

1
ErsatzTV.Core/Domain/ConfigElementKey.cs

@ -13,6 +13,7 @@
public static ConfigElementKey FFmpegSaveReports => new("ffmpeg.save_reports"); public static ConfigElementKey FFmpegSaveReports => new("ffmpeg.save_reports");
public static ConfigElementKey FFmpegPreferredLanguageCode => new("ffmpeg.preferred_language_code"); public static ConfigElementKey FFmpegPreferredLanguageCode => new("ffmpeg.preferred_language_code");
public static ConfigElementKey FFmpegGlobalWatermarkId => new("ffmpeg.global_watermark_id"); public static ConfigElementKey FFmpegGlobalWatermarkId => new("ffmpeg.global_watermark_id");
public static ConfigElementKey FFmpegGlobalFallbackFillerId => new("ffmpeg.global_fallback_filler_id");
public static ConfigElementKey FFmpegSegmenterTimeout => new("ffmpeg.segmenter.timeout_seconds"); public static ConfigElementKey FFmpegSegmenterTimeout => new("ffmpeg.segmenter.timeout_seconds");
public static ConfigElementKey FFmpegWorkAheadSegmenters => new("ffmpeg.segmenter.work_ahead_limit"); public static ConfigElementKey FFmpegWorkAheadSegmenters => new("ffmpeg.segmenter.work_ahead_limit");
public static ConfigElementKey SearchIndexVersion => new("search_index.version"); public static ConfigElementKey SearchIndexVersion => new("search_index.version");

51
ErsatzTV.Core/Scheduling/MediaItemsForCollection.cs

@ -0,0 +1,51 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
namespace ErsatzTV.Core.Scheduling
{
public static class MediaItemsForCollection
{
public static async Task<List<MediaItem>> Collect(
IMediaCollectionRepository mediaCollectionRepository,
ITelevisionRepository televisionRepository,
IArtistRepository artistRepository,
CollectionKey collectionKey)
{
switch (collectionKey.CollectionType)
{
case ProgramScheduleItemCollectionType.Collection:
List<MediaItem> collectionItems =
await mediaCollectionRepository.GetItems(collectionKey.CollectionId ?? 0);
return collectionItems;
case ProgramScheduleItemCollectionType.TelevisionShow:
List<Episode> showItems =
await televisionRepository.GetShowItems(collectionKey.MediaItemId ?? 0);
return showItems.Cast<MediaItem>().ToList();
case ProgramScheduleItemCollectionType.TelevisionSeason:
List<Episode> seasonItems =
await televisionRepository.GetSeasonItems(collectionKey.MediaItemId ?? 0);
return seasonItems.Cast<MediaItem>().ToList();
case ProgramScheduleItemCollectionType.Artist:
List<MusicVideo> artistItems =
await artistRepository.GetArtistItems(collectionKey.MediaItemId ?? 0);
return artistItems.Cast<MediaItem>().ToList();
case ProgramScheduleItemCollectionType.MultiCollection:
List<MediaItem> multiCollectionItems =
await mediaCollectionRepository.GetMultiCollectionItems(
collectionKey.MultiCollectionId ?? 0);
return multiCollectionItems;
case ProgramScheduleItemCollectionType.SmartCollection:
List<MediaItem> smartCollectionItems =
await mediaCollectionRepository.GetSmartCollectionItems(
collectionKey.SmartCollectionId ?? 0);
return smartCollectionItems;
default:
return new List<MediaItem>();
}
}
}
}

41
ErsatzTV.Core/Scheduling/PlayoutBuilder.cs

@ -237,40 +237,13 @@ namespace ErsatzTV.Core.Scheduling
.ToList(); .ToList();
IEnumerable<Tuple<CollectionKey, List<MediaItem>>> tuples = await collectionKeys.Map( IEnumerable<Tuple<CollectionKey, List<MediaItem>>> tuples = await collectionKeys.Map(
async collectionKey => async collectionKey => Tuple(
{ collectionKey,
switch (collectionKey.CollectionType) await MediaItemsForCollection.Collect(
{ _mediaCollectionRepository,
case ProgramScheduleItemCollectionType.Collection: _televisionRepository,
List<MediaItem> collectionItems = _artistRepository,
await _mediaCollectionRepository.GetItems(collectionKey.CollectionId ?? 0); collectionKey))).Sequence();
return Tuple(collectionKey, collectionItems);
case ProgramScheduleItemCollectionType.TelevisionShow:
List<Episode> showItems =
await _televisionRepository.GetShowItems(collectionKey.MediaItemId ?? 0);
return Tuple(collectionKey, showItems.Cast<MediaItem>().ToList());
case ProgramScheduleItemCollectionType.TelevisionSeason:
List<Episode> seasonItems =
await _televisionRepository.GetSeasonItems(collectionKey.MediaItemId ?? 0);
return Tuple(collectionKey, seasonItems.Cast<MediaItem>().ToList());
case ProgramScheduleItemCollectionType.Artist:
List<MusicVideo> artistItems =
await _artistRepository.GetArtistItems(collectionKey.MediaItemId ?? 0);
return Tuple(collectionKey, artistItems.Cast<MediaItem>().ToList());
case ProgramScheduleItemCollectionType.MultiCollection:
List<MediaItem> multiCollectionItems =
await _mediaCollectionRepository.GetMultiCollectionItems(
collectionKey.MultiCollectionId ?? 0);
return Tuple(collectionKey, multiCollectionItems);
case ProgramScheduleItemCollectionType.SmartCollection:
List<MediaItem> smartCollectionItems =
await _mediaCollectionRepository.GetSmartCollectionItems(
collectionKey.SmartCollectionId ?? 0);
return Tuple(collectionKey, smartCollectionItems);
default:
return Tuple(collectionKey, new List<MediaItem>());
}
}).Sequence();
return Map.createRange(tuples); return Map.createRange(tuples);
} }

12
ErsatzTV.Infrastructure/Data/Configurations/ChannelConfiguration.cs

@ -21,6 +21,18 @@ namespace ErsatzTV.Infrastructure.Data.Configurations
builder.HasMany(c => c.Artwork) builder.HasMany(c => c.Artwork)
.WithOne() .WithOne()
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
builder.HasOne(i => i.Watermark)
.WithMany()
.HasForeignKey(i => i.WatermarkId)
.OnDelete(DeleteBehavior.SetNull)
.IsRequired(false);
builder.HasOne(i => i.FallbackFiller)
.WithMany()
.HasForeignKey(i => i.FallbackFillerId)
.OnDelete(DeleteBehavior.SetNull)
.IsRequired(false);
} }
} }
} }

1
ErsatzTV.Infrastructure/Data/TvContext.cs

@ -34,6 +34,7 @@ namespace ErsatzTV.Infrastructure.Data
public DbSet<MediaItem> MediaItems { get; set; } public DbSet<MediaItem> MediaItems { get; set; }
public DbSet<MediaVersion> MediaVersions { get; set; } public DbSet<MediaVersion> MediaVersions { get; set; }
public DbSet<MediaFile> MediaFiles { get; set; } public DbSet<MediaFile> MediaFiles { get; set; }
public DbSet<MediaStream> MediaStreams { get; set; }
public DbSet<Movie> Movies { get; set; } public DbSet<Movie> Movies { get; set; }
public DbSet<MovieMetadata> MovieMetadata { get; set; } public DbSet<MovieMetadata> MovieMetadata { get; set; }
public DbSet<Artist> Artists { get; set; } public DbSet<Artist> Artists { get; set; }

3670
ErsatzTV.Infrastructure/Migrations/20211025124549_Add_ChannelFallbackFiller.Designer.cs generated

File diff suppressed because it is too large Load Diff

68
ErsatzTV.Infrastructure/Migrations/20211025124549_Add_ChannelFallbackFiller.cs

@ -0,0 +1,68 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_ChannelFallbackFiller : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Channel_ChannelWatermark_WatermarkId",
table: "Channel");
migrationBuilder.AddColumn<int>(
name: "FallbackFillerId",
table: "Channel",
type: "INTEGER",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_Channel_FallbackFillerId",
table: "Channel",
column: "FallbackFillerId");
migrationBuilder.AddForeignKey(
name: "FK_Channel_ChannelWatermark_WatermarkId",
table: "Channel",
column: "WatermarkId",
principalTable: "ChannelWatermark",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
migrationBuilder.AddForeignKey(
name: "FK_Channel_FillerPreset_FallbackFillerId",
table: "Channel",
column: "FallbackFillerId",
principalTable: "FillerPreset",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Channel_ChannelWatermark_WatermarkId",
table: "Channel");
migrationBuilder.DropForeignKey(
name: "FK_Channel_FillerPreset_FallbackFillerId",
table: "Channel");
migrationBuilder.DropIndex(
name: "IX_Channel_FallbackFillerId",
table: "Channel");
migrationBuilder.DropColumn(
name: "FallbackFillerId",
table: "Channel");
migrationBuilder.AddForeignKey(
name: "FK_Channel_ChannelWatermark_WatermarkId",
table: "Channel",
column: "WatermarkId",
principalTable: "ChannelWatermark",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
}
}

15
ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs

@ -198,6 +198,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<int>("FFmpegProfileId") b.Property<int>("FFmpegProfileId")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<int?>("FallbackFillerId")
.HasColumnType("INTEGER");
b.Property<string>("Name") b.Property<string>("Name")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@ -220,6 +223,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.HasIndex("FFmpegProfileId"); b.HasIndex("FFmpegProfileId");
b.HasIndex("FallbackFillerId");
b.HasIndex("Number") b.HasIndex("Number")
.IsUnique(); .IsUnique();
@ -2293,9 +2298,17 @@ namespace ErsatzTV.Infrastructure.Migrations
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.HasOne("ErsatzTV.Core.Domain.Filler.FillerPreset", "FallbackFiller")
.WithMany()
.HasForeignKey("FallbackFillerId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ErsatzTV.Core.Domain.ChannelWatermark", "Watermark") b.HasOne("ErsatzTV.Core.Domain.ChannelWatermark", "Watermark")
.WithMany() .WithMany()
.HasForeignKey("WatermarkId"); .HasForeignKey("WatermarkId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("FallbackFiller");
b.Navigation("FFmpegProfile"); b.Navigation("FFmpegProfile");

30
ErsatzTV/Pages/ChannelEditor.razor

@ -8,8 +8,11 @@
@using System.Globalization @using System.Globalization
@using ErsatzTV.Application.Channels @using ErsatzTV.Application.Channels
@using ErsatzTV.Application.Channels.Queries @using ErsatzTV.Application.Channels.Queries
@using ErsatzTV.Application.Filler
@using ErsatzTV.Application.Filler.Queries
@using ErsatzTV.Application.Watermarks @using ErsatzTV.Application.Watermarks
@using ErsatzTV.Application.Watermarks.Queries @using ErsatzTV.Application.Watermarks.Queries
@using ErsatzTV.Core.Domain.Filler
@inject NavigationManager _navigationManager @inject NavigationManager _navigationManager
@inject ILogger<ChannelEditor> _logger @inject ILogger<ChannelEditor> _logger
@inject ISnackbar _snackbar @inject ISnackbar _snackbar
@ -41,7 +44,11 @@
<MudSelectItem Value="@profile.Id">@profile.Name</MudSelectItem> <MudSelectItem Value="@profile.Id">@profile.Name</MudSelectItem>
} }
</MudSelect> </MudSelect>
<MudSelect Class="mt-3" Label="Preferred Language" @bind-Value="_model.PreferredLanguageCode" For="@(() => _model.PreferredLanguageCode)"> <MudSelect Class="mt-3"
Label="Preferred Language"
@bind-Value="_model.PreferredLanguageCode"
For="@(() => _model.PreferredLanguageCode)"
Clearable="true">
<MudSelectItem Value="@((string) null)">(none)</MudSelectItem> <MudSelectItem Value="@((string) null)">(none)</MudSelectItem>
@foreach (CultureInfo culture in _availableCultures) @foreach (CultureInfo culture in _availableCultures)
{ {
@ -67,13 +74,25 @@
</MudItem> </MudItem>
</MudGrid> </MudGrid>
<MudSelect Class="mt-3" Label="Watermark" @bind-Value="_model.WatermarkId" For="@(() => _model.WatermarkId)" <MudSelect Class="mt-3" Label="Watermark" @bind-Value="_model.WatermarkId" For="@(() => _model.WatermarkId)"
Disabled="@(_model.StreamingMode == StreamingMode.HttpLiveStreamingDirect)"> Disabled="@(_model.StreamingMode == StreamingMode.HttpLiveStreamingDirect)"
Clearable="true">
<MudSelectItem T="int?" Value="@((int?) null)">(none)</MudSelectItem> <MudSelectItem T="int?" Value="@((int?) null)">(none)</MudSelectItem>
@foreach (WatermarkViewModel watermark in _watermarks) @foreach (WatermarkViewModel watermark in _watermarks)
{ {
<MudSelectItem T="int?" Value="@watermark.Id">@watermark.Name</MudSelectItem> <MudSelectItem T="int?" Value="@watermark.Id">@watermark.Name</MudSelectItem>
} }
</MudSelect> </MudSelect>
<MudSelect Class="mt-3"
Label="Fallback Filler"
@bind-Value="_model.FallbackFillerId"
For="@(() => _model.FallbackFillerId)"
Clearable="true">
<MudSelectItem T="int?" Value="@((int?) null)">(none)</MudSelectItem>
@foreach (FillerPresetViewModel fillerPreset in _fillerPresets)
{
<MudSelectItem T="int?" Value="@fillerPreset.Id">@fillerPreset.Name</MudSelectItem>
}
</MudSelect>
</MudCardContent> </MudCardContent>
<MudCardActions> <MudCardActions>
<MudButton ButtonType="ButtonType.Submit" Variant="Variant.Filled" Color="Color.Primary"> <MudButton ButtonType="ButtonType.Submit" Variant="Variant.Filled" Color="Color.Primary">
@ -97,12 +116,14 @@
private List<FFmpegProfileViewModel> _ffmpegProfiles; private List<FFmpegProfileViewModel> _ffmpegProfiles;
private List<CultureInfo> _availableCultures; private List<CultureInfo> _availableCultures;
private List<WatermarkViewModel> _watermarks; private List<WatermarkViewModel> _watermarks;
private List<FillerPresetViewModel> _fillerPresets;
protected override async Task OnParametersSetAsync() protected override async Task OnParametersSetAsync()
{ {
await LoadFFmpegProfiles(); await LoadFFmpegProfiles();
_availableCultures = await _mediator.Send(new GetAllLanguageCodes()); _availableCultures = await _mediator.Send(new GetAllLanguageCodes());
await LoadWatermarks(); await LoadWatermarks();
await LoadFillerPresets();
if (Id.HasValue) if (Id.HasValue)
{ {
@ -118,6 +139,7 @@
_model.StreamingMode = channelViewModel.StreamingMode; _model.StreamingMode = channelViewModel.StreamingMode;
_model.PreferredLanguageCode = channelViewModel.PreferredLanguageCode; _model.PreferredLanguageCode = channelViewModel.PreferredLanguageCode;
_model.WatermarkId = channelViewModel.WatermarkId; _model.WatermarkId = channelViewModel.WatermarkId;
_model.FallbackFillerId = channelViewModel.FallbackFillerId;
}, },
() => _navigationManager.NavigateTo("404")); () => _navigationManager.NavigateTo("404"));
} }
@ -150,6 +172,10 @@
private async Task LoadWatermarks() => private async Task LoadWatermarks() =>
_watermarks = await _mediator.Send(new GetAllWatermarks()); _watermarks = await _mediator.Send(new GetAllWatermarks());
private async Task LoadFillerPresets() =>
_fillerPresets = await _mediator.Send(new GetAllFillerPresets())
.Map(list => list.Filter(vm => vm.FillerKind == FillerKind.Fallback).ToList());
private async Task HandleSubmitAsync() private async Task HandleSubmitAsync()
{ {
_messageStore.Clear(); _messageStore.Clear();

23
ErsatzTV/Pages/Settings.razor

@ -9,8 +9,11 @@
@using ErsatzTV.Application.Configuration.Queries @using ErsatzTV.Application.Configuration.Queries
@using Unit = LanguageExt.Unit @using Unit = LanguageExt.Unit
@using ErsatzTV.Application.Configuration.Commands @using ErsatzTV.Application.Configuration.Commands
@using ErsatzTV.Application.Filler
@using ErsatzTV.Application.Filler.Queries
@using ErsatzTV.Application.Watermarks @using ErsatzTV.Application.Watermarks
@using ErsatzTV.Application.Watermarks.Queries @using ErsatzTV.Application.Watermarks.Queries
@using ErsatzTV.Core.Domain.Filler
@using ErsatzTV.Core.FFmpeg @using ErsatzTV.Core.FFmpeg
@using Microsoft.AspNetCore.Components @using Microsoft.AspNetCore.Components
@inject IMediator _mediator @inject IMediator _mediator
@ -51,13 +54,28 @@
Color="Color.Primary" Color="Color.Primary"
@bind-Checked="@_ffmpegSettings.SaveReports"/> @bind-Checked="@_ffmpegSettings.SaveReports"/>
</MudElement> </MudElement>
<MudSelect Class="mt-3" Label="Global Watermark" @bind-Value="_ffmpegSettings.GlobalWatermarkId" For="@(() => _ffmpegSettings.GlobalWatermarkId)"> <MudSelect Class="mt-3"
Label="Global Watermark"
@bind-Value="_ffmpegSettings.GlobalWatermarkId"
For="@(() => _ffmpegSettings.GlobalWatermarkId)"
Clearable="true">
<MudSelectItem T="int?" Value="@((int?) null)">(none)</MudSelectItem> <MudSelectItem T="int?" Value="@((int?) null)">(none)</MudSelectItem>
@foreach (WatermarkViewModel watermark in _watermarks) @foreach (WatermarkViewModel watermark in _watermarks)
{ {
<MudSelectItem T="int?" Value="@watermark.Id">@watermark.Name</MudSelectItem> <MudSelectItem T="int?" Value="@watermark.Id">@watermark.Name</MudSelectItem>
} }
</MudSelect> </MudSelect>
<MudSelect Class="mt-3"
Label="Global Fallback Filler"
@bind-Value="_ffmpegSettings.GlobalFallbackFillerId"
For="@(() => _ffmpegSettings.GlobalFallbackFillerId)"
Clearable="true">
<MudSelectItem T="int?" Value="@((int?) null)">(none)</MudSelectItem>
@foreach (FillerPresetViewModel fillerPreset in _fillerPresets)
{
<MudSelectItem T="int?" Value="@fillerPreset.Id">@fillerPreset.Name</MudSelectItem>
}
</MudSelect>
<MudElement HtmlTag="div" Class="mt-3"> <MudElement HtmlTag="div" Class="mt-3">
<MudTextField T="int" <MudTextField T="int"
Label="HLS Segmenter Idle Timeout" Label="HLS Segmenter Idle Timeout"
@ -153,6 +171,7 @@
private FFmpegSettingsViewModel _ffmpegSettings; private FFmpegSettingsViewModel _ffmpegSettings;
private List<CultureInfo> _availableCultures; private List<CultureInfo> _availableCultures;
private List<WatermarkViewModel> _watermarks; private List<WatermarkViewModel> _watermarks;
private List<FillerPresetViewModel> _fillerPresets;
private int _tunerCount; private int _tunerCount;
private int _libraryRefreshInterval; private int _libraryRefreshInterval;
private int _playoutDaysToBuild; private int _playoutDaysToBuild;
@ -165,6 +184,8 @@
_success = File.Exists(_ffmpegSettings.FFmpegPath) && File.Exists(_ffmpegSettings.FFprobePath); _success = File.Exists(_ffmpegSettings.FFmpegPath) && File.Exists(_ffmpegSettings.FFprobePath);
_availableCultures = await _mediator.Send(new GetAllLanguageCodes()); _availableCultures = await _mediator.Send(new GetAllLanguageCodes());
_watermarks = await _mediator.Send(new GetAllWatermarks()); _watermarks = await _mediator.Send(new GetAllWatermarks());
_fillerPresets = await _mediator.Send(new GetAllFillerPresets())
.Map(list => list.Filter(fp => fp.FillerKind == FillerKind.Fallback).ToList());
_tunerCount = await _mediator.Send(new GetHDHRTunerCount()); _tunerCount = await _mediator.Send(new GetHDHRTunerCount());
_hdhrSuccess = string.IsNullOrWhiteSpace(ValidateTunerCount(_tunerCount)); _hdhrSuccess = string.IsNullOrWhiteSpace(ValidateTunerCount(_tunerCount));
_libraryRefreshInterval = await _mediator.Send(new GetLibraryRefreshInterval()); _libraryRefreshInterval = await _mediator.Send(new GetLibraryRefreshInterval());

7
ErsatzTV/ViewModels/ChannelEditViewModel.cs

@ -13,6 +13,7 @@ namespace ErsatzTV.ViewModels
public string Logo { get; set; } public string Logo { get; set; }
public StreamingMode StreamingMode { get; set; } public StreamingMode StreamingMode { get; set; }
public int? WatermarkId { get; set; } public int? WatermarkId { get; set; }
public int? FallbackFillerId { get; set; }
public UpdateChannel ToUpdate() => public UpdateChannel ToUpdate() =>
new( new(
@ -23,7 +24,8 @@ namespace ErsatzTV.ViewModels
Logo, Logo,
PreferredLanguageCode, PreferredLanguageCode,
StreamingMode, StreamingMode,
WatermarkId); WatermarkId,
FallbackFillerId);
public CreateChannel ToCreate() => public CreateChannel ToCreate() =>
new( new(
@ -33,6 +35,7 @@ namespace ErsatzTV.ViewModels
Logo, Logo,
PreferredLanguageCode, PreferredLanguageCode,
StreamingMode, StreamingMode,
WatermarkId); WatermarkId,
FallbackFillerId);
} }
} }

Loading…
Cancel
Save