462 lines
20 KiB
C#
462 lines
20 KiB
C#
using CliWrap;
|
|
using ErsatzTV.Core;
|
|
using ErsatzTV.Core.Domain;
|
|
using ErsatzTV.Core.Domain.Filler;
|
|
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.Metadata;
|
|
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;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace ErsatzTV.Application.Streaming;
|
|
|
|
public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<GetPlayoutItemProcessByChannelNumber>
|
|
{
|
|
private readonly IArtistRepository _artistRepository;
|
|
private readonly IEmbyPathReplacementService _embyPathReplacementService;
|
|
private readonly IFFmpegProcessService _ffmpegProcessService;
|
|
private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService;
|
|
private readonly ILocalFileSystem _localFileSystem;
|
|
private readonly ILogger<GetPlayoutItemProcessByChannelNumberHandler> _logger;
|
|
private readonly IMediaCollectionRepository _mediaCollectionRepository;
|
|
private readonly IMusicVideoCreditsGenerator _musicVideoCreditsGenerator;
|
|
private readonly IPlexPathReplacementService _plexPathReplacementService;
|
|
private readonly ISongVideoGenerator _songVideoGenerator;
|
|
private readonly ITelevisionRepository _televisionRepository;
|
|
|
|
public GetPlayoutItemProcessByChannelNumberHandler(
|
|
IDbContextFactory<TvContext> dbContextFactory,
|
|
IFFmpegProcessService ffmpegProcessService,
|
|
ILocalFileSystem localFileSystem,
|
|
IPlexPathReplacementService plexPathReplacementService,
|
|
IJellyfinPathReplacementService jellyfinPathReplacementService,
|
|
IEmbyPathReplacementService embyPathReplacementService,
|
|
IMediaCollectionRepository mediaCollectionRepository,
|
|
ITelevisionRepository televisionRepository,
|
|
IArtistRepository artistRepository,
|
|
ISongVideoGenerator songVideoGenerator,
|
|
IMusicVideoCreditsGenerator musicVideoCreditsGenerator,
|
|
ILogger<GetPlayoutItemProcessByChannelNumberHandler> logger)
|
|
: base(dbContextFactory)
|
|
{
|
|
_ffmpegProcessService = ffmpegProcessService;
|
|
_localFileSystem = localFileSystem;
|
|
_plexPathReplacementService = plexPathReplacementService;
|
|
_jellyfinPathReplacementService = jellyfinPathReplacementService;
|
|
_embyPathReplacementService = embyPathReplacementService;
|
|
_mediaCollectionRepository = mediaCollectionRepository;
|
|
_televisionRepository = televisionRepository;
|
|
_artistRepository = artistRepository;
|
|
_songVideoGenerator = songVideoGenerator;
|
|
_musicVideoCreditsGenerator = musicVideoCreditsGenerator;
|
|
_logger = logger;
|
|
}
|
|
|
|
protected override async Task<Either<BaseError, PlayoutItemProcessModel>> GetProcess(
|
|
TvContext dbContext,
|
|
GetPlayoutItemProcessByChannelNumber request,
|
|
Channel channel,
|
|
string ffmpegPath,
|
|
string ffprobePath,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
DateTimeOffset now = request.Now;
|
|
|
|
Either<BaseError, PlayoutItemWithPath> maybePlayoutItem = await dbContext.PlayoutItems
|
|
.Include(i => i.MediaItem)
|
|
.ThenInclude(mi => (mi as Episode).EpisodeMetadata)
|
|
.ThenInclude(em => em.Subtitles)
|
|
.Include(i => i.MediaItem)
|
|
.ThenInclude(mi => (mi as Episode).MediaVersions)
|
|
.ThenInclude(mv => mv.MediaFiles)
|
|
.Include(i => i.MediaItem)
|
|
.ThenInclude(mi => (mi as Episode).MediaVersions)
|
|
.ThenInclude(mv => mv.Streams)
|
|
.Include(i => i.MediaItem)
|
|
.ThenInclude(mi => (mi as Movie).MovieMetadata)
|
|
.ThenInclude(mm => mm.Subtitles)
|
|
.Include(i => i.MediaItem)
|
|
.ThenInclude(mi => (mi as Movie).MediaVersions)
|
|
.ThenInclude(mv => mv.MediaFiles)
|
|
.Include(i => i.MediaItem)
|
|
.ThenInclude(mi => (mi as Movie).MediaVersions)
|
|
.ThenInclude(mv => mv.Streams)
|
|
.Include(i => i.MediaItem)
|
|
.ThenInclude(mi => (mi as MusicVideo).MusicVideoMetadata)
|
|
.ThenInclude(mvm => mvm.Subtitles)
|
|
.Include(i => i.MediaItem)
|
|
.ThenInclude(mi => (mi as MusicVideo).MediaVersions)
|
|
.ThenInclude(mv => mv.MediaFiles)
|
|
.Include(i => i.MediaItem)
|
|
.ThenInclude(mi => (mi as MusicVideo).MediaVersions)
|
|
.ThenInclude(mv => mv.Streams)
|
|
.Include(i => i.MediaItem)
|
|
.ThenInclude(mi => (mi as MusicVideo).Artist)
|
|
.ThenInclude(mv => mv.ArtistMetadata)
|
|
.Include(i => i.MediaItem)
|
|
.ThenInclude(mi => (mi as OtherVideo).OtherVideoMetadata)
|
|
.ThenInclude(ovm => ovm.Subtitles)
|
|
.Include(i => i.MediaItem)
|
|
.ThenInclude(mi => (mi as OtherVideo).MediaVersions)
|
|
.ThenInclude(ov => ov.MediaFiles)
|
|
.Include(i => i.MediaItem)
|
|
.ThenInclude(mi => (mi as OtherVideo).MediaVersions)
|
|
.ThenInclude(ov => ov.Streams)
|
|
.Include(i => i.MediaItem)
|
|
.ThenInclude(mi => (mi as Song).MediaVersions)
|
|
.ThenInclude(mv => mv.MediaFiles)
|
|
.Include(i => i.MediaItem)
|
|
.ThenInclude(mi => (mi as Song).MediaVersions)
|
|
.ThenInclude(mv => mv.Streams)
|
|
.Include(i => i.MediaItem)
|
|
.ThenInclude(mi => (mi as Song).SongMetadata)
|
|
.ThenInclude(sm => sm.Artwork)
|
|
.Include(i => i.Watermark)
|
|
.ForChannelAndTime(channel.Id, now)
|
|
.Map(o => o.ToEither<BaseError>(new UnableToLocatePlayoutItem()))
|
|
.BindT(ValidatePlayoutItemPath);
|
|
|
|
if (maybePlayoutItem.LeftAsEnumerable().Any(e => e is UnableToLocatePlayoutItem))
|
|
{
|
|
maybePlayoutItem = await CheckForFallbackFiller(dbContext, channel, now);
|
|
}
|
|
|
|
foreach (PlayoutItemWithPath playoutItemWithPath in maybePlayoutItem.RightToSeq())
|
|
{
|
|
MediaVersion version = playoutItemWithPath.PlayoutItem.MediaItem.GetHeadVersion();
|
|
|
|
string videoPath = playoutItemWithPath.Path;
|
|
MediaVersion videoVersion = version;
|
|
|
|
string audioPath = playoutItemWithPath.Path;
|
|
MediaVersion audioVersion = version;
|
|
|
|
Option<ChannelWatermark> maybeGlobalWatermark = await dbContext.ConfigElements
|
|
.GetValue<int>(ConfigElementKey.FFmpegGlobalWatermarkId)
|
|
.BindT(
|
|
watermarkId => dbContext.ChannelWatermarks
|
|
.SelectOneAsync(w => w.Id, w => w.Id == watermarkId));
|
|
|
|
if (playoutItemWithPath.PlayoutItem.MediaItem is Song song)
|
|
{
|
|
(videoPath, videoVersion) = await _songVideoGenerator.GenerateSongVideo(
|
|
song,
|
|
channel,
|
|
Optional(playoutItemWithPath.PlayoutItem.Watermark),
|
|
maybeGlobalWatermark,
|
|
ffmpegPath,
|
|
ffprobePath,
|
|
cancellationToken);
|
|
}
|
|
|
|
bool saveReports = await dbContext.ConfigElements
|
|
.GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
|
|
.Map(result => result.IfNone(false));
|
|
|
|
List<Subtitle> subtitles = await GetSubtitles(playoutItemWithPath, channel);
|
|
|
|
Command process = await _ffmpegProcessService.ForPlayoutItem(
|
|
ffmpegPath,
|
|
ffprobePath,
|
|
saveReports,
|
|
channel,
|
|
videoVersion,
|
|
audioVersion,
|
|
videoPath,
|
|
audioPath,
|
|
subtitles,
|
|
playoutItemWithPath.PlayoutItem.PreferredAudioLanguageCode ?? channel.PreferredAudioLanguageCode,
|
|
playoutItemWithPath.PlayoutItem.PreferredSubtitleLanguageCode ?? channel.PreferredSubtitleLanguageCode,
|
|
playoutItemWithPath.PlayoutItem.SubtitleMode ?? channel.SubtitleMode,
|
|
playoutItemWithPath.PlayoutItem.StartOffset,
|
|
playoutItemWithPath.PlayoutItem.FinishOffset,
|
|
request.StartAtZero ? playoutItemWithPath.PlayoutItem.StartOffset : now,
|
|
Optional(playoutItemWithPath.PlayoutItem.Watermark),
|
|
maybeGlobalWatermark,
|
|
channel.FFmpegProfile.VaapiDriver,
|
|
channel.FFmpegProfile.VaapiDevice,
|
|
request.HlsRealtime,
|
|
playoutItemWithPath.PlayoutItem.FillerKind,
|
|
playoutItemWithPath.PlayoutItem.InPoint,
|
|
playoutItemWithPath.PlayoutItem.OutPoint,
|
|
request.PtsOffset,
|
|
request.TargetFramerate,
|
|
playoutItemWithPath.PlayoutItem.DisableWatermarks);
|
|
|
|
var result = new PlayoutItemProcessModel(
|
|
process,
|
|
playoutItemWithPath.PlayoutItem.FinishOffset -
|
|
(request.StartAtZero ? playoutItemWithPath.PlayoutItem.StartOffset : now),
|
|
playoutItemWithPath.PlayoutItem.FinishOffset);
|
|
|
|
return Right<BaseError, PlayoutItemProcessModel>(result);
|
|
}
|
|
|
|
foreach (BaseError error in maybePlayoutItem.LeftToSeq())
|
|
{
|
|
Option<TimeSpan> maybeDuration = await dbContext.PlayoutItems
|
|
.Filter(pi => pi.Playout.ChannelId == channel.Id)
|
|
.Filter(pi => pi.Start > now.UtcDateTime)
|
|
.OrderBy(pi => pi.Start)
|
|
.FirstOrDefaultAsync(cancellationToken)
|
|
.Map(Optional)
|
|
.MapT(pi => pi.StartOffset - now);
|
|
|
|
DateTimeOffset finish = maybeDuration.Match(d => now.Add(d), () => now);
|
|
|
|
_logger.LogWarning(
|
|
"Error locating playout item {@Error}. Will display error from {Start} to {Finish}",
|
|
error,
|
|
now,
|
|
finish);
|
|
|
|
switch (error)
|
|
{
|
|
case UnableToLocatePlayoutItem:
|
|
Command offlineProcess = await _ffmpegProcessService.ForError(
|
|
ffmpegPath,
|
|
channel,
|
|
maybeDuration,
|
|
"Channel is Offline",
|
|
request.HlsRealtime,
|
|
request.PtsOffset,
|
|
channel.FFmpegProfile.VaapiDriver,
|
|
channel.FFmpegProfile.VaapiDevice);
|
|
|
|
return new PlayoutItemProcessModel(offlineProcess, maybeDuration, finish);
|
|
case PlayoutItemDoesNotExistOnDisk:
|
|
Command doesNotExistProcess = await _ffmpegProcessService.ForError(
|
|
ffmpegPath,
|
|
channel,
|
|
maybeDuration,
|
|
error.Value,
|
|
request.HlsRealtime,
|
|
request.PtsOffset,
|
|
channel.FFmpegProfile.VaapiDriver,
|
|
channel.FFmpegProfile.VaapiDevice);
|
|
|
|
return new PlayoutItemProcessModel(doesNotExistProcess, maybeDuration, finish);
|
|
default:
|
|
Command errorProcess = await _ffmpegProcessService.ForError(
|
|
ffmpegPath,
|
|
channel,
|
|
maybeDuration,
|
|
"Channel is Offline",
|
|
request.HlsRealtime,
|
|
request.PtsOffset,
|
|
channel.FFmpegProfile.VaapiDriver,
|
|
channel.FFmpegProfile.VaapiDevice);
|
|
|
|
return new PlayoutItemProcessModel(errorProcess, maybeDuration, finish);
|
|
}
|
|
}
|
|
|
|
return BaseError.New($"Unexpected error locating playout item for channel {channel.Number}");
|
|
}
|
|
|
|
private async Task<List<Subtitle>> GetSubtitles(
|
|
PlayoutItemWithPath playoutItemWithPath,
|
|
Channel channel)
|
|
{
|
|
List<Subtitle> allSubtitles = playoutItemWithPath.PlayoutItem.MediaItem switch
|
|
{
|
|
Episode episode => await Optional(episode.EpisodeMetadata).Flatten().HeadOrNone()
|
|
.Map(mm => mm.Subtitles ?? new List<Subtitle>())
|
|
.IfNoneAsync(new List<Subtitle>()),
|
|
Movie movie => await Optional(movie.MovieMetadata).Flatten().HeadOrNone()
|
|
.Map(mm => mm.Subtitles ?? new List<Subtitle>())
|
|
.IfNoneAsync(new List<Subtitle>()),
|
|
MusicVideo musicVideo => await GetMusicVideoSubtitles(musicVideo, channel),
|
|
OtherVideo otherVideo => await Optional(otherVideo.OtherVideoMetadata).Flatten().HeadOrNone()
|
|
.Map(mm => mm.Subtitles ?? new List<Subtitle>())
|
|
.IfNoneAsync(new List<Subtitle>()),
|
|
_ => new List<Subtitle>()
|
|
};
|
|
|
|
bool isMediaServer = playoutItemWithPath.PlayoutItem.MediaItem is PlexMovie or PlexEpisode or
|
|
JellyfinMovie or JellyfinEpisode or EmbyMovie or EmbyEpisode;
|
|
|
|
if (isMediaServer)
|
|
{
|
|
string mediaItemFolder = Path.GetDirectoryName(playoutItemWithPath.Path);
|
|
|
|
allSubtitles = allSubtitles.Map<Subtitle, Option<Subtitle>>(
|
|
subtitle =>
|
|
{
|
|
if (subtitle.SubtitleKind == SubtitleKind.Sidecar)
|
|
{
|
|
// need to prepend path with movie/episode folder
|
|
if (!string.IsNullOrWhiteSpace(mediaItemFolder))
|
|
{
|
|
subtitle.Path = Path.Combine(mediaItemFolder, subtitle.Path);
|
|
|
|
// skip subtitles that don't exist
|
|
if (!File.Exists(subtitle.Path))
|
|
{
|
|
return None;
|
|
}
|
|
}
|
|
}
|
|
|
|
return subtitle;
|
|
})
|
|
.Somes()
|
|
.ToList();
|
|
}
|
|
|
|
return allSubtitles;
|
|
}
|
|
|
|
private async Task<List<Subtitle>> GetMusicVideoSubtitles(MusicVideo musicVideo, Channel channel)
|
|
{
|
|
var subtitles = new List<Subtitle>();
|
|
|
|
bool musicVideoCredits = channel.MusicVideoCreditsMode == ChannelMusicVideoCreditsMode.GenerateSubtitles;
|
|
if (musicVideoCredits)
|
|
{
|
|
subtitles.AddRange(
|
|
await _musicVideoCreditsGenerator.GenerateCreditsSubtitle(musicVideo, channel.FFmpegProfile));
|
|
}
|
|
else
|
|
{
|
|
subtitles.AddRange(
|
|
await Optional(musicVideo.MusicVideoMetadata).Flatten().HeadOrNone()
|
|
.Map(mm => mm.Subtitles)
|
|
.IfNoneAsync(new List<Subtitle>()));
|
|
}
|
|
|
|
return subtitles;
|
|
}
|
|
|
|
private async Task<Either<BaseError, PlayoutItemWithPath>> CheckForFallbackFiller(
|
|
TvContext dbContext,
|
|
Channel channel,
|
|
DateTimeOffset now)
|
|
{
|
|
// check for channel fallback
|
|
Option<FillerPreset> maybeFallback = await dbContext.FillerPresets
|
|
.SelectOneAsync(w => w.Id, w => w.Id == channel.FallbackFillerId);
|
|
|
|
// then check for global fallback
|
|
if (maybeFallback.IsNone)
|
|
{
|
|
maybeFallback = await dbContext.ConfigElements
|
|
.GetValue<int>(ConfigElementKey.FFmpegGlobalFallbackFillerId)
|
|
.BindT(fillerId => dbContext.FillerPresets.SelectOneAsync(w => w.Id, w => w.Id == fillerId));
|
|
}
|
|
|
|
foreach (FillerPreset fallbackPreset in maybeFallback)
|
|
{
|
|
// turn this into a playout item
|
|
|
|
var collectionKey = CollectionKey.ForFillerPreset(fallbackPreset);
|
|
List<MediaItem> items = await MediaItemsForCollection.Collect(
|
|
_mediaCollectionRepository,
|
|
_televisionRepository,
|
|
_artistRepository,
|
|
collectionKey);
|
|
|
|
// TODO: shuffle? does it really matter since we loop anyway
|
|
MediaItem item = items[new Random().Next(items.Count)];
|
|
|
|
Option<TimeSpan> maybeDuration = await dbContext.PlayoutItems
|
|
.Filter(pi => pi.Playout.ChannelId == channel.Id)
|
|
.Filter(pi => pi.Start > now.UtcDateTime)
|
|
.OrderBy(pi => pi.Start)
|
|
.FirstOrDefaultAsync()
|
|
.Map(Optional)
|
|
.MapT(pi => pi.StartOffset - now);
|
|
|
|
MediaVersion version = item.GetHeadVersion();
|
|
|
|
version.MediaFiles = await dbContext.MediaFiles
|
|
.AsNoTracking()
|
|
.Filter(mf => mf.MediaVersionId == version.Id)
|
|
.ToListAsync();
|
|
|
|
version.Streams = await dbContext.MediaStreams
|
|
.AsNoTracking()
|
|
.Filter(ms => ms.MediaVersionId == version.Id)
|
|
.ToListAsync();
|
|
|
|
DateTimeOffset finish = maybeDuration.Match(
|
|
// next playout item exists
|
|
// loop until it starts
|
|
now.Add,
|
|
// no next playout item exists
|
|
// loop for 5 minutes if less than 30s, otherwise play full item
|
|
() => version.Duration < TimeSpan.FromSeconds(30)
|
|
? now.AddMinutes(5)
|
|
: now.Add(version.Duration));
|
|
|
|
var playoutItem = new PlayoutItem
|
|
{
|
|
MediaItem = item,
|
|
MediaItemId = item.Id,
|
|
Start = now.UtcDateTime,
|
|
Finish = finish.UtcDateTime,
|
|
FillerKind = FillerKind.Fallback,
|
|
InPoint = TimeSpan.Zero,
|
|
OutPoint = version.Duration,
|
|
DisableWatermarks = !fallbackPreset.AllowWatermarks
|
|
};
|
|
|
|
return await ValidatePlayoutItemPath(playoutItem);
|
|
}
|
|
|
|
return new UnableToLocatePlayoutItem();
|
|
}
|
|
|
|
private async Task<Either<BaseError, PlayoutItemWithPath>> ValidatePlayoutItemPath(PlayoutItem playoutItem)
|
|
{
|
|
string path = await GetPlayoutItemPath(playoutItem);
|
|
|
|
if (_localFileSystem.FileExists(path))
|
|
{
|
|
return new PlayoutItemWithPath(playoutItem, path);
|
|
}
|
|
|
|
return new PlayoutItemDoesNotExistOnDisk(path);
|
|
}
|
|
|
|
private async Task<string> GetPlayoutItemPath(PlayoutItem playoutItem)
|
|
{
|
|
MediaVersion version = playoutItem.MediaItem.GetHeadVersion();
|
|
|
|
MediaFile file = version.MediaFiles.Head();
|
|
string path = file.Path;
|
|
return playoutItem.MediaItem switch
|
|
{
|
|
PlexMovie plexMovie => await _plexPathReplacementService.GetReplacementPlexPath(
|
|
plexMovie.LibraryPathId,
|
|
path),
|
|
PlexEpisode plexEpisode => await _plexPathReplacementService.GetReplacementPlexPath(
|
|
plexEpisode.LibraryPathId,
|
|
path),
|
|
JellyfinMovie jellyfinMovie => await _jellyfinPathReplacementService.GetReplacementJellyfinPath(
|
|
jellyfinMovie.LibraryPathId,
|
|
path),
|
|
JellyfinEpisode jellyfinEpisode => await _jellyfinPathReplacementService.GetReplacementJellyfinPath(
|
|
jellyfinEpisode.LibraryPathId,
|
|
path),
|
|
EmbyMovie embyMovie => await _embyPathReplacementService.GetReplacementEmbyPath(
|
|
embyMovie.LibraryPathId,
|
|
path),
|
|
EmbyEpisode embyEpisode => await _embyPathReplacementService.GetReplacementEmbyPath(
|
|
embyEpisode.LibraryPathId,
|
|
path),
|
|
_ => path
|
|
};
|
|
}
|
|
|
|
private record PlayoutItemWithPath(PlayoutItem PlayoutItem, string Path);
|
|
}
|