diff --git a/CHANGELOG.md b/CHANGELOG.md index d71ec79db..fd885f9db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Remote stream definitions (yaml files) can now contain `title`, `plot`, `year` and `content_rating` fields - Remote streams can now have thumbnails (same name as yaml file but with image extension) - This metadata will be used in generated XMLTV entries, using a template that can be customized like other media kinds +- Add `Download Media Sample` button to playback troubleshooting + - This button will extract up to 30 seconds of the media item and zip it ### Fixed - Fix startup on systems unsupported by NvEncSharp @@ -48,7 +50,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - AMD VAAPI: - work around buggy ffmpeg behavior where hevc_vaapi encoder with RadeonSI driver incorrectly outputs height of 1088 instead of 1080 - fix green padding when encoding h264 using main profile - +- Automatically kill playback troubleshooting ffmpeg process if it hasn't completed after two minutes ### Changed - No longer round framerate to nearest integer when normalizing framerate diff --git a/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs b/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs index 80a9a1fa2..0251ddebb 100644 --- a/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs +++ b/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs @@ -771,7 +771,11 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< PlayoutItem playoutItem, CancellationToken cancellationToken) { - string path = await GetPlayoutItemPath(playoutItem, cancellationToken); + string path = await playoutItem.MediaItem.GetLocalPath( + _plexPathReplacementService, + _jellyfinPathReplacementService, + _embyPathReplacementService, + cancellationToken); if (_isDebugNoSync) { @@ -853,42 +857,6 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< return new PlayoutItemDoesNotExistOnDisk(path); } - private async Task GetPlayoutItemPath(PlayoutItem playoutItem, CancellationToken cancellationToken) - { - 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, - cancellationToken), - PlexEpisode plexEpisode => await _plexPathReplacementService.GetReplacementPlexPath( - plexEpisode.LibraryPathId, - path, - cancellationToken), - JellyfinMovie jellyfinMovie => await _jellyfinPathReplacementService.GetReplacementJellyfinPath( - jellyfinMovie.LibraryPathId, - path, - cancellationToken), - JellyfinEpisode jellyfinEpisode => await _jellyfinPathReplacementService.GetReplacementJellyfinPath( - jellyfinEpisode.LibraryPathId, - path, - cancellationToken), - EmbyMovie embyMovie => await _embyPathReplacementService.GetReplacementEmbyPath( - embyMovie.LibraryPathId, - path, - cancellationToken), - EmbyEpisode embyEpisode => await _embyPathReplacementService.GetReplacementEmbyPath( - embyEpisode.LibraryPathId, - path, - cancellationToken), - _ => path - }; - } - private DeadAirFallbackResult GetDecoDeadAirFallback(Playout playout, DateTimeOffset now) { DecoEntries decoEntries = _decoSelector.GetDecoEntries(playout, now); diff --git a/ErsatzTV.Application/Troubleshooting/Commands/ArchiveMediaSample.cs b/ErsatzTV.Application/Troubleshooting/Commands/ArchiveMediaSample.cs new file mode 100644 index 000000000..feaa35486 --- /dev/null +++ b/ErsatzTV.Application/Troubleshooting/Commands/ArchiveMediaSample.cs @@ -0,0 +1,3 @@ +namespace ErsatzTV.Application.Troubleshooting; + +public record ArchiveMediaSample(int MediaItemId) : IRequest>; diff --git a/ErsatzTV.Application/Troubleshooting/Commands/ArchiveMediaSampleHandler.cs b/ErsatzTV.Application/Troubleshooting/Commands/ArchiveMediaSampleHandler.cs new file mode 100644 index 000000000..3850aee40 --- /dev/null +++ b/ErsatzTV.Application/Troubleshooting/Commands/ArchiveMediaSampleHandler.cs @@ -0,0 +1,169 @@ +using System.IO.Abstractions; +using System.IO.Compression; +using CliWrap; +using CliWrap.Buffered; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Emby; +using ErsatzTV.Core.Interfaces.Jellyfin; +using ErsatzTV.Core.Interfaces.Plex; +using ErsatzTV.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace ErsatzTV.Application.Troubleshooting; + +public class ArchiveMediaSampleHandler( + IDbContextFactory dbContextFactory, + IPlexPathReplacementService plexPathReplacementService, + IJellyfinPathReplacementService jellyfinPathReplacementService, + IEmbyPathReplacementService embyPathReplacementService, + IFileSystem fileSystem, + ILogger logger) + : TroubleshootingHandlerBase( + plexPathReplacementService, + jellyfinPathReplacementService, + embyPathReplacementService, + fileSystem), IRequestHandler> +{ + private readonly IFileSystem _fileSystem = fileSystem; + + public async Task> Handle(ArchiveMediaSample request, CancellationToken cancellationToken) + { + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + Validation> validation = await Validate( + dbContext, + request, + cancellationToken); + + foreach ((MediaItem mediaItem, string ffmpegPath) in validation.SuccessToSeq()) + { + Option maybeMediaSample = await GetMediaSample( + request, + dbContext, + mediaItem, + ffmpegPath, + cancellationToken); + + foreach (string mediaSample in maybeMediaSample) + { + return await GetArchive(request, mediaSample, cancellationToken); + } + } + + return Option.None; + } + + private async Task> GetArchive( + ArchiveMediaSample request, + string mediaSample, + CancellationToken cancellationToken) + { + string tempFile = Path.GetTempFileName(); + + try + { + await using ZipArchive zipArchive = await ZipFile.OpenAsync( + tempFile, + ZipArchiveMode.Update, + cancellationToken); + + string fileName = Path.GetFileName(mediaSample); + await zipArchive.CreateEntryFromFileAsync(mediaSample, fileName, cancellationToken); + + return tempFile; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to archive media sample for media item {MediaItemId}", request.MediaItemId); + _fileSystem.File.Delete(tempFile); + } + + return Option.None; + } + + private async Task> GetMediaSample( + ArchiveMediaSample request, + TvContext dbContext, + MediaItem mediaItem, + string ffmpegPath, + CancellationToken cancellationToken) + { + try + { + string mediaItemPath = await GetMediaItemPath(dbContext, mediaItem, cancellationToken); + if (string.IsNullOrEmpty(mediaItemPath)) + { + logger.LogWarning( + "Media item {MediaItemId} does not exist on disk; cannot extract media sample.", + mediaItem.Id); + + return Option.None; + } + + string extension = Path.GetExtension(mediaItemPath); + if (string.IsNullOrWhiteSpace(extension)) + { + // this can help with remote servers (e.g. mediaItemPath is http://localhost/whatever) + extension = Path.GetExtension(await GetLocalPath(mediaItem, cancellationToken)); + + if (string.IsNullOrWhiteSpace(extension)) + { + // fall back to mkv when extension is otherwise unknown + extension = "mkv"; + } + } + + string tempPath = Path.GetTempPath(); + string fileName = Path.ChangeExtension(Guid.NewGuid().ToString(), extension); + string outputPath = Path.Combine(tempPath, fileName); + + List arguments = + [ + "-nostdin", + "-i", mediaItemPath, + "-t", "30", + "-map", "0", + "-c", "copy", + outputPath + ]; + + using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(2)); + using var linkedTokenSource = + CancellationTokenSource.CreateLinkedTokenSource(cts.Token, cancellationToken); + + logger.LogDebug("media sample arguments {Arguments}", arguments); + + BufferedCommandResult result = await Cli.Wrap(ffmpegPath) + .WithArguments(arguments) + .WithWorkingDirectory(FileSystemLayout.FontsCacheFolder) + .WithStandardErrorPipe(PipeTarget.Null) + .WithValidation(CommandResultValidation.None) + .ExecuteBufferedAsync(linkedTokenSource.Token); + + if (result.IsSuccess) + { + return outputPath; + } + + logger.LogWarning( + "Failed to extract media sample for media item {MediaItemId} - exit code {ExitCode}", + request.MediaItemId, + result.ExitCode); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to extract media sample for media item {MediaItemId}", request.MediaItemId); + } + + return Option.None; + } + + private static async Task>> Validate( + TvContext dbContext, + ArchiveMediaSample request, + CancellationToken cancellationToken) => + (await MediaItemMustExist(dbContext, request.MediaItemId, cancellationToken), + await FFmpegPathMustExist(dbContext, cancellationToken)) + .Apply((mediaItem, ffmpegPath) => Tuple(mediaItem, ffmpegPath)); +} diff --git a/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs b/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs index 7f94f29a8..cb1ba5bec 100644 --- a/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs +++ b/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs @@ -1,10 +1,8 @@ using System.IO.Abstractions; -using Dapper; using ErsatzTV.Application.Streaming; 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; @@ -39,7 +37,11 @@ public class PrepareTroubleshootingPlaybackHandler( IMediator mediator, LoggingLevelSwitches loggingLevelSwitches, ILogger logger) - : IRequestHandler> + : TroubleshootingHandlerBase( + plexPathReplacementService, + jellyfinPathReplacementService, + embyPathReplacementService, + fileSystem), IRequestHandler> { public async Task> Handle( PrepareTroubleshootingPlayback request, @@ -105,7 +107,10 @@ public class PrepareTroubleshootingPlaybackHandler( entityLocker.UnlockTroubleshootingPlayback(); } - return result.Map(model => new PlayoutItemResult(model.Process, model.GraphicsEngineContext, model.MediaItemId)); + return result.Map(model => new PlayoutItemResult( + model.Process, + model.GraphicsEngineContext, + model.MediaItemId)); } if (maybeChannel.IsNone) @@ -375,80 +380,13 @@ public class PrepareTroubleshootingPlaybackHandler( TvContext dbContext, PrepareTroubleshootingPlayback request, CancellationToken cancellationToken) => - (await MediaItemMustExist(dbContext, request, cancellationToken), + (await MediaItemMustExist(dbContext, request.MediaItemId, cancellationToken), await FFmpegPathMustExist(dbContext, cancellationToken), await FFprobePathMustExist(dbContext, cancellationToken), await FFmpegProfileMustExist(dbContext, request, cancellationToken)) .Apply((mediaItem, ffmpegPath, ffprobePath, ffmpegProfile) => Tuple(mediaItem, ffmpegPath, ffprobePath, ffmpegProfile)); - private static async Task> MediaItemMustExist( - TvContext dbContext, - PrepareTroubleshootingPlayback request, - CancellationToken cancellationToken) => - 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, cancellationToken) - .Map(o => o.ToValidation(new UnableToLocatePlayoutItem())); - - private static Task> FFmpegPathMustExist( - TvContext dbContext, - CancellationToken cancellationToken) => - dbContext.ConfigElements.GetValue(ConfigElementKey.FFmpegPath, cancellationToken) - .FilterT(File.Exists) - .Map(maybePath => maybePath.ToValidation("FFmpeg path does not exist on filesystem")); - private static Task> FFprobePathMustExist( TvContext dbContext, CancellationToken cancellationToken) => @@ -464,115 +402,4 @@ public class PrepareTroubleshootingPlaybackHandler( .Include(p => p.Resolution) .SelectOneAsync(p => p.Id, p => p.Id == request.FFmpegProfileId, cancellationToken) .Map(o => o.ToValidation($"FFmpegProfile {request.FFmpegProfileId} does not exist")); - - private async Task GetMediaItemPath( - TvContext dbContext, - MediaItem mediaItem, - CancellationToken cancellationToken) - { - string path = await GetLocalPath(mediaItem, cancellationToken); - - // check filesystem first - if (fileSystem.File.Exists(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 maybeId = await dbContext.Connection.QuerySingleOrDefaultAsync( - @"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 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 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 GetLocalPath(MediaItem mediaItem, CancellationToken cancellationToken) - { - MediaVersion version = mediaItem.GetHeadVersion(); - MediaFile file = version.MediaFiles.Head(); - - string path = file.Path; - return mediaItem switch - { - PlexMovie plexMovie => await plexPathReplacementService.GetReplacementPlexPath( - plexMovie.LibraryPathId, - path, - cancellationToken), - PlexEpisode plexEpisode => await plexPathReplacementService.GetReplacementPlexPath( - plexEpisode.LibraryPathId, - path, - cancellationToken), - JellyfinMovie jellyfinMovie => await jellyfinPathReplacementService.GetReplacementJellyfinPath( - jellyfinMovie.LibraryPathId, - path, - cancellationToken), - JellyfinEpisode jellyfinEpisode => await jellyfinPathReplacementService.GetReplacementJellyfinPath( - jellyfinEpisode.LibraryPathId, - path, - cancellationToken), - EmbyMovie embyMovie => await embyPathReplacementService.GetReplacementEmbyPath( - embyMovie.LibraryPathId, - path, - cancellationToken), - EmbyEpisode embyEpisode => await embyPathReplacementService.GetReplacementEmbyPath( - embyEpisode.LibraryPathId, - path, - cancellationToken), - _ => path - }; - } } diff --git a/ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlaybackHandler.cs b/ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlaybackHandler.cs index f700bf308..b4724e8da 100644 --- a/ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlaybackHandler.cs +++ b/ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlaybackHandler.cs @@ -120,7 +120,8 @@ public partial class StartTroubleshootingPlaybackHandler( try { - using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(2)); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, cts.Token); Command processWithPipe = request.PlayoutItemResult.Process; foreach (GraphicsEngineContext graphicsEngineContext in request.PlayoutItemResult.GraphicsEngineContext) diff --git a/ErsatzTV.Application/Troubleshooting/Commands/TroubleshootingHandlerBase.cs b/ErsatzTV.Application/Troubleshooting/Commands/TroubleshootingHandlerBase.cs new file mode 100644 index 000000000..e4d0e2c6c --- /dev/null +++ b/ErsatzTV.Application/Troubleshooting/Commands/TroubleshootingHandlerBase.cs @@ -0,0 +1,167 @@ +using System.IO.Abstractions; +using Dapper; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Errors; +using ErsatzTV.Core.Extensions; +using ErsatzTV.Core.Interfaces.Emby; +using ErsatzTV.Core.Interfaces.Jellyfin; +using ErsatzTV.Core.Interfaces.Plex; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Infrastructure.Extensions; +using Microsoft.EntityFrameworkCore; + +namespace ErsatzTV.Application.Troubleshooting; + +public abstract class TroubleshootingHandlerBase( + IPlexPathReplacementService plexPathReplacementService, + IJellyfinPathReplacementService jellyfinPathReplacementService, + IEmbyPathReplacementService embyPathReplacementService, + IFileSystem fileSystem) +{ + protected static async Task> MediaItemMustExist( + TvContext dbContext, + int mediaItemId, + CancellationToken cancellationToken) => + 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) + .AsSplitQuery() + .SingleOrDefaultAsync(mi => mi.Id == mediaItemId, cancellationToken) + .Map(Optional) + .Map(o => o.ToValidation(new UnableToLocatePlayoutItem())); + + protected static Task> FFmpegPathMustExist( + TvContext dbContext, + CancellationToken cancellationToken) => + dbContext.ConfigElements.GetValue(ConfigElementKey.FFmpegPath, cancellationToken) + .FilterT(File.Exists) + .Map(maybePath => maybePath.ToValidation("FFmpeg path does not exist on filesystem")); + + protected Task GetLocalPath(MediaItem mediaItem, CancellationToken cancellationToken) => + mediaItem.GetLocalPath( + plexPathReplacementService, + jellyfinPathReplacementService, + embyPathReplacementService, + cancellationToken); + + protected async Task GetMediaItemPath( + TvContext dbContext, + MediaItem mediaItem, + CancellationToken cancellationToken) + { + string path = await GetLocalPath(mediaItem, cancellationToken); + + // check filesystem first + if (fileSystem.File.Exists(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 maybeId = await dbContext.Connection.QuerySingleOrDefaultAsync( + @"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) + { + return $"http://localhost:{Settings.StreamingPort}/media/plex/{plexMediaSourceId}/{pmf.Key}"; + } + + break; + } + + // attempt to remotely stream jellyfin + Option 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 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; + } +} diff --git a/ErsatzTV.Core/Extensions/MediaItemExtensions.cs b/ErsatzTV.Core/Extensions/MediaItemExtensions.cs index d77528859..0bd638a9f 100644 --- a/ErsatzTV.Core/Extensions/MediaItemExtensions.cs +++ b/ErsatzTV.Core/Extensions/MediaItemExtensions.cs @@ -7,101 +7,103 @@ namespace ErsatzTV.Core.Extensions; public static class MediaItemExtensions { - public static Option GetNonZeroDuration(this MediaItem mediaItem) + extension(MediaItem mediaItem) { - Option maybeDuration = mediaItem switch + public Option GetNonZeroDuration() { - Movie m => m.MediaVersions.HeadOrNone().Map(v => v.Duration), - Episode e => e.MediaVersions.HeadOrNone().Map(v => v.Duration), - MusicVideo mv => mv.MediaVersions.HeadOrNone().Map(v => v.Duration), - OtherVideo ov => ov.MediaVersions.HeadOrNone().Map(v => v.Duration), - Song s => s.MediaVersions.HeadOrNone().Map(v => v.Duration), - ChapterMediaItem c => c.MediaVersion.Duration, - _ => None - }; + Option maybeDuration = mediaItem switch + { + Movie m => m.MediaVersions.HeadOrNone().Map(v => v.Duration), + Episode e => e.MediaVersions.HeadOrNone().Map(v => v.Duration), + MusicVideo mv => mv.MediaVersions.HeadOrNone().Map(v => v.Duration), + OtherVideo ov => ov.MediaVersions.HeadOrNone().Map(v => v.Duration), + Song s => s.MediaVersions.HeadOrNone().Map(v => v.Duration), + ChapterMediaItem c => c.MediaVersion.Duration, + _ => None + }; - // zero duration is invalid, so return none in that case - return maybeDuration.Any(duration => duration == TimeSpan.Zero) ? Option.None : maybeDuration; - } + // zero duration is invalid, so return none in that case + return maybeDuration.Any(duration => duration == TimeSpan.Zero) ? Option.None : maybeDuration; + } - public static TimeSpan GetDurationForPlayout(this MediaItem mediaItem) - { - if (mediaItem is Image image) + public TimeSpan GetDurationForPlayout() { - return TimeSpan.FromSeconds(image.ImageMetadata.Head().DurationSeconds ?? Image.DefaultSeconds); - } + if (mediaItem is Image image) + { + return TimeSpan.FromSeconds(image.ImageMetadata.Head().DurationSeconds ?? Image.DefaultSeconds); + } - MediaVersion version = mediaItem.GetHeadVersion(); + MediaVersion version = mediaItem.GetHeadVersion(); - if (mediaItem is RemoteStream remoteStream) - { - return version.Duration == TimeSpan.Zero && remoteStream.Duration.HasValue - ? remoteStream.Duration.Value - : version.Duration; + if (mediaItem is RemoteStream remoteStream) + { + return version.Duration == TimeSpan.Zero && remoteStream.Duration.HasValue + ? remoteStream.Duration.Value + : version.Duration; + } + + return version.Duration; } - return version.Duration; - } + public MediaVersion GetHeadVersion() => + mediaItem switch + { + Movie m => m.MediaVersions.Head(), + Episode e => e.MediaVersions.Head(), + MusicVideo mv => mv.MediaVersions.Head(), + OtherVideo ov => ov.MediaVersions.Head(), + Song s => s.MediaVersions.Head(), + Image i => i.MediaVersions.Head(), + RemoteStream rs => rs.MediaVersions.Head(), + ChapterMediaItem c => c.MediaVersion, + _ => throw new ArgumentOutOfRangeException(nameof(mediaItem)) + }; - public static MediaVersion GetHeadVersion(this MediaItem mediaItem) => - mediaItem switch + public async Task GetLocalPath( + IPlexPathReplacementService plexPathReplacementService, + IJellyfinPathReplacementService jellyfinPathReplacementService, + IEmbyPathReplacementService embyPathReplacementService, + CancellationToken cancellationToken, + bool log = true) { - Movie m => m.MediaVersions.Head(), - Episode e => e.MediaVersions.Head(), - MusicVideo mv => mv.MediaVersions.Head(), - OtherVideo ov => ov.MediaVersions.Head(), - Song s => s.MediaVersions.Head(), - Image i => i.MediaVersions.Head(), - RemoteStream rs => rs.MediaVersions.Head(), - ChapterMediaItem c => c.MediaVersion, - _ => throw new ArgumentOutOfRangeException(nameof(mediaItem)) - }; - - public static async Task GetLocalPath( - this MediaItem mediaItem, - IPlexPathReplacementService plexPathReplacementService, - IJellyfinPathReplacementService jellyfinPathReplacementService, - IEmbyPathReplacementService embyPathReplacementService, - CancellationToken cancellationToken, - bool log = true) - { - MediaVersion version = mediaItem.GetHeadVersion(); + MediaVersion version = mediaItem.GetHeadVersion(); - MediaFile file = version.MediaFiles.Head(); - string path = file.Path; - return mediaItem switch - { - PlexMovie plexMovie => await plexPathReplacementService.GetReplacementPlexPath( - plexMovie.LibraryPathId, - path, - cancellationToken, - log), - PlexEpisode plexEpisode => await plexPathReplacementService.GetReplacementPlexPath( - plexEpisode.LibraryPathId, - path, - cancellationToken, - log), - JellyfinMovie jellyfinMovie => await jellyfinPathReplacementService.GetReplacementJellyfinPath( - jellyfinMovie.LibraryPathId, - path, - cancellationToken, - log), - JellyfinEpisode jellyfinEpisode => await jellyfinPathReplacementService.GetReplacementJellyfinPath( - jellyfinEpisode.LibraryPathId, - path, - cancellationToken, - log), - EmbyMovie embyMovie => await embyPathReplacementService.GetReplacementEmbyPath( - embyMovie.LibraryPathId, - path, - cancellationToken, - log), - EmbyEpisode embyEpisode => await embyPathReplacementService.GetReplacementEmbyPath( - embyEpisode.LibraryPathId, - path, - cancellationToken, - log), - _ => path - }; + MediaFile file = version.MediaFiles.Head(); + string path = file.Path; + return mediaItem switch + { + PlexMovie plexMovie => await plexPathReplacementService.GetReplacementPlexPath( + plexMovie.LibraryPathId, + path, + cancellationToken, + log), + PlexEpisode plexEpisode => await plexPathReplacementService.GetReplacementPlexPath( + plexEpisode.LibraryPathId, + path, + cancellationToken, + log), + JellyfinMovie jellyfinMovie => await jellyfinPathReplacementService.GetReplacementJellyfinPath( + jellyfinMovie.LibraryPathId, + path, + cancellationToken, + log), + JellyfinEpisode jellyfinEpisode => await jellyfinPathReplacementService.GetReplacementJellyfinPath( + jellyfinEpisode.LibraryPathId, + path, + cancellationToken, + log), + EmbyMovie embyMovie => await embyPathReplacementService.GetReplacementEmbyPath( + embyMovie.LibraryPathId, + path, + cancellationToken, + log), + EmbyEpisode embyEpisode => await embyPathReplacementService.GetReplacementEmbyPath( + embyEpisode.LibraryPathId, + path, + cancellationToken, + log), + _ => path + }; + } } } diff --git a/ErsatzTV/Controllers/Api/TroubleshootController.cs b/ErsatzTV/Controllers/Api/TroubleshootController.cs index 7b3360411..8eabe6a0b 100644 --- a/ErsatzTV/Controllers/Api/TroubleshootController.cs +++ b/ErsatzTV/Controllers/Api/TroubleshootController.cs @@ -164,7 +164,13 @@ public class TroubleshootController( Option maybeArchivePath = await mediator.Send(new ArchiveTroubleshootingResults(), cancellationToken); foreach (string archivePath in maybeArchivePath) { - FileStream fs = System.IO.File.OpenRead(archivePath); + var fs = new FileStream( + archivePath, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + 4096, + FileOptions.DeleteOnClose); return File( fs, "application/zip", @@ -173,4 +179,27 @@ public class TroubleshootController( return NotFound(); } + + [HttpHead("api/troubleshoot/playback/sample/{mediaItemId:int}")] + [HttpGet("api/troubleshoot/playback/sample/{mediaItemId:int}")] + public async Task TroubleshootPlaybackSample(int mediaItemId, CancellationToken cancellationToken) + { + Option maybeArchivePath = await mediator.Send(new ArchiveMediaSample(mediaItemId), cancellationToken); + foreach (string archivePath in maybeArchivePath) + { + var fs = new FileStream( + archivePath, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + 4096, + FileOptions.DeleteOnClose); + return File( + fs, + "application/zip", + $"ersatztv-media-sample-{DateTimeOffset.Now.ToUnixTimeSeconds()}.zip"); + } + + return NotFound(); + } } diff --git a/ErsatzTV/Pages/Troubleshooting/PlaybackTroubleshooting.razor b/ErsatzTV/Pages/Troubleshooting/PlaybackTroubleshooting.razor index e005930ad..4713c4765 100644 --- a/ErsatzTV/Pages/Troubleshooting/PlaybackTroubleshooting.razor +++ b/ErsatzTV/Pages/Troubleshooting/PlaybackTroubleshooting.razor @@ -22,14 +22,26 @@ - - Download Results - +
+ + Download Results + + @if (MediaItemId.HasValue) + { + + Download Media Sample + + } +
@@ -64,7 +76,7 @@
Stream Selector
- + @foreach (string selector in _streamSelectors) { @selector @@ -402,6 +414,11 @@ await JsRuntime.InvokeVoidAsync("window.open", "api/troubleshoot/playback/archive"); } + private async Task DownloadSample() + { + await JsRuntime.InvokeVoidAsync("window.open", $"api/troubleshoot/playback/sample/{MediaItemId}"); + } + private async Task HandleTroubleshootingCompleted(PlaybackTroubleshootingCompletedNotification result) { await InvokeAsync(async () => { await _logsField.SetText(string.Empty); });