diff --git a/CHANGELOG.md b/CHANGELOG.md index e4f5f571c..23acd6344 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Preview button will be red when preview is disabled due to browser incompatibility - Add AV1 encoding support with NVIDIA, VAAPI and QSV acceleration - This also requires `HLS Segmenter (fmp4)` +- Add `Stream Selector` option to playback troubleshooting tool + - This can be helpful for validating stream selector behavior with specific content + - Manual subtitle selection will be disabled when using a stream selector ### Fixed - Fix green output when libplacebo tonemapping is used with NVIDIA acceleration and 10-bit output in FFmpeg Profile diff --git a/ErsatzTV.Application/Troubleshooting/Commands/ArchiveTroubleshootingResultsHandler.cs b/ErsatzTV.Application/Troubleshooting/Commands/ArchiveTroubleshootingResultsHandler.cs index ffba97528..6fb07d345 100644 --- a/ErsatzTV.Application/Troubleshooting/Commands/ArchiveTroubleshootingResultsHandler.cs +++ b/ErsatzTV.Application/Troubleshooting/Commands/ArchiveTroubleshootingResultsHandler.cs @@ -22,14 +22,22 @@ public class ArchiveTroubleshootingResultsHandler(ILocalFileSystem localFileSyst { hasReport = true; zipArchive.CreateEntryFromFile(file, fileName); + continue; } if (Path.GetExtension(file).Equals(".json", StringComparison.OrdinalIgnoreCase)) { zipArchive.CreateEntryFromFile(file, fileName); + continue; } if (fileName.Contains("capabilities", StringComparison.OrdinalIgnoreCase)) + { + zipArchive.CreateEntryFromFile(file, fileName); + continue; + } + + if (fileName.Contains("stream-selector", StringComparison.OrdinalIgnoreCase)) { zipArchive.CreateEntryFromFile(file, fileName); } diff --git a/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlayback.cs b/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlayback.cs index 52889a0ee..00bc539d7 100644 --- a/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlayback.cs +++ b/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlayback.cs @@ -8,6 +8,7 @@ public record PrepareTroubleshootingPlayback( StreamingMode StreamingMode, int MediaItemId, int FFmpegProfileId, + string StreamSelector, List WatermarkIds, List GraphicsElementIds, int? SubtitleId, diff --git a/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs b/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs index 44a946dc9..b4c2c702c 100644 --- a/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs +++ b/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs @@ -108,6 +108,12 @@ public class PrepareTroubleshootingPlaybackHandler( //SongVideoMode = ChannelSongVideoMode.WithProgress }; + if (!string.IsNullOrEmpty(request.StreamSelector)) + { + channel.StreamSelectorMode = ChannelStreamSelectorMode.Custom; + channel.StreamSelector = request.StreamSelector; + } + List watermarks = []; if (request.WatermarkIds.Count > 0) { @@ -211,7 +217,7 @@ public class PrepareTroubleshootingPlaybackHandler( new MediaItemAudioVersion(mediaItem, version), videoPath, mediaPath, - _ => GetSelectedSubtitle(mediaItem, request), + _ => GetSubtitles(mediaItem, request), string.Empty, string.Empty, string.Empty, @@ -240,35 +246,33 @@ public class PrepareTroubleshootingPlaybackHandler( return playoutItemResult; } - private static async Task> GetSelectedSubtitle( - MediaItem mediaItem, - PrepareTroubleshootingPlayback request) + private static async Task> GetSubtitles(MediaItem mediaItem, PrepareTroubleshootingPlayback request) { - if (request.SubtitleId is not null) + List allSubtitles = mediaItem switch { - List 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"); - } + 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"); + } + + if (request.SubtitleId is not null) + { allSubtitles.RemoveAll(s => s.Id != request.SubtitleId.Value); foreach (Subtitle subtitle in allSubtitles) @@ -279,7 +283,7 @@ public class PrepareTroubleshootingPlaybackHandler( } } - return []; + return allSubtitles; } private static async Task>> Validate( diff --git a/ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlayback.cs b/ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlayback.cs index d1ccebf65..dec63762f 100644 --- a/ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlayback.cs +++ b/ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlayback.cs @@ -5,6 +5,7 @@ namespace ErsatzTV.Application.Troubleshooting; public record StartTroubleshootingPlayback( Guid SessionId, + string StreamSelector, PlayoutItemResult PlayoutItemResult, MediaItemInfo MediaItemInfo, TroubleshootingInfo TroubleshootingInfo) : IRequest, IFFmpegWorkerRequest; diff --git a/ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlaybackHandler.cs b/ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlaybackHandler.cs index 59cc54353..4b6ac5666 100644 --- a/ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlaybackHandler.cs +++ b/ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlaybackHandler.cs @@ -61,6 +61,18 @@ public class StartTroubleshootingPlaybackHandler( troubleshootingInfoJson, cancellationToken); + // write stream selector + if (!string.IsNullOrWhiteSpace(request.StreamSelector)) + { + string fullPath = Path.Combine(FileSystemLayout.ChannelStreamSelectorsFolder, request.StreamSelector); + if (File.Exists(fullPath)) + { + File.Copy( + fullPath, + Path.Combine(FileSystemLayout.TranscodeTroubleshootingFolder, "stream-selector.yml")); + } + } + HardwareAccelerationKind hwAccel = request.TroubleshootingInfo.FFmpegProfiles.Head().HardwareAcceleration; if (hwAccel is HardwareAccelerationKind.Qsv) { diff --git a/ErsatzTV/Controllers/Api/TroubleshootController.cs b/ErsatzTV/Controllers/Api/TroubleshootController.cs index 75132b244..4a7f03b8d 100644 --- a/ErsatzTV/Controllers/Api/TroubleshootController.cs +++ b/ErsatzTV/Controllers/Api/TroubleshootController.cs @@ -34,6 +34,8 @@ public class TroubleshootController( [FromQuery] List graphicsElement, [FromQuery] + string streamSelector, + [FromQuery] int? subtitleId, [FromQuery] int seekSeconds, @@ -48,6 +50,7 @@ public class TroubleshootController( streamingMode, mediaItem, ffmpegProfile, + streamSelector, watermark, graphicsElement, subtitleId, @@ -82,6 +85,7 @@ public class TroubleshootController( await channelWriter.WriteAsync( new StartTroubleshootingPlayback( sessionId, + streamSelector, playoutItemResult, mediaInfo, troubleshootingInfo), @@ -141,7 +145,13 @@ public class TroubleshootController( Option ss = seekSeconds > 0 ? seekSeconds : Option.None; Option maybeArchivePath = await mediator.Send( - new ArchiveTroubleshootingResults(mediaItem, ffmpegProfile, streamingMode, watermark, graphicsElement, ss), + new ArchiveTroubleshootingResults( + mediaItem, + ffmpegProfile, + streamingMode, + watermark, + graphicsElement, + ss), cancellationToken); foreach (string archivePath in maybeArchivePath) diff --git a/ErsatzTV/Pages/PlaybackTroubleshooting.razor b/ErsatzTV/Pages/PlaybackTroubleshooting.razor index 92ba1f518..637a2d2b4 100644 --- a/ErsatzTV/Pages/PlaybackTroubleshooting.razor +++ b/ErsatzTV/Pages/PlaybackTroubleshooting.razor @@ -1,4 +1,5 @@ @page "/system/troubleshooting/playback" +@using ErsatzTV.Application.Channels @using ErsatzTV.Application.FFmpegProfiles @using ErsatzTV.Application.Graphics @using ErsatzTV.Application.MediaItems @@ -7,6 +8,7 @@ @using ErsatzTV.Application.Watermarks @using ErsatzTV.Core.Notifications @using MediatR.Courier +@using Microsoft.AspNetCore.WebUtilities @implements IDisposable @inject IMediator Mediator @inject NavigationManager NavigationManager @@ -55,6 +57,17 @@ } + +
+ Stream Selector +
+ + @foreach (string selector in _streamSelectors) + { + @selector + } + +
Streaming Mode @@ -68,7 +81,7 @@
Subtitle
- + (none) @foreach (SubtitleViewModel subtitleStream in _subtitleStreams) { @@ -149,12 +162,14 @@ private CancellationTokenSource _cts; private readonly List _ffmpegProfiles = []; + private readonly List _streamSelectors = []; private readonly List _watermarks = []; private readonly List _subtitleStreams = []; private readonly List _graphicsElements = []; private MediaItemInfo _info; private StreamingMode _streamingMode = StreamingMode.HttpLiveStreamingSegmenter; private int _ffmpegProfileId; + private string _streamSelector; private IEnumerable _watermarkNames = new System.Collections.Generic.HashSet(); private IEnumerable _graphicsElementNames = new System.Collections.Generic.HashSet(); private bool _startFromBeginning; @@ -193,6 +208,10 @@ _ffmpegProfileId = _ffmpegProfiles.Map(f => f.Id).Head(); } + _streamSelectors.Clear(); + _streamSelectors.AddRange(await Mediator.Send(new GetChannelStreamSelectors(), token)); + _streamSelector = null; + _watermarks.Clear(); _watermarks.AddRange(await Mediator.Send(new GetAllWatermarks(), token)); @@ -221,14 +240,21 @@ private async Task PreviewChannel() { - 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}&streamingMode={(int)_streamingMode}&seekSeconds={_seekSeconds}"; + var baseUri = NavigationManager.ToAbsoluteUri(NavigationManager.Uri).ToString(); + string apiUri = baseUri.Replace("/system/troubleshooting/playback", "/api/troubleshoot/playback.m3u8"); + var queryString = new Dictionary + { + ["mediaItem"] = (MediaItemId ?? 0).ToString(), + ["ffmpegProfile"] = _ffmpegProfileId.ToString(), + ["streamingMode"] = ((int)_streamingMode).ToString(), + ["seekSeconds"] = _seekSeconds.ToString() + }; + foreach (string watermarkName in _watermarkNames) { foreach (WatermarkViewModel watermark in _watermarks.Where(wm => wm.Name == watermarkName)) { - uri.Query += $"&watermark={watermark.Id}"; + queryString.Add("watermark", watermark.Id.ToString()); } } @@ -236,16 +262,21 @@ { foreach (GraphicsElementViewModel graphicsElement in _graphicsElements.Where(ge => ge.Name == graphicsElementName)) { - uri.Query += $"&graphicsElement={graphicsElement.Id}"; + queryString.Add("graphicsElement", graphicsElement.Id.ToString()); } } - if (_subtitleId is not null) + if (!string.IsNullOrWhiteSpace(_streamSelector)) { - uri.Query += $"&subtitleId={_subtitleId.Value}"; + queryString.Add("streamSelector", _streamSelector); + } + else if (_subtitleId is not null) + { + queryString.Add("subtitleId", _subtitleId.Value.ToString()); } - await JsRuntime.InvokeVoidAsync("previewChannel", uri.ToString()); + string uriWithQuery = QueryHelpers.AddQueryString(apiUri, queryString); + await JsRuntime.InvokeVoidAsync("previewChannel", uriWithQuery); await Task.Delay(TimeSpan.FromSeconds(1)); @@ -281,13 +312,20 @@ private async Task DownloadResults() { - var uri = $"api/troubleshoot/playback/archive?mediaItem={MediaItemId ?? 0}&ffmpegProfile={_ffmpegProfileId}&streamingMode={(int)_streamingMode}&seekSeconds={_seekSeconds}"; + string apiUri = "api/troubleshoot/playback/archive"; + var queryString = new Dictionary + { + ["mediaItem"] = (MediaItemId ?? 0).ToString(), + ["ffmpegProfile"] = _ffmpegProfileId.ToString(), + ["streamingMode"] = ((int)_streamingMode).ToString(), + ["seekSeconds"] = _seekSeconds.ToString() + }; foreach (string watermarkName in _watermarkNames) { foreach (WatermarkViewModel watermark in _watermarks.Where(wm => wm.Name == watermarkName)) { - uri += $"&watermark={watermark.Id}"; + queryString.Add("watermark", watermark.Id.ToString()); } } @@ -295,11 +333,12 @@ { foreach (GraphicsElementViewModel graphicsElement in _graphicsElements.Where(ge => ge.Name == graphicsElementName)) { - uri += $"&graphicsElement={graphicsElement.Id}"; + queryString.Add("graphicsElement", graphicsElement.Id.ToString()); } } - await JsRuntime.InvokeVoidAsync("window.open", uri); + string uriWithQuery = QueryHelpers.AddQueryString(apiUri, queryString); + await JsRuntime.InvokeVoidAsync("window.open", uriWithQuery); } private void HandleTroubleshootingCompleted(PlaybackTroubleshootingCompletedNotification result)