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/). @@ -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
- 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
- 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
- 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/). @@ -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)
- 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
- Fix bug where looping motion graphics wouldn't be displayed when seeking into second half of content
### Changed
- 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 @@ -461,7 +461,9 @@ public class HlsSessionWorker : IHlsSessionWorker
realtime,
_channelStart,
ptsOffset,
_targetFramerate);
_targetFramerate,
IsTroubleshooting: false,
Option<int>.None);
// _logger.LogInformation("Request {@Request}", request);

3
ErsatzTV.Application/Streaming/PlayoutItemProcessModel.cs

@ -9,4 +9,5 @@ public record PlayoutItemProcessModel( @@ -9,4 +9,5 @@ public record PlayoutItemProcessModel(
Option<TimeSpan> MaybeDuration,
DateTimeOffset Until,
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 @@ -49,22 +49,41 @@ public abstract class FFmpegProcessHandler<T> : IRequestHandler<T, Either<BaseEr
await FFprobePathMustExist(dbContext, cancellationToken))
.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,
T request,
CancellationToken cancellationToken) =>
dbContext.Channels
CancellationToken cancellationToken)
{
Option<Channel> maybeChannel = await dbContext.Channels
.AsNoTracking()
.Include(c => c.FFmpegProfile)
.ThenInclude(p => p.Resolution)
.Include(c => c.Artwork)
.Include(c => c.Watermark)
.SelectOneAsync(c => c.Number, c => c.Number == request.ChannelNumber, cancellationToken)
.MapT(channel =>
.SelectOneAsync(c => c.Number, c => c.Number == request.ChannelNumber, cancellationToken);
foreach (var channel in maybeChannel)
{
channel.StreamingMode = request.Mode;
foreach (int ffmpegProfileId in request.FFmpegProfileId)
{
channel.StreamingMode = request.Mode;
return channel;
})
.Map(o => o.ToValidation<BaseError>($"Channel number {request.ChannelNumber} does not exist."));
Option<FFmpegProfile> maybeFFmpegProfile = await dbContext.FFmpegProfiles
.AsNoTracking()
.Include(ff => ff.Resolution)
.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(
TvContext dbContext,

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

@ -10,4 +10,5 @@ public record FFmpegProcessRequest( @@ -10,4 +10,5 @@ public record FFmpegProcessRequest(
bool StartAtZero,
bool HlsRealtime,
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 @@ -11,7 +11,8 @@ public record GetConcatProcessByChannelNumber : FFmpegProcessRequest
false,
true,
DateTimeOffset.Now, // unused
TimeSpan.Zero)
TimeSpan.Zero,
Option<int>.None)
{
Scheme = scheme;
Host = host;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -25,6 +25,12 @@ public class ArchiveTroubleshootingResultsHandler(ILocalFileSystem localFileSyst @@ -25,6 +25,12 @@ public class ArchiveTroubleshootingResultsHandler(ILocalFileSystem localFileSyst
continue;
}
if (fileName.Equals("logs.txt", StringComparison.OrdinalIgnoreCase))
{
zipArchive.CreateEntryFromFile(file, fileName);
continue;
}
if (Path.GetExtension(file).Equals(".json", StringComparison.OrdinalIgnoreCase))
{
zipArchive.CreateEntryFromFile(file, fileName);

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

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

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

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
using Dapper;
using ErsatzTV.Application.Streaming;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
@ -49,6 +50,70 @@ public class PrepareTroubleshootingPlaybackHandler( @@ -49,6 +50,70 @@ public class PrepareTroubleshootingPlaybackHandler(
{
using var logContext = LogContext.PushProperty(InMemoryLogService.CorrelationIdKey, request.SessionId);
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(
dbContext,
request,
@ -67,7 +132,9 @@ public class PrepareTroubleshootingPlaybackHandler( @@ -67,7 +132,9 @@ public class PrepareTroubleshootingPlaybackHandler(
catch (Exception ex)
{
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");
return BaseError.New(ex.Message);
}
@ -222,7 +289,7 @@ public class PrepareTroubleshootingPlaybackHandler( @@ -222,7 +289,7 @@ public class PrepareTroubleshootingPlaybackHandler(
PlayoutItemResult playoutItemResult = await ffmpegProcessService.ForPlayoutItem(
ffmpegPath,
ffprobePath,
true,
saveReports: true,
channel,
new MediaItemVideoVersion(mediaItem, videoVersion),
new MediaItemAudioVersion(mediaItem, version),

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

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

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

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

12
ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs

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

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

@ -3,4 +3,8 @@ using ErsatzTV.Core.Interfaces.Streaming; @@ -3,4 +3,8 @@ using ErsatzTV.Core.Interfaces.Streaming;
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( @@ -63,7 +63,7 @@ public class MotionElement(
ProbeResult probeResult = await ProbeMotionElement(context.FrameSize);
var overlayDuration = motionElement.EndBehavior switch
{
MotionEndBehavior.Loop => context.Duration,
MotionEndBehavior.Loop => context.Seek + context.Duration,
MotionEndBehavior.Hold => probeResult.Duration + holdDuration,
_ => probeResult.Duration
};

144
ErsatzTV/Controllers/Api/TroubleshootController.cs

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

2
ErsatzTV/Controllers/InternalController.cs

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

2
ErsatzTV/Controllers/IptvController.cs

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

1
ErsatzTV/ErsatzTV.csproj

@ -30,6 +30,7 @@ @@ -30,6 +30,7 @@
<PackageReference Include="Blazored.FluentValidation" Version="2.2.0" />
<PackageReference Include="BlazorSortable" Version="5.1.4" />
<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.AspNetCore" Version="11.3.1" />
<PackageReference Include="Heron.MudCalendar" Version="3.3.0" />

16
ErsatzTV/Pages/Channels.razor

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

294
ErsatzTV/Pages/Troubleshooting/PlaybackTroubleshooting.razor

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

Loading…
Cancel
Save