* fix formatting rules * reformat ersatztv * reformat ersatztv.application * reformat ersatztv.core * refactor ersatztv.core.tests * reformat ersatztv.ffmpeg * reformat ersatztv.ffmpeg.tests * reformat ersatztv.infrastructure * cleanup infra mysql * cleanup infra sqlite * cleanup infra tests * cleanup ersatztv.scanner * cleanup ersatztv.scanner.tests * sln cleanup * update dependencies
457 lines
18 KiB
C#
457 lines
18 KiB
C#
using Dapper;
|
|
using ErsatzTV.Core;
|
|
using ErsatzTV.Core.Domain;
|
|
using ErsatzTV.Core.Domain.Filler;
|
|
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.Locking;
|
|
using ErsatzTV.Core.Interfaces.Metadata;
|
|
using ErsatzTV.Core.Interfaces.Plex;
|
|
using ErsatzTV.Core.Notifications;
|
|
using ErsatzTV.FFmpeg;
|
|
using ErsatzTV.FFmpeg.State;
|
|
using ErsatzTV.Infrastructure.Data;
|
|
using ErsatzTV.Infrastructure.Extensions;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace ErsatzTV.Application.Troubleshooting;
|
|
|
|
public class PrepareTroubleshootingPlaybackHandler(
|
|
IDbContextFactory<TvContext> dbContextFactory,
|
|
IPlexPathReplacementService plexPathReplacementService,
|
|
IJellyfinPathReplacementService jellyfinPathReplacementService,
|
|
IEmbyPathReplacementService embyPathReplacementService,
|
|
IFFmpegProcessService ffmpegProcessService,
|
|
ILocalFileSystem localFileSystem,
|
|
ISongVideoGenerator songVideoGenerator,
|
|
IEntityLocker entityLocker,
|
|
IMediator mediator,
|
|
ILogger<PrepareTroubleshootingPlaybackHandler> logger)
|
|
: IRequestHandler<PrepareTroubleshootingPlayback, Either<BaseError, PlayoutItemResult>>
|
|
{
|
|
public async Task<Either<BaseError, PlayoutItemResult>> Handle(
|
|
PrepareTroubleshootingPlayback request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
|
Validation<BaseError, Tuple<MediaItem, string, string, FFmpegProfile>> validation = await Validate(
|
|
dbContext,
|
|
request);
|
|
return await validation.Match(
|
|
tuple => GetProcess(dbContext, request, tuple.Item1, tuple.Item2, tuple.Item3, tuple.Item4),
|
|
error => Task.FromResult<Either<BaseError, PlayoutItemResult>>(error.Join()));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
entityLocker.UnlockTroubleshootingPlayback();
|
|
await mediator.Publish(new PlaybackTroubleshootingCompletedNotification(-1), cancellationToken);
|
|
logger.LogError(ex, "Error while preparing troubleshooting playback");
|
|
return BaseError.New(ex.Message);
|
|
}
|
|
}
|
|
|
|
private async Task<Either<BaseError, PlayoutItemResult>> GetProcess(
|
|
TvContext dbContext,
|
|
PrepareTroubleshootingPlayback request,
|
|
MediaItem mediaItem,
|
|
string ffmpegPath,
|
|
string ffprobePath,
|
|
FFmpegProfile ffmpegProfile)
|
|
{
|
|
if (entityLocker.IsTroubleshootingPlaybackLocked())
|
|
{
|
|
return BaseError.New("Troubleshooting playback is locked");
|
|
}
|
|
|
|
entityLocker.LockTroubleshootingPlayback();
|
|
|
|
localFileSystem.EnsureFolderExists(FileSystemLayout.TranscodeTroubleshootingFolder);
|
|
localFileSystem.EmptyFolder(FileSystemLayout.TranscodeTroubleshootingFolder);
|
|
|
|
const ChannelSubtitleMode SUBTITLE_MODE = ChannelSubtitleMode.Any;
|
|
|
|
MediaVersion version = mediaItem.GetHeadVersion();
|
|
|
|
string mediaPath = await GetMediaItemPath(dbContext, mediaItem);
|
|
if (string.IsNullOrEmpty(mediaPath))
|
|
{
|
|
logger.LogWarning("Media item {MediaItemId} does not exist on disk; cannot troubleshoot.", mediaItem.Id);
|
|
return BaseError.New("Media item does not exist on disk");
|
|
}
|
|
|
|
var channel = new Channel(Guid.Empty)
|
|
{
|
|
Artwork = [],
|
|
Name = "ETV",
|
|
Number = ".troubleshooting",
|
|
FFmpegProfile = ffmpegProfile,
|
|
StreamingMode = StreamingMode.HttpLiveStreamingSegmenter,
|
|
StreamSelectorMode = ChannelStreamSelectorMode.Troubleshooting,
|
|
SubtitleMode = SUBTITLE_MODE
|
|
};
|
|
|
|
List<ChannelWatermark> watermarks = [];
|
|
if (request.WatermarkIds.Count > 0)
|
|
{
|
|
List<ChannelWatermark> channelWatermarks = await dbContext.ChannelWatermarks
|
|
.Where(w => request.WatermarkIds.Contains(w.Id))
|
|
.ToListAsync();
|
|
|
|
watermarks.AddRange(channelWatermarks);
|
|
}
|
|
|
|
string videoPath = mediaPath;
|
|
MediaVersion videoVersion = version;
|
|
|
|
if (mediaItem is Song song)
|
|
{
|
|
(videoPath, videoVersion) = await songVideoGenerator.GenerateSongVideo(
|
|
song,
|
|
channel,
|
|
Option<ChannelWatermark>.None,
|
|
Option<ChannelWatermark>.None,
|
|
ffmpegPath,
|
|
ffprobePath,
|
|
CancellationToken.None);
|
|
|
|
// override watermark as song_progress_overlay.png
|
|
if (videoVersion is BackgroundImageMediaVersion { IsSongWithProgress: true })
|
|
{
|
|
double ratio = channel.FFmpegProfile.Resolution.Width /
|
|
(double)channel.FFmpegProfile.Resolution.Height;
|
|
bool is43 = Math.Abs(ratio - 4.0 / 3.0) < 0.01;
|
|
string image = is43 ? "song_progress_overlay_43.png" : "song_progress_overlay.png";
|
|
|
|
watermarks.Clear();
|
|
watermarks.Add(
|
|
new ChannelWatermark
|
|
{
|
|
Mode = ChannelWatermarkMode.Permanent,
|
|
Size = WatermarkSize.Scaled,
|
|
WidthPercent = 100,
|
|
HorizontalMarginPercent = 0,
|
|
VerticalMarginPercent = 0,
|
|
Opacity = 100,
|
|
Location = WatermarkLocation.TopLeft,
|
|
ImageSource = ChannelWatermarkImageSource.Resource,
|
|
Image = image
|
|
});
|
|
}
|
|
}
|
|
|
|
DateTimeOffset now = DateTimeOffset.Now;
|
|
|
|
var duration = TimeSpan.FromSeconds(Math.Min(version.Duration.TotalSeconds, 30));
|
|
if (duration <= TimeSpan.Zero)
|
|
{
|
|
duration = TimeSpan.FromSeconds(30);
|
|
}
|
|
|
|
// we cannot burst live input
|
|
bool hlsRealtime = mediaItem is RemoteStream { IsLive: true };
|
|
|
|
TimeSpan inPoint = TimeSpan.Zero;
|
|
TimeSpan outPoint = duration;
|
|
if (!hlsRealtime)
|
|
{
|
|
foreach (int seekSeconds in request.SeekSeconds)
|
|
{
|
|
inPoint = TimeSpan.FromSeconds(seekSeconds);
|
|
if (inPoint > version.Duration)
|
|
{
|
|
inPoint = version.Duration - duration;
|
|
}
|
|
|
|
if (inPoint + duration > version.Duration)
|
|
{
|
|
duration = version.Duration - inPoint;
|
|
}
|
|
|
|
outPoint = inPoint + duration;
|
|
}
|
|
}
|
|
|
|
List<GraphicsElement> graphicsElements = await dbContext.GraphicsElements
|
|
.Where(ge => request.GraphicsElementIds.Contains(ge.Id))
|
|
.ToListAsync();
|
|
|
|
PlayoutItemResult playoutItemResult = await ffmpegProcessService.ForPlayoutItem(
|
|
ffmpegPath,
|
|
ffprobePath,
|
|
true,
|
|
channel,
|
|
videoVersion,
|
|
new MediaItemAudioVersion(mediaItem, version),
|
|
videoPath,
|
|
mediaPath,
|
|
_ => GetSelectedSubtitle(mediaItem, request),
|
|
string.Empty,
|
|
string.Empty,
|
|
string.Empty,
|
|
SUBTITLE_MODE,
|
|
now,
|
|
now + duration,
|
|
now,
|
|
watermarks,
|
|
Option<ChannelWatermark>.None,
|
|
graphicsElements.Map(ge => new PlayoutItemGraphicsElement { GraphicsElement = ge }).ToList(),
|
|
ffmpegProfile.VaapiDisplay,
|
|
ffmpegProfile.VaapiDriver,
|
|
ffmpegProfile.VaapiDevice,
|
|
Option<int>.None,
|
|
hlsRealtime,
|
|
mediaItem is RemoteStream { IsLive: true } ? StreamInputKind.Live : StreamInputKind.Vod,
|
|
FillerKind.None,
|
|
inPoint,
|
|
outPoint,
|
|
channelStartTime: DateTimeOffset.Now,
|
|
0,
|
|
None,
|
|
false,
|
|
FileSystemLayout.TranscodeTroubleshootingFolder,
|
|
_ => { });
|
|
|
|
return playoutItemResult;
|
|
}
|
|
|
|
private static async Task<List<Subtitle>> GetSelectedSubtitle(
|
|
MediaItem mediaItem,
|
|
PrepareTroubleshootingPlayback request)
|
|
{
|
|
if (request.SubtitleId is not null)
|
|
{
|
|
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([]),
|
|
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");
|
|
}
|
|
|
|
allSubtitles.RemoveAll(s => s.Id != request.SubtitleId.Value);
|
|
|
|
foreach (Subtitle subtitle in allSubtitles)
|
|
{
|
|
// pretend subtitle is forced
|
|
subtitle.Forced = true;
|
|
return [subtitle];
|
|
}
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
private static async Task<Validation<BaseError, Tuple<MediaItem, string, string, FFmpegProfile>>> Validate(
|
|
TvContext dbContext,
|
|
PrepareTroubleshootingPlayback request) =>
|
|
(await MediaItemMustExist(dbContext, request),
|
|
await FFmpegPathMustExist(dbContext),
|
|
await FFprobePathMustExist(dbContext),
|
|
await FFmpegProfileMustExist(dbContext, request))
|
|
.Apply((mediaItem, ffmpegPath, ffprobePath, ffmpegProfile) =>
|
|
Tuple(mediaItem, ffmpegPath, ffprobePath, ffmpegProfile));
|
|
|
|
private static async Task<Validation<BaseError, MediaItem>> MediaItemMustExist(
|
|
TvContext dbContext,
|
|
PrepareTroubleshootingPlayback request) =>
|
|
await dbContext.MediaItems
|
|
.AsNoTracking()
|
|
.Include(mi => (mi as Episode).EpisodeMetadata)
|
|
.ThenInclude(em => em.Subtitles)
|
|
.Include(mi => (mi as Episode).MediaVersions)
|
|
.ThenInclude(mv => mv.MediaFiles)
|
|
.Include(mi => (mi as Episode).MediaVersions)
|
|
.ThenInclude(mv => mv.Streams)
|
|
.Include(mi => (mi as Episode).Season)
|
|
.ThenInclude(s => s.Show)
|
|
.ThenInclude(s => s.ShowMetadata)
|
|
.Include(mi => (mi as Movie).MovieMetadata)
|
|
.ThenInclude(mm => mm.Subtitles)
|
|
.Include(mi => (mi as Movie).MediaVersions)
|
|
.ThenInclude(mv => mv.MediaFiles)
|
|
.Include(mi => (mi as Movie).MediaVersions)
|
|
.ThenInclude(mv => mv.Streams)
|
|
.Include(mi => (mi as MusicVideo).MusicVideoMetadata)
|
|
.ThenInclude(mvm => mvm.Subtitles)
|
|
.Include(mi => (mi as MusicVideo).MusicVideoMetadata)
|
|
.ThenInclude(mvm => mvm.Artists)
|
|
.Include(mi => (mi as MusicVideo).MusicVideoMetadata)
|
|
.ThenInclude(mvm => mvm.Studios)
|
|
.Include(mi => (mi as MusicVideo).MusicVideoMetadata)
|
|
.ThenInclude(mvm => mvm.Directors)
|
|
.Include(mi => (mi as MusicVideo).MediaVersions)
|
|
.ThenInclude(mv => mv.MediaFiles)
|
|
.Include(mi => (mi as MusicVideo).MediaVersions)
|
|
.ThenInclude(mv => mv.Streams)
|
|
.Include(mi => (mi as MusicVideo).Artist)
|
|
.ThenInclude(mv => mv.ArtistMetadata)
|
|
.Include(mi => (mi as OtherVideo).OtherVideoMetadata)
|
|
.ThenInclude(ovm => ovm.Subtitles)
|
|
.Include(mi => (mi as OtherVideo).MediaVersions)
|
|
.ThenInclude(ov => ov.MediaFiles)
|
|
.Include(mi => (mi as OtherVideo).MediaVersions)
|
|
.ThenInclude(ov => ov.Streams)
|
|
.Include(mi => (mi as Song).MediaVersions)
|
|
.ThenInclude(mv => mv.MediaFiles)
|
|
.Include(mi => (mi as Song).MediaVersions)
|
|
.ThenInclude(mv => mv.Streams)
|
|
.Include(mi => (mi as Song).SongMetadata)
|
|
.ThenInclude(sm => sm.Artwork)
|
|
.Include(mi => (mi as Image).MediaVersions)
|
|
.ThenInclude(mv => mv.MediaFiles)
|
|
.Include(mi => (mi as Image).MediaVersions)
|
|
.ThenInclude(mv => mv.Streams)
|
|
.Include(mi => (mi as Image).ImageMetadata)
|
|
.Include(mi => (mi as RemoteStream).MediaVersions)
|
|
.ThenInclude(mv => mv.MediaFiles)
|
|
.Include(mi => (mi as RemoteStream).MediaVersions)
|
|
.ThenInclude(mv => mv.Streams)
|
|
.Include(mi => (mi as RemoteStream).RemoteStreamMetadata)
|
|
.SelectOneAsync(mi => mi.Id, mi => mi.Id == request.MediaItemId)
|
|
.Map(o => o.ToValidation<BaseError>(new UnableToLocatePlayoutItem()));
|
|
|
|
private static Task<Validation<BaseError, string>> FFmpegPathMustExist(TvContext dbContext) =>
|
|
dbContext.ConfigElements.GetValue<string>(ConfigElementKey.FFmpegPath)
|
|
.FilterT(File.Exists)
|
|
.Map(maybePath => maybePath.ToValidation<BaseError>("FFmpeg path does not exist on filesystem"));
|
|
|
|
private static Task<Validation<BaseError, string>> FFprobePathMustExist(TvContext dbContext) =>
|
|
dbContext.ConfigElements.GetValue<string>(ConfigElementKey.FFprobePath)
|
|
.FilterT(File.Exists)
|
|
.Map(maybePath => maybePath.ToValidation<BaseError>("FFprobe path does not exist on filesystem"));
|
|
|
|
private static Task<Validation<BaseError, FFmpegProfile>> FFmpegProfileMustExist(
|
|
TvContext dbContext,
|
|
PrepareTroubleshootingPlayback request) =>
|
|
dbContext.FFmpegProfiles
|
|
.Include(p => p.Resolution)
|
|
.SelectOneAsync(p => p.Id, p => p.Id == request.FFmpegProfileId)
|
|
.Map(o => o.ToValidation<BaseError>($"FFmpegProfile {request.FFmpegProfileId} does not exist"));
|
|
|
|
private async Task<string> GetMediaItemPath(
|
|
TvContext dbContext,
|
|
MediaItem mediaItem)
|
|
{
|
|
string path = await GetLocalPath(mediaItem);
|
|
|
|
// check filesystem first
|
|
if (localFileSystem.FileExists(path))
|
|
{
|
|
if (mediaItem is RemoteStream remoteStream)
|
|
{
|
|
path = !string.IsNullOrWhiteSpace(remoteStream.Url)
|
|
? remoteStream.Url
|
|
: $"http://localhost:{Settings.StreamingPort}/ffmpeg/remote-stream/{remoteStream.Id}";
|
|
}
|
|
|
|
return path;
|
|
}
|
|
|
|
// attempt to remotely stream plex
|
|
MediaFile file = 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 { 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 $"http://localhost:{Settings.StreamingPort}/media/plex/{plexMediaSourceId}/{pmf.Key}";
|
|
}
|
|
|
|
break;
|
|
}
|
|
|
|
// attempt to remotely stream jellyfin
|
|
Option<string> jellyfinItemId = mediaItem switch
|
|
{
|
|
JellyfinEpisode e => e.ItemId,
|
|
JellyfinMovie m => m.ItemId,
|
|
_ => None
|
|
};
|
|
|
|
foreach (string itemId in jellyfinItemId)
|
|
{
|
|
return $"http://localhost:{Settings.StreamingPort}/media/jellyfin/{itemId}";
|
|
}
|
|
|
|
// attempt to remotely stream emby
|
|
Option<string> embyItemId = mediaItem switch
|
|
{
|
|
EmbyEpisode e => e.ItemId,
|
|
EmbyMovie m => m.ItemId,
|
|
_ => None
|
|
};
|
|
|
|
foreach (string itemId in embyItemId)
|
|
{
|
|
return $"http://localhost:{Settings.StreamingPort}/media/emby/{itemId}";
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private async Task<string> GetLocalPath(MediaItem mediaItem)
|
|
{
|
|
MediaVersion version = mediaItem.GetHeadVersion();
|
|
MediaFile file = version.MediaFiles.Head();
|
|
|
|
string path = file.Path;
|
|
return 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
|
|
};
|
|
}
|
|
}
|