mirror of https://github.com/ErsatzTV/ErsatzTV.git
10 changed files with 1133 additions and 809 deletions
@ -0,0 +1,18 @@ |
|||||||
|
using ErsatzTV.Core.Domain; |
||||||
|
|
||||||
|
namespace ErsatzTV.Core.Interfaces.Scheduling; |
||||||
|
|
||||||
|
public interface IPlayoutItemConverter |
||||||
|
{ |
||||||
|
Task<Option<Core.Next.PlayoutItem>> ToNext( |
||||||
|
string channelNumber, |
||||||
|
PlayoutItem playoutItem, |
||||||
|
CancellationToken cancellationToken); |
||||||
|
|
||||||
|
Task<Option<Core.Next.PlayoutItem>> ToNext( |
||||||
|
Option<Channel> maybeChannel, |
||||||
|
Option<ChannelWatermark> maybeGlobalWatermark, |
||||||
|
TimeSpan playoutOffset, |
||||||
|
PlayoutItem playoutItem, |
||||||
|
CancellationToken cancellationToken); |
||||||
|
} |
||||||
@ -0,0 +1,5 @@ |
|||||||
|
using ErsatzTV.Core.Domain; |
||||||
|
|
||||||
|
namespace ErsatzTV.Infrastructure.Scheduling; |
||||||
|
|
||||||
|
public sealed class DynamicPlayoutItem : PlayoutItem; |
||||||
@ -0,0 +1,365 @@ |
|||||||
|
using System.IO.Abstractions; |
||||||
|
using Dapper; |
||||||
|
using ErsatzTV.Core; |
||||||
|
using ErsatzTV.Core.Domain; |
||||||
|
using ErsatzTV.Core.Domain.Filler; |
||||||
|
using ErsatzTV.Core.Domain.Scheduling; |
||||||
|
using ErsatzTV.Core.Errors; |
||||||
|
using ErsatzTV.Core.Extensions; |
||||||
|
using ErsatzTV.Core.Interfaces.Emby; |
||||||
|
using ErsatzTV.Core.Interfaces.FFmpeg; |
||||||
|
using ErsatzTV.Core.Interfaces.Jellyfin; |
||||||
|
using ErsatzTV.Core.Interfaces.Plex; |
||||||
|
using ErsatzTV.Core.Interfaces.Repositories; |
||||||
|
using ErsatzTV.Core.Scheduling; |
||||||
|
using ErsatzTV.Infrastructure.Data; |
||||||
|
using ErsatzTV.Infrastructure.Extensions; |
||||||
|
using Microsoft.EntityFrameworkCore; |
||||||
|
|
||||||
|
namespace ErsatzTV.Infrastructure.Scheduling; |
||||||
|
|
||||||
|
public class DynamicPlayoutItemService( |
||||||
|
IFileSystem fileSystem, |
||||||
|
IMediaCollectionRepository mediaCollectionRepository, |
||||||
|
ITelevisionRepository televisionRepository, |
||||||
|
IArtistRepository artistRepository, |
||||||
|
IPlexPathReplacementService plexPathReplacementService, |
||||||
|
IJellyfinPathReplacementService jellyfinPathReplacementService, |
||||||
|
IEmbyPathReplacementService embyPathReplacementService, |
||||||
|
IDecoSelector decoSelector) : IDynamicPlayoutItemService |
||||||
|
{ |
||||||
|
private static readonly Random FallbackRandom = new(); |
||||||
|
|
||||||
|
#pragma warning disable CA1805
|
||||||
|
#if DEBUG_NO_SYNC
|
||||||
|
private static readonly bool IsDebugNoSync = true; |
||||||
|
#else
|
||||||
|
private static readonly bool IsDebugNoSync = false; |
||||||
|
#endif
|
||||||
|
#pragma warning restore CA1805
|
||||||
|
|
||||||
|
public async Task<Either<BaseError, PlayoutItemWithPath>> CheckForFallbackFiller( |
||||||
|
TvContext dbContext, |
||||||
|
Channel channel, |
||||||
|
DateTimeOffset now, |
||||||
|
CancellationToken cancellationToken) |
||||||
|
{ |
||||||
|
Either<BaseError, PlayoutItemWithPath> result = new UnableToLocatePlayoutItem(); |
||||||
|
|
||||||
|
Option<Playout> maybePlayout = await dbContext.Playouts |
||||||
|
.AsNoTracking() |
||||||
|
|
||||||
|
// get playout deco
|
||||||
|
.Include(p => p.Deco) |
||||||
|
.ThenInclude(d => d.DecoWatermarks) |
||||||
|
.ThenInclude(d => d.Watermark) |
||||||
|
.Include(p => p.Deco) |
||||||
|
.ThenInclude(d => d.DecoGraphicsElements) |
||||||
|
.ThenInclude(d => d.GraphicsElement) |
||||||
|
|
||||||
|
// get playout templates (and deco templates/decos)
|
||||||
|
.Include(p => p.Templates) |
||||||
|
.ThenInclude(t => t.DecoTemplate) |
||||||
|
.ThenInclude(t => t.Items) |
||||||
|
.ThenInclude(i => i.Deco) |
||||||
|
.ThenInclude(d => d.DecoWatermarks) |
||||||
|
.ThenInclude(d => d.Watermark) |
||||||
|
.SelectOneAsync( |
||||||
|
p => p.ChannelId, |
||||||
|
p => p.ChannelId == (channel.MirrorSourceChannelId ?? channel.Id), |
||||||
|
cancellationToken); |
||||||
|
|
||||||
|
foreach (Playout playout in maybePlayout) |
||||||
|
{ |
||||||
|
result = await CheckForFallbackFiller( |
||||||
|
dbContext, |
||||||
|
channel, |
||||||
|
playout, |
||||||
|
now, |
||||||
|
cancellationToken); |
||||||
|
} |
||||||
|
|
||||||
|
if (maybePlayout.IsNone) |
||||||
|
{ |
||||||
|
result = await CheckForFallbackFiller( |
||||||
|
dbContext, |
||||||
|
channel, |
||||||
|
null, |
||||||
|
now, |
||||||
|
cancellationToken); |
||||||
|
} |
||||||
|
|
||||||
|
return result; |
||||||
|
} |
||||||
|
|
||||||
|
private async Task<Either<BaseError, PlayoutItemWithPath>> CheckForFallbackFiller( |
||||||
|
TvContext dbContext, |
||||||
|
Channel channel, |
||||||
|
Playout playout, |
||||||
|
DateTimeOffset now, |
||||||
|
CancellationToken cancellationToken) |
||||||
|
{ |
||||||
|
Option<FillerPreset> maybeFallback = Option<FillerPreset>.None; |
||||||
|
|
||||||
|
DeadAirFallbackResult decoDeadAirFallback = GetDecoDeadAirFallback(playout, now); |
||||||
|
switch (decoDeadAirFallback) |
||||||
|
{ |
||||||
|
case CustomDeadAirFallback custom: |
||||||
|
maybeFallback = new FillerPreset |
||||||
|
{ |
||||||
|
// always allow watermarks here
|
||||||
|
// deco settings will disable watermarks if appropriate
|
||||||
|
AllowWatermarks = true, |
||||||
|
|
||||||
|
CollectionType = custom.CollectionType, |
||||||
|
CollectionId = custom.CollectionId, |
||||||
|
MediaItemId = custom.MediaItemId, |
||||||
|
MultiCollectionId = custom.MultiCollectionId, |
||||||
|
SmartCollectionId = custom.SmartCollectionId |
||||||
|
}; |
||||||
|
break; |
||||||
|
case DisableDeadAirFallback: |
||||||
|
// do nothing
|
||||||
|
break; |
||||||
|
case InheritDeadAirFallback: |
||||||
|
// check for channel fallback
|
||||||
|
maybeFallback = await dbContext.FillerPresets |
||||||
|
.SelectOneAsync(w => w.Id, w => w.Id == channel.FallbackFillerId, cancellationToken); |
||||||
|
|
||||||
|
// then check for global fallback
|
||||||
|
if (maybeFallback.IsNone) |
||||||
|
{ |
||||||
|
maybeFallback = await dbContext.ConfigElements |
||||||
|
.GetValue<int>(ConfigElementKey.FFmpegGlobalFallbackFillerId, cancellationToken) |
||||||
|
.BindT(fillerId => dbContext.FillerPresets.SelectOneAsync( |
||||||
|
w => w.Id, |
||||||
|
w => w.Id == fillerId, |
||||||
|
cancellationToken)); |
||||||
|
} |
||||||
|
|
||||||
|
break; |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
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, |
||||||
|
cancellationToken); |
||||||
|
|
||||||
|
// ignore the fallback filler preset if it has no items
|
||||||
|
if (items.Count == 0) |
||||||
|
{ |
||||||
|
break; |
||||||
|
} |
||||||
|
|
||||||
|
// get a random item
|
||||||
|
MediaItem item = items[FallbackRandom.Next(items.Count)]; |
||||||
|
|
||||||
|
Option<TimeSpan> maybeDuration = await dbContext.PlayoutItems |
||||||
|
.Filter(pi => pi.Playout.ChannelId == (channel.MirrorSourceChannelId ?? channel.Id)) |
||||||
|
.Filter(pi => pi.Start > now.UtcDateTime) |
||||||
|
.OrderBy(pi => pi.Start) |
||||||
|
.FirstOrDefaultAsync(cancellationToken) |
||||||
|
.Map(Optional) |
||||||
|
.MapT(pi => pi.StartOffset - now); |
||||||
|
|
||||||
|
MediaVersion version = item.GetHeadVersion(); |
||||||
|
|
||||||
|
version.MediaFiles = await dbContext.MediaFiles |
||||||
|
.AsNoTracking() |
||||||
|
.Filter(mf => mf.MediaVersionId == version.Id) |
||||||
|
.ToListAsync(cancellationToken); |
||||||
|
|
||||||
|
version.Streams = await dbContext.MediaStreams |
||||||
|
.AsNoTracking() |
||||||
|
.Filter(ms => ms.MediaVersionId == version.Id) |
||||||
|
.ToListAsync(cancellationToken); |
||||||
|
|
||||||
|
// always play min(duration to next item, version.Duration)
|
||||||
|
TimeSpan duration = await maybeDuration.IfNoneAsync(version.Duration); |
||||||
|
if (version.Duration < duration) |
||||||
|
{ |
||||||
|
duration = version.Duration; |
||||||
|
} |
||||||
|
|
||||||
|
DateTimeOffset finish = now.Add(duration); |
||||||
|
|
||||||
|
var playoutItem = new PlayoutItem |
||||||
|
{ |
||||||
|
MediaItem = item, |
||||||
|
MediaItemId = item.Id, |
||||||
|
Start = now.UtcDateTime, |
||||||
|
Finish = finish.UtcDateTime, |
||||||
|
FillerKind = FillerKind.Fallback, |
||||||
|
InPoint = TimeSpan.Zero, |
||||||
|
OutPoint = duration, |
||||||
|
DisableWatermarks = !fallbackPreset.AllowWatermarks, |
||||||
|
Watermarks = [], |
||||||
|
PlayoutItemWatermarks = [], |
||||||
|
GraphicsElements = [], |
||||||
|
PlayoutItemGraphicsElements = [] |
||||||
|
}; |
||||||
|
|
||||||
|
return await ValidatePlayoutItemPath(dbContext, playoutItem, cancellationToken); |
||||||
|
} |
||||||
|
|
||||||
|
return new UnableToLocatePlayoutItem(); |
||||||
|
} |
||||||
|
|
||||||
|
public async Task<Either<BaseError, PlayoutItemWithPath>> ValidatePlayoutItemPath( |
||||||
|
TvContext dbContext, |
||||||
|
PlayoutItem playoutItem, |
||||||
|
CancellationToken cancellationToken) |
||||||
|
{ |
||||||
|
string path = await playoutItem.MediaItem.GetLocalPath( |
||||||
|
plexPathReplacementService, |
||||||
|
jellyfinPathReplacementService, |
||||||
|
embyPathReplacementService, |
||||||
|
cancellationToken); |
||||||
|
|
||||||
|
if (IsDebugNoSync) |
||||||
|
{ |
||||||
|
// pretend it exists so we get a nice error message
|
||||||
|
return new PlayoutItemWithPath(playoutItem, path); |
||||||
|
} |
||||||
|
|
||||||
|
// check filesystem first
|
||||||
|
if (fileSystem.File.Exists(path)) |
||||||
|
{ |
||||||
|
if (playoutItem.MediaItem is RemoteStream remoteStream) |
||||||
|
{ |
||||||
|
path = !string.IsNullOrWhiteSpace(remoteStream.Url) |
||||||
|
? remoteStream.Url |
||||||
|
: $"http://localhost:{Settings.StreamingPort}/ffmpeg/remote-stream/{remoteStream.Id}"; |
||||||
|
} |
||||||
|
|
||||||
|
return new PlayoutItemWithPath(playoutItem, path); |
||||||
|
} |
||||||
|
|
||||||
|
// attempt to remotely stream plex
|
||||||
|
MediaFile file = playoutItem.MediaItem.GetHeadVersion().MediaFiles.Head(); |
||||||
|
switch (file) |
||||||
|
{ |
||||||
|
case PlexMediaFile pmf: |
||||||
|
Option<int> maybeId = await dbContext.Connection.QuerySingleOrDefaultAsync<int>( |
||||||
|
@"SELECT PMS.Id FROM PlexMediaSource PMS
|
||||||
|
INNER JOIN Library L on PMS.Id = L.MediaSourceId |
||||||
|
INNER JOIN LibraryPath LP on L.Id = LP.LibraryId |
||||||
|
WHERE LP.Id = @LibraryPathId",
|
||||||
|
new { playoutItem.MediaItem.LibraryPathId }) |
||||||
|
.Map(Optional); |
||||||
|
|
||||||
|
foreach (int plexMediaSourceId in maybeId) |
||||||
|
{ |
||||||
|
return new PlayoutItemWithPath( |
||||||
|
playoutItem, |
||||||
|
$"http://localhost:{Settings.StreamingPort}/media/plex/{plexMediaSourceId}/{pmf.Key}"); |
||||||
|
} |
||||||
|
|
||||||
|
break; |
||||||
|
} |
||||||
|
|
||||||
|
// attempt to remotely stream jellyfin
|
||||||
|
Option<string> jellyfinItemId = playoutItem.MediaItem switch |
||||||
|
{ |
||||||
|
JellyfinEpisode e => e.ItemId, |
||||||
|
JellyfinMovie m => m.ItemId, |
||||||
|
_ => None |
||||||
|
}; |
||||||
|
|
||||||
|
foreach (string itemId in jellyfinItemId) |
||||||
|
{ |
||||||
|
return new PlayoutItemWithPath( |
||||||
|
playoutItem, |
||||||
|
$"http://localhost:{Settings.StreamingPort}/media/jellyfin/{itemId}"); |
||||||
|
} |
||||||
|
|
||||||
|
// attempt to remotely stream emby
|
||||||
|
Option<string> embyItemId = playoutItem.MediaItem switch |
||||||
|
{ |
||||||
|
EmbyEpisode e => e.ItemId, |
||||||
|
EmbyMovie m => m.ItemId, |
||||||
|
_ => None |
||||||
|
}; |
||||||
|
|
||||||
|
foreach (string itemId in embyItemId) |
||||||
|
{ |
||||||
|
return new PlayoutItemWithPath( |
||||||
|
playoutItem, |
||||||
|
$"http://localhost:{Settings.StreamingPort}/media/emby/{itemId}"); |
||||||
|
} |
||||||
|
|
||||||
|
return new PlayoutItemDoesNotExistOnDisk(path); |
||||||
|
} |
||||||
|
|
||||||
|
private DeadAirFallbackResult GetDecoDeadAirFallback(Playout playout, DateTimeOffset now) |
||||||
|
{ |
||||||
|
DecoEntries decoEntries = decoSelector.GetDecoEntries(playout, now); |
||||||
|
|
||||||
|
// first, check deco template / active deco
|
||||||
|
foreach (Deco templateDeco in decoEntries.TemplateDeco) |
||||||
|
{ |
||||||
|
switch (templateDeco.DeadAirFallbackMode) |
||||||
|
{ |
||||||
|
case DecoMode.Override: |
||||||
|
//_logger.LogDebug("Dead air fallback will come from template deco (override)");
|
||||||
|
return new CustomDeadAirFallback( |
||||||
|
templateDeco.DeadAirFallbackCollectionType, |
||||||
|
templateDeco.DeadAirFallbackCollectionId, |
||||||
|
templateDeco.DeadAirFallbackMediaItemId, |
||||||
|
templateDeco.DeadAirFallbackMultiCollectionId, |
||||||
|
templateDeco.DeadAirFallbackSmartCollectionId); |
||||||
|
case DecoMode.Disable: |
||||||
|
//_logger.LogDebug("Dead air fallback is disabled by template deco");
|
||||||
|
return new DisableDeadAirFallback(); |
||||||
|
case DecoMode.Inherit: |
||||||
|
//_logger.LogDebug("Dead air fallback will inherit from playout deco");
|
||||||
|
break; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// second, check playout deco
|
||||||
|
foreach (Deco playoutDeco in decoEntries.PlayoutDeco) |
||||||
|
{ |
||||||
|
switch (playoutDeco.DeadAirFallbackMode) |
||||||
|
{ |
||||||
|
case DecoMode.Override: |
||||||
|
//_logger.LogDebug("Dead air fallback will come from playout deco (override)");
|
||||||
|
return new CustomDeadAirFallback( |
||||||
|
playoutDeco.DeadAirFallbackCollectionType, |
||||||
|
playoutDeco.DeadAirFallbackCollectionId, |
||||||
|
playoutDeco.DeadAirFallbackMediaItemId, |
||||||
|
playoutDeco.DeadAirFallbackMultiCollectionId, |
||||||
|
playoutDeco.DeadAirFallbackSmartCollectionId); |
||||||
|
case DecoMode.Disable: |
||||||
|
//_logger.LogDebug("Dead air fallback is disabled by playout deco");
|
||||||
|
return new DisableDeadAirFallback(); |
||||||
|
case DecoMode.Inherit: |
||||||
|
//_logger.LogDebug("Dead air fallback will inherit from channel and/or global setting");
|
||||||
|
break; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return new InheritDeadAirFallback(); |
||||||
|
} |
||||||
|
|
||||||
|
private abstract record DeadAirFallbackResult; |
||||||
|
|
||||||
|
private sealed record InheritDeadAirFallback : DeadAirFallbackResult; |
||||||
|
|
||||||
|
private sealed record DisableDeadAirFallback : DeadAirFallbackResult; |
||||||
|
|
||||||
|
private sealed record CustomDeadAirFallback( |
||||||
|
CollectionType CollectionType, |
||||||
|
int? CollectionId, |
||||||
|
int? MediaItemId, |
||||||
|
int? MultiCollectionId, |
||||||
|
int? SmartCollectionId) : DeadAirFallbackResult; |
||||||
|
} |
||||||
@ -0,0 +1,19 @@ |
|||||||
|
using ErsatzTV.Core; |
||||||
|
using ErsatzTV.Core.Domain; |
||||||
|
using ErsatzTV.Infrastructure.Data; |
||||||
|
|
||||||
|
namespace ErsatzTV.Infrastructure.Scheduling; |
||||||
|
|
||||||
|
public interface IDynamicPlayoutItemService |
||||||
|
{ |
||||||
|
Task<Either<BaseError, PlayoutItemWithPath>> CheckForFallbackFiller( |
||||||
|
TvContext dbContext, |
||||||
|
Channel channel, |
||||||
|
DateTimeOffset now, |
||||||
|
CancellationToken cancellationToken); |
||||||
|
|
||||||
|
Task<Either<BaseError, PlayoutItemWithPath>> ValidatePlayoutItemPath( |
||||||
|
TvContext dbContext, |
||||||
|
PlayoutItem playoutItem, |
||||||
|
CancellationToken cancellationToken); |
||||||
|
} |
||||||
@ -0,0 +1,541 @@ |
|||||||
|
using System.Collections.Immutable; |
||||||
|
using System.CommandLine.Parsing; |
||||||
|
using System.Globalization; |
||||||
|
using System.IO.Abstractions; |
||||||
|
using ErsatzTV.Core; |
||||||
|
using ErsatzTV.Core.Domain; |
||||||
|
using ErsatzTV.Core.Extensions; |
||||||
|
using ErsatzTV.Core.FFmpeg; |
||||||
|
using ErsatzTV.Core.Interfaces.Emby; |
||||||
|
using ErsatzTV.Core.Interfaces.FFmpeg; |
||||||
|
using ErsatzTV.Core.Interfaces.Jellyfin; |
||||||
|
using ErsatzTV.Core.Interfaces.Plex; |
||||||
|
using ErsatzTV.Core.Interfaces.Scheduling; |
||||||
|
using ErsatzTV.FFmpeg.State; |
||||||
|
using ErsatzTV.Infrastructure.Data; |
||||||
|
using ErsatzTV.Infrastructure.Extensions; |
||||||
|
using Microsoft.EntityFrameworkCore; |
||||||
|
|
||||||
|
namespace ErsatzTV.Infrastructure.Scheduling; |
||||||
|
|
||||||
|
public class PlayoutItemConverter( |
||||||
|
IFileSystem fileSystem, |
||||||
|
IPlexPathReplacementService plexPathReplacementService, |
||||||
|
IJellyfinPathReplacementService jellyfinPathReplacementService, |
||||||
|
IEmbyPathReplacementService embyPathReplacementService, |
||||||
|
ICustomStreamSelector customStreamSelector, |
||||||
|
IFFmpegStreamSelector ffmpegStreamSelector, |
||||||
|
IWatermarkSelector watermarkSelector, |
||||||
|
IDbContextFactory<TvContext> dbContextFactory) : IPlayoutItemConverter |
||||||
|
{ |
||||||
|
public async Task<Option<Core.Next.PlayoutItem>> ToNext( |
||||||
|
string channelNumber, |
||||||
|
PlayoutItem playoutItem, |
||||||
|
CancellationToken cancellationToken) |
||||||
|
{ |
||||||
|
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); |
||||||
|
|
||||||
|
TimeSpan playoutOffset = TimeSpan.Zero; |
||||||
|
|
||||||
|
Option<Channel> maybeChannel = await dbContext.Channels |
||||||
|
.AsNoTracking() |
||||||
|
.Include(c => c.MirrorSourceChannel) |
||||||
|
.Filter(c => c.PlayoutSource == ChannelPlayoutSource.Mirror && c.MirrorSourceChannelId != null) |
||||||
|
.SelectOneAsync( |
||||||
|
c => c.Number == channelNumber, |
||||||
|
c => c.Number == channelNumber, |
||||||
|
cancellationToken); |
||||||
|
foreach (Channel channel in maybeChannel) |
||||||
|
{ |
||||||
|
playoutOffset = channel.PlayoutOffset ?? TimeSpan.Zero; |
||||||
|
} |
||||||
|
|
||||||
|
Option<ChannelWatermark> maybeGlobalWatermark = await dbContext.ConfigElements |
||||||
|
.GetValue<int>(ConfigElementKey.FFmpegGlobalWatermarkId, cancellationToken) |
||||||
|
.BindT(watermarkId => dbContext.ChannelWatermarks |
||||||
|
.SelectOneAsync(w => w.Id, w => w.Id == watermarkId, cancellationToken)); |
||||||
|
|
||||||
|
Option<Channel> maybeChannelForArtwork = await dbContext.Channels |
||||||
|
.AsNoTracking() |
||||||
|
.Include(c => c.Watermark) |
||||||
|
.Include(c => c.Artwork) |
||||||
|
.SingleOrDefaultAsync(c => c.Number == channelNumber, cancellationToken) |
||||||
|
.Map(Optional); |
||||||
|
|
||||||
|
return await ToNext( |
||||||
|
maybeChannelForArtwork, |
||||||
|
maybeGlobalWatermark, |
||||||
|
playoutOffset, |
||||||
|
playoutItem, |
||||||
|
cancellationToken); |
||||||
|
} |
||||||
|
|
||||||
|
public async Task<Option<Core.Next.PlayoutItem>> ToNext( |
||||||
|
Option<Channel> maybeChannel, |
||||||
|
Option<ChannelWatermark> maybeGlobalWatermark, |
||||||
|
TimeSpan playoutOffset, |
||||||
|
PlayoutItem playoutItem, |
||||||
|
CancellationToken cancellationToken) |
||||||
|
{ |
||||||
|
if (playoutItem is not DynamicPlayoutItem && |
||||||
|
playoutItem.MediaItem is not Episode && playoutItem.MediaItem is not Movie && |
||||||
|
playoutItem.MediaItem is not OtherVideo && playoutItem.MediaItem is not MusicVideo && |
||||||
|
playoutItem.MediaItem is not RemoteStream && playoutItem.MediaItem is not Image) |
||||||
|
{ |
||||||
|
return Option<Core.Next.PlayoutItem>.None; |
||||||
|
} |
||||||
|
|
||||||
|
playoutItem.Start += playoutOffset; |
||||||
|
playoutItem.Finish += playoutOffset; |
||||||
|
|
||||||
|
var nextPlayoutItem = new Core.Next.PlayoutItem |
||||||
|
{ |
||||||
|
Id = playoutItem is DynamicPlayoutItem ? Guid.NewGuid().ToString() : playoutItem.Id.ToString(CultureInfo.InvariantCulture), |
||||||
|
Start = playoutItem.StartOffset, |
||||||
|
Finish = playoutItem.FinishOffset |
||||||
|
}; |
||||||
|
|
||||||
|
Option<Core.Next.Source> maybeSource = await SourceForItem(playoutItem, cancellationToken); |
||||||
|
if (maybeSource.IsNone) |
||||||
|
{ |
||||||
|
return Option<Core.Next.PlayoutItem>.None; |
||||||
|
} |
||||||
|
|
||||||
|
foreach (Core.Next.Source source in maybeSource) |
||||||
|
{ |
||||||
|
if (playoutItem is not DynamicPlayoutItem) |
||||||
|
{ |
||||||
|
if (playoutItem.InPoint > TimeSpan.Zero) |
||||||
|
{ |
||||||
|
source.InPointMs = (long)playoutItem.InPoint.TotalMilliseconds; |
||||||
|
} |
||||||
|
|
||||||
|
var duration = playoutItem.MediaItem.GetDurationForPlayout(); |
||||||
|
if (playoutItem.OutPoint > TimeSpan.Zero && playoutItem.OutPoint < duration) |
||||||
|
{ |
||||||
|
source.OutPointMs = (long)playoutItem.OutPoint.TotalMilliseconds; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
nextPlayoutItem.Source = source; |
||||||
|
} |
||||||
|
|
||||||
|
if (playoutItem is not DynamicPlayoutItem) |
||||||
|
{ |
||||||
|
// if no audio streams, use lavfi to insert silence
|
||||||
|
MediaVersion headVersion = playoutItem.MediaItem.GetHeadVersion(); |
||||||
|
if (headVersion.Streams.All(s => s.MediaStreamKind is not MediaStreamKind.Audio)) |
||||||
|
{ |
||||||
|
var videoSource = nextPlayoutItem.Source; |
||||||
|
|
||||||
|
nextPlayoutItem.Source = null; |
||||||
|
nextPlayoutItem.Tracks = new Core.Next.PlayoutItemTracks |
||||||
|
{ |
||||||
|
Audio = new Core.Next.TrackSelection |
||||||
|
{ |
||||||
|
Source = |
||||||
|
new Core.Next.Source |
||||||
|
{ |
||||||
|
SourceType = Core.Next.SourceType.Lavfi, |
||||||
|
Params = "anullsrc=channel_layout=stereo:sample_rate=48000" |
||||||
|
} |
||||||
|
}, |
||||||
|
Video = new Core.Next.TrackSelection |
||||||
|
{ |
||||||
|
Source = videoSource |
||||||
|
} |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
foreach (Channel channel in maybeChannel) |
||||||
|
{ |
||||||
|
var audioVersion = new MediaItemAudioVersion(playoutItem.MediaItem, headVersion); |
||||||
|
await SelectTracks( |
||||||
|
channel, |
||||||
|
playoutItem, |
||||||
|
audioVersion, |
||||||
|
nextPlayoutItem, |
||||||
|
playoutItem.PreferredAudioLanguageCode ?? channel.PreferredAudioLanguageCode, |
||||||
|
playoutItem.PreferredAudioTitle ?? channel.PreferredAudioTitle, |
||||||
|
playoutItem.PreferredSubtitleLanguageCode ?? channel.PreferredSubtitleLanguageCode, |
||||||
|
playoutItem.SubtitleMode ?? channel.SubtitleMode, |
||||||
|
cancellationToken); |
||||||
|
await SelectWatermark( |
||||||
|
maybeGlobalWatermark, |
||||||
|
channel, |
||||||
|
playoutItem, |
||||||
|
nextPlayoutItem); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return nextPlayoutItem; |
||||||
|
} |
||||||
|
|
||||||
|
private async Task<Option<Core.Next.Source>> SourceForItem( |
||||||
|
PlayoutItem playoutItem, |
||||||
|
CancellationToken cancellationToken) |
||||||
|
{ |
||||||
|
if (playoutItem is DynamicPlayoutItem) |
||||||
|
{ |
||||||
|
return new Core.Next.Source |
||||||
|
{ |
||||||
|
SourceType = Core.Next.SourceType.Dynamic, |
||||||
|
Uri = $"http://localhost:{Settings.StreamingPort}/media/fallback" |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
if (playoutItem.MediaItem is RemoteStream remoteStream) |
||||||
|
{ |
||||||
|
if (!string.IsNullOrWhiteSpace(remoteStream.Url)) |
||||||
|
{ |
||||||
|
if (remoteStream.Url.StartsWith("rtsp://", StringComparison.OrdinalIgnoreCase)) |
||||||
|
{ |
||||||
|
return new Core.Next.Source |
||||||
|
{ |
||||||
|
SourceType = Core.Next.SourceType.Rtsp, |
||||||
|
Uri = remoteStream.Url |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
return new Core.Next.Source |
||||||
|
{ |
||||||
|
SourceType = Core.Next.SourceType.Http, |
||||||
|
Uri = remoteStream.Url, |
||||||
|
IsLive = remoteStream.IsLive, |
||||||
|
KeepAlive = true, |
||||||
|
Reconnect = true |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(remoteStream.Script)) |
||||||
|
{ |
||||||
|
var split = CommandLineParser.SplitCommandLine(remoteStream.Script).ToList(); |
||||||
|
if (split.Count > 0) |
||||||
|
{ |
||||||
|
var source = new Core.Next.Source |
||||||
|
{ |
||||||
|
SourceType = Core.Next.SourceType.Script, |
||||||
|
Command = split.Head(), |
||||||
|
IsLive = remoteStream.IsLive |
||||||
|
}; |
||||||
|
|
||||||
|
if (split.Count > 1) |
||||||
|
{ |
||||||
|
source.Args = split.Tail().ToList(); |
||||||
|
} |
||||||
|
|
||||||
|
return source; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return Option<Core.Next.Source>.None; |
||||||
|
} |
||||||
|
|
||||||
|
string path = await playoutItem.MediaItem.GetLocalPath( |
||||||
|
plexPathReplacementService, |
||||||
|
jellyfinPathReplacementService, |
||||||
|
embyPathReplacementService, |
||||||
|
cancellationToken, |
||||||
|
log: false); |
||||||
|
|
||||||
|
// check filesystem first
|
||||||
|
if (fileSystem.File.Exists(path)) |
||||||
|
{ |
||||||
|
return new Core.Next.Source |
||||||
|
{ |
||||||
|
SourceType = Core.Next.SourceType.Local, |
||||||
|
Path = path, |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
MediaFile file = playoutItem.MediaItem.GetHeadVersion().MediaFiles.Head(); |
||||||
|
int mediaSourceId = playoutItem.MediaItem.LibraryPath.Library.MediaSourceId; |
||||||
|
if (file is PlexMediaFile pmf) |
||||||
|
{ |
||||||
|
return new Core.Next.Source |
||||||
|
{ |
||||||
|
SourceType = Core.Next.SourceType.Http, |
||||||
|
Uri = $"http://localhost:{Settings.StreamingPort}/media/plex/{mediaSourceId}/{pmf.Key}", |
||||||
|
KeepAlive = false, |
||||||
|
Reconnect = true |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
Option<string> jellyfinItemId = playoutItem.MediaItem switch |
||||||
|
{ |
||||||
|
JellyfinEpisode e => e.ItemId, |
||||||
|
JellyfinMovie m => m.ItemId, |
||||||
|
_ => None |
||||||
|
}; |
||||||
|
|
||||||
|
foreach (string itemId in jellyfinItemId) |
||||||
|
{ |
||||||
|
return new Core.Next.Source |
||||||
|
{ |
||||||
|
SourceType = Core.Next.SourceType.Http, |
||||||
|
Uri = $"http://localhost:{Settings.StreamingPort}/media/jellyfin/{itemId}", |
||||||
|
KeepAlive = false, |
||||||
|
Reconnect = true |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
// attempt to remotely stream emby
|
||||||
|
Option<string> embyItemId = playoutItem.MediaItem switch |
||||||
|
{ |
||||||
|
EmbyEpisode e => e.ItemId, |
||||||
|
EmbyMovie m => m.ItemId, |
||||||
|
_ => None |
||||||
|
}; |
||||||
|
|
||||||
|
foreach (string itemId in embyItemId) |
||||||
|
{ |
||||||
|
return new Core.Next.Source |
||||||
|
{ |
||||||
|
SourceType = Core.Next.SourceType.Http, |
||||||
|
Uri = $"http://localhost:{Settings.StreamingPort}/media/emby/{itemId}", |
||||||
|
KeepAlive = false, |
||||||
|
Reconnect = true |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
return Option<Core.Next.Source>.None; |
||||||
|
} |
||||||
|
|
||||||
|
private async Task SelectTracks( |
||||||
|
Channel channel, |
||||||
|
PlayoutItem playoutItem, |
||||||
|
MediaItemAudioVersion audioVersion, |
||||||
|
Core.Next.PlayoutItem nextPlayoutItem, |
||||||
|
string preferredAudioLanguage, |
||||||
|
string preferredAudioTitle, |
||||||
|
string preferredSubtitleLanguage, |
||||||
|
ChannelSubtitleMode subtitleMode, |
||||||
|
CancellationToken cancellationToken) |
||||||
|
{ |
||||||
|
List<Subtitle> allSubtitles = await GetSubtitles(audioVersion.MediaItem, playoutItem.Id, playoutItem.InPoint); |
||||||
|
|
||||||
|
Option<MediaStream> maybeAudioStream = Option<MediaStream>.None; |
||||||
|
Option<Subtitle> maybeSubtitle = Option<Subtitle>.None; |
||||||
|
|
||||||
|
if (channel.StreamSelectorMode is ChannelStreamSelectorMode.Custom) |
||||||
|
{ |
||||||
|
StreamSelectorResult result = await customStreamSelector.SelectStreams( |
||||||
|
channel, |
||||||
|
nextPlayoutItem.Start, |
||||||
|
audioVersion, |
||||||
|
allSubtitles); |
||||||
|
maybeAudioStream = result.AudioStream; |
||||||
|
maybeSubtitle = result.Subtitle; |
||||||
|
} |
||||||
|
|
||||||
|
if (channel.StreamSelectorMode is ChannelStreamSelectorMode.Default || maybeAudioStream.IsNone) |
||||||
|
{ |
||||||
|
maybeAudioStream = |
||||||
|
await ffmpegStreamSelector.SelectAudioStream( |
||||||
|
audioVersion, |
||||||
|
channel.StreamingMode, |
||||||
|
channel, |
||||||
|
preferredAudioLanguage, |
||||||
|
preferredAudioTitle, |
||||||
|
shouldLogMessages: false, |
||||||
|
cancellationToken); |
||||||
|
|
||||||
|
maybeSubtitle = |
||||||
|
await ffmpegStreamSelector.SelectSubtitleStream( |
||||||
|
allSubtitles.ToImmutableList(), |
||||||
|
channel, |
||||||
|
preferredSubtitleLanguage, |
||||||
|
subtitleMode, |
||||||
|
shouldLogMessages: false, |
||||||
|
cancellationToken); |
||||||
|
} |
||||||
|
|
||||||
|
foreach (MediaStream audioStream in maybeAudioStream) |
||||||
|
{ |
||||||
|
if (nextPlayoutItem.Tracks?.Audio?.StreamIndex is null) |
||||||
|
{ |
||||||
|
nextPlayoutItem.Tracks ??= new Core.Next.PlayoutItemTracks(); |
||||||
|
nextPlayoutItem.Tracks.Audio ??= new Core.Next.TrackSelection(); |
||||||
|
nextPlayoutItem.Tracks.Audio.StreamIndex = audioStream.Index; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
foreach (Subtitle subtitle in maybeSubtitle) |
||||||
|
{ |
||||||
|
if (subtitle.SubtitleKind is SubtitleKind.Embedded) |
||||||
|
{ |
||||||
|
if (nextPlayoutItem.Tracks?.Subtitle?.StreamIndex is null) |
||||||
|
{ |
||||||
|
nextPlayoutItem.Tracks ??= new Core.Next.PlayoutItemTracks(); |
||||||
|
nextPlayoutItem.Tracks.Subtitle ??= new Core.Next.TrackSelection(); |
||||||
|
nextPlayoutItem.Tracks.Subtitle.StreamIndex = subtitle.StreamIndex; |
||||||
|
} |
||||||
|
} |
||||||
|
else if (!subtitle.Path.StartsWith("http", StringComparison.OrdinalIgnoreCase)) |
||||||
|
{ |
||||||
|
if (nextPlayoutItem.Tracks?.Subtitle?.Source is null) |
||||||
|
{ |
||||||
|
nextPlayoutItem.Tracks ??= new Core.Next.PlayoutItemTracks(); |
||||||
|
nextPlayoutItem.Tracks.Subtitle ??= new Core.Next.TrackSelection(); |
||||||
|
nextPlayoutItem.Tracks.Subtitle.Source = new Core.Next.Source |
||||||
|
{ |
||||||
|
SourceType = Core.Next.SourceType.Local, |
||||||
|
Path = subtitle.Path, |
||||||
|
}; |
||||||
|
} |
||||||
|
} |
||||||
|
else if (subtitle.Path.StartsWith("http://localhost", StringComparison.OrdinalIgnoreCase)) |
||||||
|
{ |
||||||
|
if (nextPlayoutItem.Tracks?.Subtitle?.Source is null) |
||||||
|
{ |
||||||
|
nextPlayoutItem.Tracks ??= new Core.Next.PlayoutItemTracks(); |
||||||
|
nextPlayoutItem.Tracks.Subtitle ??= new Core.Next.TrackSelection(); |
||||||
|
nextPlayoutItem.Tracks.Subtitle.Source = new Core.Next.Source |
||||||
|
{ |
||||||
|
SourceType = Core.Next.SourceType.Http, |
||||||
|
Uri = subtitle.Path, |
||||||
|
KeepAlive = false, |
||||||
|
Reconnect = true |
||||||
|
}; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private async Task SelectWatermark( |
||||||
|
Option<ChannelWatermark> maybeGlobalWatermark, |
||||||
|
Channel channel, |
||||||
|
PlayoutItem playoutItem, |
||||||
|
Core.Next.PlayoutItem nextPlayoutItem) |
||||||
|
{ |
||||||
|
List<WatermarkOptions> watermarks = watermarkSelector.SelectWatermarks( |
||||||
|
maybeGlobalWatermark, |
||||||
|
channel, |
||||||
|
playoutItem, |
||||||
|
playoutItem.StartOffset, |
||||||
|
shouldLogMessages: false); |
||||||
|
|
||||||
|
// single, permanent or intermittent watermarks are supported
|
||||||
|
if (watermarks.Count == 1 && watermarks.All(wm => |
||||||
|
wm.Watermark.Mode is ChannelWatermarkMode.Permanent or ChannelWatermarkMode.Intermittent)) |
||||||
|
{ |
||||||
|
foreach (WatermarkOptions watermarkOptions in watermarks) |
||||||
|
{ |
||||||
|
if (nextPlayoutItem.Watermark is null) |
||||||
|
{ |
||||||
|
Core.Next.WatermarkLocation location = watermarkOptions.Watermark.Location switch |
||||||
|
{ |
||||||
|
WatermarkLocation.TopMiddle => Core.Next.WatermarkLocation.TopCenter, |
||||||
|
WatermarkLocation.TopRight => Core.Next.WatermarkLocation.TopRight, |
||||||
|
WatermarkLocation.LeftMiddle => Core.Next.WatermarkLocation.CenterLeft, |
||||||
|
WatermarkLocation.MiddleCenter => Core.Next.WatermarkLocation.Center, |
||||||
|
WatermarkLocation.RightMiddle => Core.Next.WatermarkLocation.CenterRight, |
||||||
|
WatermarkLocation.BottomLeft => Core.Next.WatermarkLocation.BottomLeft, |
||||||
|
WatermarkLocation.BottomMiddle => Core.Next.WatermarkLocation.BottomCenter, |
||||||
|
WatermarkLocation.BottomRight => Core.Next.WatermarkLocation.BottomRight, |
||||||
|
_ => Core.Next.WatermarkLocation.TopLeft, |
||||||
|
}; |
||||||
|
|
||||||
|
nextPlayoutItem.Watermark = new Core.Next.Watermark |
||||||
|
{ |
||||||
|
Location = location, |
||||||
|
HorizontalMarginPercent = watermarkOptions.Watermark.HorizontalMarginPercent, |
||||||
|
VerticalMarginPercent = watermarkOptions.Watermark.VerticalMarginPercent, |
||||||
|
OpacityPercent = watermarkOptions.Watermark.Opacity, |
||||||
|
StreamIndex = await watermarkOptions.ImageStreamIndex.IfNoneAsync(0), |
||||||
|
WithinSourceContent = watermarkOptions.Watermark.PlaceWithinSourceContent, |
||||||
|
}; |
||||||
|
|
||||||
|
if (watermarkOptions.Watermark.Size is WatermarkSize.Scaled) |
||||||
|
{ |
||||||
|
nextPlayoutItem.Watermark.WidthPercent = watermarkOptions.Watermark.WidthPercent; |
||||||
|
} |
||||||
|
|
||||||
|
if (watermarkOptions.ImagePath.StartsWith("http", StringComparison.OrdinalIgnoreCase)) |
||||||
|
{ |
||||||
|
nextPlayoutItem.Watermark.Source = new Core.Next.PlayoutItemSource |
||||||
|
{ |
||||||
|
SourceType = Core.Next.SourceType.Http, |
||||||
|
Uri = watermarkOptions.ImagePath, |
||||||
|
}; |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
nextPlayoutItem.Watermark.Source = new Core.Next.PlayoutItemSource |
||||||
|
{ |
||||||
|
SourceType = Core.Next.SourceType.Local, |
||||||
|
Path = watermarkOptions.ImagePath, |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
if (watermarkOptions.Watermark.Mode is ChannelWatermarkMode.Intermittent) |
||||||
|
{ |
||||||
|
nextPlayoutItem.Watermark.Timing = new Core.Next.Timing |
||||||
|
{ |
||||||
|
TimingType = Core.Next.TimingType.Periodic, |
||||||
|
Clock = Core.Next.PeriodicClock.Wall, |
||||||
|
FrequencyMs = watermarkOptions.Watermark.FrequencyMinutes * 60 * 1000, |
||||||
|
HoldMs = watermarkOptions.Watermark.DurationSeconds * 1000, |
||||||
|
}; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private static async Task<List<Subtitle>> GetSubtitles( |
||||||
|
MediaItem mediaItem, |
||||||
|
int playoutItemId, |
||||||
|
TimeSpan playoutItemInPoint) |
||||||
|
{ |
||||||
|
List<Subtitle> allSubtitles = mediaItem switch |
||||||
|
{ |
||||||
|
Episode episode => await Optional(episode.EpisodeMetadata).Flatten().HeadOrNone() |
||||||
|
.Map(mm => mm.Subtitles ?? []) |
||||||
|
.IfNoneAsync([]), |
||||||
|
Movie movie => await Optional(movie.MovieMetadata).Flatten().HeadOrNone() |
||||||
|
.Map(mm => mm.Subtitles ?? []) |
||||||
|
.IfNoneAsync([]), |
||||||
|
MusicVideo => GetMusicVideoSubtitles(playoutItemId, playoutItemInPoint), |
||||||
|
OtherVideo otherVideo => await Optional(otherVideo.OtherVideoMetadata).Flatten().HeadOrNone() |
||||||
|
.Map(mm => mm.Subtitles ?? []) |
||||||
|
.IfNoneAsync([]), |
||||||
|
_ => [] |
||||||
|
}; |
||||||
|
|
||||||
|
bool isMediaServer = mediaItem is PlexMovie or PlexEpisode or |
||||||
|
JellyfinMovie or JellyfinEpisode or EmbyMovie or EmbyEpisode; |
||||||
|
|
||||||
|
if (isMediaServer) |
||||||
|
{ |
||||||
|
// closed captions are currently unsupported
|
||||||
|
allSubtitles.RemoveAll(s => s.Codec == "eia_608"); |
||||||
|
} |
||||||
|
|
||||||
|
// TODO: external image subtitles
|
||||||
|
allSubtitles.RemoveAll(s => s.IsImage && s.SubtitleKind is not SubtitleKind.Embedded); |
||||||
|
|
||||||
|
return allSubtitles; |
||||||
|
} |
||||||
|
|
||||||
|
private static List<Subtitle> GetMusicVideoSubtitles(int playoutItemId, TimeSpan playoutItemInPoint) |
||||||
|
{ |
||||||
|
string seekToMs = playoutItemInPoint > TimeSpan.Zero |
||||||
|
? $"?seekToMs={(long)playoutItemInPoint.TotalMilliseconds}" |
||||||
|
: string.Empty; |
||||||
|
|
||||||
|
return |
||||||
|
[ |
||||||
|
new Subtitle |
||||||
|
{ |
||||||
|
Codec = "ass", |
||||||
|
Default = true, |
||||||
|
Forced = true, |
||||||
|
IsExtracted = false, |
||||||
|
SubtitleKind = SubtitleKind.Generated, |
||||||
|
Path = $"http://localhost:{Settings.StreamingPort}/ffmpeg/music-video-credits/{playoutItemId}{seekToMs}", |
||||||
|
SDH = false |
||||||
|
} |
||||||
|
]; |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue