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
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace ErsatzTV.Application
|
||||
{
|
||||
public interface IFFmpegWorkerRequest
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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>>;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
{
|
||||
public GetConcatProcessByChannelNumber(string scheme, string host, string channelNumber) : base(
|
||||
channelNumber,
|
||||
"ts")
|
||||
"ts",
|
||||
false)
|
||||
{
|
||||
Scheme = scheme;
|
||||
Host = host;
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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
|
||||
version,
|
||||
playoutItemWithPath.Path,
|
||||
playoutItemWithPath.PlayoutItem.StartOffset,
|
||||
now,
|
||||
request.StartAtZero ? playoutItemWithPath.PlayoutItem.StartOffset : now,
|
||||
maybeGlobalWatermark,
|
||||
maybeVaapiDriver));
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
{
|
||||
TransportStream = 1,
|
||||
HttpLiveStreamingDirect = 2,
|
||||
HttpLiveStreamingHybrid = 3
|
||||
HttpLiveStreamingHybrid = 3,
|
||||
HttpLiveStreamingSegmenter = 4
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ErsatzTV.Core.Errors
|
||||
{
|
||||
public class ChannelHasProcess : BaseError
|
||||
{
|
||||
public ChannelHasProcess() : base("Channel already has ffmpeg process")
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
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
|
||||
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(
|
||||
|
||||
@@ -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
|
||||
{
|
||||
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
|
||||
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
|
||||
}
|
||||
});
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
{
|
||||
FFmpegPlaybackSettings playbackSettings = _playbackSettingsCalculator.ConcatSettings;
|
||||
|
||||
return new FFmpegProcessBuilder(ffmpegPath, saveReports)
|
||||
return new FFmpegProcessBuilder(ffmpegPath, saveReports, _logger)
|
||||
.WithThreads(1)
|
||||
.WithQuiet()
|
||||
.WithFormatFlags(playbackSettings.FormatFlags)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ namespace ErsatzTV.Core.Iptv
|
||||
{
|
||||
StreamingMode.HttpLiveStreamingDirect => "m3u8?mode=hls-direct",
|
||||
StreamingMode.HttpLiveStreamingHybrid => "m3u8",
|
||||
StreamingMode.HttpLiveStreamingSegmenter => "m3u8?mode=segmenter",
|
||||
_ => "ts"
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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
|
||||
[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
|
||||
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")]
|
||||
|
||||
@@ -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)">
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+42
-1
@@ -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;
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
services.AddHostedService<FFmpegLocatorService>();
|
||||
services.AddHostedService<WorkerService>();
|
||||
services.AddHostedService<SchedulerService>();
|
||||
services.AddHostedService<FFmpegWorkerService>();
|
||||
services.AddHostedService<FFmpegSchedulerService>();
|
||||
}
|
||||
|
||||
private void AddChannel<TMessageType>(IServiceCollection services)
|
||||
|
||||
Reference in New Issue
Block a user