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 4 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. @@ -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/).
## [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
### Fixed

3
ErsatzTV.Application/Channels/ChannelViewModel.cs

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

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

@ -13,5 +13,6 @@ namespace ErsatzTV.Application.Channels.Commands @@ -13,5 +13,6 @@ namespace ErsatzTV.Application.Channels.Commands
string Logo,
string PreferredLanguageCode,
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; @@ -7,6 +7,7 @@ using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using LanguageExt;
@ -42,9 +43,10 @@ namespace ErsatzTV.Application.Channels.Commands @@ -42,9 +43,10 @@ namespace ErsatzTV.Application.Channels.Commands
(ValidateName(request), await ValidateNumber(dbContext, request),
await FFmpegProfileMustExist(dbContext, request),
ValidatePreferredLanguage(request),
await WatermarkMustExist(dbContext, request))
await WatermarkMustExist(dbContext, request),
await FillerPresetMustExist(dbContext, request))
.Apply(
(name, number, ffmpegProfileId, preferredLanguageCode, watermarkId) =>
(name, number, ffmpegProfileId, preferredLanguageCode, watermarkId, fillerPresetId) =>
{
var artwork = new List<Artwork>();
if (!string.IsNullOrWhiteSpace(request.Logo))
@ -74,6 +76,11 @@ namespace ErsatzTV.Application.Channels.Commands @@ -74,6 +76,11 @@ namespace ErsatzTV.Application.Channels.Commands
channel.WatermarkId = id;
}
foreach (int id in fillerPresetId)
{
channel.FallbackFillerId = id;
}
return channel;
});
@ -131,5 +138,25 @@ namespace ErsatzTV.Application.Channels.Commands @@ -131,5 +138,25 @@ namespace ErsatzTV.Application.Channels.Commands
.MapT(_ => Optional(createChannel.WatermarkId))
.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 @@ -14,5 +14,6 @@ namespace ErsatzTV.Application.Channels.Commands
string Logo,
string PreferredLanguageCode,
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 @@ -67,6 +67,7 @@ namespace ErsatzTV.Application.Channels.Commands
c.StreamingMode = update.StreamingMode;
c.WatermarkId = update.WatermarkId;
c.FallbackFillerId = update.FallbackFillerId;
await dbContext.SaveChangesAsync();
return ProjectToViewModel(c);
}

3
ErsatzTV.Application/Channels/Mapper.cs

@ -15,7 +15,8 @@ namespace ErsatzTV.Application.Channels @@ -15,7 +15,8 @@ namespace ErsatzTV.Application.Channels
GetLogo(channel),
channel.PreferredLanguageCode,
channel.StreamingMode,
channel.WatermarkId);
channel.WatermarkId,
channel.FallbackFillerId);
private static string GetLogo(Channel channel) =>
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 @@ -115,6 +115,17 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
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(
ConfigElementKey.FFmpegSegmenterTimeout,
request.Settings.HlsSegmenterIdleTimeout);

1
ErsatzTV.Application/FFmpegProfiles/FFmpegSettingsViewModel.cs

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

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

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

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

@ -1,17 +1,21 @@ @@ -1,17 +1,21 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.Emby;
using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Runtime;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using LanguageExt;
@ -24,6 +28,9 @@ namespace ErsatzTV.Application.Streaming.Queries @@ -24,6 +28,9 @@ namespace ErsatzTV.Application.Streaming.Queries
FFmpegProcessHandler<GetPlayoutItemProcessByChannelNumber>
{
private readonly IEmbyPathReplacementService _embyPathReplacementService;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
private readonly ITelevisionRepository _televisionRepository;
private readonly IArtistRepository _artistRepository;
private readonly FFmpegProcessService _ffmpegProcessService;
private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService;
private readonly ILocalFileSystem _localFileSystem;
@ -37,6 +44,9 @@ namespace ErsatzTV.Application.Streaming.Queries @@ -37,6 +44,9 @@ namespace ErsatzTV.Application.Streaming.Queries
IPlexPathReplacementService plexPathReplacementService,
IJellyfinPathReplacementService jellyfinPathReplacementService,
IEmbyPathReplacementService embyPathReplacementService,
IMediaCollectionRepository mediaCollectionRepository,
ITelevisionRepository televisionRepository,
IArtistRepository artistRepository,
IRuntimeInfo runtimeInfo)
: base(dbContextFactory)
{
@ -45,6 +55,9 @@ namespace ErsatzTV.Application.Streaming.Queries @@ -45,6 +55,9 @@ namespace ErsatzTV.Application.Streaming.Queries
_plexPathReplacementService = plexPathReplacementService;
_jellyfinPathReplacementService = jellyfinPathReplacementService;
_embyPathReplacementService = embyPathReplacementService;
_mediaCollectionRepository = mediaCollectionRepository;
_televisionRepository = televisionRepository;
_artistRepository = artistRepository;
_runtimeInfo = runtimeInfo;
}
@ -85,6 +98,11 @@ namespace ErsatzTV.Application.Streaming.Queries @@ -85,6 +98,11 @@ namespace ErsatzTV.Application.Streaming.Queries
.Map(o => o.ToEither<BaseError>(new UnableToLocatePlayoutItem()))
.BindT(ValidatePlayoutItemPath);
if (maybePlayoutItem.LeftAsEnumerable().Any(e => e is UnableToLocatePlayoutItem))
{
maybePlayoutItem = await CheckForFallbackFiller(dbContext, channel, now);
}
return await maybePlayoutItem.Match(
async playoutItemWithPath =>
{
@ -158,8 +176,7 @@ namespace ErsatzTV.Application.Streaming.Queries @@ -158,8 +176,7 @@ namespace ErsatzTV.Application.Streaming.Queries
maybeDuration,
"Channel is Offline",
request.HlsRealtime);
return new PlayoutItemProcessModel(errorProcess, finish);
}
else
@ -211,6 +228,95 @@ namespace ErsatzTV.Application.Streaming.Queries @@ -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)
{
string path = await GetPlayoutItemPath(playoutItem);

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

@ -63,6 +63,77 @@ namespace ErsatzTV.Core.Tests.Scheduling @@ -63,6 +63,77 @@ namespace ErsatzTV.Core.Tests.Scheduling
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]
public void Should_Not_Have_Gap_With_Exact_Tail()
{

3
ErsatzTV.Core/Domain/Channel.cs

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

1
ErsatzTV.Core/Domain/ConfigElementKey.cs

@ -13,6 +13,7 @@ @@ -13,6 +13,7 @@
public static ConfigElementKey FFmpegSaveReports => new("ffmpeg.save_reports");
public static ConfigElementKey FFmpegPreferredLanguageCode => new("ffmpeg.preferred_language_code");
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 FFmpegWorkAheadSegmenters => new("ffmpeg.segmenter.work_ahead_limit");
public static ConfigElementKey SearchIndexVersion => new("search_index.version");

51
ErsatzTV.Core/Scheduling/MediaItemsForCollection.cs

@ -0,0 +1,51 @@ @@ -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 @@ -237,40 +237,13 @@ namespace ErsatzTV.Core.Scheduling
.ToList();
IEnumerable<Tuple<CollectionKey, List<MediaItem>>> tuples = await collectionKeys.Map(
async collectionKey =>
{
switch (collectionKey.CollectionType)
{
case ProgramScheduleItemCollectionType.Collection:
List<MediaItem> collectionItems =
await _mediaCollectionRepository.GetItems(collectionKey.CollectionId ?? 0);
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();
async collectionKey => Tuple(
collectionKey,
await MediaItemsForCollection.Collect(
_mediaCollectionRepository,
_televisionRepository,
_artistRepository,
collectionKey))).Sequence();
return Map.createRange(tuples);
}

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

@ -21,6 +21,18 @@ namespace ErsatzTV.Infrastructure.Data.Configurations @@ -21,6 +21,18 @@ namespace ErsatzTV.Infrastructure.Data.Configurations
builder.HasMany(c => c.Artwork)
.WithOne()
.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 @@ -34,6 +34,7 @@ namespace ErsatzTV.Infrastructure.Data
public DbSet<MediaItem> MediaItems { get; set; }
public DbSet<MediaVersion> MediaVersions { get; set; }
public DbSet<MediaFile> MediaFiles { get; set; }
public DbSet<MediaStream> MediaStreams { get; set; }
public DbSet<Movie> Movies { get; set; }
public DbSet<MovieMetadata> MovieMetadata { 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 @@ @@ -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 @@ -198,6 +198,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<int>("FFmpegProfileId")
.HasColumnType("INTEGER");
b.Property<int?>("FallbackFillerId")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
@ -220,6 +223,8 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -220,6 +223,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.HasIndex("FFmpegProfileId");
b.HasIndex("FallbackFillerId");
b.HasIndex("Number")
.IsUnique();
@ -2293,9 +2298,17 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -2293,9 +2298,17 @@ namespace ErsatzTV.Infrastructure.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.Filler.FillerPreset", "FallbackFiller")
.WithMany()
.HasForeignKey("FallbackFillerId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ErsatzTV.Core.Domain.ChannelWatermark", "Watermark")
.WithMany()
.HasForeignKey("WatermarkId");
.HasForeignKey("WatermarkId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("FallbackFiller");
b.Navigation("FFmpegProfile");

30
ErsatzTV/Pages/ChannelEditor.razor

@ -8,8 +8,11 @@ @@ -8,8 +8,11 @@
@using System.Globalization
@using ErsatzTV.Application.Channels
@using ErsatzTV.Application.Channels.Queries
@using ErsatzTV.Application.Filler
@using ErsatzTV.Application.Filler.Queries
@using ErsatzTV.Application.Watermarks
@using ErsatzTV.Application.Watermarks.Queries
@using ErsatzTV.Core.Domain.Filler
@inject NavigationManager _navigationManager
@inject ILogger<ChannelEditor> _logger
@inject ISnackbar _snackbar
@ -41,7 +44,11 @@ @@ -41,7 +44,11 @@
<MudSelectItem Value="@profile.Id">@profile.Name</MudSelectItem>
}
</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>
@foreach (CultureInfo culture in _availableCultures)
{
@ -67,13 +74,25 @@ @@ -67,13 +74,25 @@
</MudItem>
</MudGrid>
<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>
@foreach (WatermarkViewModel watermark in _watermarks)
{
<MudSelectItem T="int?" Value="@watermark.Id">@watermark.Name</MudSelectItem>
}
</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>
<MudCardActions>
<MudButton ButtonType="ButtonType.Submit" Variant="Variant.Filled" Color="Color.Primary">
@ -97,12 +116,14 @@ @@ -97,12 +116,14 @@
private List<FFmpegProfileViewModel> _ffmpegProfiles;
private List<CultureInfo> _availableCultures;
private List<WatermarkViewModel> _watermarks;
private List<FillerPresetViewModel> _fillerPresets;
protected override async Task OnParametersSetAsync()
{
await LoadFFmpegProfiles();
_availableCultures = await _mediator.Send(new GetAllLanguageCodes());
await LoadWatermarks();
await LoadFillerPresets();
if (Id.HasValue)
{
@ -118,6 +139,7 @@ @@ -118,6 +139,7 @@
_model.StreamingMode = channelViewModel.StreamingMode;
_model.PreferredLanguageCode = channelViewModel.PreferredLanguageCode;
_model.WatermarkId = channelViewModel.WatermarkId;
_model.FallbackFillerId = channelViewModel.FallbackFillerId;
},
() => _navigationManager.NavigateTo("404"));
}
@ -150,6 +172,10 @@ @@ -150,6 +172,10 @@
private async Task LoadWatermarks() =>
_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()
{
_messageStore.Clear();

23
ErsatzTV/Pages/Settings.razor

@ -9,8 +9,11 @@ @@ -9,8 +9,11 @@
@using ErsatzTV.Application.Configuration.Queries
@using Unit = LanguageExt.Unit
@using ErsatzTV.Application.Configuration.Commands
@using ErsatzTV.Application.Filler
@using ErsatzTV.Application.Filler.Queries
@using ErsatzTV.Application.Watermarks
@using ErsatzTV.Application.Watermarks.Queries
@using ErsatzTV.Core.Domain.Filler
@using ErsatzTV.Core.FFmpeg
@using Microsoft.AspNetCore.Components
@inject IMediator _mediator
@ -51,13 +54,28 @@ @@ -51,13 +54,28 @@
Color="Color.Primary"
@bind-Checked="@_ffmpegSettings.SaveReports"/>
</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>
@foreach (WatermarkViewModel watermark in _watermarks)
{
<MudSelectItem T="int?" Value="@watermark.Id">@watermark.Name</MudSelectItem>
}
</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">
<MudTextField T="int"
Label="HLS Segmenter Idle Timeout"
@ -153,6 +171,7 @@ @@ -153,6 +171,7 @@
private FFmpegSettingsViewModel _ffmpegSettings;
private List<CultureInfo> _availableCultures;
private List<WatermarkViewModel> _watermarks;
private List<FillerPresetViewModel> _fillerPresets;
private int _tunerCount;
private int _libraryRefreshInterval;
private int _playoutDaysToBuild;
@ -165,6 +184,8 @@ @@ -165,6 +184,8 @@
_success = File.Exists(_ffmpegSettings.FFmpegPath) && File.Exists(_ffmpegSettings.FFprobePath);
_availableCultures = await _mediator.Send(new GetAllLanguageCodes());
_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());
_hdhrSuccess = string.IsNullOrWhiteSpace(ValidateTunerCount(_tunerCount));
_libraryRefreshInterval = await _mediator.Send(new GetLibraryRefreshInterval());

7
ErsatzTV/ViewModels/ChannelEditViewModel.cs

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

Loading…
Cancel
Save