Browse Source

feat: support dynamic fallback filler when using next engine (#2919)

pull/2920/head
Jason Dove 2 months ago committed by GitHub
parent
commit
294a2ff10e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 505
      ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs
  2. 355
      ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs
  3. 18
      ErsatzTV.Core/Interfaces/Scheduling/IPlayoutItemConverter.cs
  4. 69
      ErsatzTV.Core/Next/Playout.cs
  5. 5
      ErsatzTV.Infrastructure/Scheduling/DynamicPlayoutItem.cs
  6. 365
      ErsatzTV.Infrastructure/Scheduling/DynamicPlayoutItemService.cs
  7. 19
      ErsatzTV.Infrastructure/Scheduling/IDynamicPlayoutItemService.cs
  8. 541
      ErsatzTV.Infrastructure/Scheduling/PlayoutItemConverter.cs
  9. 63
      ErsatzTV/Controllers/InternalController.cs
  10. 2
      ErsatzTV/Startup.cs

505
ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs

@ -1,5 +1,3 @@ @@ -1,5 +1,3 @@
using System.Collections.Immutable;
using System.CommandLine.Parsing;
using System.Globalization;
using System.IO.Abstractions;
using System.Runtime.InteropServices;
@ -7,33 +5,22 @@ using System.Text; @@ -7,33 +5,22 @@ using System.Text;
using CliWrap;
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.Metadata;
using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.FFmpeg.State;
using ErsatzTV.Core.Interfaces.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using ErsatzTV.Infrastructure.Scheduling;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using CommandResult = CliWrap.CommandResult;
using PlayoutItem = ErsatzTV.Core.Domain.PlayoutItem;
using WatermarkLocation = ErsatzTV.FFmpeg.State.WatermarkLocation;
namespace ErsatzTV.Application.Playouts;
public partial class SyncNextPlayoutHandler(
IPlayoutItemConverter playoutItemConverter,
IFileSystem fileSystem,
ILocalFileSystem localFileSystem,
IPlexPathReplacementService plexPathReplacementService,
IJellyfinPathReplacementService jellyfinPathReplacementService,
IEmbyPathReplacementService embyPathReplacementService,
ICustomStreamSelector customStreamSelector,
IFFmpegStreamSelector ffmpegStreamSelector,
IWatermarkSelector watermarkSelector,
IDbContextFactory<TvContext> dbContextFactory,
ILogger<SyncNextPlayoutHandler> logger)
: IRequestHandler<SyncNextPlayout>
@ -211,11 +198,42 @@ public partial class SyncNextPlayoutHandler( @@ -211,11 +198,42 @@ public partial class SyncNextPlayoutHandler(
logger.LogDebug("Located {Count} local playout items", playoutItems.Count);
// fill in all gaps
List<PlayoutItem> newPlayoutItems = [];
Option<DateTimeOffset> lastFinish = Option<DateTimeOffset>.None;
playoutItems.Sort((a, b) => a.StartOffset.CompareTo(b.StartOffset));
foreach (var playoutItem in playoutItems)
{
foreach (var finish in lastFinish)
{
if (finish < playoutItem.StartOffset)
{
newPlayoutItems.Add(
new DynamicPlayoutItem
{
Start = finish.UtcDateTime,
Finish = playoutItem.Start
});
}
}
lastFinish = playoutItem.FinishOffset;
newPlayoutItems.Add(playoutItem);
}
playoutItems = newPlayoutItems;
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);
foreach (IGrouping<DateTime, PlayoutItem> group in playoutItems.GroupBy(pi => pi.StartOffset.Date)
.Where(g => g.Any()))
{
@ -229,404 +247,21 @@ public partial class SyncNextPlayoutHandler( @@ -229,404 +247,21 @@ public partial class SyncNextPlayoutHandler(
var playout = new Core.Next.Playout { Version = "https://ersatztv.org/playout/version/0.0.1", Items = [] };
foreach (PlayoutItem playoutItem in group)
{
if (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)
{
continue;
}
MediaVersion headVersion = playoutItem.MediaItem.GetHeadVersion();
playoutItem.Start += playoutOffset;
playoutItem.Finish += playoutOffset;
var nextPlayoutItem = new Core.Next.PlayoutItem
{
Id = playoutItem.Id.ToString(CultureInfo.InvariantCulture),
Start = playoutItem.StartOffset,
Finish = playoutItem.FinishOffset
};
Option<Core.Next.Source> maybeSource = await SourceForItem(playoutItem, cancellationToken);
if (maybeSource.IsNone)
{
continue;
}
foreach (Core.Next.Source source in maybeSource)
{
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 no audio streams, use lavfi to insert silence
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
}
};
}
maybeChannel = await dbContext.Channels
.AsNoTracking()
.Include(c => c.Watermark)
.Include(c => c.Artwork)
.SingleOrDefaultAsync(c => c.Number == channelNumber, cancellationToken);
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);
}
playout.Items.Add(nextPlayoutItem);
}
await fileSystem.File.WriteAllTextAsync(fileName, Core.Next.Serialize.ToJson(playout), cancellationToken);
}
}
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,
Option<Core.Next.PlayoutItem> maybeNextPlayoutItem = await playoutItemConverter.ToNext(
maybeChannelForArtwork,
maybeGlobalWatermark,
playoutOffset,
playoutItem,
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)
foreach (var nextPlayoutItem in maybeNextPlayoutItem)
{
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
};
playout.Items.Add(nextPlayoutItem);
}
}
}
}
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 async Task<Option<Core.Next.Source>> SourceForItem(
PlayoutItem playoutItem,
CancellationToken cancellationToken)
{
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
};
await fileSystem.File.WriteAllTextAsync(fileName, Core.Next.Serialize.ToJson(playout), cancellationToken);
}
return Option<Core.Next.Source>.None;
}
private void CleanOldVersions(
@ -688,60 +323,4 @@ public partial class SyncNextPlayoutHandler( @@ -688,60 +323,4 @@ public partial class SyncNextPlayoutHandler(
}
}
}
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
}
];
}
}

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

@ -1,25 +1,18 @@ @@ -1,25 +1,18 @@
using System.IO.Abstractions;
using CliWrap;
using Dapper;
using CliWrap;
using ErsatzTV.Application.Playouts;
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.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.Repositories;
using ErsatzTV.Core.Interfaces.Streaming;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.FFmpeg;
using ErsatzTV.FFmpeg.State;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using ErsatzTV.Infrastructure.Scheduling;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
@ -27,58 +20,35 @@ namespace ErsatzTV.Application.Streaming; @@ -27,58 +20,35 @@ namespace ErsatzTV.Application.Streaming;
public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<GetPlayoutItemProcessByChannelNumber>
{
private static readonly Random FallbackRandom = new();
private readonly IArtistRepository _artistRepository;
private readonly IEmbyPathReplacementService _embyPathReplacementService;
private readonly IExternalJsonPlayoutItemProvider _externalJsonPlayoutItemProvider;
private readonly IFFmpegProcessService _ffmpegProcessService;
private readonly IFileSystem _fileSystem;
private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService;
private readonly ILogger<GetPlayoutItemProcessByChannelNumberHandler> _logger;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
private readonly IMusicVideoCreditsGenerator _musicVideoCreditsGenerator;
private readonly IWatermarkSelector _watermarkSelector;
private readonly IGraphicsElementSelector _graphicsElementSelector;
private readonly IDecoSelector _decoSelector;
private readonly IPlexPathReplacementService _plexPathReplacementService;
private readonly IDynamicPlayoutItemService _dynamicPlayoutItemService;
private readonly ISongVideoGenerator _songVideoGenerator;
private readonly ITelevisionRepository _televisionRepository;
private readonly bool _isDebugNoSync;
public GetPlayoutItemProcessByChannelNumberHandler(
IDbContextFactory<TvContext> dbContextFactory,
IFFmpegProcessService ffmpegProcessService,
IFileSystem fileSystem,
IExternalJsonPlayoutItemProvider externalJsonPlayoutItemProvider,
IPlexPathReplacementService plexPathReplacementService,
IJellyfinPathReplacementService jellyfinPathReplacementService,
IEmbyPathReplacementService embyPathReplacementService,
IMediaCollectionRepository mediaCollectionRepository,
ITelevisionRepository televisionRepository,
IArtistRepository artistRepository,
ISongVideoGenerator songVideoGenerator,
IMusicVideoCreditsGenerator musicVideoCreditsGenerator,
IWatermarkSelector watermarkSelector,
IGraphicsElementSelector graphicsElementSelector,
IDecoSelector decoSelector,
IDynamicPlayoutItemService dynamicPlayoutItemService,
ILogger<GetPlayoutItemProcessByChannelNumberHandler> logger)
: base(dbContextFactory)
{
_ffmpegProcessService = ffmpegProcessService;
_fileSystem = fileSystem;
_externalJsonPlayoutItemProvider = externalJsonPlayoutItemProvider;
_plexPathReplacementService = plexPathReplacementService;
_jellyfinPathReplacementService = jellyfinPathReplacementService;
_embyPathReplacementService = embyPathReplacementService;
_mediaCollectionRepository = mediaCollectionRepository;
_televisionRepository = televisionRepository;
_artistRepository = artistRepository;
_songVideoGenerator = songVideoGenerator;
_musicVideoCreditsGenerator = musicVideoCreditsGenerator;
_watermarkSelector = watermarkSelector;
_graphicsElementSelector = graphicsElementSelector;
_decoSelector = decoSelector;
_dynamicPlayoutItemService = dynamicPlayoutItemService;
_logger = logger;
#if DEBUG_NO_SYNC
@ -211,7 +181,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< @@ -211,7 +181,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
.Include(i => i.Watermarks)
.ForChannelAndTime(channel.MirrorSourceChannelId ?? channel.Id, now)
.Map(o => o.ToEither<BaseError>(new UnableToLocatePlayoutItem()))
.BindT(item => ValidatePlayoutItemPath(dbContext, item, cancellationToken));
.BindT(item => _dynamicPlayoutItemService.ValidatePlayoutItemPath(dbContext, item, cancellationToken));
if (maybePlayoutItem.LeftAsEnumerable().Any(e => e is UnableToLocatePlayoutItem))
{
@ -224,38 +194,11 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< @@ -224,38 +194,11 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
if (maybePlayoutItem.LeftAsEnumerable().Any(e => e is 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)
{
maybePlayoutItem = await CheckForFallbackFiller(dbContext, channel, playout, now, cancellationToken);
}
if (maybePlayout.IsNone)
{
maybePlayoutItem = await CheckForFallbackFiller(dbContext, channel, null, now, cancellationToken);
}
maybePlayoutItem = await _dynamicPlayoutItemService.CheckForFallbackFiller(
dbContext,
channel,
now,
cancellationToken);
}
foreach (PlayoutItemWithPath playoutItemWithPath in maybePlayoutItem.RightToSeq())
@ -679,280 +622,4 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< @@ -679,280 +622,4 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
return subtitles;
}
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();
}
private 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)
{
_logger.LogDebug(
"Attempting to stream Plex file {PlexFileName} using key {PlexKey}",
pmf.Path,
pmf.Key);
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;
}

18
ErsatzTV.Core/Interfaces/Scheduling/IPlayoutItemConverter.cs

@ -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);
}

69
ErsatzTV.Core/Next/Playout.cs

@ -105,6 +105,25 @@ namespace ErsatzTV.Core.Next @@ -105,6 +105,25 @@ namespace ErsatzTV.Core.Next
///
/// An external command whose stdout is an MPEG-TS stream, proxied to ffmpeg over loopback
/// HTTP.
///
/// A placeholder source resolved at playback time by fetching a `PlayoutItem` JSON document
/// over HTTP(S). The returned item replaces this one; its `start` is forced to the current
/// transcode position and its `finish` is clamped to the placeholder's `finish`. Because
/// `start` advances each tick, the resolver is re-hit while the transcode position remains
/// inside the placeholder window, allowing a sequence of distinct items to be returned for a
/// single placeholder. The resolved item's `source` may not itself be `dynamic`.
///
/// The channel automatically injects the following request headers (in addition to any
/// configured via `headers`), all formatted per RFC 3339 for timestamps:
///
/// - `X-Etv-Channel` — the channel number this request is for.
/// - `X-Etv-Dynamic-Id` — a stable identifier for the placeholder; resolvers can use it to
/// coalesce or cache decisions across the multiple calls that occur while transcoding stays
/// inside one placeholder window.
/// - `X-Etv-Now` — the current transcode position; this is the `start` that will be forced
/// onto the resolved item.
/// - `X-Etv-Until` — the placeholder's `finish`; the resolver should not return an item that
/// extends beyond this (it will be clamped if it does).
/// </summary>
public partial class Source
{
@ -137,6 +156,9 @@ namespace ErsatzTV.Core.Next @@ -137,6 +156,9 @@ namespace ErsatzTV.Core.Next
/// <summary>
/// Custom HTTP headers, e.g. ["Authorization: Bearer {{TOKEN}}"].
///
/// Custom HTTP headers, e.g. ["Authorization: Bearer {{TOKEN}}"]. Supports {{TEMPLATE}}
/// expansion.
/// </summary>
[JsonProperty("headers")]
public List<string> Headers { get; set; }
@ -161,6 +183,8 @@ namespace ErsatzTV.Core.Next @@ -161,6 +183,8 @@ namespace ErsatzTV.Core.Next
/// <summary>
/// Socket timeout in microseconds.
///
/// Request timeout in microseconds. Defaults to 10 seconds.
/// </summary>
[JsonProperty("timeout_us")]
public long? TimeoutUs { get; set; }
@ -169,12 +193,18 @@ namespace ErsatzTV.Core.Next @@ -169,12 +193,18 @@ namespace ErsatzTV.Core.Next
/// URI template, e.g. "https://example.com/file.mkv?token={{MY_SECRET}}".
///
/// RTSP URI template, e.g. "rtsp://user:{{PASSWORD}}@camera.lan:554/stream".
///
/// URI template for the resolver endpoint, e.g.
/// "http://localhost:8409/fallback?channel=5&token={{MY_SECRET}}". Must respond with a JSON
/// `PlayoutItem`.
/// </summary>
[JsonProperty("uri", NullValueHandling = NullValueHandling.Ignore)]
public string Uri { get; set; }
/// <summary>
/// Custom User-Agent string.
///
/// Custom User-Agent string. Supports {{TEMPLATE}} expansion.
/// </summary>
[JsonProperty("user_agent")]
public string UserAgent { get; set; }
@ -339,6 +369,25 @@ namespace ErsatzTV.Core.Next @@ -339,6 +369,25 @@ namespace ErsatzTV.Core.Next
///
/// An external command whose stdout is an MPEG-TS stream, proxied to ffmpeg over loopback
/// HTTP.
///
/// A placeholder source resolved at playback time by fetching a `PlayoutItem` JSON document
/// over HTTP(S). The returned item replaces this one; its `start` is forced to the current
/// transcode position and its `finish` is clamped to the placeholder's `finish`. Because
/// `start` advances each tick, the resolver is re-hit while the transcode position remains
/// inside the placeholder window, allowing a sequence of distinct items to be returned for a
/// single placeholder. The resolved item's `source` may not itself be `dynamic`.
///
/// The channel automatically injects the following request headers (in addition to any
/// configured via `headers`), all formatted per RFC 3339 for timestamps:
///
/// - `X-Etv-Channel` — the channel number this request is for.
/// - `X-Etv-Dynamic-Id` — a stable identifier for the placeholder; resolvers can use it to
/// coalesce or cache decisions across the multiple calls that occur while transcoding stays
/// inside one placeholder window.
/// - `X-Etv-Now` — the current transcode position; this is the `start` that will be forced
/// onto the resolved item.
/// - `X-Etv-Until` — the placeholder's `finish`; the resolver should not return an item that
/// extends beyond this (it will be clamped if it does).
/// </summary>
public partial class PlayoutItemSource
{
@ -371,6 +420,9 @@ namespace ErsatzTV.Core.Next @@ -371,6 +420,9 @@ namespace ErsatzTV.Core.Next
/// <summary>
/// Custom HTTP headers, e.g. ["Authorization: Bearer {{TOKEN}}"].
///
/// Custom HTTP headers, e.g. ["Authorization: Bearer {{TOKEN}}"]. Supports {{TEMPLATE}}
/// expansion.
/// </summary>
[JsonProperty("headers")]
public List<string> Headers { get; set; }
@ -395,6 +447,8 @@ namespace ErsatzTV.Core.Next @@ -395,6 +447,8 @@ namespace ErsatzTV.Core.Next
/// <summary>
/// Socket timeout in microseconds.
///
/// Request timeout in microseconds. Defaults to 10 seconds.
/// </summary>
[JsonProperty("timeout_us")]
public long? TimeoutUs { get; set; }
@ -403,12 +457,18 @@ namespace ErsatzTV.Core.Next @@ -403,12 +457,18 @@ namespace ErsatzTV.Core.Next
/// URI template, e.g. "https://example.com/file.mkv?token={{MY_SECRET}}".
///
/// RTSP URI template, e.g. "rtsp://user:{{PASSWORD}}@camera.lan:554/stream".
///
/// URI template for the resolver endpoint, e.g.
/// "http://localhost:8409/fallback?channel=5&token={{MY_SECRET}}". Must respond with a JSON
/// `PlayoutItem`.
/// </summary>
[JsonProperty("uri", NullValueHandling = NullValueHandling.Ignore)]
public string Uri { get; set; }
/// <summary>
/// Custom User-Agent string.
///
/// Custom User-Agent string. Supports {{TEMPLATE}} expansion.
/// </summary>
[JsonProperty("user_agent")]
public string UserAgent { get; set; }
@ -487,7 +547,7 @@ namespace ErsatzTV.Core.Next @@ -487,7 +547,7 @@ namespace ErsatzTV.Core.Next
public TimingType TimingType { get; set; }
}
public enum SourceType { Http, Lavfi, Local, Rtsp, Script };
public enum SourceType { Dynamic, Http, Lavfi, Local, Rtsp, Script };
/// <summary>
/// Anchor position within the primary content frame.
@ -518,7 +578,7 @@ namespace ErsatzTV.Core.Next @@ -518,7 +578,7 @@ namespace ErsatzTV.Core.Next
public static string ToJson(this Playout self) => JsonConvert.SerializeObject(self, ErsatzTV.Core.Next.Converter.Settings);
}
internal static class Converter
public static class Converter
{
public static readonly JsonSerializerSettings Settings = new JsonSerializerSettings
{
@ -546,6 +606,8 @@ namespace ErsatzTV.Core.Next @@ -546,6 +606,8 @@ namespace ErsatzTV.Core.Next
var value = serializer.Deserialize<string>(reader);
switch (value)
{
case "dynamic":
return SourceType.Dynamic;
case "http":
return SourceType.Http;
case "lavfi":
@ -570,6 +632,9 @@ namespace ErsatzTV.Core.Next @@ -570,6 +632,9 @@ namespace ErsatzTV.Core.Next
var value = (SourceType)untypedValue;
switch (value)
{
case SourceType.Dynamic:
serializer.Serialize(writer, "dynamic");
return;
case SourceType.Http:
serializer.Serialize(writer, "http");
return;

5
ErsatzTV.Infrastructure/Scheduling/DynamicPlayoutItem.cs

@ -0,0 +1,5 @@ @@ -0,0 +1,5 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Infrastructure.Scheduling;
public sealed class DynamicPlayoutItem : PlayoutItem;

365
ErsatzTV.Infrastructure/Scheduling/DynamicPlayoutItemService.cs

@ -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;
}

19
ErsatzTV.Infrastructure/Scheduling/IDynamicPlayoutItemService.cs

@ -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);
}

541
ErsatzTV.Infrastructure/Scheduling/PlayoutItemConverter.cs

@ -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
}
];
}
}

63
ErsatzTV/Controllers/InternalController.cs

@ -11,12 +11,18 @@ using ErsatzTV.Application.Subtitles.Queries; @@ -11,12 +11,18 @@ using ErsatzTV.Application.Subtitles.Queries;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.Scheduling;
using ErsatzTV.Core.Interfaces.Streaming;
using ErsatzTV.Extensions;
using ErsatzTV.FFmpeg;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Scheduling;
using Flurl;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Primitives;
using Newtonsoft.Json;
namespace ErsatzTV.Controllers;
@ -26,14 +32,23 @@ public class InternalController : StreamingControllerBase @@ -26,14 +32,23 @@ public class InternalController : StreamingControllerBase
{
private readonly ILogger<InternalController> _logger;
private readonly IMediator _mediator;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IDynamicPlayoutItemService _dynamicPlayoutItemService;
private readonly IPlayoutItemConverter _playoutItemConverter;
public InternalController(
IGraphicsEngine graphicsEngine,
IMediator mediator,
IDbContextFactory<TvContext> dbContextFactory,
IDynamicPlayoutItemService dynamicPlayoutItemService,
IPlayoutItemConverter playoutItemConverter,
ILogger<InternalController> logger)
: base(graphicsEngine, logger)
{
_mediator = mediator;
_dbContextFactory = dbContextFactory;
_dynamicPlayoutItemService = dynamicPlayoutItemService;
_playoutItemConverter = playoutItemConverter;
_logger = logger;
}
@ -267,6 +282,54 @@ public class InternalController : StreamingControllerBase @@ -267,6 +282,54 @@ public class InternalController : StreamingControllerBase
return new NotFoundResult();
}
[HttpGet("/media/fallback")]
public async Task<IActionResult> GetFallbackPlayoutJson(CancellationToken cancellationToken)
{
if (!Request.Headers.TryGetValue("x-etv-channel", out StringValues channelNumber) || channelNumber.Count != 1)
{
return BadRequest();
}
if (!Request.Headers.TryGetValue("x-etv-now", out StringValues nowString) || nowString.Count != 1 ||
!DateTimeOffset.TryParse(nowString[0], out DateTimeOffset now))
{
return BadRequest();
}
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<Channel> maybeChannel = await dbContext.Channels
.SingleOrDefaultAsync(c => c.Number == channelNumber[0], cancellationToken)
.Map(Optional);
foreach (var channel in maybeChannel)
{
Either<BaseError, PlayoutItemWithPath> maybePlayoutItem =
await _dynamicPlayoutItemService.CheckForFallbackFiller(
dbContext,
channel,
now,
cancellationToken);
foreach (var itemWithPath in maybePlayoutItem.RightToSeq())
{
Option<Core.Next.PlayoutItem> maybeNextPlayoutItem = await _playoutItemConverter.ToNext(
channelNumber[0],
itemWithPath.PlayoutItem,
cancellationToken);
foreach (Core.Next.PlayoutItem nextPlayoutItem in maybeNextPlayoutItem)
{
return Content(
JsonConvert.SerializeObject(nextPlayoutItem, Core.Next.Converter.Settings),
"application/json");
}
}
}
return NotFound();
}
private async Task<IActionResult> GetTsLegacyStream(string channelNumber)
{
var request = new GetPlayoutItemProcessByChannelNumber(

2
ErsatzTV/Startup.cs

@ -824,6 +824,8 @@ public class Startup @@ -824,6 +824,8 @@ public class Startup
services.AddScoped<IHlsInitSegmentCache, HlsInitSegmentCache>();
services.AddScoped<IMpegTsScriptService, MpegTsScriptService>();
services.AddScoped<ILanguageCodeService, LanguageCodeService>();
services.AddScoped<IPlayoutItemConverter, PlayoutItemConverter>();
services.AddScoped<IDynamicPlayoutItemService, DynamicPlayoutItemService>();
services.AddScoped<IFFmpegProcessService, FFmpegLibraryProcessService>();
services.AddScoped<IPipelineBuilderFactory, PipelineBuilderFactory>();

Loading…
Cancel
Save