add stream selector to playback troubleshooting (#2474)

This commit is contained in:
Jason Dove
2025-10-01 11:10:38 -05:00
committed by GitHub
parent 077fed6cac
commit 18905a79dc
8 changed files with 120 additions and 42 deletions
+3
View File
@@ -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 - Preview button will be red when preview is disabled due to browser incompatibility
- Add AV1 encoding support with NVIDIA, VAAPI and QSV acceleration - Add AV1 encoding support with NVIDIA, VAAPI and QSV acceleration
- This also requires `HLS Segmenter (fmp4)` - 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 ### Fixed
- Fix green output when libplacebo tonemapping is used with NVIDIA acceleration and 10-bit output in FFmpeg Profile - Fix green output when libplacebo tonemapping is used with NVIDIA acceleration and 10-bit output in FFmpeg Profile
@@ -22,14 +22,22 @@ public class ArchiveTroubleshootingResultsHandler(ILocalFileSystem localFileSyst
{ {
hasReport = true; hasReport = true;
zipArchive.CreateEntryFromFile(file, fileName); zipArchive.CreateEntryFromFile(file, fileName);
continue;
} }
if (Path.GetExtension(file).Equals(".json", StringComparison.OrdinalIgnoreCase)) if (Path.GetExtension(file).Equals(".json", StringComparison.OrdinalIgnoreCase))
{ {
zipArchive.CreateEntryFromFile(file, fileName); zipArchive.CreateEntryFromFile(file, fileName);
continue;
} }
if (fileName.Contains("capabilities", StringComparison.OrdinalIgnoreCase)) if (fileName.Contains("capabilities", StringComparison.OrdinalIgnoreCase))
{
zipArchive.CreateEntryFromFile(file, fileName);
continue;
}
if (fileName.Contains("stream-selector", StringComparison.OrdinalIgnoreCase))
{ {
zipArchive.CreateEntryFromFile(file, fileName); zipArchive.CreateEntryFromFile(file, fileName);
} }
@@ -8,6 +8,7 @@ public record PrepareTroubleshootingPlayback(
StreamingMode StreamingMode, StreamingMode StreamingMode,
int MediaItemId, int MediaItemId,
int FFmpegProfileId, int FFmpegProfileId,
string StreamSelector,
List<int> WatermarkIds, List<int> WatermarkIds,
List<int> GraphicsElementIds, List<int> GraphicsElementIds,
int? SubtitleId, int? SubtitleId,
@@ -108,6 +108,12 @@ public class PrepareTroubleshootingPlaybackHandler(
//SongVideoMode = ChannelSongVideoMode.WithProgress //SongVideoMode = ChannelSongVideoMode.WithProgress
}; };
if (!string.IsNullOrEmpty(request.StreamSelector))
{
channel.StreamSelectorMode = ChannelStreamSelectorMode.Custom;
channel.StreamSelector = request.StreamSelector;
}
List<WatermarkOptions> watermarks = []; List<WatermarkOptions> watermarks = [];
if (request.WatermarkIds.Count > 0) if (request.WatermarkIds.Count > 0)
{ {
@@ -211,7 +217,7 @@ public class PrepareTroubleshootingPlaybackHandler(
new MediaItemAudioVersion(mediaItem, version), new MediaItemAudioVersion(mediaItem, version),
videoPath, videoPath,
mediaPath, mediaPath,
_ => GetSelectedSubtitle(mediaItem, request), _ => GetSubtitles(mediaItem, request),
string.Empty, string.Empty,
string.Empty, string.Empty,
string.Empty, string.Empty,
@@ -240,35 +246,33 @@ public class PrepareTroubleshootingPlaybackHandler(
return playoutItemResult; return playoutItemResult;
} }
private static async Task<List<Subtitle>> GetSelectedSubtitle( private static async Task<List<Subtitle>> GetSubtitles(MediaItem mediaItem, PrepareTroubleshootingPlayback request)
MediaItem mediaItem,
PrepareTroubleshootingPlayback request)
{ {
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");
}
if (request.SubtitleId is not null) 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); allSubtitles.RemoveAll(s => s.Id != request.SubtitleId.Value);
foreach (Subtitle subtitle in allSubtitles) foreach (Subtitle subtitle in allSubtitles)
@@ -279,7 +283,7 @@ public class PrepareTroubleshootingPlaybackHandler(
} }
} }
return []; return allSubtitles;
} }
private static async Task<Validation<BaseError, Tuple<MediaItem, string, string, FFmpegProfile>>> Validate( private static async Task<Validation<BaseError, Tuple<MediaItem, string, string, FFmpegProfile>>> Validate(
@@ -5,6 +5,7 @@ namespace ErsatzTV.Application.Troubleshooting;
public record StartTroubleshootingPlayback( public record StartTroubleshootingPlayback(
Guid SessionId, Guid SessionId,
string StreamSelector,
PlayoutItemResult PlayoutItemResult, PlayoutItemResult PlayoutItemResult,
MediaItemInfo MediaItemInfo, MediaItemInfo MediaItemInfo,
TroubleshootingInfo TroubleshootingInfo) : IRequest, IFFmpegWorkerRequest; TroubleshootingInfo TroubleshootingInfo) : IRequest, IFFmpegWorkerRequest;
@@ -61,6 +61,18 @@ public class StartTroubleshootingPlaybackHandler(
troubleshootingInfoJson, troubleshootingInfoJson,
cancellationToken); 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; HardwareAccelerationKind hwAccel = request.TroubleshootingInfo.FFmpegProfiles.Head().HardwareAcceleration;
if (hwAccel is HardwareAccelerationKind.Qsv) if (hwAccel is HardwareAccelerationKind.Qsv)
{ {
@@ -34,6 +34,8 @@ public class TroubleshootController(
[FromQuery] [FromQuery]
List<int> graphicsElement, List<int> graphicsElement,
[FromQuery] [FromQuery]
string streamSelector,
[FromQuery]
int? subtitleId, int? subtitleId,
[FromQuery] [FromQuery]
int seekSeconds, int seekSeconds,
@@ -48,6 +50,7 @@ public class TroubleshootController(
streamingMode, streamingMode,
mediaItem, mediaItem,
ffmpegProfile, ffmpegProfile,
streamSelector,
watermark, watermark,
graphicsElement, graphicsElement,
subtitleId, subtitleId,
@@ -82,6 +85,7 @@ public class TroubleshootController(
await channelWriter.WriteAsync( await channelWriter.WriteAsync(
new StartTroubleshootingPlayback( new StartTroubleshootingPlayback(
sessionId, sessionId,
streamSelector,
playoutItemResult, playoutItemResult,
mediaInfo, mediaInfo,
troubleshootingInfo), troubleshootingInfo),
@@ -141,7 +145,13 @@ public class TroubleshootController(
Option<int> ss = seekSeconds > 0 ? seekSeconds : Option<int>.None; Option<int> ss = seekSeconds > 0 ? seekSeconds : Option<int>.None;
Option<string> maybeArchivePath = await mediator.Send( Option<string> maybeArchivePath = await mediator.Send(
new ArchiveTroubleshootingResults(mediaItem, ffmpegProfile, streamingMode, watermark, graphicsElement, ss), new ArchiveTroubleshootingResults(
mediaItem,
ffmpegProfile,
streamingMode,
watermark,
graphicsElement,
ss),
cancellationToken); cancellationToken);
foreach (string archivePath in maybeArchivePath) foreach (string archivePath in maybeArchivePath)
+52 -13
View File
@@ -1,4 +1,5 @@
@page "/system/troubleshooting/playback" @page "/system/troubleshooting/playback"
@using ErsatzTV.Application.Channels
@using ErsatzTV.Application.FFmpegProfiles @using ErsatzTV.Application.FFmpegProfiles
@using ErsatzTV.Application.Graphics @using ErsatzTV.Application.Graphics
@using ErsatzTV.Application.MediaItems @using ErsatzTV.Application.MediaItems
@@ -7,6 +8,7 @@
@using ErsatzTV.Application.Watermarks @using ErsatzTV.Application.Watermarks
@using ErsatzTV.Core.Notifications @using ErsatzTV.Core.Notifications
@using MediatR.Courier @using MediatR.Courier
@using Microsoft.AspNetCore.WebUtilities
@implements IDisposable @implements IDisposable
@inject IMediator Mediator @inject IMediator Mediator
@inject NavigationManager NavigationManager @inject NavigationManager NavigationManager
@@ -55,6 +57,17 @@
} }
</MudSelect> </MudSelect>
</MudStack> </MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Stream Selector</MudText>
</div>
<MudSelect @bind-Value="_streamSelector" For="@(() => _streamSelector)" Clearable="true">
@foreach (string selector in _streamSelectors)
{
<MudSelectItem T="string" Value="@selector">@selector</MudSelectItem>
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5"> <MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex"> <div class="d-flex">
<MudText>Streaming Mode</MudText> <MudText>Streaming Mode</MudText>
@@ -68,7 +81,7 @@
<div class="d-flex"> <div class="d-flex">
<MudText>Subtitle</MudText> <MudText>Subtitle</MudText>
</div> </div>
<MudSelect @bind-Value="_subtitleId" For="@(() => _subtitleId)" Clearable="true"> <MudSelect @bind-Value="_subtitleId" For="@(() => _subtitleId)" Clearable="true" Disabled="@(!string.IsNullOrWhiteSpace(_streamSelector))">
<MudSelectItem T="int?" Value="@((int?)null)">(none)</MudSelectItem> <MudSelectItem T="int?" Value="@((int?)null)">(none)</MudSelectItem>
@foreach (SubtitleViewModel subtitleStream in _subtitleStreams) @foreach (SubtitleViewModel subtitleStream in _subtitleStreams)
{ {
@@ -149,12 +162,14 @@
private CancellationTokenSource _cts; private CancellationTokenSource _cts;
private readonly List<FFmpegProfileViewModel> _ffmpegProfiles = []; private readonly List<FFmpegProfileViewModel> _ffmpegProfiles = [];
private readonly List<string> _streamSelectors = [];
private readonly List<WatermarkViewModel> _watermarks = []; private readonly List<WatermarkViewModel> _watermarks = [];
private readonly List<SubtitleViewModel> _subtitleStreams = []; private readonly List<SubtitleViewModel> _subtitleStreams = [];
private readonly List<GraphicsElementViewModel> _graphicsElements = []; private readonly List<GraphicsElementViewModel> _graphicsElements = [];
private MediaItemInfo _info; private MediaItemInfo _info;
private StreamingMode _streamingMode = StreamingMode.HttpLiveStreamingSegmenter; private StreamingMode _streamingMode = StreamingMode.HttpLiveStreamingSegmenter;
private int _ffmpegProfileId; private int _ffmpegProfileId;
private string _streamSelector;
private IEnumerable<string> _watermarkNames = new System.Collections.Generic.HashSet<string>(); private IEnumerable<string> _watermarkNames = new System.Collections.Generic.HashSet<string>();
private IEnumerable<string> _graphicsElementNames = new System.Collections.Generic.HashSet<string>(); private IEnumerable<string> _graphicsElementNames = new System.Collections.Generic.HashSet<string>();
private bool _startFromBeginning; private bool _startFromBeginning;
@@ -193,6 +208,10 @@
_ffmpegProfileId = _ffmpegProfiles.Map(f => f.Id).Head(); _ffmpegProfileId = _ffmpegProfiles.Map(f => f.Id).Head();
} }
_streamSelectors.Clear();
_streamSelectors.AddRange(await Mediator.Send(new GetChannelStreamSelectors(), token));
_streamSelector = null;
_watermarks.Clear(); _watermarks.Clear();
_watermarks.AddRange(await Mediator.Send(new GetAllWatermarks(), token)); _watermarks.AddRange(await Mediator.Send(new GetAllWatermarks(), token));
@@ -221,14 +240,21 @@
private async Task PreviewChannel() private async Task PreviewChannel()
{ {
var uri = new UriBuilder(NavigationManager.ToAbsoluteUri(NavigationManager.Uri)); var baseUri = NavigationManager.ToAbsoluteUri(NavigationManager.Uri).ToString();
uri.Path = uri.Path.Replace("/system/troubleshooting/playback", "/api/troubleshoot/playback.m3u8"); string apiUri = baseUri.Replace("/system/troubleshooting/playback", "/api/troubleshoot/playback.m3u8");
uri.Query = $"?mediaItem={MediaItemId}&ffmpegProfile={_ffmpegProfileId}&streamingMode={(int)_streamingMode}&seekSeconds={_seekSeconds}"; var queryString = new Dictionary<string, string>
{
["mediaItem"] = (MediaItemId ?? 0).ToString(),
["ffmpegProfile"] = _ffmpegProfileId.ToString(),
["streamingMode"] = ((int)_streamingMode).ToString(),
["seekSeconds"] = _seekSeconds.ToString()
};
foreach (string watermarkName in _watermarkNames) foreach (string watermarkName in _watermarkNames)
{ {
foreach (WatermarkViewModel watermark in _watermarks.Where(wm => wm.Name == watermarkName)) 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)) 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)); await Task.Delay(TimeSpan.FromSeconds(1));
@@ -281,13 +312,20 @@
private async Task DownloadResults() 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<string, string>
{
["mediaItem"] = (MediaItemId ?? 0).ToString(),
["ffmpegProfile"] = _ffmpegProfileId.ToString(),
["streamingMode"] = ((int)_streamingMode).ToString(),
["seekSeconds"] = _seekSeconds.ToString()
};
foreach (string watermarkName in _watermarkNames) foreach (string watermarkName in _watermarkNames)
{ {
foreach (WatermarkViewModel watermark in _watermarks.Where(wm => wm.Name == watermarkName)) 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)) 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) private void HandleTroubleshootingCompleted(PlaybackTroubleshootingCompletedNotification result)