Browse Source

add channel playback troubleshooter (#2641)

* fix motion graphics loop when seeking

* add channel playback troubleshooter

* fix errors
pull/2639/head
Jason Dove 2 months ago committed by GitHub
parent
commit
42b35f7aae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 4
      CHANGELOG.md
  2. 4
      ErsatzTV.Application/Streaming/HlsSessionWorker.cs
  3. 3
      ErsatzTV.Application/Streaming/PlayoutItemProcessModel.cs
  4. 37
      ErsatzTV.Application/Streaming/Queries/FFmpegProcessHandler.cs
  5. 3
      ErsatzTV.Application/Streaming/Queries/FFmpegProcessRequest.cs
  6. 3
      ErsatzTV.Application/Streaming/Queries/GetConcatProcessByChannelNumber.cs
  7. 3
      ErsatzTV.Application/Streaming/Queries/GetConcatProcessByChannelNumberHandler.cs
  8. 3
      ErsatzTV.Application/Streaming/Queries/GetErrorProcess.cs
  9. 3
      ErsatzTV.Application/Streaming/Queries/GetErrorProcessHandler.cs
  10. 7
      ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumber.cs
  11. 58
      ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs
  12. 3
      ErsatzTV.Application/Streaming/Queries/GetWrappedProcessByChannelNumber.cs
  13. 3
      ErsatzTV.Application/Streaming/Queries/GetWrappedProcessByChannelNumberHandler.cs
  14. 11
      ErsatzTV.Application/Troubleshooting/Commands/ArchiveTroubleshootingResults.cs
  15. 6
      ErsatzTV.Application/Troubleshooting/Commands/ArchiveTroubleshootingResultsHandler.cs
  16. 4
      ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlayback.cs
  17. 71
      ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs
  18. 2
      ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlayback.cs
  19. 17
      ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlaybackHandler.cs
  20. 12
      ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs
  21. 6
      ErsatzTV.Core/Interfaces/FFmpeg/PlayoutItemResult.cs
  22. 2
      ErsatzTV.Infrastructure/Streaming/Graphics/Motion/MotionElement.cs
  23. 144
      ErsatzTV/Controllers/Api/TroubleshootController.cs
  24. 2
      ErsatzTV/Controllers/InternalController.cs
  25. 2
      ErsatzTV/Controllers/IptvController.cs
  26. 1
      ErsatzTV/ErsatzTV.csproj
  27. 16
      ErsatzTV/Pages/Channels.razor
  28. 294
      ErsatzTV/Pages/Troubleshooting/PlaybackTroubleshooting.razor
  29. 3
      ErsatzTV/Pages/Troubleshooting/Troubleshooting.razor

4
CHANGELOG.md

@ -48,6 +48,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- For example, adding `<etv:episode_number_key>{{ episode_number }}</etv:episode_number_key>` to `episode.sbntxt` will also add the `episode_number_key` field to all EPG items in the graphics engine - For example, adding `<etv:episode_number_key>{{ episode_number }}</etv:episode_number_key>` to `episode.sbntxt` will also add the `episode_number_key` field to all EPG items in the graphics engine
- All values parsed from XMLTV will be available as strings in the graphics engine (not numbers) - All values parsed from XMLTV will be available as strings in the graphics engine (not numbers)
- All `etv:` nodes will be stripped from the XMLTV data when requested by a client - All `etv:` nodes will be stripped from the XMLTV data when requested by a client
- Add channel troubleshooting button to channels list
- This will open the playback troubleshooting tool in "channel" mode
- This mode requires entering a date and time, and will play up to 30 seconds of *one item from that channel's playout*
### Fixed ### Fixed
- Fix HLS Direct playback with Jellyfin 10.11 - Fix HLS Direct playback with Jellyfin 10.11
@ -71,6 +74,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- This fix applies to all libraries (local and media server) - This fix applies to all libraries (local and media server)
- Fix (3 year old) bug removing tags from local libraries when they are removed from NFO files (all content types) - Fix (3 year old) bug removing tags from local libraries when they are removed from NFO files (all content types)
- New scans will properly remove old tags; NFO files may need to be touched to force updating during a scan - New scans will properly remove old tags; NFO files may need to be touched to force updating during a scan
- Fix bug where looping motion graphics wouldn't be displayed when seeking into second half of content
### Changed ### Changed
- Use smaller batch size for search index updates (100, down from 1000) - Use smaller batch size for search index updates (100, down from 1000)

4
ErsatzTV.Application/Streaming/HlsSessionWorker.cs

@ -461,7 +461,9 @@ public class HlsSessionWorker : IHlsSessionWorker
realtime, realtime,
_channelStart, _channelStart,
ptsOffset, ptsOffset,
_targetFramerate); _targetFramerate,
IsTroubleshooting: false,
Option<int>.None);
// _logger.LogInformation("Request {@Request}", request); // _logger.LogInformation("Request {@Request}", request);

3
ErsatzTV.Application/Streaming/PlayoutItemProcessModel.cs

@ -9,4 +9,5 @@ public record PlayoutItemProcessModel(
Option<TimeSpan> MaybeDuration, Option<TimeSpan> MaybeDuration,
DateTimeOffset Until, DateTimeOffset Until,
bool IsComplete, bool IsComplete,
Option<long> SegmentKey); Option<long> SegmentKey,
Option<int> MediaItemId);

37
ErsatzTV.Application/Streaming/Queries/FFmpegProcessHandler.cs

@ -49,22 +49,41 @@ public abstract class FFmpegProcessHandler<T> : IRequestHandler<T, Either<BaseEr
await FFprobePathMustExist(dbContext, cancellationToken)) await FFprobePathMustExist(dbContext, cancellationToken))
.Apply((channel, ffmpegPath, ffprobePath) => Tuple(channel, ffmpegPath, ffprobePath)); .Apply((channel, ffmpegPath, ffprobePath) => Tuple(channel, ffmpegPath, ffprobePath));
private static Task<Validation<BaseError, Channel>> ChannelMustExist( private static async Task<Validation<BaseError, Channel>> ChannelMustExist(
TvContext dbContext, TvContext dbContext,
T request, T request,
CancellationToken cancellationToken) => CancellationToken cancellationToken)
dbContext.Channels {
Option<Channel> maybeChannel = await dbContext.Channels
.AsNoTracking()
.Include(c => c.FFmpegProfile) .Include(c => c.FFmpegProfile)
.ThenInclude(p => p.Resolution) .ThenInclude(p => p.Resolution)
.Include(c => c.Artwork) .Include(c => c.Artwork)
.Include(c => c.Watermark) .Include(c => c.Watermark)
.SelectOneAsync(c => c.Number, c => c.Number == request.ChannelNumber, cancellationToken) .SelectOneAsync(c => c.Number, c => c.Number == request.ChannelNumber, cancellationToken);
.MapT(channel =>
foreach (var channel in maybeChannel)
{
channel.StreamingMode = request.Mode;
foreach (int ffmpegProfileId in request.FFmpegProfileId)
{ {
channel.StreamingMode = request.Mode; Option<FFmpegProfile> maybeFFmpegProfile = await dbContext.FFmpegProfiles
return channel; .AsNoTracking()
}) .Include(ff => ff.Resolution)
.Map(o => o.ToValidation<BaseError>($"Channel number {request.ChannelNumber} does not exist.")); .SelectOneAsync(ff => ff.Id, ff => ff.Id == ffmpegProfileId, cancellationToken);
foreach (var ffmpegProfile in maybeFFmpegProfile)
{
channel.FFmpegProfile = ffmpegProfile;
channel.FFmpegProfileId = ffmpegProfile.Id;
}
}
return channel;
}
return BaseError.New($"Channel number {request.ChannelNumber} does not exist.");
}
private static Task<Validation<BaseError, string>> FFmpegPathMustExist( private static Task<Validation<BaseError, string>> FFmpegPathMustExist(
TvContext dbContext, TvContext dbContext,

3
ErsatzTV.Application/Streaming/Queries/FFmpegProcessRequest.cs

@ -10,4 +10,5 @@ public record FFmpegProcessRequest(
bool StartAtZero, bool StartAtZero,
bool HlsRealtime, bool HlsRealtime,
DateTimeOffset ChannelStartTime, DateTimeOffset ChannelStartTime,
TimeSpan PtsOffset) : IRequest<Either<BaseError, PlayoutItemProcessModel>>; TimeSpan PtsOffset,
Option<int> FFmpegProfileId) : IRequest<Either<BaseError, PlayoutItemProcessModel>>;

3
ErsatzTV.Application/Streaming/Queries/GetConcatProcessByChannelNumber.cs

@ -11,7 +11,8 @@ public record GetConcatProcessByChannelNumber : FFmpegProcessRequest
false, false,
true, true,
DateTimeOffset.Now, // unused DateTimeOffset.Now, // unused
TimeSpan.Zero) TimeSpan.Zero,
Option<int>.None)
{ {
Scheme = scheme; Scheme = scheme;
Host = host; Host = host;

3
ErsatzTV.Application/Streaming/Queries/GetConcatProcessByChannelNumberHandler.cs

@ -44,6 +44,7 @@ public class GetConcatProcessByChannelNumberHandler : FFmpegProcessHandler<GetCo
Option<TimeSpan>.None, Option<TimeSpan>.None,
DateTimeOffset.MaxValue, DateTimeOffset.MaxValue,
true, true,
Option<long>.None); Option<long>.None,
Option<int>.None);
} }
} }

3
ErsatzTV.Application/Streaming/Queries/GetErrorProcess.cs

@ -16,4 +16,5 @@ public record GetErrorProcess(
true, true,
HlsRealtime, HlsRealtime,
DateTimeOffset.Now, // unused DateTimeOffset.Now, // unused
PtsOffset); PtsOffset,
Option<int>.None);

3
ErsatzTV.Application/Streaming/Queries/GetErrorProcessHandler.cs

@ -42,6 +42,7 @@ public class GetErrorProcessHandler(
request.MaybeDuration, request.MaybeDuration,
request.Until, request.Until,
true, true,
now.ToUnixTimeSeconds()); now.ToUnixTimeSeconds(),
Option<int>.None);
} }
} }

7
ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumber.cs

@ -10,11 +10,14 @@ public record GetPlayoutItemProcessByChannelNumber(
bool HlsRealtime, bool HlsRealtime,
DateTimeOffset ChannelStart, DateTimeOffset ChannelStart,
TimeSpan PtsOffset, TimeSpan PtsOffset,
Option<int> TargetFramerate) : FFmpegProcessRequest( Option<int> TargetFramerate,
bool IsTroubleshooting,
Option<int> FFmpegProfileId) : FFmpegProcessRequest(
ChannelNumber, ChannelNumber,
Mode, Mode,
Now, Now,
StartAtZero, StartAtZero,
HlsRealtime, HlsRealtime,
ChannelStart, ChannelStart,
PtsOffset); PtsOffset,
FFmpegProfileId);

58
ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs

@ -283,18 +283,31 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
bool isComplete = true; bool isComplete = true;
// if we are working ahead, limit to 44s (multiple of segment size) TimeSpan limit = TimeSpan.Zero;
if (!request.HlsRealtime) if (!request.HlsRealtime)
{ {
TimeSpan limit = TimeSpan.FromSeconds(44); // if we are working ahead, limit to 44s (multiple of segment size)
limit = TimeSpan.FromSeconds(44);
}
if (duration > limit) if (request.IsTroubleshooting)
{ {
finish = effectiveNow + limit; // if we are troubleshooting, limit to 30s
outPoint = inPoint + limit; limit = TimeSpan.FromSeconds(30);
duration = limit; }
isComplete = false;
} if (limit > TimeSpan.Zero && duration > limit)
{
finish = effectiveNow + limit;
outPoint = inPoint + limit;
duration = limit;
isComplete = false;
}
if (request.IsTroubleshooting)
{
channel.Number = ".troubleshooting";
} }
if (_isDebugNoSync) if (_isDebugNoSync)
@ -318,7 +331,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
duration, duration,
finish, finish,
true, true,
now.ToUnixTimeSeconds()); now.ToUnixTimeSeconds(),
Option<int>.None);
} }
MediaVersion version = playoutItemWithPath.PlayoutItem.MediaItem.GetHeadVersion(); MediaVersion version = playoutItemWithPath.PlayoutItem.MediaItem.GetHeadVersion();
@ -392,7 +406,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
bool saveReports = await dbContext.ConfigElements bool saveReports = await dbContext.ConfigElements
.GetValue<bool>(ConfigElementKey.FFmpegSaveReports, cancellationToken) .GetValue<bool>(ConfigElementKey.FFmpegSaveReports, cancellationToken)
.Map(result => result.IfNone(false)); .Map(result => result.IfNone(false)) || request.IsTroubleshooting;
_logger.LogDebug( _logger.LogDebug(
"S: {Start}, F: {Finish}, In: {InPoint}, Out: {OutPoint}, EffNow: {EffectiveNow}, Dur: {Duration}", "S: {Start}, F: {Finish}, In: {InPoint}, Out: {OutPoint}, EffNow: {EffectiveNow}, Dur: {Duration}",
@ -435,7 +449,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
request.ChannelStartTime, request.ChannelStartTime,
request.PtsOffset, request.PtsOffset,
request.TargetFramerate, request.TargetFramerate,
Option<string>.None, request.IsTroubleshooting ? FileSystemLayout.TranscodeTroubleshootingFolder : Option<string>.None,
_ => { }, _ => { },
canProxy: true, canProxy: true,
cancellationToken); cancellationToken);
@ -446,7 +460,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
duration, duration,
finish, finish,
isComplete, isComplete,
effectiveNow.ToUnixTimeSeconds()); effectiveNow.ToUnixTimeSeconds(),
playoutItemResult.MediaItemId);
return Right<BaseError, PlayoutItemProcessModel>(result); return Right<BaseError, PlayoutItemProcessModel>(result);
} }
@ -465,6 +480,14 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
DateTimeOffset finish = maybeNextStart.Match(s => s, () => now); DateTimeOffset finish = maybeNextStart.Match(s => s, () => now);
if (request.IsTroubleshooting)
{
channel.Number = ".troubleshooting";
maybeDuration = TimeSpan.FromSeconds(30);
finish = now + TimeSpan.FromSeconds(30);
}
_logger.LogWarning( _logger.LogWarning(
"Error locating playout item {@Error}. Will display error from {Start} to {Finish}", "Error locating playout item {@Error}. Will display error from {Start} to {Finish}",
error, error,
@ -493,7 +516,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
maybeDuration, maybeDuration,
finish, finish,
true, true,
now.ToUnixTimeSeconds()); now.ToUnixTimeSeconds(),
Option<int>.None);
case PlayoutItemDoesNotExistOnDisk: case PlayoutItemDoesNotExistOnDisk:
Command doesNotExistProcess = await _ffmpegProcessService.ForError( Command doesNotExistProcess = await _ffmpegProcessService.ForError(
ffmpegPath, ffmpegPath,
@ -514,7 +538,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
maybeDuration, maybeDuration,
finish, finish,
true, true,
now.ToUnixTimeSeconds()); now.ToUnixTimeSeconds(),
Option<int>.None);
default: default:
Command errorProcess = await _ffmpegProcessService.ForError( Command errorProcess = await _ffmpegProcessService.ForError(
ffmpegPath, ffmpegPath,
@ -535,7 +560,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
maybeDuration, maybeDuration,
finish, finish,
true, true,
now.ToUnixTimeSeconds()); now.ToUnixTimeSeconds(),
Option<int>.None);
} }
} }

3
ErsatzTV.Application/Streaming/Queries/GetWrappedProcessByChannelNumber.cs

@ -15,7 +15,8 @@ public record GetWrappedProcessByChannelNumber : FFmpegProcessRequest
false, false,
true, true,
DateTimeOffset.Now, // unused DateTimeOffset.Now, // unused
TimeSpan.Zero) TimeSpan.Zero,
Option<int>.None)
{ {
Scheme = scheme; Scheme = scheme;
Host = host; Host = host;

3
ErsatzTV.Application/Streaming/Queries/GetWrappedProcessByChannelNumberHandler.cs

@ -46,6 +46,7 @@ public class GetWrappedProcessByChannelNumberHandler : FFmpegProcessHandler<GetW
Option<TimeSpan>.None, Option<TimeSpan>.None,
DateTimeOffset.MaxValue, DateTimeOffset.MaxValue,
true, true,
Option<long>.None); Option<long>.None,
Option<int>.None);
} }
} }

11
ErsatzTV.Application/Troubleshooting/Commands/ArchiveTroubleshootingResults.cs

@ -1,12 +1,3 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Troubleshooting; namespace ErsatzTV.Application.Troubleshooting;
public record ArchiveTroubleshootingResults( public record ArchiveTroubleshootingResults : IRequest<Option<string>>;
int MediaItemId,
int FFmpegProfileId,
StreamingMode StreamingMode,
List<int> WatermarkIds,
List<int> GraphicsElementIds,
Option<int> SeekSeconds)
: IRequest<Option<string>>;

6
ErsatzTV.Application/Troubleshooting/Commands/ArchiveTroubleshootingResultsHandler.cs

@ -25,6 +25,12 @@ public class ArchiveTroubleshootingResultsHandler(ILocalFileSystem localFileSyst
continue; continue;
} }
if (fileName.Equals("logs.txt", StringComparison.OrdinalIgnoreCase))
{
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);

4
ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlayback.cs

@ -8,10 +8,12 @@ public record PrepareTroubleshootingPlayback(
Guid SessionId, Guid SessionId,
StreamingMode StreamingMode, StreamingMode StreamingMode,
int MediaItemId, int MediaItemId,
int ChannelId,
int FFmpegProfileId, int FFmpegProfileId,
string StreamSelector, string StreamSelector,
List<int> WatermarkIds, List<int> WatermarkIds,
List<int> GraphicsElementIds, List<int> GraphicsElementIds,
int? SubtitleId, int? SubtitleId,
Option<int> SeekSeconds) Option<int> SeekSeconds,
Option<DateTimeOffset> Start)
: IRequest<Either<BaseError, PlayoutItemResult>>; : IRequest<Either<BaseError, PlayoutItemResult>>;

71
ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs

@ -1,4 +1,5 @@
using Dapper; using Dapper;
using ErsatzTV.Application.Streaming;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler; using ErsatzTV.Core.Domain.Filler;
@ -49,6 +50,70 @@ public class PrepareTroubleshootingPlaybackHandler(
{ {
using var logContext = LogContext.PushProperty(InMemoryLogService.CorrelationIdKey, request.SessionId); using var logContext = LogContext.PushProperty(InMemoryLogService.CorrelationIdKey, request.SessionId);
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
if (request.ChannelId > 0)
{
if (request.Start.IsNone)
{
return BaseError.New("Channel start is required");
}
if (entityLocker.IsTroubleshootingPlaybackLocked())
{
return BaseError.New("Troubleshooting playback is locked");
}
entityLocker.LockTroubleshootingPlayback();
localFileSystem.EnsureFolderExists(FileSystemLayout.TranscodeTroubleshootingFolder);
localFileSystem.EmptyFolder(FileSystemLayout.TranscodeTroubleshootingFolder);
foreach (var start in request.Start)
{
Option<Channel> maybeChannel = await dbContext.Channels
.AsNoTracking()
.SelectOneAsync(c => c.Id, c => c.Id == request.ChannelId, cancellationToken);
foreach (var channel in maybeChannel)
{
Either<BaseError, PlayoutItemProcessModel> result = await mediator.Send(
new GetPlayoutItemProcessByChannelNumber(
channel.Number,
request.StreamingMode,
start,
StartAtZero: false,
HlsRealtime: false,
start,
TimeSpan.Zero,
TargetFramerate: Option<int>.None,
IsTroubleshooting: true,
request.FFmpegProfileId),
cancellationToken);
foreach (var error in result.LeftToSeq())
{
await mediator.Publish(
new PlaybackTroubleshootingCompletedNotification(
-1,
#pragma warning disable CA2201
new Exception(error.ToString()),
#pragma warning restore CA2201
Option<double>.None),
cancellationToken);
entityLocker.UnlockTroubleshootingPlayback();
}
return result.Map(model => new PlayoutItemResult(model.Process, model.GraphicsEngineContext, model.MediaItemId));
}
if (maybeChannel.IsNone)
{
entityLocker.UnlockTroubleshootingPlayback();
return BaseError.New($"Channel {request.ChannelId} does not exist");
}
}
}
Validation<BaseError, Tuple<MediaItem, string, string, FFmpegProfile>> validation = await Validate( Validation<BaseError, Tuple<MediaItem, string, string, FFmpegProfile>> validation = await Validate(
dbContext, dbContext,
request, request,
@ -67,7 +132,9 @@ public class PrepareTroubleshootingPlaybackHandler(
catch (Exception ex) catch (Exception ex)
{ {
entityLocker.UnlockTroubleshootingPlayback(); entityLocker.UnlockTroubleshootingPlayback();
await mediator.Publish(new PlaybackTroubleshootingCompletedNotification(-1, ex, Option<double>.None), cancellationToken); await mediator.Publish(
new PlaybackTroubleshootingCompletedNotification(-1, ex, Option<double>.None),
cancellationToken);
logger.LogError(ex, "Error while preparing troubleshooting playback"); logger.LogError(ex, "Error while preparing troubleshooting playback");
return BaseError.New(ex.Message); return BaseError.New(ex.Message);
} }
@ -222,7 +289,7 @@ public class PrepareTroubleshootingPlaybackHandler(
PlayoutItemResult playoutItemResult = await ffmpegProcessService.ForPlayoutItem( PlayoutItemResult playoutItemResult = await ffmpegProcessService.ForPlayoutItem(
ffmpegPath, ffmpegPath,
ffprobePath, ffprobePath,
true, saveReports: true,
channel, channel,
new MediaItemVideoVersion(mediaItem, videoVersion), new MediaItemVideoVersion(mediaItem, videoVersion),
new MediaItemAudioVersion(mediaItem, version), new MediaItemAudioVersion(mediaItem, version),

2
ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlayback.cs

@ -7,5 +7,5 @@ public record StartTroubleshootingPlayback(
Guid SessionId, Guid SessionId,
string StreamSelector, string StreamSelector,
PlayoutItemResult PlayoutItemResult, PlayoutItemResult PlayoutItemResult,
MediaItemInfo MediaItemInfo, Option<MediaItemInfo> MediaItemInfo,
TroubleshootingInfo TroubleshootingInfo) : IRequest, IFFmpegWorkerRequest; TroubleshootingInfo TroubleshootingInfo) : IRequest, IFFmpegWorkerRequest;

17
ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlaybackHandler.cs

@ -45,11 +45,14 @@ public partial class StartTroubleshootingPlaybackHandler(
using var logContext = LogContext.PushProperty(InMemoryLogService.CorrelationIdKey, request.SessionId); using var logContext = LogContext.PushProperty(InMemoryLogService.CorrelationIdKey, request.SessionId);
// write media info without title // write media info without title
string infoJson = JsonSerializer.Serialize(request.MediaItemInfo with { Title = null }, Options); foreach (var mediaInfo in request.MediaItemInfo)
await File.WriteAllTextAsync( {
Path.Combine(FileSystemLayout.TranscodeTroubleshootingFolder, "media_info.json"), string infoJson = JsonSerializer.Serialize(mediaInfo with { Title = null }, Options);
infoJson, await File.WriteAllTextAsync(
cancellationToken); Path.Combine(FileSystemLayout.TranscodeTroubleshootingFolder, "media_info.json"),
infoJson,
cancellationToken);
}
// write troubleshooting info // write troubleshooting info
string troubleshootingInfoJson = JsonSerializer.Serialize( string troubleshootingInfoJson = JsonSerializer.Serialize(
@ -140,6 +143,8 @@ public partial class StartTroubleshootingPlaybackHandler(
.WithValidation(CommandResultValidation.None) .WithValidation(CommandResultValidation.None)
.ExecuteAsync(linkedCts.Token); .ExecuteAsync(linkedCts.Token);
logger.LogDebug("Troubleshooting playback completed with exit code {ExitCode}", commandResult.ExitCode);
try try
{ {
IEnumerable<string> logs = logService.Sink.GetLogs(request.SessionId); IEnumerable<string> logs = logService.Sink.GetLogs(request.SessionId);
@ -176,8 +181,6 @@ public partial class StartTroubleshootingPlaybackHandler(
maybeSpeed), maybeSpeed),
linkedCts.Token); linkedCts.Token);
logger.LogDebug("Troubleshooting playback completed with exit code {ExitCode}", commandResult.ExitCode);
if (commandResult.ExitCode != 0) if (commandResult.ExitCode != 0)
{ {
await linkedCts.CancelAsync(); await linkedCts.CancelAsync();

12
ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs

@ -566,7 +566,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
videoVersion.MediaVersion is BackgroundImageMediaVersion { IsSongWithProgress: true }, videoVersion.MediaVersion is BackgroundImageMediaVersion { IsSongWithProgress: true },
false, false,
GetTonemapAlgorithm(playbackSettings), GetTonemapAlgorithm(playbackSettings),
channel.UniqueId == Guid.Empty); channel.Number == ".troubleshooting");
_logger.LogDebug("FFmpeg desired state {FrameState}", desiredState); _logger.LogDebug("FFmpeg desired state {FrameState}", desiredState);
@ -598,7 +598,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
graphicsEngineInput, graphicsEngineInput,
pipeline); pipeline);
return new PlayoutItemResult(command, graphicsEngineContext); return new PlayoutItemResult(command, graphicsEngineContext, videoVersion.MediaItem.Id);
} }
private async Task<ScanKind> ProbeScanKind( private async Task<ScanKind> ProbeScanKind(
@ -788,7 +788,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
_logger.LogDebug("HW accel mode: {HwAccel}", hwAccel); _logger.LogDebug("HW accel mode: {HwAccel}", hwAccel);
var ffmpegState = new FFmpegState( var ffmpegState = new FFmpegState(
false, channel.Number == ".troubleshooting",
HardwareAccelerationMode.None, // no hw accel decode since errors loop HardwareAccelerationMode.None, // no hw accel decode since errors loop
hwAccel, hwAccel,
VaapiDriverName(hwAccel, vaapiDriver), VaapiDriverName(hwAccel, vaapiDriver),
@ -812,7 +812,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
false, false,
false, false,
GetTonemapAlgorithm(playbackSettings), GetTonemapAlgorithm(playbackSettings),
channel.UniqueId == Guid.Empty); channel.Number == ".troubleshooting");
var ffmpegSubtitleStream = new ErsatzTV.FFmpeg.MediaStream(0, "ass", StreamKind.Video); var ffmpegSubtitleStream = new ErsatzTV.FFmpeg.MediaStream(0, "ass", StreamKind.Video);
@ -836,7 +836,9 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
VaapiDisplayName(hwAccel, vaapiDisplay), VaapiDisplayName(hwAccel, vaapiDisplay),
VaapiDriverName(hwAccel, vaapiDriver), VaapiDriverName(hwAccel, vaapiDriver),
VaapiDeviceName(hwAccel, vaapiDevice), VaapiDeviceName(hwAccel, vaapiDevice),
FileSystemLayout.FFmpegReportsFolder, channel.Number == ".troubleshooting"
? FileSystemLayout.TranscodeTroubleshootingFolder
: FileSystemLayout.FFmpegReportsFolder,
FileSystemLayout.FontsCacheFolder, FileSystemLayout.FontsCacheFolder,
ffmpegPath); ffmpegPath);

6
ErsatzTV.Core/Interfaces/FFmpeg/PlayoutItemResult.cs

@ -3,4 +3,8 @@ using ErsatzTV.Core.Interfaces.Streaming;
namespace ErsatzTV.Core.Interfaces.FFmpeg; namespace ErsatzTV.Core.Interfaces.FFmpeg;
public record PlayoutItemResult(Command Process, Option<GraphicsEngineContext> GraphicsEngineContext); public record PlayoutItemResult(
Command Process,
Option<GraphicsEngineContext> GraphicsEngineContext,
Option<int> MediaItemId);

2
ErsatzTV.Infrastructure/Streaming/Graphics/Motion/MotionElement.cs

@ -63,7 +63,7 @@ public class MotionElement(
ProbeResult probeResult = await ProbeMotionElement(context.FrameSize); ProbeResult probeResult = await ProbeMotionElement(context.FrameSize);
var overlayDuration = motionElement.EndBehavior switch var overlayDuration = motionElement.EndBehavior switch
{ {
MotionEndBehavior.Loop => context.Duration, MotionEndBehavior.Loop => context.Seek + context.Duration,
MotionEndBehavior.Hold => probeResult.Duration + holdDuration, MotionEndBehavior.Hold => probeResult.Duration + holdDuration,
_ => probeResult.Duration _ => probeResult.Duration
}; };

144
ErsatzTV/Controllers/Api/TroubleshootController.cs

@ -29,6 +29,8 @@ public class TroubleshootController(
[FromQuery] [FromQuery]
int mediaItem, int mediaItem,
[FromQuery] [FromQuery]
int channel,
[FromQuery]
int ffmpegProfile, int ffmpegProfile,
[FromQuery] [FromQuery]
StreamingMode streamingMode, StreamingMode streamingMode,
@ -42,6 +44,8 @@ public class TroubleshootController(
int? subtitleId, int? subtitleId,
[FromQuery] [FromQuery]
int seekSeconds, int seekSeconds,
[FromQuery]
DateTimeOffset? start,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var sessionId = Guid.NewGuid(); var sessionId = Guid.NewGuid();
@ -56,12 +60,14 @@ public class TroubleshootController(
sessionId, sessionId,
streamingMode, streamingMode,
mediaItem, mediaItem,
channel,
ffmpegProfile, ffmpegProfile,
streamSelector, streamSelector,
watermark, watermark,
graphicsElement, graphicsElement,
subtitleId, subtitleId,
ss), ss,
Optional(start)),
cancellationToken); cancellationToken);
if (result.IsLeft) if (result.IsLeft)
@ -72,75 +78,75 @@ public class TroubleshootController(
foreach (PlayoutItemResult playoutItemResult in result.RightToSeq()) foreach (PlayoutItemResult playoutItemResult in result.RightToSeq())
{ {
Either<BaseError, MediaItemInfo> maybeMediaInfo = Either<BaseError, MediaItemInfo> maybeMediaInfo =
await mediator.Send(new GetMediaItemInfo(mediaItem), cancellationToken); await mediator.Send(
foreach (MediaItemInfo mediaInfo in maybeMediaInfo.RightToSeq()) new GetMediaItemInfo(await playoutItemResult.MediaItemId.IfNoneAsync(0)),
cancellationToken);
try
{ {
try TroubleshootingInfo troubleshootingInfo = await mediator.Send(
new GetTroubleshootingInfo(),
cancellationToken);
// filter ffmpeg profiles
troubleshootingInfo.FFmpegProfiles.RemoveAll(p => p.Id != ffmpegProfile);
// filter watermarks
troubleshootingInfo.Watermarks.RemoveAll(p => !watermark.Contains(p.Id));
await channelWriter.WriteAsync(
new StartTroubleshootingPlayback(
sessionId,
streamSelector,
playoutItemResult,
maybeMediaInfo.ToOption(),
troubleshootingInfo),
cancellationToken);
string playlistFile = Path.Combine(FileSystemLayout.TranscodeTroubleshootingFolder, "live.m3u8");
while (!localFileSystem.FileExists(playlistFile))
{ {
TroubleshootingInfo troubleshootingInfo = await mediator.Send( await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken);
new GetTroubleshootingInfo(), if (cancellationToken.IsCancellationRequested || notifier.IsFailed(sessionId))
cancellationToken);
// filter ffmpeg profiles
troubleshootingInfo.FFmpegProfiles.RemoveAll(p => p.Id != ffmpegProfile);
// filter watermarks
troubleshootingInfo.Watermarks.RemoveAll(p => !watermark.Contains(p.Id));
await channelWriter.WriteAsync(
new StartTroubleshootingPlayback(
sessionId,
streamSelector,
playoutItemResult,
mediaInfo,
troubleshootingInfo),
cancellationToken);
string playlistFile = Path.Combine(FileSystemLayout.TranscodeTroubleshootingFolder, "live.m3u8");
while (!localFileSystem.FileExists(playlistFile))
{ {
await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken); break;
if (cancellationToken.IsCancellationRequested || notifier.IsFailed(sessionId))
{
break;
}
} }
}
int initialSegmentCount = await configElementRepository
.GetValue<int>(ConfigElementKey.FFmpegInitialSegmentCount, cancellationToken)
.Map(maybeCount => maybeCount.Match(c => c, () => 1));
int initialSegmentCount = await configElementRepository initialSegmentCount = Math.Max(initialSegmentCount, 2);
.GetValue<int>(ConfigElementKey.FFmpegInitialSegmentCount, cancellationToken)
.Map(maybeCount => maybeCount.Match(c => c, () => 1));
initialSegmentCount = Math.Max(initialSegmentCount, 2); bool hasSegments = false;
while (!hasSegments)
{
await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken);
bool hasSegments = false; string[] segmentFiles = streamingMode switch
while (!hasSegments)
{ {
await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken); // StreamingMode.HttpLiveStreamingSegmenter => Directory.GetFiles(
// FileSystemLayout.TranscodeTroubleshootingFolder,
string[] segmentFiles = streamingMode switch // "*.m4s"),
{ _ => Directory.GetFiles(FileSystemLayout.TranscodeTroubleshootingFolder, "*.ts")
// StreamingMode.HttpLiveStreamingSegmenter => Directory.GetFiles( };
// FileSystemLayout.TranscodeTroubleshootingFolder,
// "*.m4s"),
_ => Directory.GetFiles(FileSystemLayout.TranscodeTroubleshootingFolder, "*.ts")
};
if (segmentFiles.Length >= initialSegmentCount)
{
hasSegments = true;
}
}
if (!notifier.IsFailed(sessionId)) if (segmentFiles.Length >= initialSegmentCount)
{ {
return Redirect("~/iptv/session/.troubleshooting/live.m3u8"); hasSegments = true;
} }
} }
finally
if (!notifier.IsFailed(sessionId))
{ {
notifier.RemoveSession(sessionId); return Redirect("~/iptv/session/.troubleshooting/live.m3u8");
} }
} }
finally
{
notifier.RemoveSession(sessionId);
}
} }
} }
catch (Exception) catch (Exception)
@ -153,33 +159,9 @@ public class TroubleshootController(
[HttpHead("api/troubleshoot/playback/archive")] [HttpHead("api/troubleshoot/playback/archive")]
[HttpGet("api/troubleshoot/playback/archive")] [HttpGet("api/troubleshoot/playback/archive")]
public async Task<IActionResult> TroubleshootPlaybackArchive( public async Task<IActionResult> TroubleshootPlaybackArchive(CancellationToken cancellationToken)
[FromQuery]
int mediaItem,
[FromQuery]
int ffmpegProfile,
[FromQuery]
StreamingMode streamingMode,
[FromQuery]
List<int> watermark,
[FromQuery]
List<int> graphicsElement,
[FromQuery]
int seekSeconds,
CancellationToken cancellationToken)
{ {
Option<int> ss = seekSeconds > 0 ? seekSeconds : Option<int>.None; Option<string> maybeArchivePath = await mediator.Send(new ArchiveTroubleshootingResults(), cancellationToken);
Option<string> maybeArchivePath = await mediator.Send(
new ArchiveTroubleshootingResults(
mediaItem,
ffmpegProfile,
streamingMode,
watermark,
graphicsElement,
ss),
cancellationToken);
foreach (string archivePath in maybeArchivePath) foreach (string archivePath in maybeArchivePath)
{ {
FileStream fs = System.IO.File.OpenRead(archivePath); FileStream fs = System.IO.File.OpenRead(archivePath);

2
ErsatzTV/Controllers/InternalController.cs

@ -259,6 +259,8 @@ public class InternalController : StreamingControllerBase
true, true,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
Option<int>.None,
IsTroubleshooting: false,
Option<int>.None); Option<int>.None);
Either<BaseError, PlayoutItemProcessModel> result = await _mediator.Send(request); Either<BaseError, PlayoutItemProcessModel> result = await _mediator.Send(request);

2
ErsatzTV/Controllers/IptvController.cs

@ -346,6 +346,8 @@ public class IptvController : StreamingControllerBase
true, true,
DateTimeOffset.Now, DateTimeOffset.Now,
TimeSpan.Zero, TimeSpan.Zero,
Option<int>.None,
IsTroubleshooting: false,
Option<int>.None); Option<int>.None);
Either<BaseError, PlayoutItemProcessModel> result = await _mediator.Send(request); Either<BaseError, PlayoutItemProcessModel> result = await _mediator.Send(request);

1
ErsatzTV/ErsatzTV.csproj

@ -30,6 +30,7 @@
<PackageReference Include="Blazored.FluentValidation" Version="2.2.0" /> <PackageReference Include="Blazored.FluentValidation" Version="2.2.0" />
<PackageReference Include="BlazorSortable" Version="5.1.4" /> <PackageReference Include="BlazorSortable" Version="5.1.4" />
<PackageReference Include="Bugsnag.AspNet.Core" Version="4.1.0" /> <PackageReference Include="Bugsnag.AspNet.Core" Version="4.1.0" />
<PackageReference Include="Chronic.Core" Version="0.4.0" />
<PackageReference Include="FluentValidation" Version="12.1.0" /> <PackageReference Include="FluentValidation" Version="12.1.0" />
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.1" /> <PackageReference Include="FluentValidation.AspNetCore" Version="11.3.1" />
<PackageReference Include="Heron.MudCalendar" Version="3.3.0" /> <PackageReference Include="Heron.MudCalendar" Version="3.3.0" />

16
ErsatzTV/Pages/Channels.razor

@ -36,7 +36,7 @@
<col style="width: 15%"/> <col style="width: 15%"/>
<col style="width: 15%"/> <col style="width: 15%"/>
<col style="width: 15%"/> <col style="width: 15%"/>
<col style="width: 240px;"/> <col style="width: 300px;"/>
</MudHidden> </MudHidden>
</ColGroup> </ColGroup>
<HeaderContent> <HeaderContent>
@ -110,13 +110,25 @@
} }
else else
{ {
<div style="width: 48px"></div> <div style="width: 48px"></div>
} }
<MudTooltip Text="Edit Channel"> <MudTooltip Text="Edit Channel">
<MudIconButton Icon="@Icons.Material.Filled.Edit" <MudIconButton Icon="@Icons.Material.Filled.Edit"
Href="@($"channels/{context.Id}")"> Href="@($"channels/{context.Id}")">
</MudIconButton> </MudIconButton>
</MudTooltip> </MudTooltip>
@if (context.PlayoutCount > 0)
{
<MudTooltip Text="Troubleshoot Channel">
<MudIconButton Icon="@Icons.Material.Filled.Troubleshoot"
Href="@($"system/troubleshooting/playback?channel={context.Id}")">
</MudIconButton>
</MudTooltip>
}
else
{
<div style="width: 48px"></div>
}
<MudTooltip Text="Delete Channel"> <MudTooltip Text="Delete Channel">
<MudIconButton Icon="@Icons.Material.Filled.Delete" <MudIconButton Icon="@Icons.Material.Filled.Delete"
OnClick="@(_ => DeleteChannelAsync(context))"> OnClick="@(_ => DeleteChannelAsync(context))">

294
ErsatzTV/Pages/Troubleshooting/PlaybackTroubleshooting.razor

@ -1,4 +1,5 @@
@page "/system/troubleshooting/playback" @page "/system/troubleshooting/playback"
@using System.Globalization
@using ErsatzTV.Application.Channels @using ErsatzTV.Application.Channels
@using ErsatzTV.Application.FFmpegProfiles @using ErsatzTV.Application.FFmpegProfiles
@using ErsatzTV.Application.Graphics @using ErsatzTV.Application.Graphics
@ -32,21 +33,21 @@
</MudPaper> </MudPaper>
<div class="d-flex flex-column" style="height: 100vh; overflow-x: auto"> <div class="d-flex flex-column" style="height: 100vh; overflow-x: auto">
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8"> <MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudText Typo="Typo.h5" Class="mb-2">Media Item</MudText> <MudText Typo="Typo.h5" Class="mb-2">
<MudDivider Class="mb-6"/> @if (_channelMode)
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5"> {
<div class="d-flex"> @:Channel
<MudText>Media Item ID</MudText> }
</div> else
<MudTextField T="int?" Value="MediaItemId" ValueChanged="@(async x => await OnMediaItemIdChanged(x, CancellationToken.None))"/> {
</MudStack> @(_info?.Kind ?? "Playback")
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5"> }
<div class="d-flex"> Settings
<MudText>Title</MudText> @if (!string.IsNullOrWhiteSpace(_title))
</div> {
<MudTextField Value="@(_info?.Title)" Disabled="true"/> @: - @_title
</MudStack> }
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Playback Settings</MudText> </MudText>
<MudDivider Class="mb-6"/> <MudDivider Class="mb-6"/>
<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">
@ -70,55 +71,67 @@
} }
</MudSelect> </MudSelect>
</MudStack> </MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5"> @if (_channelMode)
<div class="d-flex"> {
<MudText>Subtitle</MudText> <MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
</div> <div class="d-flex">
<MudSelect @bind-Value="_subtitleId" For="@(() => _subtitleId)" Clearable="true" Disabled="@(!string.IsNullOrWhiteSpace(_streamSelector))"> <MudText>Date and Time</MudText>
<MudSelectItem T="int?" Value="@((int?)null)">(none)</MudSelectItem> </div>
@foreach (SubtitleViewModel subtitleStream in _subtitleStreams) <MudTextField T="string" @bind-Value="_startString" OnBlur="@OnStartStringBlur" />
{ </MudStack>
<MudSelectItem T="int?" Value="@subtitleStream.Id">@($"{subtitleStream.Id}: {subtitleStream.Language} - {subtitleStream.Title} ({subtitleStream.Codec})")</MudSelectItem> }
} else
</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>Subtitle</MudText>
<MudText>Watermarks</MudText> </div>
</div> <MudSelect @bind-Value="_subtitleId" For="@(() => _subtitleId)" Clearable="true" Disabled="@(!string.IsNullOrWhiteSpace(_streamSelector))">
<MudSelect T="string" @bind-SelectedValues="_watermarkNames" Clearable="true" MultiSelection="true"> <MudSelectItem T="int?" Value="@((int?)null)">(none)</MudSelectItem>
@foreach (WatermarkViewModel watermark in _watermarks) @foreach (SubtitleViewModel subtitleStream in _subtitleStreams)
{ {
<MudSelectItem T="string" Value="@watermark.Name">@watermark.Name</MudSelectItem> <MudSelectItem T="int?" Value="@subtitleStream.Id">@($"{subtitleStream.Id}: {subtitleStream.Language} - {subtitleStream.Title} ({subtitleStream.Codec})")</MudSelectItem>
} }
</MudSelect> </MudSelect>
</MudStack> </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>Graphics Elements</MudText> <MudText>Watermarks</MudText>
</div> </div>
<MudSelect T="string" @bind-SelectedValues="_graphicsElementNames" Clearable="true" MultiSelection="true"> <MudSelect T="string" @bind-SelectedValues="_watermarkNames" Clearable="true" MultiSelection="true">
@foreach (GraphicsElementViewModel graphicsElement in _graphicsElements) @foreach (WatermarkViewModel watermark in _watermarks)
{ {
<MudSelectItem T="string" Value="@graphicsElement.Name">@graphicsElement.Name</MudSelectItem> <MudSelectItem T="string" Value="@watermark.Name">@watermark.Name</MudSelectItem>
} }
</MudSelect> </MudSelect>
</MudStack> </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>Start From Beginning</MudText> <MudText>Graphics Elements</MudText>
</div> </div>
<MudCheckBox T="bool" <MudSelect T="string" @bind-SelectedValues="_graphicsElementNames" Clearable="true" MultiSelection="true">
Dense="true" @foreach (GraphicsElementViewModel graphicsElement in _graphicsElements)
Disabled="@(string.Equals(_info?.Kind, "RemoteStream", StringComparison.OrdinalIgnoreCase))" {
ValueChanged="@(c => OnStartFromBeginningChanged(c))"/> <MudSelectItem T="string" Value="@graphicsElement.Name">@graphicsElement.Name</MudSelectItem>
</MudStack> }
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5"> </MudSelect>
<div class="d-flex"> </MudStack>
<MudText>Seek Seconds</MudText> <MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
</div> <div class="d-flex">
<MudTextField @bind-Value="@(_seekSeconds)" Disabled="@(_startFromBeginning)"/> <MudText>Start From Beginning</MudText>
</MudStack> </div>
<MudCheckBox T="bool"
Dense="true"
Disabled="@(string.Equals(_info?.Kind, "RemoteStream", StringComparison.OrdinalIgnoreCase))"
ValueChanged="@(c => OnStartFromBeginningChanged(c))"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Seek Seconds</MudText>
</div>
<MudTextField @bind-Value="@(_seekSeconds)" Disabled="@(_startFromBeginning)"/>
</MudStack>
}
<MudText Typo="Typo.h5" Class="mt-10 mb-2">Preview</MudText> <MudText Typo="Typo.h5" Class="mt-10 mb-2">Preview</MudText>
<MudDivider Class="mb-6"/> <MudDivider Class="mb-6"/>
<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">
@ -126,7 +139,7 @@
<MudButton Variant="Variant.Filled" <MudButton Variant="Variant.Filled"
Color="Color.Primary" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.PlayCircle" StartIcon="@Icons.Material.Filled.PlayCircle"
Disabled="@(Locker.IsTroubleshootingPlaybackLocked() || MediaItemId is null)" Disabled="@(Locker.IsTroubleshootingPlaybackLocked() || (_channelMode && !_start.HasValue))"
OnClick="@PreviewChannel"> OnClick="@PreviewChannel">
Play Play
</MudButton> </MudButton>
@ -155,7 +168,7 @@
} }
<MudDivider Class="mb-6"/> <MudDivider Class="mb-6"/>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="gap-md-8 mb-5"> <MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="gap-md-8 mb-5">
<MudTextField @bind-Value="_logs" ReadOnly="true" Lines="20" Variant="Variant.Outlined" /> <MudTextField T="string" @ref="_logsField" ReadOnly="true" Lines="20" Variant="Variant.Outlined" />
</MudStack> </MudStack>
<div class="mb-6"> <div class="mb-6">
<br/> <br/>
@ -168,14 +181,17 @@
@code { @code {
private CancellationTokenSource _cts; private CancellationTokenSource _cts;
private readonly DateTimeFormatInfo _dtf = CultureInfo.CurrentUICulture.DateTimeFormat;
private readonly List<FFmpegProfileViewModel> _ffmpegProfiles = []; private readonly List<FFmpegProfileViewModel> _ffmpegProfiles = [];
private readonly List<string> _streamSelectors = []; 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 string _title;
private MediaItemInfo _info; private MediaItemInfo _info;
private readonly StreamingMode _streamingMode = StreamingMode.HttpLiveStreamingSegmenter; private readonly StreamingMode _streamingMode = StreamingMode.HttpLiveStreamingSegmenter;
private int _ffmpegProfileId; private int _ffmpegProfileId;
private bool _channelMode;
private string _streamSelector; 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>();
@ -184,11 +200,16 @@
private int _seekSeconds; private int _seekSeconds;
private bool _hasPlayed; private bool _hasPlayed;
private double? _lastSpeed; private double? _lastSpeed;
private string _logs; private MudTextField<string> _logsField;
private DateTimeOffset? _start;
private string _startString;
[SupplyParameterFromQuery(Name = "mediaItem")] [SupplyParameterFromQuery(Name = "mediaItem")]
public int? MediaItemId { get; set; } public int? MediaItemId { get; set; }
[SupplyParameterFromQuery(Name = "channel")]
public int? ChannelId { get; set; }
public void Dispose() public void Dispose()
{ {
_cts?.Cancel(); _cts?.Cancel();
@ -230,7 +251,17 @@
if (MediaItemId is not null) if (MediaItemId is not null)
{ {
await OnMediaItemIdChanged(MediaItemId, token); _channelMode = false;
await LoadMediaItem(MediaItemId.Value, token);
}
else if (ChannelId is not null)
{
_channelMode = true;
await LoadChannel(ChannelId.Value, token);
}
else
{
NavigationManager.NavigateTo("", new NavigationOptions { ReplaceHistoryEntry = true });
} }
} }
catch (OperationCanceledException) catch (OperationCanceledException)
@ -249,19 +280,39 @@
private async Task PreviewChannel() private async Task PreviewChannel()
{ {
_logs = null; await _logsField.SetText(string.Empty);
_lastSpeed = null; _lastSpeed = null;
var baseUri = NavigationManager.ToAbsoluteUri(NavigationManager.Uri).ToString(); var baseUri = NavigationManager.ToAbsoluteUri(NavigationManager.Uri).ToString();
string apiUri = baseUri.Replace("/system/troubleshooting/playback", "/api/troubleshoot/playback.m3u8"); string apiUri = baseUri.Replace("/system/troubleshooting/playback", "/api/troubleshoot/playback.m3u8");
var queryString = new List<KeyValuePair<string, string>> var queryString = new List<KeyValuePair<string, string>>
{ {
new("mediaItem", (MediaItemId ?? 0).ToString()),
new("ffmpegProfile", _ffmpegProfileId.ToString()), new("ffmpegProfile", _ffmpegProfileId.ToString()),
new("streamingMode", ((int)_streamingMode).ToString()), new("streamingMode", ((int)_streamingMode).ToString()),
new("seekSeconds", _seekSeconds.ToString())
}; };
if (_channelMode)
{
if (!_start.HasValue)
{
return;
}
queryString.AddRange(
[
new KeyValuePair<string, string>("channel", (ChannelId ?? 0).ToString()),
new KeyValuePair<string, string>("start", _start!.Value.ToString("o"))
]);
}
else
{
queryString.AddRange(
[
new KeyValuePair<string, string>("mediaItem", (MediaItemId ?? 0).ToString()),
new KeyValuePair<string, string>("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))
@ -295,66 +346,65 @@
_hasPlayed = true; _hasPlayed = true;
} }
private async Task OnMediaItemIdChanged(int? mediaItemId, CancellationToken cancellationToken) private async Task LoadMediaItem(int mediaItemId, CancellationToken cancellationToken)
{ {
MediaItemId = mediaItemId;
_hasPlayed = false; _hasPlayed = false;
_info = null;
foreach (int id in Optional(mediaItemId)) Either<BaseError, MediaItemInfo> maybeInfo = await Mediator.Send(new GetMediaItemInfo(mediaItemId), cancellationToken);
foreach (MediaItemInfo info in maybeInfo.RightToSeq())
{ {
Either<BaseError, MediaItemInfo> maybeInfo = await Mediator.Send(new GetMediaItemInfo(id), cancellationToken); IEnumerable<char> kindString = info.Kind.SelectMany((c, i) => i != 0 && char.IsUpper(c) && !char.IsUpper(info.Kind[i - 1]) ? new[] { ' ', c } : new[] { c });
foreach (MediaItemInfo info in maybeInfo.RightToSeq()) _info = info with { Kind = new string(kindString.ToArray()) };
{ _title = info.Title;
_info = info;
OnStartFromBeginningChanged(string.Equals(info.Kind, "RemoteStream", StringComparison.OrdinalIgnoreCase));
_subtitleId = null; OnStartFromBeginningChanged(string.Equals(info.Kind, "RemoteStream", StringComparison.OrdinalIgnoreCase));
_subtitleStreams.Clear();
_subtitleStreams.AddRange(await Mediator.Send(new GetTroubleshootingSubtitles(id), cancellationToken));
}
if (maybeInfo.IsLeft) _subtitleId = null;
{ _subtitleStreams.Clear();
MediaItemId = null; _subtitleStreams.AddRange(await Mediator.Send(new GetTroubleshootingSubtitles(mediaItemId), cancellationToken));
} }
if (maybeInfo.IsLeft)
{
NavigationManager.NavigateTo("", new NavigationOptions { ReplaceHistoryEntry = true });
} }
StateHasChanged(); StateHasChanged();
} }
private async Task DownloadResults() private async Task LoadChannel(int channelId, CancellationToken cancellationToken)
{ {
var queryString = new List<KeyValuePair<string, string>> _hasPlayed = false;
{ _info = null;
new("mediaItem", (MediaItemId ?? 0).ToString()),
new("ffmpegProfile", _ffmpegProfileId.ToString()),
new("streamingMode", ((int)_streamingMode).ToString()),
new("seekSeconds", _seekSeconds.ToString())
};
foreach (string watermarkName in _watermarkNames) Option<ChannelViewModel> maybeChannel = await Mediator.Send(new GetChannelById(channelId), cancellationToken);
foreach (ChannelViewModel channel in maybeChannel)
{ {
foreach (WatermarkViewModel watermark in _watermarks.Where(wm => wm.Name == watermarkName)) _title = channel.Name;
_ffmpegProfileId = channel.FFmpegProfileId;
if (channel.StreamSelectorMode is ChannelStreamSelectorMode.Custom)
{ {
queryString.Add(new KeyValuePair<string, string>("watermark", watermark.Id.ToString())); _streamSelector = channel.StreamSelector;
} }
} }
foreach (string graphicsElementName in _graphicsElementNames) if (maybeChannel.IsNone)
{ {
foreach (GraphicsElementViewModel graphicsElement in _graphicsElements.Where(ge => ge.Name == graphicsElementName)) NavigationManager.NavigateTo("", new NavigationOptions { ReplaceHistoryEntry = true });
{
queryString.Add(new KeyValuePair<string, string>("graphicsElement", graphicsElement.Id.ToString()));
}
} }
string uriWithQuery = QueryHelpers.AddQueryString("api/troubleshoot/playback/archive", queryString); StateHasChanged();
await JsRuntime.InvokeVoidAsync("window.open", uriWithQuery); }
private async Task DownloadResults()
{
await JsRuntime.InvokeVoidAsync("window.open", "api/troubleshoot/playback/archive");
} }
private void HandleTroubleshootingCompleted(PlaybackTroubleshootingCompletedNotification result) private async Task HandleTroubleshootingCompleted(PlaybackTroubleshootingCompletedNotification result)
{ {
_logs = null; await InvokeAsync(async () => { await _logsField.SetText(string.Empty); });
_lastSpeed = null; _lastSpeed = null;
foreach (double speed in result.MaybeSpeed) foreach (double speed in result.MaybeSpeed)
@ -374,15 +424,15 @@
string logFileName = Path.Combine(FileSystemLayout.TranscodeTroubleshootingFolder, "logs.txt"); string logFileName = Path.Combine(FileSystemLayout.TranscodeTroubleshootingFolder, "logs.txt");
if (LocalFileSystem.FileExists(logFileName)) if (LocalFileSystem.FileExists(logFileName))
{ {
_logs = File.ReadAllText(logFileName); string text = await File.ReadAllTextAsync(logFileName);
InvokeAsync(StateHasChanged); await InvokeAsync(async () => { await _logsField.SetText(text); });
} }
else else
{ {
foreach (var exception in result.MaybeException) foreach (var exception in result.MaybeException)
{ {
_logs = exception.Message + Environment.NewLine + Environment.NewLine + exception; string text = exception.Message + Environment.NewLine + Environment.NewLine + exception;
InvokeAsync(StateHasChanged); await InvokeAsync(async () => { await _logsField.SetText(text); });
} }
} }
} }
@ -402,4 +452,26 @@
return "mud-warning-text"; return "mud-warning-text";
} }
private async Task OnStartStringBlur(FocusEventArgs e)
{
await TryNormalizeStartString();
}
private async Task TryNormalizeStartString()
{
if (string.IsNullOrWhiteSpace(_startString))
{
_start = null;
return;
}
var parser = new Chronic.Core.Parser();
var parsedResult = parser.Parse(_startString);
if (DateTimeOffset.TryParse(parsedResult?.Start?.ToString() ?? _startString, out DateTimeOffset dateTimeOffset))
{
_start = dateTimeOffset;
await InvokeAsync(() => { _startString = dateTimeOffset.ToString("G", _dtf); });
}
}
} }

3
ErsatzTV/Pages/Troubleshooting/Troubleshooting.razor

@ -17,14 +17,17 @@
<MudTabPanel Text="Tools"> <MudTabPanel Text="Tools">
<MudPaper Class="pa-6" Style="max-height: 500px"> <MudPaper Class="pa-6" Style="max-height: 500px">
<MudStack> <MudStack>
<!--
<MudButton Variant="Variant.Filled" <MudButton Variant="Variant.Filled"
Color="Color.Secondary" Color="Color.Secondary"
StartIcon="@Icons.Material.Filled.Troubleshoot" StartIcon="@Icons.Material.Filled.Troubleshoot"
Href="system/troubleshooting/playback" Href="system/troubleshooting/playback"
Style="margin-right: auto" Style="margin-right: auto"
Disabled="true"
Class="mt-6"> Class="mt-6">
Playback Troubleshooting Playback Troubleshooting
</MudButton> </MudButton>
-->
<MudButton Variant="Variant.Filled" <MudButton Variant="Variant.Filled"
Color="Color.Secondary" Color="Color.Secondary"
StartIcon="@Icons.Material.Filled.Checklist" StartIcon="@Icons.Material.Filled.Checklist"

Loading…
Cancel
Save