add global and channel fallback filler (#459)
* configure channel and global fallback filler * play random item from configured channel/global fallback filler as needed
This commit is contained in:
+9
-1
@@ -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
|
||||
|
||||
@@ -10,5 +10,6 @@ namespace ErsatzTV.Application.Channels
|
||||
string Logo,
|
||||
string PreferredLanguageCode,
|
||||
StreamingMode StreamingMode,
|
||||
int? WatermarkId);
|
||||
int? WatermarkId,
|
||||
int? FallbackFillerId);
|
||||
}
|
||||
|
||||
@@ -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>>;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
(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
|
||||
channel.WatermarkId = id;
|
||||
}
|
||||
|
||||
foreach (int id in fillerPresetId)
|
||||
{
|
||||
channel.FallbackFillerId = id;
|
||||
}
|
||||
|
||||
return channel;
|
||||
});
|
||||
|
||||
@@ -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."));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>>;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
result.GlobalWatermarkId = watermarkId;
|
||||
}
|
||||
|
||||
foreach (int fallbackFillerId in fallbackFiller)
|
||||
{
|
||||
result.GlobalFallbackFillerId = fallbackFillerId;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
+108
-2
@@ -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
|
||||
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
|
||||
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
|
||||
_plexPathReplacementService = plexPathReplacementService;
|
||||
_jellyfinPathReplacementService = jellyfinPathReplacementService;
|
||||
_embyPathReplacementService = embyPathReplacementService;
|
||||
_mediaCollectionRepository = mediaCollectionRepository;
|
||||
_televisionRepository = televisionRepository;
|
||||
_artistRepository = artistRepository;
|
||||
_runtimeInfo = runtimeInfo;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
maybeDuration,
|
||||
"Channel is Offline",
|
||||
request.HlsRealtime);
|
||||
|
||||
|
||||
|
||||
return new PlayoutItemProcessModel(errorProcess, finish);
|
||||
}
|
||||
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)
|
||||
{
|
||||
string path = await GetPlayoutItemPath(playoutItem);
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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
|
||||
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; }
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
b.HasIndex("FFmpegProfileId");
|
||||
|
||||
b.HasIndex("FallbackFillerId");
|
||||
|
||||
b.HasIndex("Number")
|
||||
.IsUnique();
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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 @@
|
||||
<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 @@
|
||||
</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 @@
|
||||
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 @@
|
||||
_model.StreamingMode = channelViewModel.StreamingMode;
|
||||
_model.PreferredLanguageCode = channelViewModel.PreferredLanguageCode;
|
||||
_model.WatermarkId = channelViewModel.WatermarkId;
|
||||
_model.FallbackFillerId = channelViewModel.FallbackFillerId;
|
||||
},
|
||||
() => _navigationManager.NavigateTo("404"));
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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 @@
|
||||
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 @@
|
||||
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 @@
|
||||
_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());
|
||||
|
||||
@@ -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
|
||||
Logo,
|
||||
PreferredLanguageCode,
|
||||
StreamingMode,
|
||||
WatermarkId);
|
||||
WatermarkId,
|
||||
FallbackFillerId);
|
||||
|
||||
public CreateChannel ToCreate() =>
|
||||
new(
|
||||
@@ -33,6 +35,7 @@ namespace ErsatzTV.ViewModels
|
||||
Logo,
|
||||
PreferredLanguageCode,
|
||||
StreamingMode,
|
||||
WatermarkId);
|
||||
WatermarkId,
|
||||
FallbackFillerId);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user