From e81a8e58ea301b4d94846b1b08032f4998752c5b Mon Sep 17 00:00:00 2001 From: Jason Dove Date: Thu, 5 May 2022 13:31:09 -0500 Subject: [PATCH] fix error continuity (#787) * fix fallback filler playback * use new transcoder logic for errors * use realtime option for error streams --- CHANGELOG.md | 2 + ...layoutItemProcessByChannelNumberHandler.cs | 8 +- .../FFmpeg/TranscodingTests.cs | 1 + .../MediaItem/BackgroundImageMediaVersion.cs | 28 +++- .../FFmpeg/FFmpegLibraryProcessService.cs | 142 ++++++++++++++++-- .../FFmpegPlaybackSettingsCalculator.cs | 14 +- ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs | 91 +---------- ErsatzTV.Core/FFmpeg/SongVideoGenerator.cs | 24 +-- ErsatzTV.FFmpeg/Encoder/EncoderLibx265.cs | 4 +- ErsatzTV.FFmpeg/Filter/ComplexFilter.cs | 28 ++-- ErsatzTV.FFmpeg/InputFile.cs | 11 ++ ErsatzTV.FFmpeg/Option/LavfiInputOption.cs | 21 +++ 12 files changed, 230 insertions(+), 144 deletions(-) create mode 100644 ErsatzTV.FFmpeg/Option/LavfiInputOption.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index f933b9f9e..0ffb17083 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Fix search index validation on startup; improper validation was causing a rebuild with every startup - Block library scanning until search index has been recreated/upgraded - Fix occasional erroneous log messages when HLS channel playback times out because all clients have left +- Fix fallback filler playback +- Fix stream continuity when error messages are displayed ### Added - Add `show_genre` and `show_tag` to search index for seasons and episodes diff --git a/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs b/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs index 5a109de53..9e5d1eade 100644 --- a/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs +++ b/ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs @@ -254,16 +254,16 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler< List allSubtitles = playoutItemWithPath.PlayoutItem.MediaItem switch { Episode episode => Optional(episode.EpisodeMetadata).Flatten().HeadOrNone() - .Map(mm => mm.Subtitles) + .Map(mm => mm.Subtitles ?? new List()) .IfNone(new List()), Movie movie => Optional(movie.MovieMetadata).Flatten().HeadOrNone() - .Map(mm => mm.Subtitles) + .Map(mm => mm.Subtitles ?? new List()) .IfNone(new List()), MusicVideo musicVideo => Optional(musicVideo.MusicVideoMetadata).Flatten().HeadOrNone() - .Map(mm => mm.Subtitles) + .Map(mm => mm.Subtitles ?? new List()) .IfNone(new List()), OtherVideo otherVideo => Optional(otherVideo.OtherVideoMetadata).Flatten().HeadOrNone() - .Map(mm => mm.Subtitles) + .Map(mm => mm.Subtitles ?? new List()) .IfNone(new List()), _ => new List() }; diff --git a/ErsatzTV.Core.Tests/FFmpeg/TranscodingTests.cs b/ErsatzTV.Core.Tests/FFmpeg/TranscodingTests.cs index eeeb43972..99a6a2b5e 100644 --- a/ErsatzTV.Core.Tests/FFmpeg/TranscodingTests.cs +++ b/ErsatzTV.Core.Tests/FFmpeg/TranscodingTests.cs @@ -301,6 +301,7 @@ public class TranscodingTests oldService, new FFmpegPlaybackSettingsCalculator(), new FakeStreamSelector(), + new Mock().Object, LoggerFactory.CreateLogger()); var v = new MediaVersion diff --git a/ErsatzTV.Core/Domain/MediaItem/BackgroundImageMediaVersion.cs b/ErsatzTV.Core/Domain/MediaItem/BackgroundImageMediaVersion.cs index 07c899bb5..a6d56cfe8 100644 --- a/ErsatzTV.Core/Domain/MediaItem/BackgroundImageMediaVersion.cs +++ b/ErsatzTV.Core/Domain/MediaItem/BackgroundImageMediaVersion.cs @@ -1,5 +1,31 @@ -namespace ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.FFmpeg; +using ErsatzTV.FFmpeg.Format; + +namespace ErsatzTV.Core.Domain; public class BackgroundImageMediaVersion : MediaVersion { + public static BackgroundImageMediaVersion ForPath(string path, IDisplaySize resolution) => + new() + { + Chapters = new List(), + // image has been pre-generated with correct size + Height = resolution.Height, + Width = resolution.Width, + SampleAspectRatio = "1:1", + Streams = new List + { + new() + { + MediaStreamKind = MediaStreamKind.Video, + Index = 0, + Codec = VideoFormat.GeneratedImage, + PixelFormat = new PixelFormatUnknown().Name // the resulting pixel format is unknown + } + }, + MediaFiles = new List + { + new() { Path = path } + } + }; } diff --git a/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs b/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs index 23ce9ce68..816d81973 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs @@ -18,16 +18,19 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService private readonly IFFmpegStreamSelector _ffmpegStreamSelector; private readonly ILogger _logger; private readonly FFmpegPlaybackSettingsCalculator _playbackSettingsCalculator; + private readonly ITempFilePool _tempFilePool; public FFmpegLibraryProcessService( FFmpegProcessService ffmpegProcessService, FFmpegPlaybackSettingsCalculator playbackSettingsCalculator, IFFmpegStreamSelector ffmpegStreamSelector, + ITempFilePool tempFilePool, ILogger logger) { _ffmpegProcessService = ffmpegProcessService; _playbackSettingsCalculator = playbackSettingsCalculator; _ffmpegStreamSelector = ffmpegStreamSelector; + _tempFilePool = tempFilePool; _logger = logger; } @@ -174,14 +177,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService Option watermarkInputFile = GetWatermarkInputFile(watermarkOptions, maybeFadePoints); - string videoFormat = playbackSettings.VideoFormat switch - { - FFmpegProfileVideoFormat.Hevc => VideoFormat.Hevc, - FFmpegProfileVideoFormat.H264 => VideoFormat.H264, - FFmpegProfileVideoFormat.Mpeg2Video => VideoFormat.Mpeg2Video, - FFmpegProfileVideoFormat.Copy => VideoFormat.Copy, - _ => throw new ArgumentOutOfRangeException($"unexpected video format {playbackSettings.VideoFormat}") - }; + string videoFormat = GetVideoFormat(playbackSettings); HardwareAccelerationMode hwAccel = playbackSettings.HardwareAcceleration switch { @@ -254,14 +250,128 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService return GetCommand(ffmpegPath, videoInputFile, audioInputFile, watermarkInputFile, None, pipeline); } - public Task ForError( + public async Task ForError( string ffmpegPath, Channel channel, Option duration, string errorMessage, bool hlsRealtime, - long ptsOffset) => - _ffmpegProcessService.ForError(ffmpegPath, channel, duration, errorMessage, hlsRealtime, ptsOffset); + long ptsOffset) + { + FFmpegPlaybackSettings playbackSettings = _playbackSettingsCalculator.CalculateErrorSettings( + channel.StreamingMode, + channel.FFmpegProfile, + hlsRealtime); + + IDisplaySize desiredResolution = channel.FFmpegProfile.Resolution; + + var fontSize = (int)Math.Round(channel.FFmpegProfile.Resolution.Height / 20.0); + var margin = (int)Math.Round(channel.FFmpegProfile.Resolution.Height * 0.05); + + string subtitleFile = await new SubtitleBuilder(_tempFilePool) + .WithResolution(desiredResolution) + .WithFontName("Roboto") + .WithFontSize(fontSize) + .WithAlignment(2) + .WithMarginV(margin) + .WithPrimaryColor("&HFFFFFF") + .WithFormattedContent(errorMessage.Replace(Environment.NewLine, "\\N")) + .BuildFile(); + + string audioFormat = playbackSettings.AudioFormat switch + { + FFmpegProfileAudioFormat.Ac3 => AudioFormat.Ac3, + _ => AudioFormat.Aac + }; + + var audioState = new AudioState( + audioFormat, + playbackSettings.AudioChannels, + playbackSettings.AudioBitrate, + playbackSettings.AudioBufferSize, + playbackSettings.AudioSampleRate, + Option.None, + playbackSettings.NormalizeLoudness); + + var desiredState = new FrameState( + playbackSettings.RealtimeOutput, + false, + GetVideoFormat(playbackSettings), + new PixelFormatYuv420P(), + new FrameSize(desiredResolution.Width, desiredResolution.Height), + new FrameSize(desiredResolution.Width, desiredResolution.Height), + playbackSettings.FrameRate, + playbackSettings.VideoBitrate, + playbackSettings.VideoBufferSize, + playbackSettings.VideoTrackTimeScale, + playbackSettings.Deinterlace); + + OutputFormatKind outputFormat = channel.StreamingMode == StreamingMode.HttpLiveStreamingSegmenter + ? OutputFormatKind.Hls + : OutputFormatKind.MpegTs; + + Option hlsPlaylistPath = outputFormat == OutputFormatKind.Hls + ? Path.Combine(FileSystemLayout.TranscodeFolder, channel.Number, "live.m3u8") + : Option.None; + + Option hlsSegmentTemplate = outputFormat == OutputFormatKind.Hls + ? Path.Combine(FileSystemLayout.TranscodeFolder, channel.Number, "live%06d.ts") + : Option.None; + + string videoPath = Path.Combine(FileSystemLayout.ResourcesCacheFolder, "background.png"); + + var videoVersion = BackgroundImageMediaVersion.ForPath(videoPath, desiredResolution); + + var ffmpegVideoStream = new VideoStream( + 0, + VideoFormat.GeneratedImage, + new PixelFormatYuv420P(), + new FrameSize(videoVersion.Width, videoVersion.Height), + None, + true); + + var videoInputFile = new VideoInputFile(videoPath, new List { ffmpegVideoStream }); + + var ffmpegState = new FFmpegState( + false, + HardwareAccelerationMode.None, + None, + None, + playbackSettings.StreamSeek, + duration, + channel.StreamingMode != StreamingMode.HttpLiveStreamingDirect, + "ErsatzTV", + channel.Name, + None, + outputFormat, + hlsPlaylistPath, + hlsSegmentTemplate, + ptsOffset); + + var ffmpegSubtitleStream = new ErsatzTV.FFmpeg.MediaStream(0, "ass", StreamKind.Video); + + var audioInputFile = new NullAudioInputFile(audioState); + + var subtitleInputFile = new SubtitleInputFile( + subtitleFile, + new List { ffmpegSubtitleStream }, + false); + + _logger.LogDebug("FFmpeg desired error state {FrameState}", desiredState); + + var pipelineBuilder = new PipelineBuilder( + videoInputFile, + audioInputFile, + None, + subtitleInputFile, + FileSystemLayout.FFmpegReportsFolder, + FileSystemLayout.FontsCacheFolder, + _logger); + + FFmpegPipeline pipeline = pipelineBuilder.Build(ffmpegState, desiredState); + + return GetCommand(ffmpegPath, videoInputFile, audioInputFile, None, None, pipeline); + } public Command ConcatChannel(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host) { @@ -478,4 +588,14 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService private static Option VaapiDeviceName(HardwareAccelerationMode accelerationMode, string vaapiDevice) => accelerationMode == HardwareAccelerationMode.Vaapi ? vaapiDevice : Option.None; + + private static string GetVideoFormat(FFmpegPlaybackSettings playbackSettings) => + playbackSettings.VideoFormat switch + { + FFmpegProfileVideoFormat.Hevc => VideoFormat.Hevc, + FFmpegProfileVideoFormat.H264 => VideoFormat.H264, + FFmpegProfileVideoFormat.Mpeg2Video => VideoFormat.Mpeg2Video, + FFmpegProfileVideoFormat.Copy => VideoFormat.Copy, + _ => throw new ArgumentOutOfRangeException($"unexpected video format {playbackSettings.VideoFormat}") + }; } diff --git a/ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs b/ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs index db5f9d268..5543f157d 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs @@ -170,14 +170,22 @@ public class FFmpegPlaybackSettingsCalculator return result; } - public FFmpegPlaybackSettings CalculateErrorSettings(FFmpegProfile ffmpegProfile) => + public FFmpegPlaybackSettings CalculateErrorSettings( + StreamingMode streamingMode, + FFmpegProfile ffmpegProfile, + bool hlsRealtime) => new() { HardwareAcceleration = HardwareAccelerationKind.None, - ThreadCount = ffmpegProfile.ThreadCount, + ThreadCount = 1, FormatFlags = CommonFormatFlags, VideoFormat = ffmpegProfile.VideoFormat, - AudioFormat = ffmpegProfile.AudioFormat + AudioFormat = ffmpegProfile.AudioFormat, + RealtimeOutput = streamingMode switch + { + StreamingMode.HttpLiveStreamingSegmenter => hlsRealtime, + _ => true + } }; private static bool NeedToScale(FFmpegProfile ffmpegProfile, MediaVersion version) => diff --git a/ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs b/ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs index 6490b44ff..1c6621ac2 100644 --- a/ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs +++ b/ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs @@ -40,92 +40,6 @@ public class FFmpegProcessService _logger = logger; } - public async Task ForError( - string ffmpegPath, - Channel channel, - Option duration, - string errorMessage, - bool hlsRealtime, - long ptsOffset) - { - FFmpegPlaybackSettings playbackSettings = - _playbackSettingsCalculator.CalculateErrorSettings(channel.FFmpegProfile); - - IDisplaySize desiredResolution = channel.FFmpegProfile.Resolution; - - var fontSize = (int)Math.Round(channel.FFmpegProfile.Resolution.Height / 20.0); - var margin = (int)Math.Round(channel.FFmpegProfile.Resolution.Height * 0.05); - - string subtitleFile = await new SubtitleBuilder(_tempFilePool) - .WithResolution(desiredResolution) - .WithFontName("Roboto") - .WithFontSize(fontSize) - .WithAlignment(2) - .WithMarginV(margin) - .WithPrimaryColor("&HFFFFFF") - .WithFormattedContent(errorMessage.Replace(Environment.NewLine, "\\N")) - .BuildFile(); - - var videoStream = new MediaStream { Index = 0 }; - var audioStream = new MediaStream { Index = 0 }; - - string videoCodec = playbackSettings.VideoFormat switch - { - FFmpegProfileVideoFormat.Hevc => "libx265", - FFmpegProfileVideoFormat.Mpeg2Video => "mpeg2video", - _ => "libx264" - }; - - string audioCodec = playbackSettings.AudioFormat switch - { - FFmpegProfileAudioFormat.Ac3 => "ac3", - _ => "aac" - }; - - FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath, false, _logger) - .WithThreads(1) - .WithQuiet() - .WithFormatFlags(playbackSettings.FormatFlags) - .WithRealtimeOutput(playbackSettings.RealtimeOutput) - .WithLoopedImage(Path.Combine(FileSystemLayout.ResourcesCacheFolder, "background.png")) - .WithLibavfilter() - .WithInput("anullsrc") - .WithSubtitleFile(subtitleFile) - .WithFilterComplex( - videoStream, - audioStream, - Path.Combine(FileSystemLayout.ResourcesCacheFolder, "background.png"), - "fake-audio-path", - playbackSettings.VideoFormat) - .WithPixfmt("yuv420p") - .WithPlaybackArgs(playbackSettings, videoCodec, audioCodec) - .WithMetadata(channel, None); - - await duration.IfSomeAsync(d => builder = builder.WithDuration(d)); - - Process process = channel.StreamingMode switch - { - // HLS needs to segment and generate playlist - StreamingMode.HttpLiveStreamingSegmenter => - builder.WithHls( - channel.Number, - None, - ptsOffset, - playbackSettings.VideoTrackTimeScale, - playbackSettings.FrameRate) - .Build(), - _ => builder.WithFormat("mpegts") - .WithPipe() - .Build() - }; - - return Cli.Wrap(process.StartInfo.FileName) - .WithArguments(process.StartInfo.ArgumentList) - .WithValidation(CommandResultValidation.None) - .WithEnvironmentVariables(process.StartInfo.Environment.ToDictionary(kvp => kvp.Key, kvp => kvp.Value)) - .WithStandardErrorPipe(PipeTarget.ToStream(Stream.Null)); - } - public Command WrapSegmenter(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host) { FFmpegPlaybackSettings playbackSettings = _playbackSettingsCalculator.ConcatSettings; @@ -231,7 +145,10 @@ public class FFmpegProcessService watermarkPath); FFmpegPlaybackSettings playbackSettings = - _playbackSettingsCalculator.CalculateErrorSettings(channel.FFmpegProfile); + _playbackSettingsCalculator.CalculateErrorSettings( + StreamingMode.TransportStream, + channel.FFmpegProfile, + false); FFmpegPlaybackSettings scalePlaybackSettings = _playbackSettingsCalculator.CalculateSettings( StreamingMode.TransportStream, diff --git a/ErsatzTV.Core/FFmpeg/SongVideoGenerator.cs b/ErsatzTV.Core/FFmpeg/SongVideoGenerator.cs index 9d04dba68..1b6e2cc0b 100644 --- a/ErsatzTV.Core/FFmpeg/SongVideoGenerator.cs +++ b/ErsatzTV.Core/FFmpeg/SongVideoGenerator.cs @@ -2,7 +2,6 @@ using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.Images; -using ErsatzTV.FFmpeg.Format; using ErsatzTV.FFmpeg.State; namespace ErsatzTV.Core.FFmpeg; @@ -233,28 +232,7 @@ public class SongVideoGenerator : ISongVideoGenerator foreach (string si in maybeSongImage.RightToSeq()) { videoPath = si; - videoVersion = new BackgroundImageMediaVersion - { - Chapters = new List(), - // song image has been pre-generated with correct size - Height = channel.FFmpegProfile.Resolution.Height, - Width = channel.FFmpegProfile.Resolution.Width, - SampleAspectRatio = "1:1", - Streams = new List - { - new() - { - MediaStreamKind = MediaStreamKind.Video, - Index = 0, - Codec = VideoFormat.GeneratedImage, - PixelFormat = new PixelFormatUnknown().Name // the resulting pixel format is unknown - } - }, - MediaFiles = new List - { - new() { Path = si } - } - }; + videoVersion = BackgroundImageMediaVersion.ForPath(si, channel.FFmpegProfile.Resolution); } return Tuple(videoPath, videoVersion); diff --git a/ErsatzTV.FFmpeg/Encoder/EncoderLibx265.cs b/ErsatzTV.FFmpeg/Encoder/EncoderLibx265.cs index f83123010..a92e4211c 100644 --- a/ErsatzTV.FFmpeg/Encoder/EncoderLibx265.cs +++ b/ErsatzTV.FFmpeg/Encoder/EncoderLibx265.cs @@ -5,7 +5,9 @@ namespace ErsatzTV.FFmpeg.Encoder; public class EncoderLibx265 : EncoderBase { // TODO: is tag:v needed for mpegts? - public override IList OutputOptions => new List { "-c:v", Name, "-tag:v", "hvc1" }; + public override IList OutputOptions => new List + { "-c:v", Name, "-tag:v", "hvc1", "-x265-params", "log-level=error" }; + public override string Name => "libx265"; public override StreamKind Kind => StreamKind.Video; diff --git a/ErsatzTV.FFmpeg/Filter/ComplexFilter.cs b/ErsatzTV.FFmpeg/Filter/ComplexFilter.cs index 6f790fac3..0d2564b6d 100644 --- a/ErsatzTV.FFmpeg/Filter/ComplexFilter.cs +++ b/ErsatzTV.FFmpeg/Filter/ComplexFilter.cs @@ -258,21 +258,21 @@ public class ComplexFilter : IPipelineStep } } - if (!string.IsNullOrWhiteSpace(audioFilterComplex) || !string.IsNullOrWhiteSpace(videoFilterComplex)) - { - var filterComplex = string.Join( - ";", - new[] - { - audioFilterComplex, - videoFilterComplex, - watermarkFilterComplex, - subtitleFilterComplex, - watermarkOverlayFilterComplex, - subtitleOverlayFilterComplex - }.Where( - s => !string.IsNullOrWhiteSpace(s))); + var filterComplex = string.Join( + ";", + new[] + { + audioFilterComplex, + videoFilterComplex, + watermarkFilterComplex, + subtitleFilterComplex, + watermarkOverlayFilterComplex, + subtitleOverlayFilterComplex + }.Where( + s => !string.IsNullOrWhiteSpace(s))); + if (!string.IsNullOrWhiteSpace(filterComplex)) + { result.AddRange(new[] { "-filter_complex", filterComplex }); } diff --git a/ErsatzTV.FFmpeg/InputFile.cs b/ErsatzTV.FFmpeg/InputFile.cs index 49bbc1fde..d33756a51 100644 --- a/ErsatzTV.FFmpeg/InputFile.cs +++ b/ErsatzTV.FFmpeg/InputFile.cs @@ -45,6 +45,17 @@ public record AudioInputFile(string Path, IList AudioStreams, Audio } } +public record NullAudioInputFile : AudioInputFile +{ + public NullAudioInputFile(AudioState DesiredState) : base( + "anullsrc", + new List { new(0, "unknown", -1) }, + DesiredState) => + InputOptions.Add(new LavfiInputOption()); + + public void Deconstruct(out AudioState DesiredState) => DesiredState = this.DesiredState; +} + public record VideoInputFile(string Path, IList VideoStreams) : InputFile( Path, VideoStreams.Cast().ToList()) diff --git a/ErsatzTV.FFmpeg/Option/LavfiInputOption.cs b/ErsatzTV.FFmpeg/Option/LavfiInputOption.cs new file mode 100644 index 000000000..1be17c3bd --- /dev/null +++ b/ErsatzTV.FFmpeg/Option/LavfiInputOption.cs @@ -0,0 +1,21 @@ +using ErsatzTV.FFmpeg.Environment; + +namespace ErsatzTV.FFmpeg.Option; + +public class LavfiInputOption : IInputOption +{ + public IList EnvironmentVariables => Array.Empty(); + public IList GlobalOptions => Array.Empty(); + + public IList InputOptions(InputFile inputFile) => new List { "-f", "lavfi" }; + + public IList FilterOptions => Array.Empty(); + public IList OutputOptions => Array.Empty(); + public FrameState NextState(FrameState currentState) => currentState; + + public bool AppliesTo(AudioInputFile audioInputFile) => true; + + public bool AppliesTo(VideoInputFile videoInputFile) => false; + + public bool AppliesTo(ConcatInputFile concatInputFile) => false; +}