Browse Source

rework hls segmenter (#407)

* rework hls segmenter to start more quickly

* don't use realtime encoding for hls until we're at least a minute ahead

* ugly but functional playlist filtering
pull/408/head
Jason Dove 5 years ago committed by GitHub
parent
commit
cf5718c288
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      CHANGELOG.md
  2. 4
      ErsatzTV.Application/ErsatzTV.Application.csproj
  3. 9
      ErsatzTV.Application/Streaming/Commands/CleanUpFFmpegSessions.cs
  4. 27
      ErsatzTV.Application/Streaming/Commands/CleanUpFFmpegSessionsHandler.cs
  5. 60
      ErsatzTV.Application/Streaming/Commands/StartFFmpegSessionHandler.cs
  6. 207
      ErsatzTV.Application/Streaming/HlsSessionWorker.cs
  7. 7
      ErsatzTV.Application/Streaming/PlayoutItemProcessModel.cs
  8. 9
      ErsatzTV.Application/Streaming/Queries/FFmpegProcessHandler.cs
  9. 9
      ErsatzTV.Application/Streaming/Queries/FFmpegProcessRequest.cs
  10. 8
      ErsatzTV.Application/Streaming/Queries/GetConcatProcessByChannelNumber.cs
  11. 9
      ErsatzTV.Application/Streaming/Queries/GetConcatProcessByChannelNumberHandler.cs
  12. 15
      ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumber.cs
  13. 55
      ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs
  14. 4
      ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj
  15. 1
      ErsatzTV.Core.Tests/FFmpeg/TranscodingTests.cs
  16. 9
      ErsatzTV.Core/Errors/ChannelHasProcess.cs
  17. 9
      ErsatzTV.Core/Errors/ChannelSessionAlreadyActive.cs
  18. 4
      ErsatzTV.Core/ErsatzTV.Core.csproj
  19. 17
      ErsatzTV.Core/FFmpeg/FFmpegProcessBuilder.cs
  20. 4
      ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs
  21. 87
      ErsatzTV.Core/FFmpeg/FFmpegSegmenterService.cs
  22. 111
      ErsatzTV.Core/FFmpeg/HlsPlaylistFilter.cs
  23. 9
      ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegSegmenterService.cs
  24. 10
      ErsatzTV.Core/Interfaces/FFmpeg/IHlsSessionWorker.cs
  25. 4
      ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj
  26. 48
      ErsatzTV/Controllers/InternalController.cs
  27. 43
      ErsatzTV/Controllers/IptvController.cs
  28. 4
      ErsatzTV/ErsatzTV.csproj
  29. 34
      ErsatzTV/Services/FFmpegSchedulerService.cs
  30. 59
      ErsatzTV/Services/FFmpegWorkerService.cs
  31. 3
      ErsatzTV/Startup.cs

2
CHANGELOG.md

@ -5,7 +5,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased] ## [Unreleased]
### Changed ### Changed
- Reduce initial `HLS Segmenter` delay from 5 seconds to 3 seconds - Remove forced initial delay from `HLS Segmenter` streaming mode
## [0.1.0-alpha] - 2021-10-08 ## [0.1.0-alpha] - 2021-10-08
### Added ### Added

4
ErsatzTV.Application/ErsatzTV.Application.csproj

@ -7,10 +7,6 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="AsyncFixer" Version="1.5.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="MediatR" Version="9.0.0" /> <PackageReference Include="MediatR" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="5.0.0" /> <PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="5.0.0" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.0.63"> <PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.0.63">

9
ErsatzTV.Application/Streaming/Commands/CleanUpFFmpegSessions.cs

@ -1,9 +0,0 @@
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Application.Streaming.Commands
{
public record CleanUpFFmpegSessions : IRequest<Either<BaseError, Unit>>, IFFmpegWorkerRequest;
}

27
ErsatzTV.Application/Streaming/Commands/CleanUpFFmpegSessionsHandler.cs

@ -1,27 +0,0 @@
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Application.Streaming.Commands
{
public class CleanUpFFmpegSessionsHandler : IRequestHandler<CleanUpFFmpegSessions, Either<BaseError, Unit>>
{
private readonly ChannelWriter<IFFmpegWorkerRequest> _channel;
public CleanUpFFmpegSessionsHandler(ChannelWriter<IFFmpegWorkerRequest> channel)
{
_channel = channel;
}
public async Task<Either<BaseError, Unit>>
Handle(CleanUpFFmpegSessions request, CancellationToken cancellationToken)
{
await _channel.WriteAsync(request, cancellationToken);
return Unit.Default;
}
}
}

60
ErsatzTV.Application/Streaming/Commands/StartFFmpegSessionHandler.cs

@ -1,14 +1,13 @@
using System; using System;
using System.IO; using System.IO;
using System.Threading; using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks; using System.Threading.Tasks;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Errors; using ErsatzTV.Core.Errors;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
using LanguageExt; using LanguageExt;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude; using static LanguageExt.Prelude;
@ -16,21 +15,21 @@ namespace ErsatzTV.Application.Streaming.Commands
{ {
public class StartFFmpegSessionHandler : MediatR.IRequestHandler<StartFFmpegSession, Either<BaseError, Unit>> public class StartFFmpegSessionHandler : MediatR.IRequestHandler<StartFFmpegSession, Either<BaseError, Unit>>
{ {
private readonly ChannelWriter<IFFmpegWorkerRequest> _channel;
private readonly ILogger<StartFFmpegSessionHandler> _logger; private readonly ILogger<StartFFmpegSessionHandler> _logger;
private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly IFFmpegSegmenterService _ffmpegSegmenterService; private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
private readonly ILocalFileSystem _localFileSystem; private readonly ILocalFileSystem _localFileSystem;
public StartFFmpegSessionHandler( public StartFFmpegSessionHandler(
IFFmpegSegmenterService ffmpegSegmenterService,
ILocalFileSystem localFileSystem, ILocalFileSystem localFileSystem,
ChannelWriter<IFFmpegWorkerRequest> channel, ILogger<StartFFmpegSessionHandler> logger,
ILogger<StartFFmpegSessionHandler> logger) IServiceScopeFactory serviceScopeFactory,
IFFmpegSegmenterService ffmpegSegmenterService)
{ {
_ffmpegSegmenterService = ffmpegSegmenterService;
_localFileSystem = localFileSystem; _localFileSystem = localFileSystem;
_channel = channel;
_logger = logger; _logger = logger;
_serviceScopeFactory = serviceScopeFactory;
_ffmpegSegmenterService = ffmpegSegmenterService;
} }
public Task<Either<BaseError, Unit>> Handle(StartFFmpegSession request, CancellationToken cancellationToken) => public Task<Either<BaseError, Unit>> Handle(StartFFmpegSession request, CancellationToken cancellationToken) =>
@ -43,24 +42,51 @@ namespace ErsatzTV.Application.Streaming.Commands
private async Task<Unit> StartProcess(StartFFmpegSession request) private async Task<Unit> StartProcess(StartFFmpegSession request)
{ {
await _channel.WriteAsync(request); using IServiceScope scope = _serviceScopeFactory.CreateScope();
HlsSessionWorker worker = scope.ServiceProvider.GetRequiredService<HlsSessionWorker>();
_ffmpegSegmenterService.SessionWorkers.AddOrUpdate(request.ChannelNumber, _ => worker, (_, _) => worker);
// fire and forget worker
_ = worker.Run(request.ChannelNumber)
.ContinueWith(
_ => _ffmpegSegmenterService.SessionWorkers.TryRemove(
request.ChannelNumber,
out IHlsSessionWorker _),
TaskScheduler.Default);
string playlistFileName = Path.Combine(
FileSystemLayout.TranscodeFolder,
request.ChannelNumber,
"live.m3u8");
// TODO: find some other way to let ffmpeg get ahead while (!File.Exists(playlistFileName))
await Task.Delay(FFmpegSegmenterService.SegmenterDelay); {
await Task.Delay(TimeSpan.FromMilliseconds(100));
}
return Unit.Default; return Unit.Default;
} }
private Task<Validation<BaseError, Unit>> Validate(StartFFmpegSession request) => private Task<Validation<BaseError, Unit>> Validate(StartFFmpegSession request) =>
ProcessMustNotExist(request) SessionMustBeInactive(request)
.BindT(_ => FolderMustBeEmpty(request)); .BindT(_ => FolderMustBeEmpty(request));
private Task<Validation<BaseError, Unit>> ProcessMustNotExist(StartFFmpegSession request) => private Task<Validation<BaseError, Unit>> SessionMustBeInactive(StartFFmpegSession request)
Optional(_ffmpegSegmenterService.ProcessExistsForChannel(request.ChannelNumber)) {
.Filter(exists => exists == false) var result = Optional(_ffmpegSegmenterService.SessionWorkers.TryAdd(request.ChannelNumber, null))
.Filter(success => success)
.Map(_ => Unit.Default) .Map(_ => Unit.Default)
.ToValidation<BaseError>(new ChannelHasProcess()) .ToValidation<BaseError>(new ChannelSessionAlreadyActive());
.AsTask();
if (result.IsFail && _ffmpegSegmenterService.SessionWorkers.TryGetValue(
request.ChannelNumber,
out IHlsSessionWorker worker))
{
worker?.Touch();
}
return result.AsTask();
}
private Task<Validation<BaseError, Unit>> FolderMustBeEmpty(StartFFmpegSession request) private Task<Validation<BaseError, Unit>> FolderMustBeEmpty(StartFFmpegSession request)
{ {

207
ErsatzTV.Application/Streaming/HlsSessionWorker.cs

@ -0,0 +1,207 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using System.Timers;
using ErsatzTV.Application.Streaming.Queries;
using ErsatzTV.Core;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.FFmpeg;
using LanguageExt;
using MediatR;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Timer = System.Timers.Timer;
namespace ErsatzTV.Application.Streaming
{
public class HlsSessionWorker : IHlsSessionWorker
{
private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly ILogger<HlsSessionWorker> _logger;
private DateTimeOffset _lastAccess;
private DateTimeOffset _transcodedUntil;
private readonly Timer _timer = new(TimeSpan.FromMinutes(2).TotalMilliseconds) { AutoReset = false };
private readonly object _sync = new();
private DateTimeOffset _playlistStart;
public HlsSessionWorker(IServiceScopeFactory serviceScopeFactory, ILogger<HlsSessionWorker> logger)
{
_serviceScopeFactory = serviceScopeFactory;
_logger = logger;
}
public DateTimeOffset PlaylistStart => _playlistStart;
public void Touch()
{
lock (_sync)
{
_lastAccess = DateTimeOffset.Now;
_timer.Stop();
_timer.Start();
}
}
public async Task Run(string channelNumber)
{
var cts = new CancellationTokenSource();
void Cancel(object o, ElapsedEventArgs e) => cts.Cancel();
try
{
_timer.Elapsed += Cancel;
CancellationToken cancellationToken = cts.Token;
_logger.LogInformation("Starting HLS session for channel {Channel}", channelNumber);
Touch();
_transcodedUntil = DateTimeOffset.Now;
_playlistStart = _transcodedUntil;
// start initial transcode WITHOUT realtime throttle
if (!await Transcode(channelNumber, true, false, cancellationToken))
{
return;
}
while (!cancellationToken.IsCancellationRequested)
{
// TODO: configurable? 5 minutes?
if (DateTimeOffset.Now - _lastAccess > TimeSpan.FromMinutes(2))
{
_logger.LogInformation("Stopping idle HLS session for channel {Channel}", channelNumber);
return;
}
var transcodedBuffer = TimeSpan.FromSeconds(
Math.Max(0, _transcodedUntil.Subtract(DateTimeOffset.Now).TotalSeconds));
if (transcodedBuffer <= TimeSpan.FromMinutes(1))
{
// only use realtime encoding when we're at least 30 seconds ahead
bool realtime = transcodedBuffer >= TimeSpan.FromSeconds(30);
if (!await Transcode(channelNumber, false, realtime, cancellationToken))
{
return;
}
}
else
{
await TrimAndDelete(channelNumber, cancellationToken);
await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken);
}
}
}
finally
{
_timer.Elapsed -= Cancel;
}
}
private async Task<bool> Transcode(string channelNumber, bool firstProcess, bool realtime, CancellationToken cancellationToken)
{
try
{
using IServiceScope scope = _serviceScopeFactory.CreateScope();
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
var request = new GetPlayoutItemProcessByChannelNumber(
channelNumber,
"segmenter",
firstProcess ? DateTimeOffset.Now : _transcodedUntil.AddSeconds(1),
!firstProcess,
realtime);
// _logger.LogInformation("Request {@Request}", request);
Either<BaseError, PlayoutItemProcessModel> result = await mediator.Send(request, cancellationToken);
// _logger.LogInformation("Result {Result}", result.ToString());
foreach (BaseError error in result.LeftAsEnumerable())
{
_logger.LogWarning(
"Failed to create process for HLS session on channel {Channel}: {Error}",
channelNumber,
error.ToString());
return false;
}
foreach (PlayoutItemProcessModel processModel in result.RightAsEnumerable())
{
await TrimAndDelete(channelNumber, cancellationToken);
Process process = processModel.Process;
_logger.LogDebug(
"ffmpeg hls arguments {FFmpegArguments}",
string.Join(" ", process.StartInfo.ArgumentList));
process.Start();
try
{
await process.WaitForExitAsync(cancellationToken);
process.WaitForExit();
}
catch (TaskCanceledException)
{
_logger.LogInformation("Terminating HLS process for channel {Channel}", channelNumber);
process.Kill();
process.WaitForExit();
return false;
}
_logger.LogInformation("HLS process has completed for channel {Channel}", channelNumber);
_transcodedUntil = processModel.Until;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error transcoding channel {Channel}", channelNumber);
return false;
}
return true;
}
private async Task TrimAndDelete(string channelNumber, CancellationToken cancellationToken)
{
string playlistFileName = Path.Combine(
FileSystemLayout.TranscodeFolder,
channelNumber,
"live.m3u8");
if (File.Exists(playlistFileName))
{
// trim playlist and insert discontinuity before appending with new ffmpeg process
string[] lines = await File.ReadAllLinesAsync(playlistFileName, cancellationToken);
TrimPlaylistResult trimResult = HlsPlaylistFilter.TrimPlaylistWithDiscontinuity(
_playlistStart,
DateTimeOffset.Now.AddMinutes(-1),
lines);
await File.WriteAllTextAsync(playlistFileName, trimResult.Playlist, cancellationToken);
// delete old segments
foreach (string file in Directory.GetFiles(
Path.Combine(FileSystemLayout.TranscodeFolder, channelNumber),
"*.ts"))
{
string fileName = Path.GetFileName(file);
if (fileName.StartsWith("live") && int.Parse(fileName.Replace("live", string.Empty).Split('.')[0]) <
int.Parse(trimResult.Sequence))
{
File.Delete(file);
}
}
_playlistStart = trimResult.PlaylistStart;
}
}
}
}

7
ErsatzTV.Application/Streaming/PlayoutItemProcessModel.cs

@ -0,0 +1,7 @@
using System;
using System.Diagnostics;
namespace ErsatzTV.Application.Streaming
{
public record PlayoutItemProcessModel(Process Process, DateTimeOffset Until);
}

9
ErsatzTV.Application/Streaming/Queries/FFmpegProcessHandler.cs

@ -1,5 +1,4 @@
using System; using System;
using System.Diagnostics;
using System.IO; using System.IO;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
@ -14,7 +13,7 @@ using static LanguageExt.Prelude;
namespace ErsatzTV.Application.Streaming.Queries namespace ErsatzTV.Application.Streaming.Queries
{ {
public abstract class FFmpegProcessHandler<T> : IRequestHandler<T, Either<BaseError, Process>> public abstract class FFmpegProcessHandler<T> : IRequestHandler<T, Either<BaseError, PlayoutItemProcessModel>>
where T : FFmpegProcessRequest where T : FFmpegProcessRequest
{ {
private readonly IDbContextFactory<TvContext> _dbContextFactory; private readonly IDbContextFactory<TvContext> _dbContextFactory;
@ -22,16 +21,16 @@ namespace ErsatzTV.Application.Streaming.Queries
protected FFmpegProcessHandler(IDbContextFactory<TvContext> dbContextFactory) => protected FFmpegProcessHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory; _dbContextFactory = dbContextFactory;
public async Task<Either<BaseError, Process>> Handle(T request, CancellationToken cancellationToken) public async Task<Either<BaseError, PlayoutItemProcessModel>> Handle(T request, CancellationToken cancellationToken)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Validation<BaseError, Tuple<Channel, string>> validation = await Validate(dbContext, request); Validation<BaseError, Tuple<Channel, string>> validation = await Validate(dbContext, request);
return await validation.Match( return await validation.Match(
tuple => GetProcess(dbContext, request, tuple.Item1, tuple.Item2), tuple => GetProcess(dbContext, request, tuple.Item1, tuple.Item2),
error => Task.FromResult<Either<BaseError, Process>>(error.Join())); error => Task.FromResult<Either<BaseError, PlayoutItemProcessModel>>(error.Join()));
} }
protected abstract Task<Either<BaseError, Process>> GetProcess( protected abstract Task<Either<BaseError, PlayoutItemProcessModel>> GetProcess(
TvContext dbContext, TvContext dbContext,
T request, T request,
Channel channel, Channel channel,

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

@ -1,4 +1,4 @@
using System.Diagnostics; using System;
using ErsatzTV.Core; using ErsatzTV.Core;
using LanguageExt; using LanguageExt;
using MediatR; using MediatR;
@ -6,5 +6,10 @@ using MediatR;
namespace ErsatzTV.Application.Streaming.Queries namespace ErsatzTV.Application.Streaming.Queries
{ {
public record FFmpegProcessRequest public record FFmpegProcessRequest
(string ChannelNumber, string Mode, bool StartAtZero) : IRequest<Either<BaseError, Process>>; (
string ChannelNumber,
string Mode,
DateTimeOffset Now,
bool StartAtZero,
bool HlsRealtime) : IRequest<Either<BaseError, PlayoutItemProcessModel>>;
} }

8
ErsatzTV.Application/Streaming/Queries/GetConcatProcessByChannelNumber.cs

@ -1,11 +1,15 @@
namespace ErsatzTV.Application.Streaming.Queries using System;
namespace ErsatzTV.Application.Streaming.Queries
{ {
public record GetConcatProcessByChannelNumber : FFmpegProcessRequest public record GetConcatProcessByChannelNumber : FFmpegProcessRequest
{ {
public GetConcatProcessByChannelNumber(string scheme, string host, string channelNumber) : base( public GetConcatProcessByChannelNumber(string scheme, string host, string channelNumber) : base(
channelNumber, channelNumber,
"ts", "ts",
false) DateTimeOffset.Now,
false,
true)
{ {
Scheme = scheme; Scheme = scheme;
Host = host; Host = host;

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

@ -1,4 +1,5 @@
using System.Diagnostics; using System;
using System.Diagnostics;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Threading.Tasks; using System.Threading.Tasks;
using ErsatzTV.Core; using ErsatzTV.Core;
@ -27,7 +28,7 @@ namespace ErsatzTV.Application.Streaming.Queries
_runtimeInfo = runtimeInfo; _runtimeInfo = runtimeInfo;
} }
protected override async Task<Either<BaseError, Process>> GetProcess( protected override async Task<Either<BaseError, PlayoutItemProcessModel>> GetProcess(
TvContext dbContext, TvContext dbContext,
GetConcatProcessByChannelNumber request, GetConcatProcessByChannelNumber request,
Channel channel, Channel channel,
@ -37,12 +38,14 @@ namespace ErsatzTV.Application.Streaming.Queries
.GetValue<bool>(ConfigElementKey.FFmpegSaveReports) .GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
.Map(result => result.IfNone(false)); .Map(result => result.IfNone(false));
return _ffmpegProcessService.ConcatChannel( Process process = _ffmpegProcessService.ConcatChannel(
ffmpegPath, ffmpegPath,
saveReports, saveReports,
channel, channel,
request.Scheme, request.Scheme,
request.Host); request.Host);
return new PlayoutItemProcessModel(process, DateTimeOffset.MaxValue);
} }
} }
} }

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

@ -1,11 +1,20 @@
namespace ErsatzTV.Application.Streaming.Queries using System;
namespace ErsatzTV.Application.Streaming.Queries
{ {
public record GetPlayoutItemProcessByChannelNumber : FFmpegProcessRequest public record GetPlayoutItemProcessByChannelNumber : FFmpegProcessRequest
{ {
public GetPlayoutItemProcessByChannelNumber(string channelNumber, string mode, bool startAtZero) : base( public GetPlayoutItemProcessByChannelNumber(
string channelNumber,
string mode,
DateTimeOffset now,
bool startAtZero,
bool hlsRealtime) : base(
channelNumber, channelNumber,
mode, mode,
startAtZero) now,
startAtZero,
hlsRealtime)
{ {
} }
} }

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

@ -48,17 +48,13 @@ namespace ErsatzTV.Application.Streaming.Queries
_runtimeInfo = runtimeInfo; _runtimeInfo = runtimeInfo;
} }
protected override async Task<Either<BaseError, Process>> GetProcess( protected override async Task<Either<BaseError, PlayoutItemProcessModel>> GetProcess(
TvContext dbContext, TvContext dbContext,
GetPlayoutItemProcessByChannelNumber request, GetPlayoutItemProcessByChannelNumber request,
Channel channel, Channel channel,
string ffmpegPath) string ffmpegPath)
{ {
DateTimeOffset now = request.Mode switch DateTimeOffset now = request.Now;
{
"segmenter" => DateTimeOffset.Now + FFmpegSegmenterService.SegmenterDelay,
_ => DateTimeOffset.Now
};
Either<BaseError, PlayoutItemWithPath> maybePlayoutItem = await dbContext.PlayoutItems Either<BaseError, PlayoutItemWithPath> maybePlayoutItem = await dbContext.PlayoutItems
.Include(i => i.MediaItem) .Include(i => i.MediaItem)
@ -108,18 +104,22 @@ namespace ErsatzTV.Application.Streaming.Queries
.GetValue<int>(ConfigElementKey.FFmpegVaapiDriver) .GetValue<int>(ConfigElementKey.FFmpegVaapiDriver)
.MapT(i => (VaapiDriver)i); .MapT(i => (VaapiDriver)i);
return Right<BaseError, Process>( Process process = await _ffmpegProcessService.ForPlayoutItem(
await _ffmpegProcessService.ForPlayoutItem( ffmpegPath,
ffmpegPath, saveReports,
saveReports, channel,
channel, version,
version, playoutItemWithPath.Path,
playoutItemWithPath.Path, playoutItemWithPath.PlayoutItem.StartOffset,
playoutItemWithPath.PlayoutItem.StartOffset, request.StartAtZero ? playoutItemWithPath.PlayoutItem.StartOffset : now,
request.StartAtZero ? playoutItemWithPath.PlayoutItem.StartOffset : now, maybeGlobalWatermark,
maybeGlobalWatermark, maybeVaapiDriver,
maybeVaapiDriver, request.StartAtZero,
request.StartAtZero)); request.HlsRealtime);
var result = new PlayoutItemProcessModel(process, playoutItemWithPath.PlayoutItem.FinishOffset);
return Right<BaseError, PlayoutItemProcessModel>(result);
}, },
async error => async error =>
{ {
@ -138,16 +138,21 @@ namespace ErsatzTV.Application.Streaming.Queries
.MapT(pi => pi.StartOffset - now), .MapT(pi => pi.StartOffset - now),
() => Option<TimeSpan>.None.AsTask()); () => Option<TimeSpan>.None.AsTask());
DateTimeOffset finish = maybeDuration.Match(d => now.Add(d), () => now);
switch (error) switch (error)
{ {
case UnableToLocatePlayoutItem: case UnableToLocatePlayoutItem:
if (channel.FFmpegProfile.Transcode) if (channel.FFmpegProfile.Transcode)
{ {
return _ffmpegProcessService.ForError( Process errorProcess = _ffmpegProcessService.ForError(
ffmpegPath, ffmpegPath,
channel, channel,
maybeDuration, maybeDuration,
"Channel is Offline"); "Channel is Offline");
return new PlayoutItemProcessModel(errorProcess, finish);
} }
else else
{ {
@ -159,7 +164,13 @@ namespace ErsatzTV.Application.Streaming.Queries
case PlayoutItemDoesNotExistOnDisk: case PlayoutItemDoesNotExistOnDisk:
if (channel.FFmpegProfile.Transcode) if (channel.FFmpegProfile.Transcode)
{ {
return _ffmpegProcessService.ForError(ffmpegPath, channel, maybeDuration, error.Value); Process errorProcess = _ffmpegProcessService.ForError(
ffmpegPath,
channel,
maybeDuration,
error.Value);
return new PlayoutItemProcessModel(errorProcess, finish);
} }
else else
{ {
@ -171,11 +182,13 @@ namespace ErsatzTV.Application.Streaming.Queries
default: default:
if (channel.FFmpegProfile.Transcode) if (channel.FFmpegProfile.Transcode)
{ {
return _ffmpegProcessService.ForError( Process errorProcess = _ffmpegProcessService.ForError(
ffmpegPath, ffmpegPath,
channel, channel,
maybeDuration, maybeDuration,
"Channel is Offline"); "Channel is Offline");
return new PlayoutItemProcessModel(errorProcess, finish);
} }
else else
{ {

4
ErsatzTV.Core.Tests/ErsatzTV.Core.Tests.csproj

@ -6,10 +6,6 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="AsyncFixer" Version="1.5.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="FluentAssertions" Version="6.1.0" /> <PackageReference Include="FluentAssertions" Version="6.1.0" />
<PackageReference Include="LanguageExt.Core" Version="3.4.15" /> <PackageReference Include="LanguageExt.Core" Version="3.4.15" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="5.0.2" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="5.0.2" />

1
ErsatzTV.Core.Tests/FFmpeg/TranscodingTests.cs

@ -185,6 +185,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
now, now,
None, None,
None, None,
false,
false); false);
process.StartInfo.RedirectStandardError = true; process.StartInfo.RedirectStandardError = true;

9
ErsatzTV.Core/Errors/ChannelHasProcess.cs

@ -1,9 +0,0 @@
namespace ErsatzTV.Core.Errors
{
public class ChannelHasProcess : BaseError
{
public ChannelHasProcess() : base("Channel already has ffmpeg process")
{
}
}
}

9
ErsatzTV.Core/Errors/ChannelSessionAlreadyActive.cs

@ -0,0 +1,9 @@
namespace ErsatzTV.Core.Errors
{
public class ChannelSessionAlreadyActive : BaseError
{
public ChannelSessionAlreadyActive() : base("Channel already has HLS session")
{
}
}
}

4
ErsatzTV.Core/ErsatzTV.Core.csproj

@ -7,10 +7,6 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="AsyncFixer" Version="1.5.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Flurl" Version="3.0.2" /> <PackageReference Include="Flurl" Version="3.0.2" />
<PackageReference Include="LanguageExt.Core" Version="3.4.15" /> <PackageReference Include="LanguageExt.Core" Version="3.4.15" />
<PackageReference Include="MediatR" Version="9.0.0" /> <PackageReference Include="MediatR" Version="9.0.0" />

17
ErsatzTV.Core/FFmpeg/FFmpegProcessBuilder.cs

@ -109,7 +109,14 @@ namespace ErsatzTV.Core.FFmpeg
{ {
if (realtimeOutput) if (realtimeOutput)
{ {
_arguments.Add("-re"); if (!_arguments.Contains("-re"))
{
_arguments.Add("-re");
}
}
else
{
_arguments.RemoveAll(s => s == "-re");
} }
return this; return this;
@ -312,8 +319,8 @@ namespace ErsatzTV.Core.FFmpeg
public FFmpegProcessBuilder WithHls(string channelNumber, MediaVersion mediaVersion, bool startAtZero) public FFmpegProcessBuilder WithHls(string channelNumber, MediaVersion mediaVersion, bool startAtZero)
{ {
const int INITIAL_SEGMENT_SECONDS = 1; const int INITIAL_SEGMENT_SECONDS = 4;
const int SUBSEQUENT_SEGMENT_SECONDS = 2; const int SUBSEQUENT_SEGMENT_SECONDS = 4;
if (!int.TryParse(mediaVersion.RFrameRate, out int frameRate)) if (!int.TryParse(mediaVersion.RFrameRate, out int frameRate))
{ {
@ -340,9 +347,9 @@ namespace ErsatzTV.Core.FFmpeg
"-force_key_frames", $"expr:gte(t,n_forced*{segmentSeconds})", "-force_key_frames", $"expr:gte(t,n_forced*{segmentSeconds})",
"-f", "hls", "-f", "hls",
"-hls_time", $"{segmentSeconds}", "-hls_time", $"{segmentSeconds}",
"-hls_list_size", "10", "-hls_list_size", "0",
"-segment_list_flags", "+live", "-segment_list_flags", "+live",
"-hls_flags", "delete_segments+program_date_time+append_list+discont_start+omit_endlist", "-hls_flags", "program_date_time+append_list+omit_endlist+independent_segments",
Path.Combine(FileSystemLayout.TranscodeFolder, channelNumber, "live.m3u8") Path.Combine(FileSystemLayout.TranscodeFolder, channelNumber, "live.m3u8")
}); });

4
ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs

@ -40,7 +40,8 @@ namespace ErsatzTV.Core.FFmpeg
DateTimeOffset now, DateTimeOffset now,
Option<ChannelWatermark> globalWatermark, Option<ChannelWatermark> globalWatermark,
Option<VaapiDriver> maybeVaapiDriver, Option<VaapiDriver> maybeVaapiDriver,
bool startAtZero) bool startAtZero,
bool hlsRealtime)
{ {
MediaStream videoStream = await _ffmpegStreamSelector.SelectVideoStream(channel, version); MediaStream videoStream = await _ffmpegStreamSelector.SelectVideoStream(channel, version);
Option<MediaStream> maybeAudioStream = await _ffmpegStreamSelector.SelectAudioStream(channel, version); Option<MediaStream> maybeAudioStream = await _ffmpegStreamSelector.SelectAudioStream(channel, version);
@ -120,6 +121,7 @@ namespace ErsatzTV.Core.FFmpeg
// HLS needs to segment and generate playlist // HLS needs to segment and generate playlist
case StreamingMode.HttpLiveStreamingSegmenter: case StreamingMode.HttpLiveStreamingSegmenter:
return builder.WithHls(channel.Number, version, startAtZero) return builder.WithHls(channel.Number, version, startAtZero)
.WithRealtimeOutput(hlsRealtime)
.Build(); .Build();
default: default:
return builder.WithFormat("mpegts") return builder.WithFormat("mpegts")

87
ErsatzTV.Core/FFmpeg/FFmpegSegmenterService.cs

@ -1,98 +1,23 @@
using System; using System.Collections.Concurrent;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.FFmpeg;
using LanguageExt;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Core.FFmpeg namespace ErsatzTV.Core.FFmpeg
{ {
public class FFmpegSegmenterService : IFFmpegSegmenterService public class FFmpegSegmenterService : IFFmpegSegmenterService
{ {
public static readonly TimeSpan SegmenterDelay = TimeSpan.FromSeconds(3); public FFmpegSegmenterService()
private static readonly ConcurrentDictionary<string, ProcessAndToken> Processes = new();
private readonly ILogger<FFmpegSegmenterService> _logger;
public FFmpegSegmenterService(ILogger<FFmpegSegmenterService> logger) => _logger = logger;
public bool ProcessExistsForChannel(string channelNumber)
{ {
if (Processes.TryGetValue(channelNumber, out ProcessAndToken processAndToken)) SessionWorkers = new ConcurrentDictionary<string, IHlsSessionWorker>();
{
if (!processAndToken.Process.HasExited || !Processes.TryRemove(
new KeyValuePair<string, ProcessAndToken>(channelNumber, processAndToken)))
{
return true;
}
}
return false;
} }
public bool TryAdd(string channelNumber, Process process) public ConcurrentDictionary<string, IHlsSessionWorker> SessionWorkers { get; }
{
var cts = new CancellationTokenSource();
var processAndToken = new ProcessAndToken(process, cts, DateTimeOffset.Now);
if (Processes.TryAdd(channelNumber, processAndToken))
{
CancellationToken token = cts.Token;
token.Register(process.Kill);
return true;
}
return false;
}
public void TouchChannel(string channelNumber) public void TouchChannel(string channelNumber)
{ {
if (Processes.TryGetValue(channelNumber, out ProcessAndToken processAndToken)) if (SessionWorkers.TryGetValue(channelNumber, out IHlsSessionWorker worker))
{
ProcessAndToken newValue = processAndToken with { LastAccess = DateTimeOffset.Now };
if (!Processes.TryUpdate(channelNumber, newValue, processAndToken))
{
_logger.LogWarning("Failed to update last access for channel {Channel}", channelNumber);
}
}
}
public void CleanUpSessions()
{
foreach ((string key, (_, CancellationTokenSource cts, DateTimeOffset lastAccess)) in Processes.ToList())
{
// TODO: configure this time span? 5 min?
if (DateTimeOffset.Now.Subtract(lastAccess) > TimeSpan.FromMinutes(2))
{
_logger.LogDebug("Cleaning up ffmpeg session for channel {Channel}", key);
cts.Cancel();
Processes.TryRemove(key, out _);
}
}
}
public Unit KillAll()
{
foreach ((string key, ProcessAndToken processAndToken) in Processes.ToList())
{ {
try worker?.Touch();
{
processAndToken.TokenSource.Cancel();
Processes.TryRemove(key, out _);
}
catch (Exception ex)
{
_logger.LogInformation(ex, "Error killing process");
}
} }
return Unit.Default;
} }
private record ProcessAndToken(Process Process, CancellationTokenSource TokenSource, DateTimeOffset LastAccess);
} }
} }

111
ErsatzTV.Core/FFmpeg/HlsPlaylistFilter.cs

@ -0,0 +1,111 @@
using System;
using System.Text;
namespace ErsatzTV.Core.FFmpeg
{
public class HlsPlaylistFilter
{
public static TrimPlaylistResult TrimPlaylist(
DateTimeOffset playlistStart,
DateTimeOffset filterBefore,
string[] lines,
int maxSegments = 10,
bool endWithDiscontinuity = false)
{
DateTimeOffset currentTime = playlistStart;
DateTimeOffset nextPlaylistStart = DateTimeOffset.MaxValue;
var discontinuitySequence = 0;
var startSequence = "0";
var output = new StringBuilder();
var started = false;
var i = 0;
var segments = 0;
while (!lines[i].StartsWith("#EXTINF:"))
{
if (lines[i].StartsWith("#EXT-X-DISCONTINUITY-SEQUENCE"))
{
discontinuitySequence = int.Parse(lines[i].Split(':')[1]);
}
i++;
}
while (i < lines.Length)
{
if (segments >= maxSegments)
{
break;
}
string line = lines[i];
// _logger.LogInformation("Line: {Line}", line);
if (line.StartsWith("#EXT-X-DISCONTINUITY"))
{
if (started)
{
output.AppendLine("#EXT-X-DISCONTINUITY");
}
else
{
discontinuitySequence++;
}
i++;
continue;
}
var duration = TimeSpan.FromSeconds(double.Parse(lines[i].TrimEnd(',').Split(':')[1]));
if (currentTime < filterBefore)
{
currentTime += duration;
i += 3;
continue;
}
nextPlaylistStart = currentTime < nextPlaylistStart ? currentTime : nextPlaylistStart;
if (!started)
{
startSequence = lines[i + 2].Replace("live", string.Empty).Split('.')[0];
output.AppendLine("#EXTM3U");
output.AppendLine("#EXT-X-VERSION:3");
output.AppendLine("#EXT-X-TARGETDURATION:4");
output.AppendLine($"#EXT-X-MEDIA-SEQUENCE:{startSequence}");
output.AppendLine($"#EXT-X-DISCONTINUITY-SEQUENCE:{discontinuitySequence}");
output.AppendLine("#EXT-X-INDEPENDENT-SEGMENTS");
output.AppendLine("#EXT-X-DISCONTINUITY");
started = true;
}
output.AppendLine(lines[i]);
string offset = currentTime.ToString("zzz").Replace(":", string.Empty);
output.AppendLine($"#EXT-X-PROGRAM-DATE-TIME:{currentTime:yyyy-MM-ddTHH:mm:ss.fff}{offset}");
output.AppendLine(lines[i + 2]);
currentTime += duration;
segments++;
i += 3;
}
if (endWithDiscontinuity)
{
output.AppendLine("#EXT-X-DISCONTINUITY");
}
return new TrimPlaylistResult(nextPlaylistStart, startSequence, output.ToString());
}
public static TrimPlaylistResult TrimPlaylistWithDiscontinuity(
DateTimeOffset playlistStart,
DateTimeOffset filterBefore,
string[] lines)
{
return TrimPlaylist(playlistStart, filterBefore, lines, int.MaxValue, true);
}
}
public record TrimPlaylistResult(DateTimeOffset PlaylistStart, string Sequence, string Playlist);
}

9
ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegSegmenterService.cs

@ -1,14 +1,11 @@
using System.Diagnostics; using System.Collections.Concurrent;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.FFmpeg namespace ErsatzTV.Core.Interfaces.FFmpeg
{ {
public interface IFFmpegSegmenterService public interface IFFmpegSegmenterService
{ {
bool ProcessExistsForChannel(string channelNumber); ConcurrentDictionary<string, IHlsSessionWorker> SessionWorkers { get; }
bool TryAdd(string channelNumber, Process process);
void TouchChannel(string channelNumber); void TouchChannel(string channelNumber);
void CleanUpSessions();
Unit KillAll();
} }
} }

10
ErsatzTV.Core/Interfaces/FFmpeg/IHlsSessionWorker.cs

@ -0,0 +1,10 @@
using System;
namespace ErsatzTV.Core.Interfaces.FFmpeg
{
public interface IHlsSessionWorker
{
DateTimeOffset PlaylistStart { get; }
void Touch();
}
}

4
ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj

@ -8,10 +8,6 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="AsyncFixer" Version="1.5.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Dapper" Version="2.0.90" /> <PackageReference Include="Dapper" Version="2.0.90" />
<PackageReference Include="Lucene.Net" Version="4.8.0-beta00014" /> <PackageReference Include="Lucene.Net" Version="4.8.0-beta00014" />
<PackageReference Include="Lucene.Net.Analysis.Common" Version="4.8.0-beta00014" /> <PackageReference Include="Lucene.Net.Analysis.Common" Version="4.8.0-beta00014" />

48
ErsatzTV/Controllers/InternalController.cs

@ -1,4 +1,6 @@
using System.Threading.Tasks; using System;
using System.Diagnostics;
using System.Threading.Tasks;
using ErsatzTV.Application.Streaming.Queries; using ErsatzTV.Application.Streaming.Queries;
using ErsatzTV.Extensions; using ErsatzTV.Extensions;
using LanguageExt; using LanguageExt;
@ -31,25 +33,29 @@ namespace ErsatzTV.Controllers
string channelNumber, string channelNumber,
[FromQuery] [FromQuery]
string mode = "mixed") => string mode = "mixed") =>
_mediator.Send(new GetPlayoutItemProcessByChannelNumber(channelNumber, mode, false)).Map( _mediator.Send(
result => new GetPlayoutItemProcessByChannelNumber(channelNumber, mode, DateTimeOffset.Now, false, true))
result.Match<IActionResult>( .Map(
process => result =>
{ result.Match<IActionResult>(
_logger.LogDebug( processModel =>
"ffmpeg arguments {FFmpegArguments}", {
string.Join(" ", process.StartInfo.ArgumentList)); Process process = processModel.Process;
process.Start();
return new FileStreamResult(process.StandardOutput.BaseStream, "video/mp2t"); _logger.LogDebug(
}, "ffmpeg arguments {FFmpegArguments}",
error => string.Join(" ", process.StartInfo.ArgumentList));
{ process.Start();
_logger.LogError( return new FileStreamResult(process.StandardOutput.BaseStream, "video/mp2t");
"Failed to create stream for channel {ChannelNumber}: {Error}", },
channelNumber, error =>
error.Value); {
return BadRequest(error.Value); _logger.LogError(
} "Failed to create stream for channel {ChannelNumber}: {Error}",
)); channelNumber,
error.Value);
return BadRequest(error.Value);
}
));
} }
} }

43
ErsatzTV/Controllers/IptvController.cs

@ -1,6 +1,7 @@
using System.Threading.Channels; using System;
using System.Diagnostics;
using System.IO;
using System.Threading.Tasks; using System.Threading.Tasks;
using ErsatzTV.Application;
using ErsatzTV.Application.Channels.Queries; using ErsatzTV.Application.Channels.Queries;
using ErsatzTV.Application.Images; using ErsatzTV.Application.Images;
using ErsatzTV.Application.Images.Queries; using ErsatzTV.Application.Images.Queries;
@ -9,6 +10,8 @@ using ErsatzTV.Application.Streaming.Queries;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors; using ErsatzTV.Core.Errors;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Iptv; using ErsatzTV.Core.Iptv;
using LanguageExt; using LanguageExt;
using MediatR; using MediatR;
@ -22,18 +25,18 @@ namespace ErsatzTV.Controllers
[ApiExplorerSettings(IgnoreApi = true)] [ApiExplorerSettings(IgnoreApi = true)]
public class IptvController : ControllerBase public class IptvController : ControllerBase
{ {
private readonly ChannelWriter<IFFmpegWorkerRequest> _channel; private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
private readonly ILogger<IptvController> _logger; private readonly ILogger<IptvController> _logger;
private readonly IMediator _mediator; private readonly IMediator _mediator;
public IptvController( public IptvController(
IMediator mediator, IMediator mediator,
ILogger<IptvController> logger, ILogger<IptvController> logger,
ChannelWriter<IFFmpegWorkerRequest> channel) IFFmpegSegmenterService ffmpegSegmenterService)
{ {
_mediator = mediator; _mediator = mediator;
_logger = logger; _logger = logger;
_channel = channel; _ffmpegSegmenterService = ffmpegSegmenterService;
} }
[HttpGet("iptv/channels.m3u")] [HttpGet("iptv/channels.m3u")]
@ -53,8 +56,10 @@ namespace ErsatzTV.Controllers
_mediator.Send(new GetConcatProcessByChannelNumber(Request.Scheme, Request.Host.ToString(), channelNumber)) _mediator.Send(new GetConcatProcessByChannelNumber(Request.Scheme, Request.Host.ToString(), channelNumber))
.Map( .Map(
result => result.Match<IActionResult>( result => result.Match<IActionResult>(
process => processModel =>
{ {
Process process = processModel.Process;
_logger.LogInformation("Starting ts stream for channel {ChannelNumber}", channelNumber); _logger.LogInformation("Starting ts stream for channel {ChannelNumber}", channelNumber);
// _logger.LogDebug( // _logger.LogDebug(
// "ffmpeg concat arguments {FFmpegArguments}", // "ffmpeg concat arguments {FFmpegArguments}",
@ -64,6 +69,26 @@ namespace ErsatzTV.Controllers
}, },
error => BadRequest(error.Value))); error => BadRequest(error.Value)));
[HttpGet("iptv/session/{channelNumber}/hls.m3u8")]
public async Task<IActionResult> GetLivePlaylist(string channelNumber)
{
if (_ffmpegSegmenterService.SessionWorkers.TryGetValue(channelNumber, out IHlsSessionWorker worker))
{
DateTimeOffset now = DateTimeOffset.Now.AddSeconds(-30);
string fileName = Path.Combine(FileSystemLayout.TranscodeFolder, channelNumber, "live.m3u8");
if (System.IO.File.Exists(fileName))
{
string[] input = await System.IO.File.ReadAllLinesAsync(fileName);
TrimPlaylistResult result = HlsPlaylistFilter.TrimPlaylist(worker.PlaylistStart, now, input);
return Content(result.Playlist, "application/vnd.apple.mpegurl");
}
}
return NotFound();
}
[HttpGet("iptv/channel/{channelNumber}.m3u8")] [HttpGet("iptv/channel/{channelNumber}.m3u8")]
public async Task<IActionResult> GetHttpLiveStreamingVideo( public async Task<IActionResult> GetHttpLiveStreamingVideo(
string channelNumber, string channelNumber,
@ -75,13 +100,13 @@ namespace ErsatzTV.Controllers
case "segmenter": case "segmenter":
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>(
_ => Redirect($"/iptv/session/{channelNumber}/live.m3u8"), _ => Redirect($"/iptv/session/{channelNumber}/hls.m3u8"),
error => error =>
{ {
switch (error) switch (error)
{ {
case ChannelHasProcess: case ChannelSessionAlreadyActive:
return RedirectPreserveMethod($"/iptv/session/{channelNumber}/live.m3u8"); return RedirectPreserveMethod($"/iptv/session/{channelNumber}/hls.m3u8");
default: default:
_logger.LogWarning( _logger.LogWarning(
"Failed to start segmenter for channel {ChannelNumber}: {Error}", "Failed to start segmenter for channel {ChannelNumber}: {Error}",

4
ErsatzTV/ErsatzTV.csproj

@ -13,10 +13,6 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="AsyncFixer" Version="1.5.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Blazored.LocalStorage" Version="4.1.5" /> <PackageReference Include="Blazored.LocalStorage" Version="4.1.5" />
<PackageReference Include="FluentValidation" Version="10.3.3" /> <PackageReference Include="FluentValidation" Version="10.3.3" />
<PackageReference Include="FluentValidation.AspNetCore" Version="10.3.3" /> <PackageReference Include="FluentValidation.AspNetCore" Version="10.3.3" />

34
ErsatzTV/Services/FFmpegSchedulerService.cs

@ -1,34 +0,0 @@
using System;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using ErsatzTV.Application;
using ErsatzTV.Application.Streaming.Commands;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Services
{
public class FFmpegSchedulerService : BackgroundService
{
private readonly ILogger<FFmpegSchedulerService> _logger;
private readonly ChannelWriter<IFFmpegWorkerRequest> _workerChannel;
public FFmpegSchedulerService(
ChannelWriter<IFFmpegWorkerRequest> workerChannel,
ILogger<FFmpegSchedulerService> logger)
{
_workerChannel = workerChannel;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
await _workerChannel.WriteAsync(new CleanUpFFmpegSessions(), cancellationToken);
await Task.Delay(TimeSpan.FromMinutes(1), cancellationToken);
}
}
}
}

59
ErsatzTV/Services/FFmpegWorkerService.cs

@ -1,16 +1,11 @@
using System; using System;
using System.Diagnostics;
using System.IO; using System.IO;
using System.Threading; using System.Threading;
using System.Threading.Channels; using System.Threading.Channels;
using System.Threading.Tasks; using System.Threading.Tasks;
using ErsatzTV.Application; using ErsatzTV.Application;
using ErsatzTV.Application.Streaming.Commands; using ErsatzTV.Application.Streaming.Commands;
using ErsatzTV.Application.Streaming.Queries;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.FFmpeg;
using LanguageExt;
using MediatR;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@ -21,20 +16,17 @@ namespace ErsatzTV.Services
public class FFmpegWorkerService : BackgroundService public class FFmpegWorkerService : BackgroundService
{ {
private readonly ChannelReader<IFFmpegWorkerRequest> _channel; private readonly ChannelReader<IFFmpegWorkerRequest> _channel;
private readonly ChannelWriter<IFFmpegWorkerRequest> _channelWriter;
private readonly ILogger<FFmpegWorkerService> _logger; private readonly ILogger<FFmpegWorkerService> _logger;
private readonly IFFmpegSegmenterService _ffmpegSegmenterService; private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
private readonly IServiceScopeFactory _serviceScopeFactory; private readonly IServiceScopeFactory _serviceScopeFactory;
public FFmpegWorkerService( public FFmpegWorkerService(
ChannelReader<IFFmpegWorkerRequest> channel, ChannelReader<IFFmpegWorkerRequest> channel,
ChannelWriter<IFFmpegWorkerRequest> channelWriter,
IServiceScopeFactory serviceScopeFactory, IServiceScopeFactory serviceScopeFactory,
ILogger<FFmpegWorkerService> logger, ILogger<FFmpegWorkerService> logger,
IFFmpegSegmenterService ffmpegSegmenterService) IFFmpegSegmenterService ffmpegSegmenterService)
{ {
_channel = channel; _channel = channel;
_channelWriter = channelWriter;
_serviceScopeFactory = serviceScopeFactory; _serviceScopeFactory = serviceScopeFactory;
_logger = logger; _logger = logger;
_ffmpegSegmenterService = ffmpegSegmenterService; _ffmpegSegmenterService = ffmpegSegmenterService;
@ -49,7 +41,7 @@ namespace ErsatzTV.Services
try try
{ {
using IServiceScope scope = _serviceScopeFactory.CreateScope(); using IServiceScope scope = _serviceScopeFactory.CreateScope();
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>(); // IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
switch (request) switch (request)
{ {
@ -58,52 +50,6 @@ namespace ErsatzTV.Services
{ {
_ffmpegSegmenterService.TouchChannel(parent.Name); _ffmpegSegmenterService.TouchChannel(parent.Name);
} }
break;
case CleanUpFFmpegSessions:
_ffmpegSegmenterService.CleanUpSessions();
break;
case StartFFmpegSession startFFmpegSession:
_logger.LogInformation(
"Starting ffmpeg session for channel {Channel}",
startFFmpegSession.ChannelNumber);
if (!_ffmpegSegmenterService.ProcessExistsForChannel(startFFmpegSession.ChannelNumber))
{
var req = new GetPlayoutItemProcessByChannelNumber(
startFFmpegSession.ChannelNumber,
"segmenter",
startFFmpegSession.StartAtZero);
Either<BaseError, Process> maybeProcess = await mediator.Send(req, cancellationToken);
maybeProcess.Match(
process =>
{
if (_ffmpegSegmenterService.TryAdd(startFFmpegSession.ChannelNumber, process))
{
_logger.LogDebug(
"ffmpeg hls arguments {FFmpegArguments}",
string.Join(" ", process.StartInfo.ArgumentList));
process.Start();
process.EnableRaisingEvents = true;
process.Exited += (_, _) =>
{
if (process.ExitCode == 0)
{
_channelWriter.TryWrite(
new StartFFmpegSession(startFFmpegSession.ChannelNumber, true));
}
else
{
_logger.LogDebug(
"hls segmenter for channel {Channel} exited with code {ExitCode}",
startFFmpegSession.ChannelNumber,
process.ExitCode);
}
};
}
},
_ => { });
}
break; break;
} }
@ -113,9 +59,6 @@ namespace ErsatzTV.Services
_logger.LogWarning(ex, "Failed to handle ffmpeg worker request"); _logger.LogWarning(ex, "Failed to handle ffmpeg worker request");
} }
} }
// kill any running processes after cancellation
_ffmpegSegmenterService.KillAll();
} }
} }
} }

3
ErsatzTV/Startup.cs

@ -7,6 +7,7 @@ using Blazored.LocalStorage;
using Dapper; using Dapper;
using ErsatzTV.Application; using ErsatzTV.Application;
using ErsatzTV.Application.Channels.Queries; using ErsatzTV.Application.Channels.Queries;
using ErsatzTV.Application.Streaming;
using ErsatzTV.Application.Streaming.Commands; using ErsatzTV.Application.Streaming.Commands;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Emby; using ErsatzTV.Core.Emby;
@ -300,6 +301,7 @@ namespace ErsatzTV
services.AddScoped<IPlexPathReplacementService, PlexPathReplacementService>(); services.AddScoped<IPlexPathReplacementService, PlexPathReplacementService>();
services.AddScoped<IFFmpegStreamSelector, FFmpegStreamSelector>(); services.AddScoped<IFFmpegStreamSelector, FFmpegStreamSelector>();
services.AddScoped<FFmpegProcessService>(); services.AddScoped<FFmpegProcessService>();
services.AddScoped<HlsSessionWorker>();
services.AddScoped<IGitHubApiClient, GitHubApiClient>(); services.AddScoped<IGitHubApiClient, GitHubApiClient>();
services.AddScoped<IHtmlSanitizer, HtmlSanitizer>( services.AddScoped<IHtmlSanitizer, HtmlSanitizer>(
_ => _ =>
@ -326,7 +328,6 @@ namespace ErsatzTV
services.AddHostedService<WorkerService>(); services.AddHostedService<WorkerService>();
services.AddHostedService<SchedulerService>(); services.AddHostedService<SchedulerService>();
services.AddHostedService<FFmpegWorkerService>(); services.AddHostedService<FFmpegWorkerService>();
services.AddHostedService<FFmpegSchedulerService>();
} }
private void AddChannel<TMessageType>(IServiceCollection services) private void AddChannel<TMessageType>(IServiceCollection services)

Loading…
Cancel
Save