Browse Source

fix segmenter timestamp continuity (#1610)

pull/1611/head
Jason Dove 2 years ago committed by GitHub
parent
commit
1f6e843a26
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      CHANGELOG.md
  2. 3
      ErsatzTV.Application/Channels/Mapper.cs
  3. 3
      ErsatzTV.Application/Channels/Queries/GetChannelResolution.cs
  4. 25
      ErsatzTV.Application/Channels/Queries/GetChannelResolutionHandler.cs
  5. 3
      ErsatzTV.Application/Channels/ResolutionViewModel.cs
  6. 3
      ErsatzTV.Application/Streaming/HlsSessionWorker.cs
  7. 30
      ErsatzTV.FFmpeg/OutputFormat/OutputFormatHls.cs
  8. 1
      ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs
  9. 21
      ErsatzTV/Controllers/IptvController.cs

2
CHANGELOG.md

@ -34,6 +34,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Automatically generate JWT tokens to allow channel previews of protected streams - Automatically generate JWT tokens to allow channel previews of protected streams
- Fix bug applying music video fallback metadata - Fix bug applying music video fallback metadata
- Fix playback of media items with no audio streams - Fix playback of media items with no audio streams
- Fix timestamp continuity in `HLS Segmenter` sessions
- This should make *some* clients happier
### Changed ### Changed
- Log search index updates under scanner category at debug level, to indicate a potential cause for the UI being out of date - Log search index updates under scanner category at debug level, to indicate a potential cause for the UI being out of date

3
ErsatzTV.Application/Channels/Mapper.cs

@ -34,6 +34,9 @@ internal static class Mapper
channel.PreferredAudioLanguageCode, channel.PreferredAudioLanguageCode,
GetStreamingMode(channel)); GetStreamingMode(channel));
internal static ResolutionViewModel ProjectToViewModel(Resolution resolution) =>
new(resolution.Height, resolution.Width);
private static string GetLogo(Channel channel) => private static string GetLogo(Channel channel) =>
Optional(channel.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Logo)) Optional(channel.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Logo))
.Match(a => a.Path, string.Empty); .Match(a => a.Path, string.Empty);

3
ErsatzTV.Application/Channels/Queries/GetChannelResolution.cs

@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Channels;
public record GetChannelResolution(string ChannelNumber) : IRequest<Option<ResolutionViewModel>>;

25
ErsatzTV.Application/Channels/Queries/GetChannelResolutionHandler.cs

@ -0,0 +1,25 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Channels;
public class GetChannelResolutionHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetChannelResolution, Option<ResolutionViewModel>>
{
public async Task<Option<ResolutionViewModel>> Handle(
GetChannelResolution request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<Channel> maybeChannel = await dbContext.Channels
.AsNoTracking()
.Include(c => c.FFmpegProfile)
.ThenInclude(ff => ff.Resolution)
.SelectOneAsync(c => c.Number, c => c.Number == request.ChannelNumber);
return maybeChannel.Map(c => Mapper.ProjectToViewModel(c.FFmpegProfile.Resolution));
}
}

3
ErsatzTV.Application/Channels/ResolutionViewModel.cs

@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Channels;
public record ResolutionViewModel(int Height, int Width);

3
ErsatzTV.Application/Streaming/HlsSessionWorker.cs

@ -181,7 +181,6 @@ public class HlsSessionWorker : IHlsSessionWorker
_logger.LogError("Transcode folder is NOT empty!"); _logger.LogError("Transcode folder is NOT empty!");
} }
Touch(); Touch();
_transcodedUntil = DateTimeOffset.Now; _transcodedUntil = DateTimeOffset.Now;
PlaylistStart = _transcodedUntil; PlaylistStart = _transcodedUntil;
@ -577,7 +576,7 @@ public class HlsSessionWorker : IHlsSessionWorker
foreach ((long pts, long duration) in queryResult.RightToSeq()) foreach ((long pts, long duration) in queryResult.RightToSeq())
{ {
result = pts + duration; result = pts + duration + 1;
} }
return result; return result;

30
ErsatzTV.FFmpeg/OutputFormat/OutputFormatHls.cs

@ -8,6 +8,7 @@ public class OutputFormatHls : IPipelineStep
private readonly Option<string> _mediaFrameRate; private readonly Option<string> _mediaFrameRate;
private readonly bool _oneSecondGop; private readonly bool _oneSecondGop;
private readonly string _playlistPath; private readonly string _playlistPath;
private readonly bool _isFirstTranscode;
private readonly string _segmentTemplate; private readonly string _segmentTemplate;
public OutputFormatHls( public OutputFormatHls(
@ -15,12 +16,14 @@ public class OutputFormatHls : IPipelineStep
Option<string> mediaFrameRate, Option<string> mediaFrameRate,
string segmentTemplate, string segmentTemplate,
string playlistPath, string playlistPath,
bool oneSecondGop = false) bool isFirstTranscode,
bool oneSecondGop)
{ {
_desiredState = desiredState; _desiredState = desiredState;
_mediaFrameRate = mediaFrameRate; _mediaFrameRate = mediaFrameRate;
_segmentTemplate = segmentTemplate; _segmentTemplate = segmentTemplate;
_playlistPath = playlistPath; _playlistPath = playlistPath;
_isFirstTranscode = isFirstTranscode;
_oneSecondGop = oneSecondGop; _oneSecondGop = oneSecondGop;
} }
@ -38,8 +41,8 @@ public class OutputFormatHls : IPipelineStep
int gop = _oneSecondGop ? frameRate : frameRate * SEGMENT_SECONDS; int gop = _oneSecondGop ? frameRate : frameRate * SEGMENT_SECONDS;
return new[] List<string> result =
{ [
"-g", $"{gop}", "-g", $"{gop}",
"-keyint_min", $"{frameRate * SEGMENT_SECONDS}", "-keyint_min", $"{frameRate * SEGMENT_SECONDS}",
"-force_key_frames", $"expr:gte(t,n_forced*{SEGMENT_SECONDS})", "-force_key_frames", $"expr:gte(t,n_forced*{SEGMENT_SECONDS})",
@ -48,11 +51,28 @@ public class OutputFormatHls : IPipelineStep
"-hls_list_size", "0", "-hls_list_size", "0",
"-segment_list_flags", "+live", "-segment_list_flags", "+live",
"-hls_segment_filename", "-hls_segment_filename",
_segmentTemplate, _segmentTemplate
];
if (_isFirstTranscode)
{
result.AddRange(
[
"-hls_flags", "program_date_time+append_list+omit_endlist+independent_segments",
_playlistPath
]);
}
else
{
result.AddRange(
[
"-hls_flags", "program_date_time+append_list+discont_start+omit_endlist+independent_segments", "-hls_flags", "program_date_time+append_list+discont_start+omit_endlist+independent_segments",
"-mpegts_flags", "+initial_discontinuity", "-mpegts_flags", "+initial_discontinuity",
_playlistPath _playlistPath
}; ]);
}
return result.ToArray();
} }
} }

1
ErsatzTV.FFmpeg/Pipeline/PipelineBuilderBase.cs

@ -275,6 +275,7 @@ public abstract class PipelineBuilderBase : IPipelineBuilder
videoStream.FrameRate, videoStream.FrameRate,
segmentTemplate, segmentTemplate,
playlistPath, playlistPath,
isFirstTranscode: ffmpegState.PtsOffset == 0,
ffmpegState.EncoderHardwareAccelerationMode is HardwareAccelerationMode.Qsv)); ffmpegState.EncoderHardwareAccelerationMode is HardwareAccelerationMode.Qsv));
} }
} }

21
ErsatzTV/Controllers/IptvController.cs

@ -191,6 +191,7 @@ public class IptvController : ControllerBase
switch (mode) switch (mode)
{ {
case "segmenter": case "segmenter":
string multiVariantPlaylist = await GetMultiVariantPlaylist(channelNumber);
_logger.LogDebug("Maybe starting ffmpeg session for channel {Channel}", channelNumber); _logger.LogDebug("Maybe starting ffmpeg session for channel {Channel}", channelNumber);
Either<BaseError, Unit> result = await _mediator.Send(new StartFFmpegSession(channelNumber, false)); Either<BaseError, Unit> result = await _mediator.Send(new StartFFmpegSession(channelNumber, false));
return result.Match<IActionResult>( return result.Match<IActionResult>(
@ -199,7 +200,7 @@ public class IptvController : ControllerBase
_logger.LogDebug( _logger.LogDebug(
"Session started; returning multi-variant playlist for channel {Channel}", "Session started; returning multi-variant playlist for channel {Channel}",
channelNumber); channelNumber);
return Content(GetMultiVariantPlaylist(channelNumber), "application/vnd.apple.mpegurl"); return Content(multiVariantPlaylist, "application/vnd.apple.mpegurl");
// return Redirect($"~/iptv/session/{channelNumber}/hls.m3u8"); // return Redirect($"~/iptv/session/{channelNumber}/hls.m3u8");
}, },
error => error =>
@ -210,7 +211,7 @@ public class IptvController : ControllerBase
_logger.LogDebug( _logger.LogDebug(
"Session is already active; returning multi-variant playlist for channel {Channel}", "Session is already active; returning multi-variant playlist for channel {Channel}",
channelNumber); channelNumber);
return Content(GetMultiVariantPlaylist(channelNumber), "application/vnd.apple.mpegurl"); return Content(multiVariantPlaylist, "application/vnd.apple.mpegurl");
// return RedirectPreserveMethod($"iptv/session/{channelNumber}/hls.m3u8"); // return RedirectPreserveMethod($"iptv/session/{channelNumber}/hls.m3u8");
default: default:
_logger.LogWarning( _logger.LogWarning(
@ -246,12 +247,20 @@ public class IptvController : ControllerBase
Right: r => new PhysicalFileResult(r.FileName, r.MimeType)); Right: r => new PhysicalFileResult(r.FileName, r.MimeType));
} }
// TODO: include resolution here? private async Task<string> GetMultiVariantPlaylist(string channelNumber)
private string GetMultiVariantPlaylist(string channelNumber) => {
$@"#EXTM3U Option<ResolutionViewModel> maybeResolution = await _mediator.Send(new GetChannelResolution(channelNumber));
string resolution = string.Empty;
foreach (ResolutionViewModel res in maybeResolution)
{
resolution = $",RESOLUTION={res.Width}x{res.Height}";
}
return $@"#EXTM3U
#EXT-X-VERSION:3 #EXT-X-VERSION:3
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=10000000 #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=10000000{resolution}
{Request.Scheme}://{Request.Host}/iptv/session/{channelNumber}/hls.m3u8{AccessTokenQuery()}"; {Request.Scheme}://{Request.Host}/iptv/session/{channelNumber}/hls.m3u8{AccessTokenQuery()}";
}
private string AccessTokenQuery() => string.IsNullOrWhiteSpace(Request.Query["access_token"]) private string AccessTokenQuery() => string.IsNullOrWhiteSpace(Request.Query["access_token"])
? string.Empty ? string.Empty

Loading…
Cancel
Save