Browse Source

add hls segmenter streaming mode (#400)

* hls segmenter wip

* log message

* close unused transcode sessions after 2 minutes

* use frame rate for 2s keyframes in hls segmenter

* add frame rate to media version

* fix segmenter framerate calculation

* automatically restart hls segmenter with next scheduled item

* cleanup

* update changelog

* decrease segmenter start delay
pull/401/head
Jason Dove 4 years ago committed by GitHub
parent
commit
6c8813ce22
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 6
      CHANGELOG.md
  2. 4
      ErsatzTV.Application/Channels/Queries/GetChannelPlaylistHandler.cs
  3. 6
      ErsatzTV.Application/IFFmpegWorkerRequest.cs
  4. 9
      ErsatzTV.Application/Streaming/Commands/CleanUpFFmpegSessions.cs
  5. 27
      ErsatzTV.Application/Streaming/Commands/CleanUpFFmpegSessionsHandler.cs
  6. 9
      ErsatzTV.Application/Streaming/Commands/StartFFmpegSession.cs
  7. 67
      ErsatzTV.Application/Streaming/Commands/StartFFmpegSessionHandler.cs
  8. 9
      ErsatzTV.Application/Streaming/Commands/TouchFFmpegSession.cs
  9. 1
      ErsatzTV.Application/Streaming/Queries/FFmpegProcessHandler.cs
  10. 3
      ErsatzTV.Application/Streaming/Queries/FFmpegProcessRequest.cs
  11. 3
      ErsatzTV.Application/Streaming/Queries/GetConcatProcessByChannelNumber.cs
  12. 5
      ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumber.cs
  13. 4
      ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs
  14. 3
      ErsatzTV.Core.Tests/FFmpeg/TranscodingTests.cs
  15. 2
      ErsatzTV.Core.Tests/Fakes/FakeLocalFileSystem.cs
  16. 1
      ErsatzTV.Core/Domain/MediaItem/MediaVersion.cs
  17. 3
      ErsatzTV.Core/Domain/StreamingMode.cs
  18. 9
      ErsatzTV.Core/Errors/ChannelHasProcess.cs
  19. 1
      ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs
  20. 41
      ErsatzTV.Core/FFmpeg/FFmpegProcessBuilder.cs
  21. 31
      ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs
  22. 96
      ErsatzTV.Core/FFmpeg/FFmpegSegmenterService.cs
  23. 7
      ErsatzTV.Core/FileSystemLayout.cs
  24. 14
      ErsatzTV.Core/Interfaces/FFmpeg/IFFmpegSegmenterService.cs
  25. 1
      ErsatzTV.Core/Interfaces/Metadata/ILocalFileSystem.cs
  26. 1
      ErsatzTV.Core/Iptv/ChannelPlaylist.cs
  27. 15
      ErsatzTV.Core/Metadata/LocalFileSystem.cs
  28. 1
      ErsatzTV.Core/Metadata/LocalStatisticsProvider.cs
  29. 1
      ErsatzTV.Infrastructure/Data/Repositories/MetadataRepository.cs
  30. 3287
      ErsatzTV.Infrastructure/Migrations/20211007201223_Add_MediaVersionRFrameRate.Designer.cs
  31. 36
      ErsatzTV.Infrastructure/Migrations/20211007201223_Add_MediaVersionRFrameRate.cs
  32. 3
      ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs
  33. 1
      ErsatzTV.sln.DotSettings
  34. 2
      ErsatzTV/Controllers/InternalController.cs
  35. 60
      ErsatzTV/Controllers/IptvController.cs
  36. 1
      ErsatzTV/Pages/ChannelEditor.razor
  37. 1
      ErsatzTV/Pages/Channels.razor
  38. 34
      ErsatzTV/Services/FFmpegSchedulerService.cs
  39. 121
      ErsatzTV/Services/FFmpegWorkerService.cs
  40. 43
      ErsatzTV/Startup.cs

6
CHANGELOG.md

@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
### Added
- Add *experimental* streaming mode `HLS Segmenter` (most similar to `HLS Hybrid`)
- This mode is intended to increase client compatibility and reduce issues at program boundaries
- Store frame rate with media statistics; this is needed to support HLS Segmenter
- This requires re-ingesting statistics for all media items the first time this version is launched
### Changed
- Use latest iHD driver (21.2.3 vs 20.1.1) in vaapi docker images

4
ErsatzTV.Application/Channels/Queries/GetChannelPlaylistHandler.cs

@ -28,6 +28,10 @@ namespace ErsatzTV.Application.Channels.Queries @@ -28,6 +28,10 @@ namespace ErsatzTV.Application.Channels.Queries
{
switch (mode.ToLowerInvariant())
{
case "segmenter":
channel.StreamingMode = StreamingMode.HttpLiveStreamingSegmenter;
result.Add(channel);
break;
case "hls-direct":
channel.StreamingMode = StreamingMode.HttpLiveStreamingDirect;
result.Add(channel);

6
ErsatzTV.Application/IFFmpegWorkerRequest.cs

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
namespace ErsatzTV.Application
{
public interface IFFmpegWorkerRequest
{
}
}

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

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
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

@ -0,0 +1,27 @@ @@ -0,0 +1,27 @@
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;
}
}
}

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

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
using ErsatzTV.Core;
using LanguageExt;
namespace ErsatzTV.Application.Streaming.Commands
{
public record StartFFmpegSession(string ChannelNumber, bool StartAtZero) :
MediatR.IRequest<Either<BaseError, Unit>>,
IFFmpegWorkerRequest;
}

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

@ -0,0 +1,67 @@ @@ -0,0 +1,67 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Metadata;
using LanguageExt;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.Streaming.Commands
{
public class StartFFmpegSessionHandler : MediatR.IRequestHandler<StartFFmpegSession, Either<BaseError, Unit>>
{
private readonly ChannelWriter<IFFmpegWorkerRequest> _channel;
private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
private readonly ILocalFileSystem _localFileSystem;
public StartFFmpegSessionHandler(
IFFmpegSegmenterService ffmpegSegmenterService,
ILocalFileSystem localFileSystem,
ChannelWriter<IFFmpegWorkerRequest> channel)
{
_ffmpegSegmenterService = ffmpegSegmenterService;
_localFileSystem = localFileSystem;
_channel = channel;
}
public Task<Either<BaseError, Unit>> Handle(StartFFmpegSession request, CancellationToken cancellationToken) =>
Validate(request)
.MapT(_ => StartProcess(request))
// this weirdness is needed to maintain the error type (.ToEitherAsync() just gives BaseError)
.Bind(v => v.ToEither().MapLeft(seq => seq.Head()).MapAsync<BaseError, Task<Unit>, Unit>(identity));
private async Task<Unit> StartProcess(StartFFmpegSession request)
{
await _channel.WriteAsync(request);
// TODO: find some other way to let ffmpeg get ahead
await Task.Delay(TimeSpan.FromSeconds(5));
return Unit.Default;
}
private Task<Validation<BaseError, Unit>> Validate(StartFFmpegSession request) =>
ProcessMustNotExist(request)
.BindT(_ => FolderMustBeEmpty(request));
private Task<Validation<BaseError, Unit>> ProcessMustNotExist(StartFFmpegSession request) =>
Optional(_ffmpegSegmenterService.ProcessExistsForChannel(request.ChannelNumber))
.Filter(containsKey => containsKey == false)
.Map(_ => Unit.Default)
.ToValidation<BaseError>(new ChannelHasProcess())
.AsTask();
private Task<Validation<BaseError, Unit>> FolderMustBeEmpty(StartFFmpegSession request)
{
string folder = Path.Combine(FileSystemLayout.TranscodeFolder, request.ChannelNumber);
_localFileSystem.EnsureFolderExists(folder);
_localFileSystem.EmptyFolder(folder);
return Task.FromResult<Validation<BaseError, Unit>>(Unit.Default);
}
}
}

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

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

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

@ -56,6 +56,7 @@ namespace ErsatzTV.Application.Streaming.Queries @@ -56,6 +56,7 @@ namespace ErsatzTV.Application.Streaming.Queries
channel.StreamingMode = request.Mode.ToLowerInvariant() switch
{
"hls-direct" => StreamingMode.HttpLiveStreamingDirect,
"segmenter" => StreamingMode.HttpLiveStreamingSegmenter,
"ts" => StreamingMode.TransportStream,
_ => channel.StreamingMode
};

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

@ -5,5 +5,6 @@ using MediatR; @@ -5,5 +5,6 @@ using MediatR;
namespace ErsatzTV.Application.Streaming.Queries
{
public record FFmpegProcessRequest(string ChannelNumber, string Mode) : IRequest<Either<BaseError, Process>>;
public record FFmpegProcessRequest
(string ChannelNumber, string Mode, bool StartAtZero) : IRequest<Either<BaseError, Process>>;
}

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

@ -4,7 +4,8 @@ @@ -4,7 +4,8 @@
{
public GetConcatProcessByChannelNumber(string scheme, string host, string channelNumber) : base(
channelNumber,
"ts")
"ts",
false)
{
Scheme = scheme;
Host = host;

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

@ -2,7 +2,10 @@ @@ -2,7 +2,10 @@
{
public record GetPlayoutItemProcessByChannelNumber : FFmpegProcessRequest
{
public GetPlayoutItemProcessByChannelNumber(string channelNumber, string mode) : base(channelNumber, mode)
public GetPlayoutItemProcessByChannelNumber(string channelNumber, string mode, bool startAtZero) : base(
channelNumber,
mode,
startAtZero)
{
}
}

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

@ -50,7 +50,7 @@ namespace ErsatzTV.Application.Streaming.Queries @@ -50,7 +50,7 @@ namespace ErsatzTV.Application.Streaming.Queries
protected override async Task<Either<BaseError, Process>> GetProcess(
TvContext dbContext,
GetPlayoutItemProcessByChannelNumber _,
GetPlayoutItemProcessByChannelNumber request,
Channel channel,
string ffmpegPath)
{
@ -111,7 +111,7 @@ namespace ErsatzTV.Application.Streaming.Queries @@ -111,7 +111,7 @@ namespace ErsatzTV.Application.Streaming.Queries
version,
playoutItemWithPath.Path,
playoutItemWithPath.PlayoutItem.StartOffset,
now,
request.StartAtZero ? playoutItemWithPath.PlayoutItem.StartOffset : now,
maybeGlobalWatermark,
maybeVaapiDriver));
},

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

@ -134,7 +134,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -134,7 +134,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
var service = new FFmpegProcessService(
new FFmpegPlaybackSettingsCalculator(),
new FakeStreamSelector(),
new Mock<IImageCache>().Object);
new Mock<IImageCache>().Object,
new Mock<ILogger<FFmpegProcessService>>().Object);
MediaVersion v = new MediaVersion();

2
ErsatzTV.Core.Tests/Fakes/FakeLocalFileSystem.cs

@ -59,6 +59,8 @@ namespace ErsatzTV.Core.Tests.Fakes @@ -59,6 +59,8 @@ namespace ErsatzTV.Core.Tests.Fakes
public Task<Either<BaseError, Unit>> CopyFile(string source, string destination) =>
Task.FromResult(Right<BaseError, Unit>(Unit.Default));
public Unit EmptyFolder(string folder) => Unit.Default;
private static List<DirectoryInfo> Split(DirectoryInfo path)
{
var result = new List<DirectoryInfo>();

1
ErsatzTV.Core/Domain/MediaItem/MediaVersion.cs

@ -13,6 +13,7 @@ namespace ErsatzTV.Core.Domain @@ -13,6 +13,7 @@ namespace ErsatzTV.Core.Domain
public TimeSpan Duration { get; set; }
public string SampleAspectRatio { get; set; }
public string DisplayAspectRatio { get; set; }
public string RFrameRate { get; set; }
public VideoScanKind VideoScanKind { get; set; }
public DateTime DateAdded { get; set; }
public DateTime DateUpdated { get; set; }

3
ErsatzTV.Core/Domain/StreamingMode.cs

@ -4,6 +4,7 @@ @@ -4,6 +4,7 @@
{
TransportStream = 1,
HttpLiveStreamingDirect = 2,
HttpLiveStreamingHybrid = 3
HttpLiveStreamingHybrid = 3,
HttpLiveStreamingSegmenter = 4
}
}

9
ErsatzTV.Core/Errors/ChannelHasProcess.cs

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

1
ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs

@ -70,6 +70,7 @@ namespace ErsatzTV.Core.FFmpeg @@ -70,6 +70,7 @@ namespace ErsatzTV.Core.FFmpeg
result.Deinterlace = false;
break;
case StreamingMode.HttpLiveStreamingHybrid:
case StreamingMode.HttpLiveStreamingSegmenter:
case StreamingMode.TransportStream:
result.HardwareAcceleration = ffmpegProfile.HardwareAcceleration;

41
ErsatzTV.Core/FFmpeg/FFmpegProcessBuilder.cs

@ -26,6 +26,7 @@ using System.Text; @@ -26,6 +26,7 @@ using System.Text;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
using LanguageExt;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Core.FFmpeg
{
@ -41,15 +42,17 @@ namespace ErsatzTV.Core.FFmpeg @@ -41,15 +42,17 @@ namespace ErsatzTV.Core.FFmpeg
private readonly List<string> _arguments = new();
private readonly string _ffmpegPath;
private readonly bool _saveReports;
private readonly ILogger _logger;
private FFmpegComplexFilterBuilder _complexFilterBuilder = new();
private bool _isConcat;
private VaapiDriver _vaapiDriver;
private HardwareAccelerationKind _hwAccel;
public FFmpegProcessBuilder(string ffmpegPath, bool saveReports)
public FFmpegProcessBuilder(string ffmpegPath, bool saveReports, ILogger logger)
{
_ffmpegPath = ffmpegPath;
_saveReports = saveReports;
_logger = logger;
}
public FFmpegProcessBuilder WithVaapiDriver(Option<VaapiDriver> maybeVaapiDriver)
@ -307,13 +310,47 @@ namespace ErsatzTV.Core.FFmpeg @@ -307,13 +310,47 @@ namespace ErsatzTV.Core.FFmpeg
return this;
}
public FFmpegProcessBuilder WithHls(string channelNumber, MediaVersion mediaVersion)
{
if (!int.TryParse(mediaVersion.RFrameRate, out int frameRate))
{
string[] split = mediaVersion.RFrameRate.Split("/");
if (int.TryParse(split[0], out int left) && int.TryParse(split[1], out int right))
{
frameRate = (int)Math.Round(left / (double)right);
}
else
{
_logger.LogInformation("Unable to detect framerate, using {FrameRate}", 24);
frameRate = 24;
}
}
_arguments.AddRange(
new[]
{
"-g", $"{frameRate * 2}",
"-keyint_min", $"{frameRate * 2}",
// "-force_key_frames",
// "expr:gte(t,n_forced*2)",
"-f", "hls",
"-hls_time", "2",
"-hls_list_size", "10",
"-segment_list_flags", "+live",
"-hls_flags", "delete_segments+program_date_time+append_list+discont_start+omit_endlist",
Path.Combine(FileSystemLayout.TranscodeFolder, channelNumber, "live.m3u8")
});
return this;
}
public FFmpegProcessBuilder WithPlaybackArgs(FFmpegPlaybackSettings playbackSettings)
{
var arguments = new List<string>
{
"-c:v", playbackSettings.VideoCodec,
"-flags", "cgop",
"-sc_threshold", "1000000000"
"-sc_threshold", "0" // disable scene change detection
};
string[] videoBitrateArgs = playbackSettings.VideoBitrate.Match(

31
ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs

@ -6,6 +6,7 @@ using ErsatzTV.Core.Domain; @@ -6,6 +6,7 @@ using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images;
using LanguageExt;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.FFmpeg
@ -14,16 +15,19 @@ namespace ErsatzTV.Core.FFmpeg @@ -14,16 +15,19 @@ namespace ErsatzTV.Core.FFmpeg
{
private readonly IFFmpegStreamSelector _ffmpegStreamSelector;
private readonly IImageCache _imageCache;
private readonly ILogger<FFmpegProcessService> _logger;
private readonly FFmpegPlaybackSettingsCalculator _playbackSettingsCalculator;
public FFmpegProcessService(
FFmpegPlaybackSettingsCalculator ffmpegPlaybackSettingsService,
IFFmpegStreamSelector ffmpegStreamSelector,
IImageCache imageCache)
IImageCache imageCache,
ILogger<FFmpegProcessService> logger)
{
_playbackSettingsCalculator = ffmpegPlaybackSettingsService;
_ffmpegStreamSelector = ffmpegStreamSelector;
_imageCache = imageCache;
_logger = logger;
}
public async Task<Process> ForPlayoutItem(
@ -56,7 +60,7 @@ namespace ErsatzTV.Core.FFmpeg @@ -56,7 +60,7 @@ namespace ErsatzTV.Core.FFmpeg
p => _imageCache.IsAnimated(p),
() => Task.FromResult(false));
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath, saveReports)
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath, saveReports, _logger)
.WithThreads(playbackSettings.ThreadCount)
.WithHardwareAcceleration(playbackSettings.HardwareAcceleration)
.WithVaapiDriver(maybeVaapiDriver)
@ -106,12 +110,21 @@ namespace ErsatzTV.Core.FFmpeg @@ -106,12 +110,21 @@ namespace ErsatzTV.Core.FFmpeg
}
});
return builder.WithPlaybackArgs(playbackSettings)
builder = builder.WithPlaybackArgs(playbackSettings)
.WithMetadata(channel, maybeAudioStream)
.WithFormat("mpegts")
.WithDuration(start + version.Duration - now)
.WithPipe()
.Build();
.WithDuration(start + version.Duration - now);
switch (channel.StreamingMode)
{
// HLS needs to segment and generate playlist
case StreamingMode.HttpLiveStreamingSegmenter:
return builder.WithHls(channel.Number, version)
.Build();
default:
return builder.WithFormat("mpegts")
.WithPipe()
.Build();
}
}
public Process ForError(string ffmpegPath, Channel channel, Option<TimeSpan> duration, string errorMessage)
@ -121,7 +134,7 @@ namespace ErsatzTV.Core.FFmpeg @@ -121,7 +134,7 @@ namespace ErsatzTV.Core.FFmpeg
IDisplaySize desiredResolution = channel.FFmpegProfile.Resolution;
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath, false)
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath, false, _logger)
.WithThreads(1)
.WithQuiet()
.WithFormatFlags(playbackSettings.FormatFlags)
@ -144,7 +157,7 @@ namespace ErsatzTV.Core.FFmpeg @@ -144,7 +157,7 @@ namespace ErsatzTV.Core.FFmpeg
{
FFmpegPlaybackSettings playbackSettings = _playbackSettingsCalculator.ConcatSettings;
return new FFmpegProcessBuilder(ffmpegPath, saveReports)
return new FFmpegProcessBuilder(ffmpegPath, saveReports, _logger)
.WithThreads(1)
.WithQuiet()
.WithFormatFlags(playbackSettings.FormatFlags)

96
ErsatzTV.Core/FFmpeg/FFmpegSegmenterService.cs

@ -0,0 +1,96 @@ @@ -0,0 +1,96 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using ErsatzTV.Core.Interfaces.FFmpeg;
using LanguageExt;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Core.FFmpeg
{
public class FFmpegSegmenterService : IFFmpegSegmenterService
{
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))
{
if (!processAndToken.Process.HasExited || !Processes.TryRemove(
new KeyValuePair<string, ProcessAndToken>(channelNumber, processAndToken)))
{
return true;
}
}
return false;
}
public bool TryAdd(string channelNumber, Process process)
{
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)
{
if (Processes.TryGetValue(channelNumber, out ProcessAndToken processAndToken))
{
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
{
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);
}
}

7
ErsatzTV.Core/FileSystemLayout.cs

@ -11,6 +11,13 @@ namespace ErsatzTV.Core @@ -11,6 +11,13 @@ namespace ErsatzTV.Core
Environment.SpecialFolderOption.Create),
"ersatztv");
// TODO: find a different spot for this; configurable?
public static readonly string TranscodeFolder = Path.Combine(
Environment.GetFolderPath(
Environment.SpecialFolder.LocalApplicationData,
Environment.SpecialFolderOption.Create),
"etv-transcode");
public static readonly string DatabasePath = Path.Combine(AppDataFolder, "ersatztv.sqlite3");
public static readonly string LogDatabasePath = Path.Combine(AppDataFolder, "logs.sqlite3");

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

@ -0,0 +1,14 @@ @@ -0,0 +1,14 @@
using System.Diagnostics;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.FFmpeg
{
public interface IFFmpegSegmenterService
{
bool ProcessExistsForChannel(string channelNumber);
bool TryAdd(string channelNumber, Process process);
void TouchChannel(string channelNumber);
void CleanUpSessions();
Unit KillAll();
}
}

1
ErsatzTV.Core/Interfaces/Metadata/ILocalFileSystem.cs

@ -15,5 +15,6 @@ namespace ErsatzTV.Core.Interfaces.Metadata @@ -15,5 +15,6 @@ namespace ErsatzTV.Core.Interfaces.Metadata
IEnumerable<string> ListFiles(string folder);
bool FileExists(string path);
Task<Either<BaseError, Unit>> CopyFile(string source, string destination);
Unit EmptyFolder(string folder);
}
}

1
ErsatzTV.Core/Iptv/ChannelPlaylist.cs

@ -44,6 +44,7 @@ namespace ErsatzTV.Core.Iptv @@ -44,6 +44,7 @@ namespace ErsatzTV.Core.Iptv
{
StreamingMode.HttpLiveStreamingDirect => "m3u8?mode=hls-direct",
StreamingMode.HttpLiveStreamingHybrid => "m3u8",
StreamingMode.HttpLiveStreamingSegmenter => "m3u8?mode=segmenter",
_ => "ts"
};

15
ErsatzTV.Core/Metadata/LocalFileSystem.cs

@ -56,5 +56,20 @@ namespace ErsatzTV.Core.Metadata @@ -56,5 +56,20 @@ namespace ErsatzTV.Core.Metadata
return BaseError.New(ex.ToString());
}
}
public Unit EmptyFolder(string folder)
{
foreach (string file in Directory.GetFiles(folder))
{
File.Delete(file);
}
foreach (string directory in Directory.GetDirectories(folder))
{
Directory.Delete(directory, true);
}
return Unit.Default;
}
}
}

1
ErsatzTV.Core/Metadata/LocalStatisticsProvider.cs

@ -188,6 +188,7 @@ namespace ErsatzTV.Core.Metadata @@ -188,6 +188,7 @@ namespace ErsatzTV.Core.Metadata
version.Width = videoStream.width;
version.Height = videoStream.height;
version.VideoScanKind = ScanKindFromFieldOrder(videoStream.field_order);
version.RFrameRate = videoStream.r_frame_rate;
var stream = new MediaStream
{

1
ErsatzTV.Infrastructure/Data/Repositories/MetadataRepository.cs

@ -133,6 +133,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -133,6 +133,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
existing.Width = incoming.Width;
existing.Height = incoming.Height;
existing.VideoScanKind = incoming.VideoScanKind;
existing.RFrameRate = incoming.RFrameRate;
}
var toAdd = incoming.Streams.Filter(s => existing.Streams.All(es => es.Index != s.Index)).ToList();

3287
ErsatzTV.Infrastructure/Migrations/20211007201223_Add_MediaVersionRFrameRate.Designer.cs generated

File diff suppressed because it is too large Load Diff

36
ErsatzTV.Infrastructure/Migrations/20211007201223_Add_MediaVersionRFrameRate.cs

@ -0,0 +1,36 @@ @@ -0,0 +1,36 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_MediaVersionRFrameRate : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "RFrameRate",
table: "MediaVersion",
type: "TEXT",
nullable: true);
migrationBuilder.Sql("UPDATE MediaVersion SET DateUpdated = '0001-01-01 00:00:00'");
migrationBuilder.Sql("UPDATE LibraryFolder SET Etag = NULL");
migrationBuilder.Sql("UPDATE EmbyMovie SET Etag = NULL");
migrationBuilder.Sql("UPDATE EmbyShow SET Etag = NULL");
migrationBuilder.Sql("UPDATE EmbySeason SET Etag = NULL");
migrationBuilder.Sql("UPDATE EmbyEpisode SET Etag = NULL");
migrationBuilder.Sql("UPDATE JellyfinMovie SET Etag = NULL");
migrationBuilder.Sql("UPDATE JellyfinShow SET Etag = NULL");
migrationBuilder.Sql("UPDATE JellyfinSeason SET Etag = NULL");
migrationBuilder.Sql("UPDATE JellyfinEpisode SET Etag = NULL");
migrationBuilder.Sql("UPDATE LibraryPath SET LastScan = '0001-01-01 00:00:00'");
migrationBuilder.Sql("UPDATE Library SET LastScan = '0001-01-01 00:00:00'");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "RFrameRate",
table: "MediaVersion");
}
}
}

3
ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs

@ -809,6 +809,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -809,6 +809,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("RFrameRate")
.HasColumnType("TEXT");
b.Property<string>("SampleAspectRatio")
.HasColumnType("TEXT");

1
ErsatzTV.sln.DotSettings

@ -42,6 +42,7 @@ @@ -42,6 +42,7 @@
<s:Boolean x:Key="/Default/UserDictionary/Words/=playout/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Playouts/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=probesize/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Segmenter/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=setsar/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=showtitle/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=strm/@EntryIndexedValue">True</s:Boolean>

2
ErsatzTV/Controllers/InternalController.cs

@ -31,7 +31,7 @@ namespace ErsatzTV.Controllers @@ -31,7 +31,7 @@ namespace ErsatzTV.Controllers
string channelNumber,
[FromQuery]
string mode = "mixed") =>
_mediator.Send(new GetPlayoutItemProcessByChannelNumber(channelNumber, mode)).Map(
_mediator.Send(new GetPlayoutItemProcessByChannelNumber(channelNumber, mode, false)).Map(
result =>
result.Match<IActionResult>(
process =>

60
ErsatzTV/Controllers/IptvController.cs

@ -1,15 +1,20 @@ @@ -1,15 +1,20 @@
using System.Threading.Channels;
using System.Threading.Tasks;
using ErsatzTV.Application;
using ErsatzTV.Application.Channels.Queries;
using ErsatzTV.Application.Images;
using ErsatzTV.Application.Images.Queries;
using ErsatzTV.Application.Streaming.Commands;
using ErsatzTV.Application.Streaming.Queries;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Iptv;
using LanguageExt;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Controllers
{
@ -17,13 +22,18 @@ namespace ErsatzTV.Controllers @@ -17,13 +22,18 @@ namespace ErsatzTV.Controllers
[ApiExplorerSettings(IgnoreApi = true)]
public class IptvController : ControllerBase
{
private readonly ChannelWriter<IFFmpegWorkerRequest> _channel;
private readonly ILogger<IptvController> _logger;
private readonly IMediator _mediator;
public IptvController(IMediator mediator, ILogger<IptvController> logger)
public IptvController(
IMediator mediator,
ILogger<IptvController> logger,
ChannelWriter<IFFmpegWorkerRequest> channel)
{
_mediator = mediator;
_logger = logger;
_channel = channel;
}
[HttpGet("iptv/channels.m3u")]
@ -55,20 +65,44 @@ namespace ErsatzTV.Controllers @@ -55,20 +65,44 @@ namespace ErsatzTV.Controllers
error => BadRequest(error.Value)));
[HttpGet("iptv/channel/{channelNumber}.m3u8")]
public Task<IActionResult> GetHttpLiveStreamingVideo(
public async Task<IActionResult> GetHttpLiveStreamingVideo(
string channelNumber,
[FromQuery]
string mode = "mixed") =>
_mediator.Send(
new GetHlsPlaylistByChannelNumber(
Request.Scheme,
Request.Host.ToString(),
channelNumber,
mode))
.Map(
result => result.Match<IActionResult>(
playlist => Content(playlist, "application/x-mpegurl"),
error => BadRequest(error.Value)));
string mode = "mixed")
{
switch (mode)
{
case "segmenter":
Either<BaseError, Unit> result = await _mediator.Send(new StartFFmpegSession(channelNumber, false));
return result.Match<IActionResult>(
_ => Redirect($"/iptv/session/{channelNumber}/live.m3u8"),
error =>
{
switch (error)
{
case ChannelHasProcess:
return RedirectPreserveMethod($"/iptv/session/{channelNumber}/live.m3u8");
default:
_logger.LogWarning(
"Failed to start segmenter for channel {ChannelNumber}: {Error}",
channelNumber,
error.ToString());
return NotFound();
}
});
default:
return await _mediator.Send(
new GetHlsPlaylistByChannelNumber(
Request.Scheme,
Request.Host.ToString(),
channelNumber,
mode))
.Map(
r => r.Match<IActionResult>(
playlist => Content(playlist, "application/x-mpegurl"),
error => BadRequest(error.Value)));
}
}
[HttpGet("iptv/logos/{fileName}")]
[HttpHead("iptv/logos/{fileName}.jpg")]

1
ErsatzTV/Pages/ChannelEditor.razor

@ -32,6 +32,7 @@ @@ -32,6 +32,7 @@
<MudSelectItem Value="@(StreamingMode.TransportStream)">MPEG-TS</MudSelectItem>
<MudSelectItem Value="@(StreamingMode.HttpLiveStreamingDirect)">HLS Direct</MudSelectItem>
<MudSelectItem Value="@(StreamingMode.HttpLiveStreamingHybrid)">HLS Hybrid</MudSelectItem>
<MudSelectItem Value="@(StreamingMode.HttpLiveStreamingSegmenter)">HLS Segmenter</MudSelectItem>
</MudSelect>
<MudSelect Class="mt-3" Label="FFmpeg Profile" @bind-Value="_model.FFmpegProfileId" For="@(() => _model.FFmpegProfileId)"
Disabled="@(_model.StreamingMode == StreamingMode.HttpLiveStreamingDirect)">

1
ErsatzTV/Pages/Channels.razor

@ -141,6 +141,7 @@ @@ -141,6 +141,7 @@
private static string GetStreamingMode(StreamingMode streamingMode) => streamingMode switch {
StreamingMode.HttpLiveStreamingDirect => "HLS Direct",
StreamingMode.HttpLiveStreamingHybrid => "HLS Hybrid",
StreamingMode.HttpLiveStreamingSegmenter => "HLS Segmenter",
_ => "MPEG-TS"
};

34
ErsatzTV/Services/FFmpegSchedulerService.cs

@ -0,0 +1,34 @@ @@ -0,0 +1,34 @@
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);
}
}
}
}

121
ErsatzTV/Services/FFmpegWorkerService.cs

@ -0,0 +1,121 @@ @@ -0,0 +1,121 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using ErsatzTV.Application;
using ErsatzTV.Application.Streaming.Commands;
using ErsatzTV.Application.Streaming.Queries;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.FFmpeg;
using LanguageExt;
using MediatR;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
namespace ErsatzTV.Services
{
public class FFmpegWorkerService : BackgroundService
{
private readonly ChannelReader<IFFmpegWorkerRequest> _channel;
private readonly ChannelWriter<IFFmpegWorkerRequest> _channelWriter;
private readonly ILogger<FFmpegWorkerService> _logger;
private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
private readonly IServiceScopeFactory _serviceScopeFactory;
public FFmpegWorkerService(
ChannelReader<IFFmpegWorkerRequest> channel,
ChannelWriter<IFFmpegWorkerRequest> channelWriter,
IServiceScopeFactory serviceScopeFactory,
ILogger<FFmpegWorkerService> logger,
IFFmpegSegmenterService ffmpegSegmenterService)
{
_channel = channel;
_channelWriter = channelWriter;
_serviceScopeFactory = serviceScopeFactory;
_logger = logger;
_ffmpegSegmenterService = ffmpegSegmenterService;
}
protected override async Task ExecuteAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("FFmpeg worker service started");
await foreach (IFFmpegWorkerRequest request in _channel.ReadAllAsync(cancellationToken))
{
try
{
using IServiceScope scope = _serviceScopeFactory.CreateScope();
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
switch (request)
{
case TouchFFmpegSession touchFFmpegSession:
foreach (DirectoryInfo parent in Optional(Directory.GetParent(touchFFmpegSession.Path)))
{
_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;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to handle ffmpeg worker request");
}
}
// kill any running processes after cancellation
_ffmpegSegmenterService.KillAll();
}
}
}

43
ErsatzTV/Startup.cs

@ -7,7 +7,7 @@ using Blazored.LocalStorage; @@ -7,7 +7,7 @@ using Blazored.LocalStorage;
using Dapper;
using ErsatzTV.Application;
using ErsatzTV.Application.Channels.Queries;
using ErsatzTV.Application.Logs.Queries;
using ErsatzTV.Application.Streaming.Commands;
using ErsatzTV.Core;
using ErsatzTV.Core.Emby;
using ErsatzTV.Core.FFmpeg;
@ -56,10 +56,12 @@ using MediatR; @@ -56,10 +56,12 @@ using MediatR;
using MediatR.Courier.DependencyInjection;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.StaticFiles;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using MudBlazor.Services;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
@ -76,6 +78,16 @@ namespace ErsatzTV @@ -76,6 +78,16 @@ namespace ErsatzTV
public void ConfigureServices(IServiceCollection services)
{
services.AddCors(
o => o.AddPolicy(
"AllowAll",
builder =>
{
builder.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader();
}));
services.AddControllers(
options =>
{
@ -123,6 +135,11 @@ namespace ErsatzTV @@ -123,6 +135,11 @@ namespace ErsatzTV
Directory.CreateDirectory(FileSystemLayout.AppDataFolder);
}
if (!Directory.Exists(FileSystemLayout.TranscodeFolder))
{
Directory.CreateDirectory(FileSystemLayout.TranscodeFolder);
}
Log.Logger.Information("Database is at {DatabasePath}", FileSystemLayout.DatabasePath);
// until we add a setting for a file-specific scheme://host:port to access
@ -183,10 +200,30 @@ namespace ErsatzTV @@ -183,10 +200,30 @@ namespace ErsatzTV
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
app.UseCors("AllowAll");
// app.UseSerilogRequestLogging();
app.UseStaticFiles();
var extensionProvider = new FileExtensionContentTypeProvider();
extensionProvider.Mappings.Add(".m3u8", "application/vnd.apple.mpegurl");
app.UseStaticFiles(
new StaticFileOptions
{
FileProvider = new PhysicalFileProvider(FileSystemLayout.TranscodeFolder),
RequestPath = "/iptv/session",
ContentTypeProvider = extensionProvider,
OnPrepareResponse = ctx =>
{
// Log.Logger.Information("Transcode access: {Test}", ctx.File.PhysicalPath);
ChannelWriter<IFFmpegWorkerRequest> writer = app.ApplicationServices
.GetRequiredService<ChannelWriter<IFFmpegWorkerRequest>>();
writer.TryWrite(new TouchFFmpegSession(ctx.File.PhysicalPath));
}
});
app.UseRouting();
app.UseEndpoints(
@ -206,10 +243,12 @@ namespace ErsatzTV @@ -206,10 +243,12 @@ namespace ErsatzTV
services.AddSingleton<ITraktApiClient, TraktApiClient>();
services.AddSingleton<IEntityLocker, EntityLocker>();
services.AddSingleton<ISearchIndex, SearchIndex>();
services.AddSingleton<IFFmpegSegmenterService, FFmpegSegmenterService>();
AddChannel<IBackgroundServiceRequest>(services);
AddChannel<IPlexBackgroundServiceRequest>(services);
AddChannel<IJellyfinBackgroundServiceRequest>(services);
AddChannel<IEmbyBackgroundServiceRequest>(services);
AddChannel<IFFmpegWorkerRequest>(services);
services.AddScoped<IFFmpegVersionHealthCheck, FFmpegVersionHealthCheck>();
services.AddScoped<IFFmpegReportsHealthCheck, FFmpegReportsHealthCheck>();
@ -286,6 +325,8 @@ namespace ErsatzTV @@ -286,6 +325,8 @@ namespace ErsatzTV
services.AddHostedService<FFmpegLocatorService>();
services.AddHostedService<WorkerService>();
services.AddHostedService<SchedulerService>();
services.AddHostedService<FFmpegWorkerService>();
services.AddHostedService<FFmpegSchedulerService>();
}
private void AddChannel<TMessageType>(IServiceCollection services)

Loading…
Cancel
Save