From 578cdb1e14140b7f75518356573bf92c530e98c2 Mon Sep 17 00:00:00 2001 From: Jason Dove <1695733+jasongdove@users.noreply.github.com> Date: Thu, 17 Jul 2025 03:51:36 +0000 Subject: [PATCH] add playback troubleshooting tool (#2155) * support media info for more content types * add playback troubleshooting page * reorganize playback troubleshooting * fix watermarks and delay * update changelog --- CHANGELOG.md | 11 + .../MediaItems/MediaItemInfo.cs | 1 + .../Queries/GetMediaItemInfoHandler.cs | 37 ++- ErsatzTV.Application/Playouts/Mapper.cs | 24 +- ErsatzTV.Application/Scheduling/Mapper.cs | 2 +- ...layoutItemProcessByChannelNumberHandler.cs | 1 + .../Commands/ArchiveTroubleshootingResults.cs | 4 + .../ArchiveTroubleshootingResultsHandler.cs | 68 ++++ .../PrepareTroubleshootingPlayback.cs | 7 + .../PrepareTroubleshootingPlaybackHandler.cs | 296 ++++++++++++++++++ .../Commands/StartTroubleshootingPlayback.cs | 5 + .../StartTroubleshootingPlaybackHandler.cs | 33 ++ .../Errors/UnableToLocateMediaItem.cs | 8 + .../FFmpeg/FFmpegLibraryProcessService.cs | 12 +- .../FFmpeg/IFFmpegProcessService.cs | 1 + .../Interfaces/Locking/IEntityLocker.cs | 4 + .../PipelineBuilderBaseTests.cs | 12 +- ErsatzTV.FFmpeg/FFmpegState.cs | 6 +- .../OutputFormat/OutputFormatHls.cs | 27 +- .../Pipeline/PipelineBuilderBase.cs | 3 +- .../Locking/EntityLocker.cs | 28 ++ .../Core/FFmpeg/TranscodingTests.cs | 2 + .../Controllers/Api/TroubleshootController.cs | 86 +++++ ErsatzTV/Pages/PlaybackTroubleshooting.razor | 165 ++++++++++ ErsatzTV/Pages/Troubleshooting.razor | 101 +++--- ErsatzTV/Pages/Watermarks.razor | 2 +- ErsatzTV/Services/FFmpegWorkerService.cs | 8 + 27 files changed, 878 insertions(+), 76 deletions(-) create mode 100644 ErsatzTV.Application/Troubleshooting/Commands/ArchiveTroubleshootingResults.cs create mode 100644 ErsatzTV.Application/Troubleshooting/Commands/ArchiveTroubleshootingResultsHandler.cs create mode 100644 ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlayback.cs create mode 100644 ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs create mode 100644 ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlayback.cs create mode 100644 ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlaybackHandler.cs create mode 100644 ErsatzTV.Core/Errors/UnableToLocateMediaItem.cs create mode 100644 ErsatzTV/Controllers/Api/TroubleshootController.cs create mode 100644 ErsatzTV/Pages/PlaybackTroubleshooting.razor diff --git a/CHANGELOG.md b/CHANGELOG.md index 43694eb1..76467b7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -84,6 +84,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Add `ETV_MAXIMUM_UPLOAD_MB` environment variable to allow uploading large watermarks - Default value is 10 - Update ffmpeg health check to link to ErsatzTV-FFmpeg release that contains binaries for win64, linux64, linuxarm64 +- Add `Playback Troubleshooting` page + - This tool lets you play specific content without needing a test channel or schedule + - You can specify + - The media item id (found in ETV media info, and ETV movie URLs) + - The ffmpeg profile to use + - The watermark to use (if any) + - Clicking `Play` will play the specified content using the desired settings + - Clicking `Download Results` will generate a zip archive containing: + - The FFmpeg report of the playback attempt + - The media info for the content + - The `Troubleshooting` > `General` output ### Changed - Allow `Other Video` libraries and `Image` libraries to use the same folders diff --git a/ErsatzTV.Application/MediaItems/MediaItemInfo.cs b/ErsatzTV.Application/MediaItems/MediaItemInfo.cs index 0cc97d76..18f5d43c 100644 --- a/ErsatzTV.Application/MediaItems/MediaItemInfo.cs +++ b/ErsatzTV.Application/MediaItems/MediaItemInfo.cs @@ -4,6 +4,7 @@ namespace ErsatzTV.Application.MediaItems; public record MediaItemInfo( int Id, + string Title, string Kind, string LibraryKind, string ServerName, diff --git a/ErsatzTV.Application/MediaItems/Queries/GetMediaItemInfoHandler.cs b/ErsatzTV.Application/MediaItems/Queries/GetMediaItemInfoHandler.cs index 396a85d9..4d284be9 100644 --- a/ErsatzTV.Application/MediaItems/Queries/GetMediaItemInfoHandler.cs +++ b/ErsatzTV.Application/MediaItems/Queries/GetMediaItemInfoHandler.cs @@ -1,5 +1,6 @@ using ErsatzTV.Core; using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Errors; using ErsatzTV.Core.Extensions; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Extensions; @@ -40,10 +41,37 @@ public class GetMediaItemInfoHandler : IRequestHandler mv.Streams) .Include(i => (i as Episode).EpisodeMetadata) .ThenInclude(mv => mv.Subtitles) + .Include(i => (i as Episode).Season) + .ThenInclude(s => s.Show) + .ThenInclude(s => s.ShowMetadata) + .Include(i => (i as OtherVideo).OtherVideoMetadata) + .ThenInclude(mv => mv.Subtitles) + .Include(i => (i as OtherVideo).MediaVersions) + .ThenInclude(mv => mv.Chapters) + .Include(i => (i as OtherVideo).MediaVersions) + .ThenInclude(mv => mv.Streams) + .Include(i => (i as Image).ImageMetadata) + .ThenInclude(mv => mv.Subtitles) + .Include(i => (i as Image).MediaVersions) + .ThenInclude(mv => mv.Chapters) + .Include(i => (i as Image).MediaVersions) + .ThenInclude(mv => mv.Streams) + .Include(i => (i as Song).SongMetadata) + .ThenInclude(mv => mv.Subtitles) + .Include(i => (i as Song).MediaVersions) + .ThenInclude(mv => mv.Chapters) + .Include(i => (i as Song).MediaVersions) + .ThenInclude(mv => mv.Streams) + .Include(i => (i as MusicVideo).MusicVideoMetadata) + .ThenInclude(mv => mv.Subtitles) + .Include(i => (i as MusicVideo).MediaVersions) + .ThenInclude(mv => mv.Chapters) + .Include(i => (i as MusicVideo).MediaVersions) + .ThenInclude(mv => mv.Streams) .SelectOneAsync(i => i.Id, i => i.Id == request.Id) .MapT(Project); - return mediaItem.ToEither(BaseError.New("Unable to locate media item")); + return mediaItem.ToEither(new UnableToLocateMediaItem()); } catch (Exception ex) { @@ -53,6 +81,8 @@ public class GetMediaItemInfoHandler : IRequestHandler.None); + MediaVersion version = mediaItem.GetHeadVersion(); string serverName = mediaItem.LibraryPath.Library.MediaSource switch @@ -68,7 +98,9 @@ public class GetMediaItemInfoHandler : IRequestHandler subtitles = mediaItem switch { Movie m => m.MovieMetadata.Map(mm => mm.Subtitles).Flatten().ToList(), - Episode e => e.EpisodeMetadata.Map(mm => mm.Subtitles).Flatten().ToList(), + Episode e => e.EpisodeMetadata.Map(em => em.Subtitles).Flatten().ToList(), + MusicVideo mv => mv.MusicVideoMetadata.Map(mvm => mvm.Subtitles).Flatten().ToList(), + Song s => s.SongMetadata.Map(sm => sm.Subtitles).Flatten().ToList(), _ => [] }; @@ -96,6 +128,7 @@ public class GetMediaItemInfoHandler : IRequestHandler new( - GetDisplayTitle(playoutItem), + GetDisplayTitle(playoutItem.MediaItem, playoutItem.ChapterTitle), playoutItem.StartOffset, playoutItem.FinishOffset, playoutItem.GetDisplayDuration()); @@ -21,9 +21,11 @@ internal static class Mapper programScheduleAlternate.DaysOfMonth, programScheduleAlternate.MonthsOfYear); - internal static string GetDisplayTitle(PlayoutItem playoutItem) + internal static string GetDisplayTitle(MediaItem mediaItem, Option maybeChapterTitle) { - switch (playoutItem.MediaItem) + string chapterTitle = maybeChapterTitle.IfNone(string.Empty); + + switch (mediaItem) { case Episode e: string showTitle = e.Season.Show.ShowMetadata.HeadOrNone() @@ -37,9 +39,9 @@ internal static class Mapper var numbersString = $"e{string.Join('e', episodeNumbers.Map(n => $"{n:00}"))}"; var titlesString = $"{string.Join('/', episodeTitles)}"; - if (!string.IsNullOrWhiteSpace(playoutItem.ChapterTitle)) + if (!string.IsNullOrWhiteSpace(chapterTitle)) { - titlesString += $" ({playoutItem.ChapterTitle})"; + titlesString += $" ({chapterTitle})"; } return $"{showTitle}s{e.Season.SeasonNumber:00}{numbersString} - {titlesString}"; @@ -50,16 +52,16 @@ internal static class Mapper .Map(am => $"{am.Title} - ").IfNone(string.Empty); return mv.MusicVideoMetadata.HeadOrNone() .Map(mvm => $"{artistName}{mvm.Title}") - .Map(s => string.IsNullOrWhiteSpace(playoutItem.ChapterTitle) + .Map(s => string.IsNullOrWhiteSpace(chapterTitle) ? s - : $"{s} ({playoutItem.ChapterTitle})") + : $"{s} ({chapterTitle})") .IfNone("[unknown music video]"); case OtherVideo ov: return ov.OtherVideoMetadata.HeadOrNone() .Map(ovm => ovm.Title ?? string.Empty) - .Map(s => string.IsNullOrWhiteSpace(playoutItem.ChapterTitle) + .Map(s => string.IsNullOrWhiteSpace(chapterTitle) ? s - : $"{s} ({playoutItem.ChapterTitle})") + : $"{s} ({chapterTitle})") .IfNone("[unknown video]"); case Song s: string songArtist = s.SongMetadata.HeadOrNone() @@ -67,9 +69,9 @@ internal static class Mapper .IfNone(string.Empty); return s.SongMetadata.HeadOrNone() .Map(sm => $"{songArtist}{sm.Title ?? string.Empty}") - .Map(t => string.IsNullOrWhiteSpace(playoutItem.ChapterTitle) + .Map(t => string.IsNullOrWhiteSpace(chapterTitle) ? t - : $"{s} ({playoutItem.ChapterTitle})") + : $"{s} ({chapterTitle})") .IfNone("[unknown song]"); case Image i: return i.ImageMetadata.HeadOrNone().Map(im => im.Title ?? string.Empty).IfNone("[unknown image]"); diff --git a/ErsatzTV.Application/Scheduling/Mapper.cs b/ErsatzTV.Application/Scheduling/Mapper.cs index a49c29aa..f7e7df40 100644 --- a/ErsatzTV.Application/Scheduling/Mapper.cs +++ b/ErsatzTV.Application/Scheduling/Mapper.cs @@ -143,7 +143,7 @@ internal static class Mapper internal static PlayoutItemPreviewViewModel ProjectToViewModel(PlayoutItem playoutItem) => new( - Playouts.Mapper.GetDisplayTitle(playoutItem), + Playouts.Mapper.GetDisplayTitle(playoutItem.MediaItem, playoutItem.ChapterTitle), playoutItem.StartOffset.TimeOfDay, playoutItem.FinishOffset.TimeOfDay, playoutItem.GetDisplayDuration()); diff --git a/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs b/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs index c33c462c..4e47824e 100644 --- a/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs +++ b/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs @@ -327,6 +327,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< request.PtsOffset, request.TargetFramerate, disableWatermarks, + Option.None, _ => { }); var result = new PlayoutItemProcessModel(process, duration, finish, true); diff --git a/ErsatzTV.Application/Troubleshooting/Commands/ArchiveTroubleshootingResults.cs b/ErsatzTV.Application/Troubleshooting/Commands/ArchiveTroubleshootingResults.cs new file mode 100644 index 00000000..53e3a2f7 --- /dev/null +++ b/ErsatzTV.Application/Troubleshooting/Commands/ArchiveTroubleshootingResults.cs @@ -0,0 +1,4 @@ +namespace ErsatzTV.Application.Troubleshooting; + +public record ArchiveTroubleshootingResults(int MediaItemId, int FFmpegProfileId, int WatermarkId) + : IRequest>; diff --git a/ErsatzTV.Application/Troubleshooting/Commands/ArchiveTroubleshootingResultsHandler.cs b/ErsatzTV.Application/Troubleshooting/Commands/ArchiveTroubleshootingResultsHandler.cs new file mode 100644 index 00000000..69cdac9b --- /dev/null +++ b/ErsatzTV.Application/Troubleshooting/Commands/ArchiveTroubleshootingResultsHandler.cs @@ -0,0 +1,68 @@ +using System.IO.Compression; +using System.Text.Json; +using System.Text.Json.Serialization; +using ErsatzTV.Application.MediaItems; +using ErsatzTV.Application.Troubleshooting.Queries; +using ErsatzTV.Core; +using ErsatzTV.Core.Interfaces.Metadata; + +namespace ErsatzTV.Application.Troubleshooting; + +public class ArchiveTroubleshootingResultsHandler(IMediator mediator, ILocalFileSystem localFileSystem) + : IRequestHandler> +{ + private static readonly JsonSerializerOptions Options = new() + { + Converters = { new JsonStringEnumConverter() }, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = true + }; + + public async Task> Handle(ArchiveTroubleshootingResults request, CancellationToken cancellationToken) + { + string tempFile = Path.GetTempFileName(); + using ZipArchive zipArchive = ZipFile.Open(tempFile, ZipArchiveMode.Update); + + string transcodeFolder = Path.Combine(FileSystemLayout.TranscodeFolder, ".troubleshooting"); + + bool hasReport = false; + foreach (string file in localFileSystem.ListFiles(transcodeFolder)) + { + // add to archive + if (Path.GetFileName(file).StartsWith("ffmpeg-", StringComparison.InvariantCultureIgnoreCase)) + { + hasReport = true; + zipArchive.CreateEntryFromFile(file, Path.GetFileName(file)); + } + } + + Either maybeMediaItemInfo = await mediator.Send(new GetMediaItemInfo(request.MediaItemId), cancellationToken); + foreach (MediaItemInfo info in maybeMediaItemInfo.RightToSeq()) + { + string infoJson = JsonSerializer.Serialize(info, Options); + string tempMediaInfoFile = Path.GetTempFileName(); + await File.WriteAllTextAsync(tempMediaInfoFile, infoJson, cancellationToken); + zipArchive.CreateEntryFromFile(tempMediaInfoFile, "media_info.json"); + } + + TroubleshootingInfo troubleshootingInfo = await mediator.Send(new GetTroubleshootingInfo(), cancellationToken); + + string troubleshootingInfoJson = JsonSerializer.Serialize( + new + { + troubleshootingInfo.Version, + Environment = troubleshootingInfo.Environment.OrderBy(x => x.Key).ToDictionary(x => x.Key, x => x.Value), + troubleshootingInfo.Health, + troubleshootingInfo.FFmpegSettings, + troubleshootingInfo.Channels, + troubleshootingInfo.FFmpegProfiles + }, + Options); + + string tempTroubleshootingInfoFile = Path.GetTempFileName(); + await File.WriteAllTextAsync(tempTroubleshootingInfoFile, troubleshootingInfoJson, cancellationToken); + zipArchive.CreateEntryFromFile(tempTroubleshootingInfoFile, "troubleshooting_info.json"); + + return hasReport ? tempFile : Option.None; + } +} diff --git a/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlayback.cs b/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlayback.cs new file mode 100644 index 00000000..fda1ed85 --- /dev/null +++ b/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlayback.cs @@ -0,0 +1,7 @@ +using CliWrap; +using ErsatzTV.Core; + +namespace ErsatzTV.Application.Troubleshooting; + +public record PrepareTroubleshootingPlayback(int MediaItemId, int FFmpegProfileId, int WatermarkId) + : IRequest>; diff --git a/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs b/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs new file mode 100644 index 00000000..cdb34fb2 --- /dev/null +++ b/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs @@ -0,0 +1,296 @@ +using CliWrap; +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.Metadata; +using ErsatzTV.Core.Interfaces.Plex; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Infrastructure.Extensions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace ErsatzTV.Application.Troubleshooting; + +public class PrepareTroubleshootingPlaybackHandler( + IDbContextFactory dbContextFactory, + IPlexPathReplacementService plexPathReplacementService, + IJellyfinPathReplacementService jellyfinPathReplacementService, + IEmbyPathReplacementService embyPathReplacementService, + IFFmpegProcessService ffmpegProcessService, + ILocalFileSystem localFileSystem, + ILogger logger) + : IRequestHandler> +{ + public async Task> Handle(PrepareTroubleshootingPlayback request, CancellationToken cancellationToken) + { + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + Validation> validation = await Validate(dbContext, request); + return await validation.Match( + tuple => GetProcess(dbContext, request, tuple.Item1, tuple.Item2, tuple.Item3, tuple.Item4), + error => Task.FromResult>(error.Join())); + } + + private async Task> GetProcess( + TvContext dbContext, + PrepareTroubleshootingPlayback request, + MediaItem mediaItem, + string ffmpegPath, + string ffprobePath, + FFmpegProfile ffmpegProfile) + { + string transcodeFolder = Path.Combine(FileSystemLayout.TranscodeFolder, ".troubleshooting"); + + localFileSystem.EnsureFolderExists(transcodeFolder); + localFileSystem.EmptyFolder(transcodeFolder); + + ChannelSubtitleMode subtitleMode = ChannelSubtitleMode.None; + + 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"); + } + + Option maybeWatermark = Option.None; + if (request.WatermarkId > 0) + { + maybeWatermark = await dbContext.ChannelWatermarks + .SelectOneAsync(cw => cw.Id, cw => cw.Id == request.WatermarkId); + } + + DateTimeOffset now = DateTimeOffset.Now; + + var duration = TimeSpan.FromSeconds(Math.Min(version.Duration.TotalSeconds, 30)); + + Command process = await ffmpegProcessService.ForPlayoutItem( + ffmpegPath, + ffprobePath, + true, + new Channel(Guid.Empty) + { + Number = ".troubleshooting", + FFmpegProfile = ffmpegProfile, + StreamingMode = StreamingMode.HttpLiveStreamingSegmenter, + SubtitleMode = subtitleMode + }, + version, + new MediaItemAudioVersion(mediaItem, version), + mediaPath, + mediaPath, + _ => Task.FromResult(new List()), + string.Empty, + string.Empty, + string.Empty, + subtitleMode, + now, + now + duration, + now, + maybeWatermark, + Option.None, + ffmpegProfile.VaapiDisplay, + ffmpegProfile.VaapiDriver, + ffmpegProfile.VaapiDevice, + Option.None, + false, + FillerKind.None, + TimeSpan.Zero, + duration, + 0, + None, + false, + transcodeFolder, + _ => { }); + + return process; + } + + private static async Task>> 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> MediaItemMustExist( + TvContext dbContext, + PrepareTroubleshootingPlayback request) + { + return await dbContext.MediaItems + .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) + .SelectOneAsync(mi => mi.Id, mi => mi.Id == request.MediaItemId) + .Map(o => o.ToValidation(new UnableToLocatePlayoutItem())); + } + + private static Task> FFmpegPathMustExist(TvContext dbContext) => + dbContext.ConfigElements.GetValue(ConfigElementKey.FFmpegPath) + .FilterT(File.Exists) + .Map(maybePath => maybePath.ToValidation("FFmpeg path does not exist on filesystem")); + + private static Task> FFprobePathMustExist(TvContext dbContext) => + dbContext.ConfigElements.GetValue(ConfigElementKey.FFprobePath) + .FilterT(File.Exists) + .Map(maybePath => maybePath.ToValidation("FFprobe path does not exist on filesystem")); + + private static Task> FFmpegProfileMustExist( + TvContext dbContext, + PrepareTroubleshootingPlayback request) => + dbContext.FFmpegProfiles + .Include(p => p.Resolution) + .SelectOneAsync(p => p.Id, p => p.Id == request.FFmpegProfileId) + .Map(o => o.ToValidation($"FFmpegProfile {request.FFmpegProfileId} does not exist")); + + private async Task GetMediaItemPath( + TvContext dbContext, + MediaItem mediaItem) + { + string path = await GetLocalPath(mediaItem); + + // check filesystem first + if (localFileSystem.FileExists(path)) + { + 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) + { + 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 + }; + } +} diff --git a/ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlayback.cs b/ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlayback.cs new file mode 100644 index 00000000..3be0e72e --- /dev/null +++ b/ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlayback.cs @@ -0,0 +1,5 @@ +using CliWrap; + +namespace ErsatzTV.Application.Troubleshooting; + +public record StartTroubleshootingPlayback(Command Command) : IRequest, IFFmpegWorkerRequest; diff --git a/ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlaybackHandler.cs b/ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlaybackHandler.cs new file mode 100644 index 00000000..6ede876c --- /dev/null +++ b/ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlaybackHandler.cs @@ -0,0 +1,33 @@ +using CliWrap; +using CliWrap.Buffered; +using ErsatzTV.Core.Interfaces.Locking; +using Microsoft.Extensions.Logging; + +namespace ErsatzTV.Application.Troubleshooting; + +public class StartTroubleshootingPlaybackHandler( + IEntityLocker entityLocker, + ILogger logger) + : IRequestHandler +{ + public async Task Handle(StartTroubleshootingPlayback request, CancellationToken cancellationToken) + { + logger.LogDebug("ffmpeg troubleshooting arguments {FFmpegArguments}", request.Command.Arguments); + + BufferedCommandResult result = await request.Command + .WithValidation(CommandResultValidation.None) + .ExecuteBufferedAsync(cancellationToken); + + entityLocker.UnlockTroubleshootingPlayback(); + + + logger.LogInformation("Troubleshooting playback completed with exit code {ExitCode}", result.ExitCode); + + foreach (KeyValuePair env in request.Command.EnvironmentVariables) + { + logger.LogInformation("{Key} => {Value}", env.Key, env.Value); + } + + // TODO: something with the result ??? + } +} diff --git a/ErsatzTV.Core/Errors/UnableToLocateMediaItem.cs b/ErsatzTV.Core/Errors/UnableToLocateMediaItem.cs new file mode 100644 index 00000000..806aa53f --- /dev/null +++ b/ErsatzTV.Core/Errors/UnableToLocateMediaItem.cs @@ -0,0 +1,8 @@ +namespace ErsatzTV.Core.Errors; + +public class UnableToLocateMediaItem : BaseError +{ + public UnableToLocateMediaItem() : base("Unable to locate media item") + { + } +} diff --git a/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs b/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs index 6d657474..017bcf98 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs @@ -73,6 +73,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService long ptsOffset, Option targetFramerate, bool disableWatermarks, + Option customReportsFolder, Action pipelineAction) { MediaStream videoStream = await _ffmpegStreamSelector.SelectVideoStream(videoVersion); @@ -422,7 +423,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService qsvExtraHardwareFrames, videoVersion is BackgroundImageMediaVersion { IsSongWithProgress: true }, false, - GetTonemapAlgorithm(playbackSettings)); + GetTonemapAlgorithm(playbackSettings), + channel.UniqueId == Guid.Empty); _logger.LogDebug("FFmpeg desired state {FrameState}", desiredState); @@ -436,7 +438,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService VaapiDisplayName(hwAccel, vaapiDisplay), VaapiDriverName(hwAccel, vaapiDriver), VaapiDeviceName(hwAccel, vaapiDevice), - FileSystemLayout.FFmpegReportsFolder, + await customReportsFolder.IfNoneAsync(FileSystemLayout.FFmpegReportsFolder), FileSystemLayout.FontsCacheFolder, ffmpegPath); @@ -578,7 +580,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService qsvExtraHardwareFrames, false, false, - GetTonemapAlgorithm(playbackSettings)); + GetTonemapAlgorithm(playbackSettings), + channel.UniqueId == Guid.Empty); var ffmpegSubtitleStream = new ErsatzTV.FFmpeg.MediaStream(0, "ass", StreamKind.Video); @@ -774,7 +777,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService Optional(channel.FFmpegProfile.QsvExtraHardwareFrames), false, false, - GetTonemapAlgorithm(playbackSettings)); + GetTonemapAlgorithm(playbackSettings), + channel.UniqueId == Guid.Empty); _logger.LogDebug("FFmpeg desired state {FrameState}", desiredState); diff --git a/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs b/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs index 7fc1dca0..3e71ab95 100644 --- a/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs +++ b/ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegProcessService.cs @@ -39,6 +39,7 @@ public interface IFFmpegProcessService long ptsOffset, Option targetFramerate, bool disableWatermarks, + Option customReportsFolder, Action pipelineAction); Task ForError( diff --git a/ErsatzTV.Core/Interfaces/Locking/IEntityLocker.cs b/ErsatzTV.Core/Interfaces/Locking/IEntityLocker.cs index 79f8b98d..d1d33f79 100644 --- a/ErsatzTV.Core/Interfaces/Locking/IEntityLocker.cs +++ b/ErsatzTV.Core/Interfaces/Locking/IEntityLocker.cs @@ -9,6 +9,7 @@ public interface IEntityLocker event EventHandler OnEmbyCollectionsChanged; event EventHandler OnJellyfinCollectionsChanged; event EventHandler OnPlexCollectionsChanged; + event EventHandler OnTroubleshootingPlaybackChanged; bool LockLibrary(int libraryId); bool UnlockLibrary(int libraryId); bool IsLibraryLocked(int libraryId); @@ -33,4 +34,7 @@ public interface IEntityLocker Task LockPlayout(int playoutId); Task UnlockPlayout(int playoutId); bool IsPlayoutLocked(int playoutId); + bool LockTroubleshootingPlayback(); + bool UnlockTroubleshootingPlayback(); + bool IsTroubleshootingPlaybackLocked(); } diff --git a/ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs b/ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs index 236fb15c..64e9fc05 100644 --- a/ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs +++ b/ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs @@ -95,7 +95,8 @@ public class PipelineBuilderBaseTests Option.None, false, false, - "clip"); + "clip", + false); var builder = new SoftwarePipelineBuilder( new DefaultFFmpegCapabilities(), @@ -191,7 +192,8 @@ public class PipelineBuilderBaseTests Option.None, false, false, - "clip"); + "clip", + false); var builder = new SoftwarePipelineBuilder( new DefaultFFmpegCapabilities(), @@ -343,7 +345,8 @@ public class PipelineBuilderBaseTests Option.None, false, false, - "clip"); + "clip", + false); var builder = new SoftwarePipelineBuilder( new DefaultFFmpegCapabilities(), @@ -433,7 +436,8 @@ public class PipelineBuilderBaseTests Option.None, false, false, - "clip"); + "clip", + false); var builder = new SoftwarePipelineBuilder( new DefaultFFmpegCapabilities(), diff --git a/ErsatzTV.FFmpeg/FFmpegState.cs b/ErsatzTV.FFmpeg/FFmpegState.cs index 67669a1a..57ddb41b 100644 --- a/ErsatzTV.FFmpeg/FFmpegState.cs +++ b/ErsatzTV.FFmpeg/FFmpegState.cs @@ -24,7 +24,8 @@ public record FFmpegState( Option MaybeQsvExtraHardwareFrames, bool IsSongWithProgress, bool IsHdrTonemap, - string TonemapAlgorithm) + string TonemapAlgorithm, + bool IsTroubleshooting) { public int QsvExtraHardwareFrames => MaybeQsvExtraHardwareFrames.IfNone(64); @@ -51,5 +52,6 @@ public record FFmpegState( Option.None, false, false, - "linear"); + "linear", + false); } diff --git a/ErsatzTV.FFmpeg/OutputFormat/OutputFormatHls.cs b/ErsatzTV.FFmpeg/OutputFormat/OutputFormatHls.cs index d35f7179..58c2d82f 100644 --- a/ErsatzTV.FFmpeg/OutputFormat/OutputFormatHls.cs +++ b/ErsatzTV.FFmpeg/OutputFormat/OutputFormatHls.cs @@ -10,6 +10,7 @@ public class OutputFormatHls : IPipelineStep private readonly bool _isFirstTranscode; private readonly Option _mediaFrameRate; private readonly bool _oneSecondGop; + private readonly bool _isTroubleshooting; private readonly string _playlistPath; private readonly string _segmentTemplate; @@ -19,7 +20,8 @@ public class OutputFormatHls : IPipelineStep string segmentTemplate, string playlistPath, bool isFirstTranscode, - bool oneSecondGop) + bool oneSecondGop, + bool isTroubleshooting) { _desiredState = desiredState; _mediaFrameRate = mediaFrameRate; @@ -27,12 +29,13 @@ public class OutputFormatHls : IPipelineStep _playlistPath = playlistPath; _isFirstTranscode = isFirstTranscode; _oneSecondGop = oneSecondGop; + _isTroubleshooting = isTroubleshooting; } - public EnvironmentVariable[] EnvironmentVariables => Array.Empty(); - public string[] GlobalOptions => Array.Empty(); - public string[] InputOptions(InputFile inputFile) => Array.Empty(); - public string[] FilterOptions => Array.Empty(); + public EnvironmentVariable[] EnvironmentVariables => []; + public string[] GlobalOptions => []; + public string[] InputOptions(InputFile inputFile) => []; + public string[] FilterOptions => []; public string[] OutputOptions { @@ -55,11 +58,21 @@ public class OutputFormatHls : IPipelineStep _segmentTemplate ]; + if (_isTroubleshooting) + { + result.AddRange( + [ + "-hls_playlist_type", "vod" + ]); + } + + string pdt = _isTroubleshooting ? string.Empty : "program_date_time+omit_endlist+"; + if (_isFirstTranscode) { result.AddRange( [ - "-hls_flags", "program_date_time+append_list+omit_endlist+independent_segments", + "-hls_flags", $"{pdt}append_list+independent_segments", _playlistPath ]); } @@ -67,7 +80,7 @@ public class OutputFormatHls : IPipelineStep { result.AddRange( [ - "-hls_flags", "program_date_time+append_list+discont_start+omit_endlist+independent_segments", + "-hls_flags", $"{pdt}append_list+discont_start+independent_segments", "-mpegts_flags", "+initial_discontinuity", _playlistPath ]); diff --git a/ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs b/ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs index bdf4d2c3..f617a7d2 100644 --- a/ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs +++ b/ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs @@ -334,7 +334,8 @@ public abstract class PipelineBuilderBase : IPipelineBuilder segmentTemplate, playlistPath, ffmpegState.PtsOffset == 0, - ffmpegState.EncoderHardwareAccelerationMode is HardwareAccelerationMode.Qsv)); + ffmpegState.EncoderHardwareAccelerationMode is HardwareAccelerationMode.Qsv, + ffmpegState.IsTroubleshooting)); } } diff --git a/ErsatzTV.Infrastructure/Locking/EntityLocker.cs b/ErsatzTV.Infrastructure/Locking/EntityLocker.cs index 942386cf..00620202 100644 --- a/ErsatzTV.Infrastructure/Locking/EntityLocker.cs +++ b/ErsatzTV.Infrastructure/Locking/EntityLocker.cs @@ -15,6 +15,7 @@ public class EntityLocker(IMediator mediator) : IEntityLocker private bool _plex; private bool _plexCollections; private bool _trakt; + private bool _troubleshootingPlayback; public event EventHandler OnLibraryChanged; public event EventHandler OnPlexChanged; @@ -23,6 +24,7 @@ public class EntityLocker(IMediator mediator) : IEntityLocker public event EventHandler OnEmbyCollectionsChanged; public event EventHandler OnJellyfinCollectionsChanged; public event EventHandler OnPlexCollectionsChanged; + public event EventHandler OnTroubleshootingPlaybackChanged; public bool LockLibrary(int libraryId) { @@ -232,4 +234,30 @@ public class EntityLocker(IMediator mediator) : IEntityLocker } public bool IsPlayoutLocked(int playoutId) => _lockedPlayouts.ContainsKey(playoutId); + + public bool LockTroubleshootingPlayback() + { + if (!_troubleshootingPlayback) + { + _troubleshootingPlayback = true; + OnTroubleshootingPlaybackChanged?.Invoke(this, EventArgs.Empty); + return true; + } + + return false; + } + + public bool UnlockTroubleshootingPlayback() + { + if (_troubleshootingPlayback) + { + _troubleshootingPlayback = false; + OnTroubleshootingPlaybackChanged?.Invoke(this, EventArgs.Empty); + return true; + } + + return false; + } + + public bool IsTroubleshootingPlaybackLocked() => _troubleshootingPlayback; } diff --git a/ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs b/ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs index 419f8579..9fd5ad1c 100644 --- a/ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs +++ b/ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs @@ -379,6 +379,7 @@ public class TranscodingTests 0, None, false, + Option.None, _ => { }); // Console.WriteLine($"ffmpeg arguments {process.Arguments}"); @@ -655,6 +656,7 @@ public class TranscodingTests 0, None, false, + Option.None, PipelineAction); // Console.WriteLine($"ffmpeg arguments {string.Join(" ", process.StartInfo.ArgumentList)}"); diff --git a/ErsatzTV/Controllers/Api/TroubleshootController.cs b/ErsatzTV/Controllers/Api/TroubleshootController.cs new file mode 100644 index 00000000..b0770f98 --- /dev/null +++ b/ErsatzTV/Controllers/Api/TroubleshootController.cs @@ -0,0 +1,86 @@ +using System.Threading.Channels; +using CliWrap; +using ErsatzTV.Application; +using ErsatzTV.Application.Troubleshooting; +using ErsatzTV.Core; +using ErsatzTV.Core.Interfaces.Locking; +using ErsatzTV.Core.Interfaces.Metadata; +using MediatR; +using Microsoft.AspNetCore.Mvc; + +namespace ErsatzTV.Controllers.Api; + +[ApiController] +public class TroubleshootController( + IEntityLocker entityLocker, + ChannelWriter channelWriter, + ILocalFileSystem localFileSystem, + IMediator mediator) : ControllerBase +{ + [HttpHead("api/troubleshoot/playback.m3u8")] + [HttpGet("api/troubleshoot/playback.m3u8")] + public async Task TroubleshootPlayback( + [FromQuery] + int mediaItem, + [FromQuery] + int ffmpegProfile, + [FromQuery] + int watermark, + CancellationToken cancellationToken) + { + entityLocker.LockTroubleshootingPlayback(); + + Either result = await mediator.Send( + new PrepareTroubleshootingPlayback(mediaItem, ffmpegProfile, watermark), + cancellationToken); + + return await result.MatchAsync( + async command => + { + await channelWriter.WriteAsync(new StartTroubleshootingPlayback(command), CancellationToken.None); + string playlistFile = Path.Combine(FileSystemLayout.TranscodeFolder, ".troubleshooting", "live.m3u8"); + + DateTimeOffset start = DateTimeOffset.Now; + while (!localFileSystem.FileExists(playlistFile) && + DateTimeOffset.Now - start < TimeSpan.FromSeconds(15)) + { + await Task.Delay(TimeSpan.FromMilliseconds(250), cancellationToken); + if (cancellationToken.IsCancellationRequested) + { + break; + } + } + + return Redirect("~/iptv/session/.troubleshooting/live.m3u8"); + }, + _ => NotFound()); + } + + [HttpHead("api/troubleshoot/playback/archive")] + [HttpGet("api/troubleshoot/playback/archive")] + public async Task TroubleshootPlaybackArchive( + [FromQuery] + int mediaItem, + [FromQuery] + int ffmpegProfile, + [FromQuery] + int watermark, + CancellationToken cancellationToken) + { + Option maybeArchivePath = await mediator.Send( + new ArchiveTroubleshootingResults(mediaItem, ffmpegProfile, watermark), + cancellationToken); + + foreach (string archivePath in maybeArchivePath) + { + FileStream fs = System.IO.File.OpenRead(archivePath); + return File( + fs, + "application/zip", + $"ersatztv-troubleshooting-{DateTimeOffset.Now.ToUnixTimeSeconds()}.zip"); + } + + return NotFound(); + } + +} diff --git a/ErsatzTV/Pages/PlaybackTroubleshooting.razor b/ErsatzTV/Pages/PlaybackTroubleshooting.razor new file mode 100644 index 00000000..ed71282d --- /dev/null +++ b/ErsatzTV/Pages/PlaybackTroubleshooting.razor @@ -0,0 +1,165 @@ +@page "/system/troubleshooting/playback" +@using ErsatzTV.Application.FFmpegProfiles +@using ErsatzTV.Application.MediaItems +@using ErsatzTV.Application.Watermarks +@implements IDisposable +@inject IMediator Mediator +@inject NavigationManager NavigationManager +@inject IJSRuntime JsRuntime +@inject IEntityLocker Locker + + + + + Download Results + + +
+ + Media Item + + +
+ Media Item ID +
+ +
+ +
+ Title +
+ +
+ Playback Settings + + +
+ FFmpeg Profile +
+ + @foreach (FFmpegProfileViewModel profile in _ffmpegProfiles) + { + @profile.Name + } + +
+ +
+ Watermark +
+ + (none) + @foreach (WatermarkViewModel watermark in _watermarks) + { + @watermark.Name + } + +
+ Preview + + +
+ + Play + +
+
+ + + + + + + + + +
+
+
+
+
+
+
+
+
+ +@code { + private readonly CancellationTokenSource _cts = new(); + + private List _ffmpegProfiles = []; + private List _watermarks = []; + private int? _mediaItemId; + private MediaItemInfo _info; + private int _ffmpegProfileId; + private int? _watermarkId; + private bool _hasPlayed; + + public void Dispose() + { + _cts.Cancel(); + _cts.Dispose(); + } + + protected override void OnInitialized() => Locker.OnTroubleshootingPlaybackChanged += LockChanged; + + protected override async Task OnParametersSetAsync() + { + _ffmpegProfiles = await Mediator.Send(new GetAllFFmpegProfiles(), _cts.Token); + if (_ffmpegProfiles.Count > 0) + { + _ffmpegProfileId = _ffmpegProfiles.Map(f => f.Id).Head(); + } + + _watermarks = await Mediator.Send(new GetAllWatermarks(), _cts.Token); + } + + private void LockChanged(object sender, EventArgs e) => InvokeAsync(StateHasChanged); + + private async Task PreviewChannel() + { + Locker.LockTroubleshootingPlayback(); + _hasPlayed = true; + + var uri = new UriBuilder(NavigationManager.ToAbsoluteUri(NavigationManager.Uri)); + uri.Path = uri.Path.Replace("/system/troubleshooting/playback", "/api/troubleshoot/playback.m3u8"); + uri.Query = $"?mediaItem={_mediaItemId}&ffmpegProfile={_ffmpegProfileId}&watermark={_watermarkId ?? 0}"; + await JsRuntime.InvokeVoidAsync("previewChannel", uri.ToString()); + } + + private async Task OnMediaItemIdChanged(int? mediaItemId) + { + _mediaItemId = mediaItemId; + _hasPlayed = false; + + foreach (int id in Optional(mediaItemId)) + { + Either maybeInfo = await Mediator.Send(new GetMediaItemInfo(id)); + foreach (MediaItemInfo info in maybeInfo.RightToSeq()) + { + _info = info; + } + + if (maybeInfo.IsLeft) + { + _mediaItemId = null; + } + } + + StateHasChanged(); + } + + private async Task DownloadResults() + { + await JsRuntime.InvokeVoidAsync("window.open", $"api/troubleshoot/playback/archive?mediaItem={_mediaItemId ?? 0}&ffmpegProfile={_ffmpegProfileId}&watermark={_watermarkId ?? 0}"); + } + +} \ No newline at end of file diff --git a/ErsatzTV/Pages/Troubleshooting.razor b/ErsatzTV/Pages/Troubleshooting.razor index f7fa7848..3da9352d 100644 --- a/ErsatzTV/Pages/Troubleshooting.razor +++ b/ErsatzTV/Pages/Troubleshooting.razor @@ -7,50 +7,65 @@ @inject IMediator Mediator @inject IJSRuntime JsRuntime - - - -
-
-                    @_troubleshootingInfo
-                
+ +
+ + Playback + +
+ Playback Troubleshooting
- - Copy - - - -
-
-                    @_nvidiaCapabilities
-                
-
- - Copy - -
- -
-
-                    @_qsvCapabilities
-                
-
- - Copy - -
- -
-
-                    @_vaapiCapabilities
-                
-
- - Copy - -
- -
+ General + + +
+
+                        @_troubleshootingInfo
+                    
+
+ + Copy + +
+ NVIDIA Capabilities + + +
+
+                        @_nvidiaCapabilities
+                    
+
+ + Copy + +
+ QSV Capabilities + + +
+
+                        @_qsvCapabilities
+                    
+
+ + Copy + +
+ VAAPI Capabilities + + +
+
+                        @_vaapiCapabilities
+                    
+
+ + Copy + +
+ +
+
@code { private readonly CancellationTokenSource _cts = new(); diff --git a/ErsatzTV/Pages/Watermarks.razor b/ErsatzTV/Pages/Watermarks.razor index 45be2a3c..cb98927d 100644 --- a/ErsatzTV/Pages/Watermarks.razor +++ b/ErsatzTV/Pages/Watermarks.razor @@ -78,7 +78,7 @@ @code { private readonly CancellationTokenSource _cts = new(); - private List _watermarks = new(); + private List _watermarks = []; public void Dispose() { diff --git a/ErsatzTV/Services/FFmpegWorkerService.cs b/ErsatzTV/Services/FFmpegWorkerService.cs index 2aef6b34..16700e49 100644 --- a/ErsatzTV/Services/FFmpegWorkerService.cs +++ b/ErsatzTV/Services/FFmpegWorkerService.cs @@ -2,7 +2,9 @@ using Bugsnag; using ErsatzTV.Application; using ErsatzTV.Application.Streaming; +using ErsatzTV.Application.Troubleshooting; using ErsatzTV.Core.Interfaces.FFmpeg; +using MediatR; namespace ErsatzTV.Services; @@ -47,6 +49,12 @@ public class FFmpegWorkerService : BackgroundService _ffmpegSegmenterService.TouchChannel(parent.Name); } + break; + + case StartTroubleshootingPlayback startTroubleshootingPlayback: + IMediator mediator = scope.ServiceProvider.GetRequiredService(); + await mediator.Send(startTroubleshootingPlayback, stoppingToken); + break; } }