|
|
|
@ -15,6 +15,7 @@ namespace ErsatzTV.Application.Streaming; |
|
|
|
|
|
|
|
|
|
|
|
public class HlsSessionWorker : IHlsSessionWorker |
|
|
|
public class HlsSessionWorker : IHlsSessionWorker |
|
|
|
{ |
|
|
|
{ |
|
|
|
|
|
|
|
private static readonly SemaphoreSlim Slim = new(1, 1); |
|
|
|
private static int _workAheadCount; |
|
|
|
private static int _workAheadCount; |
|
|
|
private readonly IHlsPlaylistFilter _hlsPlaylistFilter; |
|
|
|
private readonly IHlsPlaylistFilter _hlsPlaylistFilter; |
|
|
|
private readonly IServiceScopeFactory _serviceScopeFactory; |
|
|
|
private readonly IServiceScopeFactory _serviceScopeFactory; |
|
|
|
@ -25,8 +26,12 @@ public class HlsSessionWorker : IHlsSessionWorker |
|
|
|
private readonly object _sync = new(); |
|
|
|
private readonly object _sync = new(); |
|
|
|
private DateTimeOffset _playlistStart; |
|
|
|
private DateTimeOffset _playlistStart; |
|
|
|
private Option<int> _targetFramerate; |
|
|
|
private Option<int> _targetFramerate; |
|
|
|
|
|
|
|
private string _channelNumber; |
|
|
|
|
|
|
|
|
|
|
|
public HlsSessionWorker(IHlsPlaylistFilter hlsPlaylistFilter, IServiceScopeFactory serviceScopeFactory, ILogger<HlsSessionWorker> logger) |
|
|
|
public HlsSessionWorker( |
|
|
|
|
|
|
|
IHlsPlaylistFilter hlsPlaylistFilter, |
|
|
|
|
|
|
|
IServiceScopeFactory serviceScopeFactory, |
|
|
|
|
|
|
|
ILogger<HlsSessionWorker> logger) |
|
|
|
{ |
|
|
|
{ |
|
|
|
_hlsPlaylistFilter = hlsPlaylistFilter; |
|
|
|
_hlsPlaylistFilter = hlsPlaylistFilter; |
|
|
|
_serviceScopeFactory = serviceScopeFactory; |
|
|
|
_serviceScopeFactory = serviceScopeFactory; |
|
|
|
@ -46,13 +51,23 @@ public class HlsSessionWorker : IHlsSessionWorker |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
public async Task<Option<TrimPlaylistResult>> TrimPlaylist( |
|
|
|
|
|
|
|
DateTimeOffset filterBefore, |
|
|
|
|
|
|
|
CancellationToken cancellationToken) |
|
|
|
|
|
|
|
{ |
|
|
|
|
|
|
|
Option<string[]> maybeLines = await ReadPlaylistLines(cancellationToken); |
|
|
|
|
|
|
|
return maybeLines.Map(input => _hlsPlaylistFilter.TrimPlaylist(PlaylistStart, filterBefore, input)); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
public async Task Run(string channelNumber, TimeSpan idleTimeout) |
|
|
|
public async Task Run(string channelNumber, TimeSpan idleTimeout) |
|
|
|
{ |
|
|
|
{ |
|
|
|
var cts = new CancellationTokenSource(); |
|
|
|
var cts = new CancellationTokenSource(); |
|
|
|
void Cancel(object o, ElapsedEventArgs e) => cts.Cancel(); |
|
|
|
void Cancel(object o, ElapsedEventArgs e) => cts.Cancel(); |
|
|
|
|
|
|
|
|
|
|
|
try |
|
|
|
try |
|
|
|
{ |
|
|
|
{ |
|
|
|
|
|
|
|
_channelNumber = channelNumber; |
|
|
|
|
|
|
|
|
|
|
|
lock (_sync) |
|
|
|
lock (_sync) |
|
|
|
{ |
|
|
|
{ |
|
|
|
_timer = new Timer(idleTimeout.TotalMilliseconds) { AutoReset = false }; |
|
|
|
_timer = new Timer(idleTimeout.TotalMilliseconds) { AutoReset = false }; |
|
|
|
@ -75,7 +90,7 @@ public class HlsSessionWorker : IHlsSessionWorker |
|
|
|
_playlistStart = _transcodedUntil; |
|
|
|
_playlistStart = _transcodedUntil; |
|
|
|
|
|
|
|
|
|
|
|
bool initialWorkAhead = Volatile.Read(ref _workAheadCount) < await GetWorkAheadLimit(); |
|
|
|
bool initialWorkAhead = Volatile.Read(ref _workAheadCount) < await GetWorkAheadLimit(); |
|
|
|
if (!await Transcode(channelNumber, true, !initialWorkAhead, cancellationToken)) |
|
|
|
if (!await Transcode(true, !initialWorkAhead, cancellationToken)) |
|
|
|
{ |
|
|
|
{ |
|
|
|
return; |
|
|
|
return; |
|
|
|
} |
|
|
|
} |
|
|
|
@ -96,14 +111,14 @@ public class HlsSessionWorker : IHlsSessionWorker |
|
|
|
bool realtime = transcodedBuffer >= TimeSpan.FromSeconds(30); |
|
|
|
bool realtime = transcodedBuffer >= TimeSpan.FromSeconds(30); |
|
|
|
bool subsequentWorkAhead = |
|
|
|
bool subsequentWorkAhead = |
|
|
|
!realtime && Volatile.Read(ref _workAheadCount) < await GetWorkAheadLimit(); |
|
|
|
!realtime && Volatile.Read(ref _workAheadCount) < await GetWorkAheadLimit(); |
|
|
|
if (!await Transcode(channelNumber, false, !subsequentWorkAhead, cancellationToken)) |
|
|
|
if (!await Transcode(false, !subsequentWorkAhead, cancellationToken)) |
|
|
|
{ |
|
|
|
{ |
|
|
|
return; |
|
|
|
return; |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
else |
|
|
|
else |
|
|
|
{ |
|
|
|
{ |
|
|
|
await TrimAndDelete(channelNumber, cancellationToken); |
|
|
|
await TrimAndDelete(cancellationToken); |
|
|
|
await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken); |
|
|
|
await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken); |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
@ -118,7 +133,6 @@ public class HlsSessionWorker : IHlsSessionWorker |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
private async Task<bool> Transcode( |
|
|
|
private async Task<bool> Transcode( |
|
|
|
string channelNumber, |
|
|
|
|
|
|
|
bool firstProcess, |
|
|
|
bool firstProcess, |
|
|
|
bool realtime, |
|
|
|
bool realtime, |
|
|
|
CancellationToken cancellationToken) |
|
|
|
CancellationToken cancellationToken) |
|
|
|
@ -130,22 +144,22 @@ public class HlsSessionWorker : IHlsSessionWorker |
|
|
|
if (!realtime) |
|
|
|
if (!realtime) |
|
|
|
{ |
|
|
|
{ |
|
|
|
Interlocked.Increment(ref _workAheadCount); |
|
|
|
Interlocked.Increment(ref _workAheadCount); |
|
|
|
_logger.LogInformation("HLS segmenter will work ahead for channel {Channel}", channelNumber); |
|
|
|
_logger.LogInformation("HLS segmenter will work ahead for channel {Channel}", _channelNumber); |
|
|
|
} |
|
|
|
} |
|
|
|
else |
|
|
|
else |
|
|
|
{ |
|
|
|
{ |
|
|
|
_logger.LogInformation( |
|
|
|
_logger.LogInformation( |
|
|
|
"HLS segmenter will NOT work ahead for channel {Channel}", |
|
|
|
"HLS segmenter will NOT work ahead for channel {Channel}", |
|
|
|
channelNumber); |
|
|
|
_channelNumber); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>(); |
|
|
|
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>(); |
|
|
|
|
|
|
|
|
|
|
|
long ptsOffset = await GetPtsOffset(mediator, channelNumber, cancellationToken); |
|
|
|
long ptsOffset = await GetPtsOffset(mediator, _channelNumber, cancellationToken); |
|
|
|
// _logger.LogInformation("PTS offset: {PtsOffset}", ptsOffset);
|
|
|
|
// _logger.LogInformation("PTS offset: {PtsOffset}", ptsOffset);
|
|
|
|
|
|
|
|
|
|
|
|
var request = new GetPlayoutItemProcessByChannelNumber( |
|
|
|
var request = new GetPlayoutItemProcessByChannelNumber( |
|
|
|
channelNumber, |
|
|
|
_channelNumber, |
|
|
|
"segmenter", |
|
|
|
"segmenter", |
|
|
|
firstProcess ? DateTimeOffset.Now : _transcodedUntil.AddSeconds(1), |
|
|
|
firstProcess ? DateTimeOffset.Now : _transcodedUntil.AddSeconds(1), |
|
|
|
!firstProcess, |
|
|
|
!firstProcess, |
|
|
|
@ -163,7 +177,7 @@ public class HlsSessionWorker : IHlsSessionWorker |
|
|
|
{ |
|
|
|
{ |
|
|
|
_logger.LogWarning( |
|
|
|
_logger.LogWarning( |
|
|
|
"Failed to create process for HLS session on channel {Channel}: {Error}", |
|
|
|
"Failed to create process for HLS session on channel {Channel}: {Error}", |
|
|
|
channelNumber, |
|
|
|
_channelNumber, |
|
|
|
error.ToString()); |
|
|
|
error.ToString()); |
|
|
|
|
|
|
|
|
|
|
|
return false; |
|
|
|
return false; |
|
|
|
@ -171,7 +185,7 @@ public class HlsSessionWorker : IHlsSessionWorker |
|
|
|
|
|
|
|
|
|
|
|
foreach (PlayoutItemProcessModel processModel in result.RightAsEnumerable()) |
|
|
|
foreach (PlayoutItemProcessModel processModel in result.RightAsEnumerable()) |
|
|
|
{ |
|
|
|
{ |
|
|
|
await TrimAndDelete(channelNumber, cancellationToken); |
|
|
|
await TrimAndDelete(cancellationToken); |
|
|
|
|
|
|
|
|
|
|
|
Process process = processModel.Process; |
|
|
|
Process process = processModel.Process; |
|
|
|
|
|
|
|
|
|
|
|
@ -187,21 +201,21 @@ public class HlsSessionWorker : IHlsSessionWorker |
|
|
|
} |
|
|
|
} |
|
|
|
catch (TaskCanceledException) |
|
|
|
catch (TaskCanceledException) |
|
|
|
{ |
|
|
|
{ |
|
|
|
_logger.LogInformation("Terminating HLS process for channel {Channel}", channelNumber); |
|
|
|
_logger.LogInformation("Terminating HLS process for channel {Channel}", _channelNumber); |
|
|
|
process.Kill(); |
|
|
|
process.Kill(); |
|
|
|
process.WaitForExit(); |
|
|
|
process.WaitForExit(); |
|
|
|
|
|
|
|
|
|
|
|
return false; |
|
|
|
return false; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
_logger.LogInformation("HLS process has completed for channel {Channel}", channelNumber); |
|
|
|
_logger.LogInformation("HLS process has completed for channel {Channel}", _channelNumber); |
|
|
|
|
|
|
|
|
|
|
|
_transcodedUntil = processModel.Until; |
|
|
|
_transcodedUntil = processModel.Until; |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
catch (Exception ex) |
|
|
|
catch (Exception ex) |
|
|
|
{ |
|
|
|
{ |
|
|
|
_logger.LogError(ex, "Error transcoding channel {Channel}", channelNumber); |
|
|
|
_logger.LogError(ex, "Error transcoding channel {Channel}", _channelNumber); |
|
|
|
|
|
|
|
|
|
|
|
try |
|
|
|
try |
|
|
|
{ |
|
|
|
{ |
|
|
|
@ -223,26 +237,21 @@ public class HlsSessionWorker : IHlsSessionWorker |
|
|
|
return true; |
|
|
|
return true; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
private async Task TrimAndDelete(string channelNumber, CancellationToken cancellationToken) |
|
|
|
private async Task TrimAndDelete(CancellationToken cancellationToken) |
|
|
|
{ |
|
|
|
{ |
|
|
|
string playlistFileName = Path.Combine( |
|
|
|
Option<string[]> maybeLines = await ReadPlaylistLines(cancellationToken); |
|
|
|
FileSystemLayout.TranscodeFolder, |
|
|
|
foreach (string[] lines in maybeLines) |
|
|
|
channelNumber, |
|
|
|
|
|
|
|
"live.m3u8"); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (File.Exists(playlistFileName)) |
|
|
|
|
|
|
|
{ |
|
|
|
{ |
|
|
|
// trim playlist and insert discontinuity before appending with new ffmpeg process
|
|
|
|
// trim playlist and insert discontinuity before appending with new ffmpeg process
|
|
|
|
string[] lines = await File.ReadAllLinesAsync(playlistFileName, cancellationToken); |
|
|
|
|
|
|
|
TrimPlaylistResult trimResult = _hlsPlaylistFilter.TrimPlaylistWithDiscontinuity( |
|
|
|
TrimPlaylistResult trimResult = _hlsPlaylistFilter.TrimPlaylistWithDiscontinuity( |
|
|
|
_playlistStart, |
|
|
|
_playlistStart, |
|
|
|
DateTimeOffset.Now.AddMinutes(-1), |
|
|
|
DateTimeOffset.Now.AddMinutes(-1), |
|
|
|
lines); |
|
|
|
lines); |
|
|
|
await File.WriteAllTextAsync(playlistFileName, trimResult.Playlist, cancellationToken); |
|
|
|
await WritePlaylist(trimResult.Playlist, cancellationToken); |
|
|
|
|
|
|
|
|
|
|
|
// delete old segments
|
|
|
|
// delete old segments
|
|
|
|
var allSegments = Directory.GetFiles( |
|
|
|
var allSegments = Directory.GetFiles( |
|
|
|
Path.Combine(FileSystemLayout.TranscodeFolder, channelNumber), |
|
|
|
Path.Combine(FileSystemLayout.TranscodeFolder, _channelNumber), |
|
|
|
"live*.ts") |
|
|
|
"live*.ts") |
|
|
|
.Map( |
|
|
|
.Map( |
|
|
|
file => |
|
|
|
file => |
|
|
|
@ -302,5 +311,43 @@ public class HlsSessionWorker : IHlsSessionWorker |
|
|
|
.Map(maybeCount => maybeCount.Match(identity, () => 1)); |
|
|
|
.Map(maybeCount => maybeCount.Match(identity, () => 1)); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private async Task<Option<string[]>> ReadPlaylistLines(CancellationToken cancellationToken) |
|
|
|
|
|
|
|
{ |
|
|
|
|
|
|
|
await Slim.WaitAsync(cancellationToken); |
|
|
|
|
|
|
|
try |
|
|
|
|
|
|
|
{ |
|
|
|
|
|
|
|
string fileName = PlaylistFileName(); |
|
|
|
|
|
|
|
if (File.Exists(fileName)) |
|
|
|
|
|
|
|
{ |
|
|
|
|
|
|
|
return await File.ReadAllLinesAsync(fileName, cancellationToken); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return None; |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
finally |
|
|
|
|
|
|
|
{ |
|
|
|
|
|
|
|
Slim.Release(); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private async Task WritePlaylist(string playlist, CancellationToken cancellationToken) |
|
|
|
|
|
|
|
{ |
|
|
|
|
|
|
|
await Slim.WaitAsync(cancellationToken); |
|
|
|
|
|
|
|
try |
|
|
|
|
|
|
|
{ |
|
|
|
|
|
|
|
string fileName = PlaylistFileName(); |
|
|
|
|
|
|
|
await File.WriteAllTextAsync(fileName, playlist, cancellationToken); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
finally |
|
|
|
|
|
|
|
{ |
|
|
|
|
|
|
|
Slim.Release(); |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private string PlaylistFileName() => Path.Combine( |
|
|
|
|
|
|
|
FileSystemLayout.TranscodeFolder, |
|
|
|
|
|
|
|
_channelNumber, |
|
|
|
|
|
|
|
"live.m3u8"); |
|
|
|
|
|
|
|
|
|
|
|
private record Segment(string File, int SequenceNumber); |
|
|
|
private record Segment(string File, int SequenceNumber); |
|
|
|
} |
|
|
|
} |