mirror of https://github.com/ErsatzTV/ErsatzTV.git
10 changed files with 1133 additions and 809 deletions
@ -0,0 +1,18 @@
@@ -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 @@
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core.Domain; |
||||
|
||||
namespace ErsatzTV.Infrastructure.Scheduling; |
||||
|
||||
public sealed class DynamicPlayoutItem : PlayoutItem; |
||||
@ -0,0 +1,365 @@
@@ -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 @@
@@ -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 @@
@@ -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