diff --git a/CHANGELOG.md b/CHANGELOG.md index aafe31dd1..43b08ee25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - For example, adding `{{ episode_number }}` 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/). - 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) diff --git a/ErsatzTV.Application/Streaming/HlsSessionWorker.cs b/ErsatzTV.Application/Streaming/HlsSessionWorker.cs index 42b208dc1..215509b7d 100644 --- a/ErsatzTV.Application/Streaming/HlsSessionWorker.cs +++ b/ErsatzTV.Application/Streaming/HlsSessionWorker.cs @@ -461,7 +461,9 @@ public class HlsSessionWorker : IHlsSessionWorker realtime, _channelStart, ptsOffset, - _targetFramerate); + _targetFramerate, + IsTroubleshooting: false, + Option.None); // _logger.LogInformation("Request {@Request}", request); diff --git a/ErsatzTV.Application/Streaming/PlayoutItemProcessModel.cs b/ErsatzTV.Application/Streaming/PlayoutItemProcessModel.cs index c55c0944d..ad005871a 100644 --- a/ErsatzTV.Application/Streaming/PlayoutItemProcessModel.cs +++ b/ErsatzTV.Application/Streaming/PlayoutItemProcessModel.cs @@ -9,4 +9,5 @@ public record PlayoutItemProcessModel( Option MaybeDuration, DateTimeOffset Until, bool IsComplete, - Option SegmentKey); + Option SegmentKey, + Option MediaItemId); diff --git a/ErsatzTV.Application/Streaming/Queries/FFmpegProcessHandler.cs b/ErsatzTV.Application/Streaming/Queries/FFmpegProcessHandler.cs index 60bd4fe2d..14e56261f 100644 --- a/ErsatzTV.Application/Streaming/Queries/FFmpegProcessHandler.cs +++ b/ErsatzTV.Application/Streaming/Queries/FFmpegProcessHandler.cs @@ -49,22 +49,41 @@ public abstract class FFmpegProcessHandler : IRequestHandler Tuple(channel, ffmpegPath, ffprobePath)); - private static Task> ChannelMustExist( + private static async Task> ChannelMustExist( TvContext dbContext, T request, - CancellationToken cancellationToken) => - dbContext.Channels + CancellationToken cancellationToken) + { + Option 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($"Channel number {request.ChannelNumber} does not exist.")); + Option 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> FFmpegPathMustExist( TvContext dbContext, diff --git a/ErsatzTV.Application/Streaming/Queries/FFmpegProcessRequest.cs b/ErsatzTV.Application/Streaming/Queries/FFmpegProcessRequest.cs index d13fc61c3..ff6c2c77e 100644 --- a/ErsatzTV.Application/Streaming/Queries/FFmpegProcessRequest.cs +++ b/ErsatzTV.Application/Streaming/Queries/FFmpegProcessRequest.cs @@ -10,4 +10,5 @@ public record FFmpegProcessRequest( bool StartAtZero, bool HlsRealtime, DateTimeOffset ChannelStartTime, - TimeSpan PtsOffset) : IRequest>; + TimeSpan PtsOffset, + Option FFmpegProfileId) : IRequest>; diff --git a/ErsatzTV.Application/Streaming/Queries/GetConcatProcessByChannelNumber.cs b/ErsatzTV.Application/Streaming/Queries/GetConcatProcessByChannelNumber.cs index c373b83e5..f7f6eaa9f 100644 --- a/ErsatzTV.Application/Streaming/Queries/GetConcatProcessByChannelNumber.cs +++ b/ErsatzTV.Application/Streaming/Queries/GetConcatProcessByChannelNumber.cs @@ -11,7 +11,8 @@ public record GetConcatProcessByChannelNumber : FFmpegProcessRequest false, true, DateTimeOffset.Now, // unused - TimeSpan.Zero) + TimeSpan.Zero, + Option.None) { Scheme = scheme; Host = host; diff --git a/ErsatzTV.Application/Streaming/Queries/GetConcatProcessByChannelNumberHandler.cs b/ErsatzTV.Application/Streaming/Queries/GetConcatProcessByChannelNumberHandler.cs index a2b7d0f83..99127cfeb 100644 --- a/ErsatzTV.Application/Streaming/Queries/GetConcatProcessByChannelNumberHandler.cs +++ b/ErsatzTV.Application/Streaming/Queries/GetConcatProcessByChannelNumberHandler.cs @@ -44,6 +44,7 @@ public class GetConcatProcessByChannelNumberHandler : FFmpegProcessHandler.None, DateTimeOffset.MaxValue, true, - Option.None); + Option.None, + Option.None); } } diff --git a/ErsatzTV.Application/Streaming/Queries/GetErrorProcess.cs b/ErsatzTV.Application/Streaming/Queries/GetErrorProcess.cs index 0a4cd5b7e..18e9612e9 100644 --- a/ErsatzTV.Application/Streaming/Queries/GetErrorProcess.cs +++ b/ErsatzTV.Application/Streaming/Queries/GetErrorProcess.cs @@ -16,4 +16,5 @@ public record GetErrorProcess( true, HlsRealtime, DateTimeOffset.Now, // unused - PtsOffset); + PtsOffset, + Option.None); diff --git a/ErsatzTV.Application/Streaming/Queries/GetErrorProcessHandler.cs b/ErsatzTV.Application/Streaming/Queries/GetErrorProcessHandler.cs index 262b87fd7..f1d012bcb 100644 --- a/ErsatzTV.Application/Streaming/Queries/GetErrorProcessHandler.cs +++ b/ErsatzTV.Application/Streaming/Queries/GetErrorProcessHandler.cs @@ -42,6 +42,7 @@ public class GetErrorProcessHandler( request.MaybeDuration, request.Until, true, - now.ToUnixTimeSeconds()); + now.ToUnixTimeSeconds(), + Option.None); } } diff --git a/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumber.cs b/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumber.cs index 3bb6ae565..9df8770dc 100644 --- a/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumber.cs +++ b/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumber.cs @@ -10,11 +10,14 @@ public record GetPlayoutItemProcessByChannelNumber( bool HlsRealtime, DateTimeOffset ChannelStart, TimeSpan PtsOffset, - Option TargetFramerate) : FFmpegProcessRequest( + Option TargetFramerate, + bool IsTroubleshooting, + Option FFmpegProfileId) : FFmpegProcessRequest( ChannelNumber, Mode, Now, StartAtZero, HlsRealtime, ChannelStart, - PtsOffset); + PtsOffset, + FFmpegProfileId); diff --git a/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs b/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs index c463de009..274a08c56 100644 --- a/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs +++ b/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs @@ -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< duration, finish, true, - now.ToUnixTimeSeconds()); + now.ToUnixTimeSeconds(), + Option.None); } MediaVersion version = playoutItemWithPath.PlayoutItem.MediaItem.GetHeadVersion(); @@ -392,7 +406,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< bool saveReports = await dbContext.ConfigElements .GetValue(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< request.ChannelStartTime, request.PtsOffset, request.TargetFramerate, - Option.None, + request.IsTroubleshooting ? FileSystemLayout.TranscodeTroubleshootingFolder : Option.None, _ => { }, canProxy: true, cancellationToken); @@ -446,7 +460,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< duration, finish, isComplete, - effectiveNow.ToUnixTimeSeconds()); + effectiveNow.ToUnixTimeSeconds(), + playoutItemResult.MediaItemId); return Right(result); } @@ -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< maybeDuration, finish, true, - now.ToUnixTimeSeconds()); + now.ToUnixTimeSeconds(), + Option.None); case PlayoutItemDoesNotExistOnDisk: Command doesNotExistProcess = await _ffmpegProcessService.ForError( ffmpegPath, @@ -514,7 +538,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< maybeDuration, finish, true, - now.ToUnixTimeSeconds()); + now.ToUnixTimeSeconds(), + Option.None); default: Command errorProcess = await _ffmpegProcessService.ForError( ffmpegPath, @@ -535,7 +560,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< maybeDuration, finish, true, - now.ToUnixTimeSeconds()); + now.ToUnixTimeSeconds(), + Option.None); } } diff --git a/ErsatzTV.Application/Streaming/Queries/GetWrappedProcessByChannelNumber.cs b/ErsatzTV.Application/Streaming/Queries/GetWrappedProcessByChannelNumber.cs index 11f312bb5..2939752b3 100644 --- a/ErsatzTV.Application/Streaming/Queries/GetWrappedProcessByChannelNumber.cs +++ b/ErsatzTV.Application/Streaming/Queries/GetWrappedProcessByChannelNumber.cs @@ -15,7 +15,8 @@ public record GetWrappedProcessByChannelNumber : FFmpegProcessRequest false, true, DateTimeOffset.Now, // unused - TimeSpan.Zero) + TimeSpan.Zero, + Option.None) { Scheme = scheme; Host = host; diff --git a/ErsatzTV.Application/Streaming/Queries/GetWrappedProcessByChannelNumberHandler.cs b/ErsatzTV.Application/Streaming/Queries/GetWrappedProcessByChannelNumberHandler.cs index 6227b52d5..4866e2cd4 100644 --- a/ErsatzTV.Application/Streaming/Queries/GetWrappedProcessByChannelNumberHandler.cs +++ b/ErsatzTV.Application/Streaming/Queries/GetWrappedProcessByChannelNumberHandler.cs @@ -46,6 +46,7 @@ public class GetWrappedProcessByChannelNumberHandler : FFmpegProcessHandler.None, DateTimeOffset.MaxValue, true, - Option.None); + Option.None, + Option.None); } } diff --git a/ErsatzTV.Application/Troubleshooting/Commands/ArchiveTroubleshootingResults.cs b/ErsatzTV.Application/Troubleshooting/Commands/ArchiveTroubleshootingResults.cs index 399a66db7..25bd08175 100644 --- a/ErsatzTV.Application/Troubleshooting/Commands/ArchiveTroubleshootingResults.cs +++ b/ErsatzTV.Application/Troubleshooting/Commands/ArchiveTroubleshootingResults.cs @@ -1,12 +1,3 @@ -using ErsatzTV.Core.Domain; - namespace ErsatzTV.Application.Troubleshooting; -public record ArchiveTroubleshootingResults( - int MediaItemId, - int FFmpegProfileId, - StreamingMode StreamingMode, - List WatermarkIds, - List GraphicsElementIds, - Option SeekSeconds) - : IRequest>; +public record ArchiveTroubleshootingResults : IRequest>; diff --git a/ErsatzTV.Application/Troubleshooting/Commands/ArchiveTroubleshootingResultsHandler.cs b/ErsatzTV.Application/Troubleshooting/Commands/ArchiveTroubleshootingResultsHandler.cs index 6fb07d345..c363418bd 100644 --- a/ErsatzTV.Application/Troubleshooting/Commands/ArchiveTroubleshootingResultsHandler.cs +++ b/ErsatzTV.Application/Troubleshooting/Commands/ArchiveTroubleshootingResultsHandler.cs @@ -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); diff --git a/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlayback.cs b/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlayback.cs index a115fc153..ecff6d4ba 100644 --- a/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlayback.cs +++ b/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlayback.cs @@ -8,10 +8,12 @@ public record PrepareTroubleshootingPlayback( Guid SessionId, StreamingMode StreamingMode, int MediaItemId, + int ChannelId, int FFmpegProfileId, string StreamSelector, List WatermarkIds, List GraphicsElementIds, int? SubtitleId, - Option SeekSeconds) + Option SeekSeconds, + Option Start) : IRequest>; diff --git a/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs b/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs index 4ec0cd4c0..55bb09cfb 100644 --- a/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs +++ b/ErsatzTV.Application/Troubleshooting/Commands/PrepareTroubleshootingPlaybackHandler.cs @@ -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( { 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 maybeChannel = await dbContext.Channels + .AsNoTracking() + .SelectOneAsync(c => c.Id, c => c.Id == request.ChannelId, cancellationToken); + + foreach (var channel in maybeChannel) + { + Either result = await mediator.Send( + new GetPlayoutItemProcessByChannelNumber( + channel.Number, + request.StreamingMode, + start, + StartAtZero: false, + HlsRealtime: false, + start, + TimeSpan.Zero, + TargetFramerate: Option.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.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> validation = await Validate( dbContext, request, @@ -67,7 +132,9 @@ public class PrepareTroubleshootingPlaybackHandler( catch (Exception ex) { entityLocker.UnlockTroubleshootingPlayback(); - await mediator.Publish(new PlaybackTroubleshootingCompletedNotification(-1, ex, Option.None), cancellationToken); + await mediator.Publish( + new PlaybackTroubleshootingCompletedNotification(-1, ex, Option.None), + cancellationToken); logger.LogError(ex, "Error while preparing troubleshooting playback"); return BaseError.New(ex.Message); } @@ -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), diff --git a/ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlayback.cs b/ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlayback.cs index dec63762f..d4f8f6948 100644 --- a/ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlayback.cs +++ b/ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlayback.cs @@ -7,5 +7,5 @@ public record StartTroubleshootingPlayback( Guid SessionId, string StreamSelector, PlayoutItemResult PlayoutItemResult, - MediaItemInfo MediaItemInfo, + Option MediaItemInfo, TroubleshootingInfo TroubleshootingInfo) : IRequest, IFFmpegWorkerRequest; diff --git a/ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlaybackHandler.cs b/ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlaybackHandler.cs index 9d945d931..f700bf308 100644 --- a/ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlaybackHandler.cs +++ b/ErsatzTV.Application/Troubleshooting/Commands/StartTroubleshootingPlaybackHandler.cs @@ -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( .WithValidation(CommandResultValidation.None) .ExecuteAsync(linkedCts.Token); + logger.LogDebug("Troubleshooting playback completed with exit code {ExitCode}", commandResult.ExitCode); + try { IEnumerable logs = logService.Sink.GetLogs(request.SessionId); @@ -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(); diff --git a/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs b/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs index b5313d72e..cdd5e309e 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs @@ -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 graphicsEngineInput, pipeline); - return new PlayoutItemResult(command, graphicsEngineContext); + return new PlayoutItemResult(command, graphicsEngineContext, videoVersion.MediaItem.Id); } private async Task ProbeScanKind( @@ -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 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 VaapiDisplayName(hwAccel, vaapiDisplay), VaapiDriverName(hwAccel, vaapiDriver), VaapiDeviceName(hwAccel, vaapiDevice), - FileSystemLayout.FFmpegReportsFolder, + channel.Number == ".troubleshooting" + ? FileSystemLayout.TranscodeTroubleshootingFolder + : FileSystemLayout.FFmpegReportsFolder, FileSystemLayout.FontsCacheFolder, ffmpegPath); diff --git a/ErsatzTV.Core/Interfaces/FFmpeg/PlayoutItemResult.cs b/ErsatzTV.Core/Interfaces/FFmpeg/PlayoutItemResult.cs index 3eb1c7666..6567966af 100644 --- a/ErsatzTV.Core/Interfaces/FFmpeg/PlayoutItemResult.cs +++ b/ErsatzTV.Core/Interfaces/FFmpeg/PlayoutItemResult.cs @@ -3,4 +3,8 @@ using ErsatzTV.Core.Interfaces.Streaming; namespace ErsatzTV.Core.Interfaces.FFmpeg; -public record PlayoutItemResult(Command Process, Option GraphicsEngineContext); +public record PlayoutItemResult( + Command Process, + Option GraphicsEngineContext, + Option MediaItemId); + diff --git a/ErsatzTV.Infrastructure/Streaming/Graphics/Motion/MotionElement.cs b/ErsatzTV.Infrastructure/Streaming/Graphics/Motion/MotionElement.cs index a18fa98c0..29b738bc1 100644 --- a/ErsatzTV.Infrastructure/Streaming/Graphics/Motion/MotionElement.cs +++ b/ErsatzTV.Infrastructure/Streaming/Graphics/Motion/MotionElement.cs @@ -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 }; diff --git a/ErsatzTV/Controllers/Api/TroubleshootController.cs b/ErsatzTV/Controllers/Api/TroubleshootController.cs index 2320c0d7b..48eb93512 100644 --- a/ErsatzTV/Controllers/Api/TroubleshootController.cs +++ b/ErsatzTV/Controllers/Api/TroubleshootController.cs @@ -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( int? subtitleId, [FromQuery] int seekSeconds, + [FromQuery] + DateTimeOffset? start, CancellationToken cancellationToken) { var sessionId = Guid.NewGuid(); @@ -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( foreach (PlayoutItemResult playoutItemResult in result.RightToSeq()) { Either 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(ConfigElementKey.FFmpegInitialSegmentCount, cancellationToken) + .Map(maybeCount => maybeCount.Match(c => c, () => 1)); - int initialSegmentCount = await configElementRepository - .GetValue(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( [HttpHead("api/troubleshoot/playback/archive")] [HttpGet("api/troubleshoot/playback/archive")] - public async Task TroubleshootPlaybackArchive( - [FromQuery] - int mediaItem, - [FromQuery] - int ffmpegProfile, - [FromQuery] - StreamingMode streamingMode, - [FromQuery] - List watermark, - [FromQuery] - List graphicsElement, - [FromQuery] - int seekSeconds, - CancellationToken cancellationToken) + public async Task TroubleshootPlaybackArchive(CancellationToken cancellationToken) { - Option ss = seekSeconds > 0 ? seekSeconds : Option.None; - - Option maybeArchivePath = await mediator.Send( - new ArchiveTroubleshootingResults( - mediaItem, - ffmpegProfile, - streamingMode, - watermark, - graphicsElement, - ss), - cancellationToken); - + Option maybeArchivePath = await mediator.Send(new ArchiveTroubleshootingResults(), cancellationToken); foreach (string archivePath in maybeArchivePath) { FileStream fs = System.IO.File.OpenRead(archivePath); diff --git a/ErsatzTV/Controllers/InternalController.cs b/ErsatzTV/Controllers/InternalController.cs index 5b2dbf44c..42fd5fa00 100644 --- a/ErsatzTV/Controllers/InternalController.cs +++ b/ErsatzTV/Controllers/InternalController.cs @@ -259,6 +259,8 @@ public class InternalController : StreamingControllerBase true, DateTimeOffset.Now, TimeSpan.Zero, + Option.None, + IsTroubleshooting: false, Option.None); Either result = await _mediator.Send(request); diff --git a/ErsatzTV/Controllers/IptvController.cs b/ErsatzTV/Controllers/IptvController.cs index 2a73664ba..b3c6a6a64 100644 --- a/ErsatzTV/Controllers/IptvController.cs +++ b/ErsatzTV/Controllers/IptvController.cs @@ -346,6 +346,8 @@ public class IptvController : StreamingControllerBase true, DateTimeOffset.Now, TimeSpan.Zero, + Option.None, + IsTroubleshooting: false, Option.None); Either result = await _mediator.Send(request); diff --git a/ErsatzTV/ErsatzTV.csproj b/ErsatzTV/ErsatzTV.csproj index 953313103..314ebbeb3 100644 --- a/ErsatzTV/ErsatzTV.csproj +++ b/ErsatzTV/ErsatzTV.csproj @@ -30,6 +30,7 @@ + diff --git a/ErsatzTV/Pages/Channels.razor b/ErsatzTV/Pages/Channels.razor index 9d8975f48..294b6100e 100644 --- a/ErsatzTV/Pages/Channels.razor +++ b/ErsatzTV/Pages/Channels.razor @@ -36,7 +36,7 @@ - + @@ -110,13 +110,25 @@ } else { -
+
} + @if (context.PlayoutCount > 0) + { + + + + + } + else + { +
+ } diff --git a/ErsatzTV/Pages/Troubleshooting/PlaybackTroubleshooting.razor b/ErsatzTV/Pages/Troubleshooting/PlaybackTroubleshooting.razor index c0e441af7..5b27d6ca0 100644 --- a/ErsatzTV/Pages/Troubleshooting/PlaybackTroubleshooting.razor +++ b/ErsatzTV/Pages/Troubleshooting/PlaybackTroubleshooting.razor @@ -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 @@
- Media Item - - -
- Media Item ID -
- -
- -
- Title -
- -
- Playback Settings + + @if (_channelMode) + { + @:Channel + } + else + { + @(_info?.Kind ?? "Playback") + } + Settings + @if (!string.IsNullOrWhiteSpace(_title)) + { + @: - @_title + } +
@@ -70,55 +71,67 @@ } - -
- Subtitle -
- - (none) - @foreach (SubtitleViewModel subtitleStream in _subtitleStreams) - { - @($"{subtitleStream.Id}: {subtitleStream.Language} - {subtitleStream.Title} ({subtitleStream.Codec})") - } - -
- -
- Watermarks -
- - @foreach (WatermarkViewModel watermark in _watermarks) - { - @watermark.Name - } - -
- -
- Graphics Elements -
- - @foreach (GraphicsElementViewModel graphicsElement in _graphicsElements) - { - @graphicsElement.Name - } - -
- -
- Start From Beginning -
- -
- -
- Seek Seconds -
- -
+ @if (_channelMode) + { + +
+ Date and Time +
+ +
+ } + else + { + +
+ Subtitle +
+ + (none) + @foreach (SubtitleViewModel subtitleStream in _subtitleStreams) + { + @($"{subtitleStream.Id}: {subtitleStream.Language} - {subtitleStream.Title} ({subtitleStream.Codec})") + } + +
+ +
+ Watermarks +
+ + @foreach (WatermarkViewModel watermark in _watermarks) + { + @watermark.Name + } + +
+ +
+ Graphics Elements +
+ + @foreach (GraphicsElementViewModel graphicsElement in _graphicsElements) + { + @graphicsElement.Name + } + +
+ +
+ Start From Beginning +
+ +
+ +
+ Seek Seconds +
+ +
+ } Preview @@ -126,7 +139,7 @@ Play @@ -155,7 +168,7 @@ } - +

@@ -168,14 +181,17 @@ @code { private CancellationTokenSource _cts; + private readonly DateTimeFormatInfo _dtf = CultureInfo.CurrentUICulture.DateTimeFormat; private readonly List _ffmpegProfiles = []; private readonly List _streamSelectors = []; private readonly List _watermarks = []; private readonly List _subtitleStreams = []; private readonly List _graphicsElements = []; + private string _title; private MediaItemInfo _info; private readonly StreamingMode _streamingMode = StreamingMode.HttpLiveStreamingSegmenter; private int _ffmpegProfileId; + private bool _channelMode; private string _streamSelector; private IEnumerable _watermarkNames = new System.Collections.Generic.HashSet(); private IEnumerable _graphicsElementNames = new System.Collections.Generic.HashSet(); @@ -184,11 +200,16 @@ private int _seekSeconds; private bool _hasPlayed; private double? _lastSpeed; - private string _logs; + private MudTextField _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 @@ 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 @@ 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> { - 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("channel", (ChannelId ?? 0).ToString()), + new KeyValuePair("start", _start!.Value.ToString("o")) + ]); + } + else + { + queryString.AddRange( + [ + new KeyValuePair("mediaItem", (MediaItemId ?? 0).ToString()), + new KeyValuePair("seekSeconds", _seekSeconds.ToString()) + ]); + } + foreach (string watermarkName in _watermarkNames) { foreach (WatermarkViewModel watermark in _watermarks.Where(wm => wm.Name == watermarkName)) @@ -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 maybeInfo = await Mediator.Send(new GetMediaItemInfo(mediaItemId), cancellationToken); + foreach (MediaItemInfo info in maybeInfo.RightToSeq()) { - Either 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 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> - { - 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 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("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("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 @@ 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 @@ 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); }); + } + } + } diff --git a/ErsatzTV/Pages/Troubleshooting/Troubleshooting.razor b/ErsatzTV/Pages/Troubleshooting/Troubleshooting.razor index b0cf038a0..b73924336 100644 --- a/ErsatzTV/Pages/Troubleshooting/Troubleshooting.razor +++ b/ErsatzTV/Pages/Troubleshooting/Troubleshooting.razor @@ -17,14 +17,17 @@ +