Compare commits

...
Author SHA1 Message Date
Jason Dove 3a6d9e9f39 update changelog for release v0.1.3-alpha [no ci] 2021-10-13 15:17:23 -05:00
Jason DoveandGitHub 7ed4b8ae3c fix startup bug (#426) 2021-10-13 13:30:26 -05:00
Jason Dove be7311e620 update changelog for release v0.1.2-alpha [no ci] 2021-10-12 19:00:30 -05:00
Jason Dove 03be372070 update changelog [no ci] 2021-10-12 18:57:02 -05:00
Jason DoveandGitHub d196308ee9 fix ffmpeg profile editing (#421) 2021-10-12 18:39:51 -05:00
Jason Dove 3d68b0f055 fix vaapi migration 2021-10-12 18:22:09 -05:00
Jason Dove 37e32f06ad update changelog [no ci] 2021-10-12 17:56:56 -05:00
Jason DoveandGitHub c43ca2837d support radeon vaapi acceleration (#420) 2021-10-12 17:51:55 -05:00
Jason DoveandGitHub 992121f308 add more watermark locations (#419) 2021-10-12 07:19:52 -05:00
Jason DoveandGitHub 04adbfeffa add hls segmenter settings to optimize performance (#418)
* add hls segmenter settings to optimize performance

* use consistent setting defaults
2021-10-12 06:31:11 -05:00
Jason DoveandGitHub 1fc905c6ad upgrade vaapi to ffmpeg 4.4 (#417) 2021-10-11 22:26:32 -05:00
Jason DoveandGitHub 4b5dff2159 ffnvcodec fixes (#416) 2021-10-11 22:14:43 -05:00
Jason DoveandGitHub 2a5edf8214 ffmpeg 4.4 llvm nvidia fixes (#415) 2021-10-11 21:31:59 -05:00
Jason DoveandGitHub 69912c8cae support ffmpeg 4.4 (#414)
* support ffmpeg 4.4

* update changelog
2021-10-11 20:16:15 -05:00
Jason DoveandGitHub fd3de2d82a nvidia 10 bit fixes (#413) 2021-10-11 16:00:35 -05:00
Jason DoveandGitHub 6ba9404752 nvidia transcoding improvements (#412)
* nvidia transcoding fixes

* use yadif_cuda to deinterlace
2021-10-10 22:40:43 -05:00
Jason Dove db080375c5 update changelog for release v0.1.1-alpha [no ci] 2021-10-10 12:54:46 -05:00
Jason Dove 9abc7ad8b7 try to fix tests on windows 2021-10-10 12:39:08 -05:00
Jason DoveandGitHub 9e531a82d7 add some hls playlist filter tests (#411) 2021-10-10 12:33:02 -05:00
Jason DoveandGitHub d84bd2b948 upgrade nvidia docker image (#410) 2021-10-10 11:45:02 -05:00
Jason DoveandGitHub d7d3ec1235 add music video album to search index (#409)
* add music video album to search index

* update search docs
2021-10-10 10:28:35 -05:00
Jason Dove 742ac21ad7 update collection docs [no docker] 2021-10-10 07:53:29 -05:00
Jason DoveandGitHub 819b55e21f increase max hls segments (#408) 2021-10-10 06:47:24 -05:00
Jason DoveandGitHub cf5718c288 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
2021-10-09 22:46:38 -05:00
Jason DoveandGitHub adc7982955 reduce initial hls segmenter delay (#406) 2021-10-09 10:26:57 -05:00
77 changed files with 7856 additions and 471 deletions
+39 -1
View File
@@ -5,6 +5,41 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
## [0.1.3-alpha] - 2021-10-12
### Fixed
- Fix startup bug for nvidia docker installations
## [0.1.2-alpha] - 2021-10-12
### Added
- Include more cuda (nvidia) filters in docker image
- Enable deinterlacing with nvidia using new `yadif_cuda` filter
- Add two HLS Segmenter settings: idle timeout and work-ahead limit
- `HLS Segmenter Idle Timeout` - the number of seconds to keep transcoding a channel while no requests have been received from any client
- This setting must be greater than or equal to 30 (seconds)
- `Work-Ahead HLS Segmenter Limit` - the number of segmenters (channels) that will work-ahead simultaneously (if multiple channels are being watched)
- "working ahead" means transcoding at full speed, which can take a lot of resources
- This setting must be greater than or equal to 0
- Add more watermark locations ("middle" of each side)
- Add `VAAPI Device` setting to ffmpeg profile to support installations with multiple video cards
- Add *experimental* `RadeonSI` option for `VAAPI Driver` and include mesa drivers in vaapi docker image
### Changed
- Upgrade ffmpeg from 4.3 to 4.4 in all docker images
- Upgrading from 4.3 to 4.4 is recommended for all installations
- Move `VAAPI Driver` from settings page to ffmpeg profile to support installations with multiple video cards
### Fixed
- Fix some transcoding edge cases with nvidia and pixel format `yuv420p10le`
## [0.1.1-alpha] - 2021-10-10
### Added
- Add music video album to search index
- This requires rebuilding the search index and search results may be empty or incomplete until the rebuild is complete
### Changed
- Remove forced initial delay from `HLS Segmenter` streaming mode
- Upgrade nvidia docker image from 18.04 to 20.04
## [0.1.0-alpha] - 2021-10-08
### Added
- Add *experimental* streaming mode `HLS Segmenter` (most similar to `HLS Hybrid`)
@@ -660,7 +695,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Initial release to facilitate testing outside of Docker.
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.1.0-alpha...HEAD
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.1.3-alpha...HEAD
[0.1.3-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.1.2-alpha...v0.1.3-alpha
[0.1.2-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.1.1-alpha...v0.1.2-alpha
[0.1.1-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.1.0-alpha...v0.1.1-alpha
[0.1.0-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.62-alpha...v0.1.0-alpha
[0.0.62-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.61-alpha...v0.0.62-alpha
[0.0.61-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.60-alpha...v0.0.61-alpha
@@ -7,10 +7,6 @@
</PropertyGroup>
<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="Microsoft.Extensions.Caching.Abstractions" Version="5.0.0" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.0.63">
@@ -1,5 +1,6 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
using LanguageExt;
using MediatR;
@@ -10,6 +11,8 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
int ThreadCount,
bool Transcode,
HardwareAccelerationKind HardwareAcceleration,
VaapiDriver VaapiDriver,
string VaapiDevice,
int ResolutionId,
bool NormalizeVideo,
string VideoCodec,
@@ -45,6 +45,8 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
ThreadCount = threadCount,
Transcode = request.Transcode,
HardwareAcceleration = request.HardwareAcceleration,
VaapiDriver = request.VaapiDriver,
VaapiDevice = request.VaapiDevice,
ResolutionId = resolutionId,
NormalizeVideo = request.NormalizeVideo,
VideoCodec = request.VideoCodec,
@@ -1,5 +1,6 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
using LanguageExt;
using MediatR;
@@ -11,6 +12,8 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
int ThreadCount,
bool Transcode,
HardwareAccelerationKind HardwareAcceleration,
VaapiDriver VaapiDriver,
string VaapiDevice,
int ResolutionId,
bool NormalizeVideo,
string VideoCodec,
@@ -36,6 +36,8 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
p.ThreadCount = update.ThreadCount;
p.Transcode = update.Transcode;
p.HardwareAcceleration = update.HardwareAcceleration;
p.VaapiDriver = update.VaapiDriver;
p.VaapiDevice = update.VaapiDevice;
p.ResolutionId = update.ResolutionId;
p.NormalizeVideo = update.Transcode && update.NormalizeVideo;
p.VideoCodec = update.VideoCodec;
@@ -116,8 +116,12 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
}
await _configElementRepository.Upsert(
ConfigElementKey.FFmpegVaapiDriver,
(int)request.Settings.VaapiDriver);
ConfigElementKey.FFmpegSegmenterTimeout,
request.Settings.HlsSegmenterIdleTimeout);
await _configElementRepository.Upsert(
ConfigElementKey.FFmpegWorkAheadSegmenters,
request.Settings.WorkAheadSegmenterLimit);
return Unit.Default;
}
@@ -1,5 +1,6 @@
using ErsatzTV.Application.Resolutions;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
namespace ErsatzTV.Application.FFmpegProfiles
{
@@ -9,6 +10,8 @@ namespace ErsatzTV.Application.FFmpegProfiles
int ThreadCount,
bool Transcode,
HardwareAccelerationKind HardwareAcceleration,
VaapiDriver VaapiDriver,
string VaapiDevice,
ResolutionViewModel Resolution,
bool NormalizeVideo,
string VideoCodec,
@@ -1,6 +1,4 @@
using ErsatzTV.Core.FFmpeg;
namespace ErsatzTV.Application.FFmpegProfiles
namespace ErsatzTV.Application.FFmpegProfiles
{
public class FFmpegSettingsViewModel
{
@@ -10,6 +8,7 @@ namespace ErsatzTV.Application.FFmpegProfiles
public string PreferredLanguageCode { get; set; }
public bool SaveReports { get; set; }
public int? GlobalWatermarkId { get; set; }
public VaapiDriver VaapiDriver { get; set; }
public int HlsSegmenterIdleTimeout { get; set; }
public int WorkAheadSegmenterLimit { get; set; }
}
}
@@ -12,6 +12,8 @@ namespace ErsatzTV.Application.FFmpegProfiles
profile.ThreadCount,
profile.Transcode,
profile.HardwareAcceleration,
profile.VaapiDriver,
profile.VaapiDevice,
Project(profile.Resolution),
profile.NormalizeVideo,
profile.VideoCodec,
@@ -1,7 +1,6 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
@@ -29,8 +28,10 @@ namespace ErsatzTV.Application.FFmpegProfiles.Queries
await _configElementRepository.GetValue<string>(ConfigElementKey.FFmpegPreferredLanguageCode);
Option<int> watermark =
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegGlobalWatermarkId);
Option<int> vaapiDriver =
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegVaapiDriver);
Option<int> hlsSegmenterIdleTimeout =
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegSegmenterTimeout);
Option<int> workAheadSegmenterLimit =
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegWorkAheadSegmenters);
var result = new FFmpegSettingsViewModel
{
@@ -39,7 +40,8 @@ namespace ErsatzTV.Application.FFmpegProfiles.Queries
DefaultFFmpegProfileId = await defaultFFmpegProfileId.IfNoneAsync(0),
SaveReports = await saveReports.IfNoneAsync(false),
PreferredLanguageCode = await preferredLanguageCode.IfNoneAsync("eng"),
VaapiDriver = (VaapiDriver)await vaapiDriver.IfNoneAsync(0)
HlsSegmenterIdleTimeout = await hlsSegmenterIdleTimeout.IfNoneAsync(60),
WorkAheadSegmenterLimit = await workAheadSegmenterLimit.IfNoneAsync(1),
};
foreach (int watermarkId in watermark)
+2 -2
View File
@@ -1,5 +1,4 @@
using System;
using System.Linq;
using System.Linq;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Emby;
@@ -101,6 +100,7 @@ namespace ErsatzTV.Application.MediaCards
musicVideoMetadata.MusicVideo.Artist.ArtistMetadata.Head().Title,
musicVideoMetadata.SortTitle,
musicVideoMetadata.Plot,
musicVideoMetadata.Album,
GetThumbnail(musicVideoMetadata, None, None));
internal static ArtistCardViewModel ProjectToViewModel(ArtistMetadata artistMetadata) =>
@@ -7,6 +7,7 @@
string Subtitle,
string SortTitle,
string Plot,
string Album,
string Poster) : MediaCardViewModel(
MusicVideoId,
Title,
@@ -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;
}
@@ -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;
}
}
}
@@ -1,13 +1,15 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
@@ -15,21 +17,24 @@ namespace ErsatzTV.Application.Streaming.Commands
{
public class StartFFmpegSessionHandler : MediatR.IRequestHandler<StartFFmpegSession, Either<BaseError, Unit>>
{
private readonly ChannelWriter<IFFmpegWorkerRequest> _channel;
private readonly ILogger<StartFFmpegSessionHandler> _logger;
private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
private readonly IConfigElementRepository _configElementRepository;
private readonly ILocalFileSystem _localFileSystem;
public StartFFmpegSessionHandler(
IFFmpegSegmenterService ffmpegSegmenterService,
ILocalFileSystem localFileSystem,
ChannelWriter<IFFmpegWorkerRequest> channel,
ILogger<StartFFmpegSessionHandler> logger)
ILogger<StartFFmpegSessionHandler> logger,
IServiceScopeFactory serviceScopeFactory,
IFFmpegSegmenterService ffmpegSegmenterService,
IConfigElementRepository configElementRepository)
{
_ffmpegSegmenterService = ffmpegSegmenterService;
_localFileSystem = localFileSystem;
_channel = channel;
_logger = logger;
_serviceScopeFactory = serviceScopeFactory;
_ffmpegSegmenterService = ffmpegSegmenterService;
_configElementRepository = configElementRepository;
}
public Task<Either<BaseError, Unit>> Handle(StartFFmpegSession request, CancellationToken cancellationToken) =>
@@ -42,24 +47,55 @@ namespace ErsatzTV.Application.Streaming.Commands
private async Task<Unit> StartProcess(StartFFmpegSession request)
{
await _channel.WriteAsync(request);
TimeSpan idleTimeout = await _configElementRepository
.GetValue<int>(ConfigElementKey.FFmpegSegmenterTimeout)
.Map(maybeTimeout => maybeTimeout.Match(i => TimeSpan.FromSeconds(i), () => TimeSpan.FromMinutes(1)));
using IServiceScope scope = _serviceScopeFactory.CreateScope();
HlsSessionWorker worker = scope.ServiceProvider.GetRequiredService<HlsSessionWorker>();
_ffmpegSegmenterService.SessionWorkers.AddOrUpdate(request.ChannelNumber, _ => worker, (_, _) => worker);
// TODO: find some other way to let ffmpeg get ahead
await Task.Delay(TimeSpan.FromSeconds(5));
// fire and forget worker
_ = worker.Run(request.ChannelNumber, idleTimeout)
.ContinueWith(
_ => _ffmpegSegmenterService.SessionWorkers.TryRemove(
request.ChannelNumber,
out IHlsSessionWorker _),
TaskScheduler.Default);
string playlistFileName = Path.Combine(
FileSystemLayout.TranscodeFolder,
request.ChannelNumber,
"live.m3u8");
while (!File.Exists(playlistFileName))
{
await Task.Delay(TimeSpan.FromMilliseconds(100));
}
return Unit.Default;
}
private Task<Validation<BaseError, Unit>> Validate(StartFFmpegSession request) =>
ProcessMustNotExist(request)
SessionMustBeInactive(request)
.BindT(_ => FolderMustBeEmpty(request));
private Task<Validation<BaseError, Unit>> ProcessMustNotExist(StartFFmpegSession request) =>
Optional(_ffmpegSegmenterService.ProcessExistsForChannel(request.ChannelNumber))
.Filter(exists => exists == false)
private Task<Validation<BaseError, Unit>> SessionMustBeInactive(StartFFmpegSession request)
{
var result = Optional(_ffmpegSegmenterService.SessionWorkers.TryAdd(request.ChannelNumber, null))
.Filter(success => success)
.Map(_ => Unit.Default)
.ToValidation<BaseError>(new ChannelHasProcess())
.AsTask();
.ToValidation<BaseError>(new ChannelSessionAlreadyActive());
if (result.IsFail && _ffmpegSegmenterService.SessionWorkers.TryGetValue(
request.ChannelNumber,
out IHlsSessionWorker worker))
{
worker?.Touch();
}
return result.AsTask();
}
private Task<Validation<BaseError, Unit>> FolderMustBeEmpty(StartFFmpegSession request)
{
@@ -0,0 +1,243 @@
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.Domain;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Timer = System.Timers.Timer;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.Streaming
{
public class HlsSessionWorker : IHlsSessionWorker
{
private static int _workAheadCount;
private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly ILogger<HlsSessionWorker> _logger;
private DateTimeOffset _lastAccess;
private DateTimeOffset _transcodedUntil;
private Timer _timer;
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, TimeSpan idleTimeout)
{
var cts = new CancellationTokenSource();
void Cancel(object o, ElapsedEventArgs e) => cts.Cancel();
try
{
lock (_sync)
{
_timer = new Timer(idleTimeout.TotalMilliseconds) { AutoReset = false };
_timer.Elapsed += Cancel;
}
CancellationToken cancellationToken = cts.Token;
_logger.LogInformation("Starting HLS session for channel {Channel}", channelNumber);
Touch();
_transcodedUntil = DateTimeOffset.Now;
_playlistStart = _transcodedUntil;
bool initialWorkAhead = Volatile.Read(ref _workAheadCount) < await GetWorkAheadLimit();
if (!await Transcode(channelNumber, true, !initialWorkAhead, cancellationToken))
{
return;
}
while (!cancellationToken.IsCancellationRequested)
{
if (DateTimeOffset.Now - _lastAccess > idleTimeout)
{
_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);
bool subsequentWorkAhead =
!realtime && Volatile.Read(ref _workAheadCount) < await GetWorkAheadLimit();
if (!await Transcode(channelNumber, false, !subsequentWorkAhead, cancellationToken))
{
return;
}
}
else
{
await TrimAndDelete(channelNumber, cancellationToken);
await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken);
}
}
}
finally
{
lock (_sync)
{
_timer.Elapsed -= Cancel;
}
}
}
private async Task<bool> Transcode(string channelNumber, bool firstProcess, bool realtime, CancellationToken cancellationToken)
{
try
{
if (!realtime)
{
Interlocked.Increment(ref _workAheadCount);
_logger.LogInformation("HLS segmenter will work ahead for channel {Channel}", channelNumber);
}
else
{
_logger.LogInformation(
"HLS segmenter will NOT work ahead for channel {Channel}",
channelNumber);
}
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;
}
finally
{
Interlocked.Decrement(ref _workAheadCount);
}
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]) <
trimResult.Sequence)
{
File.Delete(file);
}
}
_playlistStart = trimResult.PlaylistStart;
}
}
private async Task<int> GetWorkAheadLimit()
{
using IServiceScope scope = _serviceScopeFactory.CreateScope();
IConfigElementRepository repo = scope.ServiceProvider.GetRequiredService<IConfigElementRepository>();
return await repo.GetValue<int>(ConfigElementKey.FFmpegWorkAheadSegmenters)
.Map(maybeCount => maybeCount.Match(identity, () => 1));
}
}
}
@@ -0,0 +1,7 @@
using System;
using System.Diagnostics;
namespace ErsatzTV.Application.Streaming
{
public record PlayoutItemProcessModel(Process Process, DateTimeOffset Until);
}
@@ -1,5 +1,4 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
@@ -14,7 +13,7 @@ using static LanguageExt.Prelude;
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
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
@@ -22,16 +21,16 @@ namespace ErsatzTV.Application.Streaming.Queries
protected FFmpegProcessHandler(IDbContextFactory<TvContext> 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();
Validation<BaseError, Tuple<Channel, string>> validation = await Validate(dbContext, request);
return await validation.Match(
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,
T request,
Channel channel,
@@ -1,4 +1,4 @@
using System.Diagnostics;
using System;
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
@@ -6,5 +6,10 @@ using MediatR;
namespace ErsatzTV.Application.Streaming.Queries
{
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>>;
}
@@ -1,11 +1,15 @@
namespace ErsatzTV.Application.Streaming.Queries
using System;
namespace ErsatzTV.Application.Streaming.Queries
{
public record GetConcatProcessByChannelNumber : FFmpegProcessRequest
{
public GetConcatProcessByChannelNumber(string scheme, string host, string channelNumber) : base(
channelNumber,
"ts",
false)
DateTimeOffset.Now,
false,
true)
{
Scheme = scheme;
Host = host;
@@ -1,4 +1,5 @@
using System.Diagnostics;
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using ErsatzTV.Core;
@@ -27,7 +28,7 @@ namespace ErsatzTV.Application.Streaming.Queries
_runtimeInfo = runtimeInfo;
}
protected override async Task<Either<BaseError, Process>> GetProcess(
protected override async Task<Either<BaseError, PlayoutItemProcessModel>> GetProcess(
TvContext dbContext,
GetConcatProcessByChannelNumber request,
Channel channel,
@@ -37,12 +38,14 @@ namespace ErsatzTV.Application.Streaming.Queries
.GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
.Map(result => result.IfNone(false));
return _ffmpegProcessService.ConcatChannel(
Process process = _ffmpegProcessService.ConcatChannel(
ffmpegPath,
saveReports,
channel,
request.Scheme,
request.Host);
return new PlayoutItemProcessModel(process, DateTimeOffset.MaxValue);
}
}
}
@@ -1,11 +1,20 @@
namespace ErsatzTV.Application.Streaming.Queries
using System;
namespace ErsatzTV.Application.Streaming.Queries
{
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,
mode,
startAtZero)
now,
startAtZero,
hlsRealtime)
{
}
}
@@ -48,13 +48,14 @@ namespace ErsatzTV.Application.Streaming.Queries
_runtimeInfo = runtimeInfo;
}
protected override async Task<Either<BaseError, Process>> GetProcess(
protected override async Task<Either<BaseError, PlayoutItemProcessModel>> GetProcess(
TvContext dbContext,
GetPlayoutItemProcessByChannelNumber request,
Channel channel,
string ffmpegPath)
{
DateTimeOffset now = DateTimeOffset.Now;
DateTimeOffset now = request.Now;
Either<BaseError, PlayoutItemWithPath> maybePlayoutItem = await dbContext.PlayoutItems
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Episode).MediaVersions)
@@ -99,21 +100,23 @@ namespace ErsatzTV.Application.Streaming.Queries
watermarkId => dbContext.ChannelWatermarks
.SelectOneAsync(w => w.Id, w => w.Id == watermarkId));
Option<VaapiDriver> maybeVaapiDriver = await dbContext.ConfigElements
.GetValue<int>(ConfigElementKey.FFmpegVaapiDriver)
.MapT(i => (VaapiDriver)i);
Process process = await _ffmpegProcessService.ForPlayoutItem(
ffmpegPath,
saveReports,
channel,
version,
playoutItemWithPath.Path,
playoutItemWithPath.PlayoutItem.StartOffset,
request.StartAtZero ? playoutItemWithPath.PlayoutItem.StartOffset : now,
maybeGlobalWatermark,
channel.FFmpegProfile.VaapiDriver,
channel.FFmpegProfile.VaapiDevice,
request.StartAtZero,
request.HlsRealtime);
return Right<BaseError, Process>(
await _ffmpegProcessService.ForPlayoutItem(
ffmpegPath,
saveReports,
channel,
version,
playoutItemWithPath.Path,
playoutItemWithPath.PlayoutItem.StartOffset,
request.StartAtZero ? playoutItemWithPath.PlayoutItem.StartOffset : now,
maybeGlobalWatermark,
maybeVaapiDriver));
var result = new PlayoutItemProcessModel(process, playoutItemWithPath.PlayoutItem.FinishOffset);
return Right<BaseError, PlayoutItemProcessModel>(result);
},
async error =>
{
@@ -132,16 +135,21 @@ namespace ErsatzTV.Application.Streaming.Queries
.MapT(pi => pi.StartOffset - now),
() => Option<TimeSpan>.None.AsTask());
DateTimeOffset finish = maybeDuration.Match(d => now.Add(d), () => now);
switch (error)
{
case UnableToLocatePlayoutItem:
if (channel.FFmpegProfile.Transcode)
{
return _ffmpegProcessService.ForError(
Process errorProcess = _ffmpegProcessService.ForError(
ffmpegPath,
channel,
maybeDuration,
"Channel is Offline");
return new PlayoutItemProcessModel(errorProcess, finish);
}
else
{
@@ -153,7 +161,13 @@ namespace ErsatzTV.Application.Streaming.Queries
case PlayoutItemDoesNotExistOnDisk:
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
{
@@ -165,11 +179,13 @@ namespace ErsatzTV.Application.Streaming.Queries
default:
if (channel.FFmpegProfile.Transcode)
{
return _ffmpegProcessService.ForError(
Process errorProcess = _ffmpegProcessService.ForError(
ffmpegPath,
channel,
maybeDuration,
"Channel is Offline");
return new PlayoutItemProcessModel(errorProcess, finish);
}
else
{
@@ -6,10 +6,6 @@
</PropertyGroup>
<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="LanguageExt.Core" Version="3.4.15" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="5.0.2" />
@@ -341,43 +341,24 @@ namespace ErsatzTV.Core.Tests.FFmpeg
}
[Test]
// TODO: get yadif_cuda working in docker
// [TestCase(true, false, false, "[0:V]yadif_cuda[v]", "[v]")]
// [TestCase(
// true,
// true,
// false,
// "[0:V]yadif_cuda,scale_npp=1920:1000:format=yuv420p,hwdownload,setsar=1,hwupload[v]",
// "[v]")]
// [TestCase(
// true,
// false,
// true,
// "[0:V]yadif_cuda,hwdownload,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
// "[v]")]
// [TestCase(
// true,
// true,
// true,
// "[0:V]yadif_cuda,scale_npp=1920:1000:format=yuv420p,hwdownload,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
// "[v]")]
[TestCase(true, false, false, "[0:0]yadif_cuda[v]", "[v]")]
[TestCase(
true,
true,
false,
"[0:0]scale_npp=1920:1000,hwdownload,format=nv12,setsar=1,hwupload[v]",
"[0:0]yadif_cuda,scale_npp=1920:1000,hwdownload,format=nv12,setsar=1,hwupload[v]",
"[v]")]
[TestCase(
true,
false,
true,
"[0:0]hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[0:0]yadif_cuda,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")]
[TestCase(
true,
true,
true,
"[0:0]scale_npp=1920:1000,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[0:0]yadif_cuda,scale_npp=1920:1000,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[v]")]
[TestCase(
false,
@@ -0,0 +1,223 @@
using System;
using ErsatzTV.Core.FFmpeg;
using FluentAssertions;
using NUnit.Framework;
namespace ErsatzTV.Core.Tests.FFmpeg
{
[TestFixture]
public class HlsPlaylistFilterTests
{
[Test]
public void HlsPlaylistFilter_ShouldRewriteProgramDateTime()
{
var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5));
string[] input = @"#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:1137
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-DISCONTINUITY
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:49.320-0500
live001137.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:53.320-0500
live001138.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500
live001139.ts".Split(Environment.NewLine);
TrimPlaylistResult result = HlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(-30), input);
result.PlaylistStart.Should().Be(start);
result.Sequence.Should().Be(1137);
result.Playlist.Should().Be(
@"#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:1137
#EXT-X-DISCONTINUITY-SEQUENCE:0
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-DISCONTINUITY
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:00.000-0500
live001137.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:04.000-0500
live001138.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:08.000-0500
live001139.ts
");
}
[Test]
public void HlsPlaylistFilter_ShouldLimitSegments()
{
var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5));
string[] input = @"#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:1137
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-DISCONTINUITY
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:49.320-0500
live001137.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:53.320-0500
live001138.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500
live001139.ts".Split(Environment.NewLine);
TrimPlaylistResult result = HlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(-30), input, 2);
result.PlaylistStart.Should().Be(start);
result.Sequence.Should().Be(1137);
result.Playlist.Should().Be(
@"#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:1137
#EXT-X-DISCONTINUITY-SEQUENCE:0
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-DISCONTINUITY
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:00.000-0500
live001137.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:04.000-0500
live001138.ts
");
}
[Test]
public void HlsPlaylistFilter_ShouldAddDiscontinuity()
{
var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5));
string[] input = @"#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:1137
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-DISCONTINUITY
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:49.320-0500
live001137.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:53.320-0500
live001138.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500
live001139.ts".Split(Environment.NewLine);
TrimPlaylistResult result = HlsPlaylistFilter.TrimPlaylist(
start,
start.AddSeconds(-30),
input,
int.MaxValue,
true);
result.PlaylistStart.Should().Be(start);
result.Sequence.Should().Be(1137);
result.Playlist.Should().Be(
@"#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:1137
#EXT-X-DISCONTINUITY-SEQUENCE:0
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-DISCONTINUITY
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:00.000-0500
live001137.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:04.000-0500
live001138.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:08.000-0500
live001139.ts
#EXT-X-DISCONTINUITY
");
}
[Test]
public void HlsPlaylistFilter_ShouldFilterOldSegments()
{
var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5));
string[] input = @"#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:1137
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-DISCONTINUITY
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:49.320-0500
live001137.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:53.320-0500
live001138.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500
live001139.ts".Split(Environment.NewLine);
TrimPlaylistResult result = HlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(6), input);
result.PlaylistStart.Should().Be(start.AddSeconds(8));
result.Sequence.Should().Be(1139);
result.Playlist.Should().Be(
@"#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:1139
#EXT-X-DISCONTINUITY-SEQUENCE:0
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-DISCONTINUITY
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:08.000-0500
live001139.ts
");
}
[Test]
public void HlsPlaylistFilter_ShouldFilterOldDiscontinuity()
{
var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5));
string[] input = @"#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:1137
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-DISCONTINUITY
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:49.320-0500
live001137.ts
#EXT-X-DISCONTINUITY
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:53.320-0500
live001138.ts
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500
live001139.ts".Split(Environment.NewLine);
TrimPlaylistResult result = HlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(6), input);
result.PlaylistStart.Should().Be(start.AddSeconds(8));
result.Sequence.Should().Be(1139);
result.Playlist.Should().Be(
@"#EXTM3U
#EXT-X-VERSION:6
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:1139
#EXT-X-DISCONTINUITY-SEQUENCE:1
#EXT-X-INDEPENDENT-SEGMENTS
#EXT-X-DISCONTINUITY
#EXTINF:4.000000,
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:08.000-0500
live001139.ts
");
}
}
}
+13 -5
View File
@@ -128,6 +128,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
p1.Start();
await p1.WaitForExitAsync();
// ReSharper disable once MethodHasAsyncOverload
p1.WaitForExit();
p1.ExitCode.Should().Be(0);
}
@@ -184,15 +186,20 @@ namespace ErsatzTV.Core.Tests.FFmpeg
now,
now,
None,
None);
VaapiDriver.Default,
"/dev/dri/renderD128",
false,
false);
process.StartInfo.RedirectStandardError = true;
process.Start().Should().BeTrue();
await process.StandardOutput.ReadToEndAsync();
process.BeginOutputReadLine();
string error = await process.StandardError.ReadToEndAsync();
await process.WaitForExitAsync();
// ReSharper disable once MethodHasAsyncOverload
process.WaitForExit();
string[] unsupportedMessages =
{
@@ -203,9 +210,9 @@ namespace ErsatzTV.Core.Tests.FFmpeg
if (profileAcceleration != HardwareAccelerationKind.None && unsupportedMessages.Any(error.Contains))
{
IEnumerable<string> quotedArgs = process.StartInfo.ArgumentList.Map(a => $"\'{a}\'");
var quotedArgs = process.StartInfo.ArgumentList.Map(a => $"\'{a}\'").ToList();
process.ExitCode.Should().Be(1, $"Error message with successful exit code? {string.Join(" ", quotedArgs)}");
Assert.Warn("Unsupported on this hardware");
Assert.Warn($"Unsupported on this hardware: ffmpeg {string.Join(" ", quotedArgs)}");
}
else if (error.Contains("Impossible to convert between"))
{
@@ -214,7 +221,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
}
else
{
process.ExitCode.Should().Be(0, error);
IEnumerable<string> quotedArgs = process.StartInfo.ArgumentList.Map(a => $"\'{a}\'");
process.ExitCode.Should().Be(0, error + Environment.NewLine + string.Join(" ", quotedArgs));
}
}
+5 -1
View File
@@ -22,7 +22,11 @@
BottomRight = 0,
BottomLeft = 1,
TopRight = 2,
TopLeft = 3
TopLeft = 3,
TopMiddle = 4,
RightMiddle = 5,
BottomMiddle = 6,
LeftMiddle = 7
}
public enum ChannelWatermarkSize
+2 -1
View File
@@ -13,7 +13,8 @@
public static ConfigElementKey FFmpegSaveReports => new("ffmpeg.save_reports");
public static ConfigElementKey FFmpegPreferredLanguageCode => new("ffmpeg.preferred_language_code");
public static ConfigElementKey FFmpegGlobalWatermarkId => new("ffmpeg.global_watermark_id");
public static ConfigElementKey FFmpegVaapiDriver => new("ffmpeg.vaapi_driver");
public static ConfigElementKey FFmpegSegmenterTimeout => new("ffmpeg.segmenter.timeout_seconds");
public static ConfigElementKey FFmpegWorkAheadSegmenters => new("ffmpeg.segmenter.work_ahead_limit");
public static ConfigElementKey SearchIndexVersion => new("search_index.version");
public static ConfigElementKey HDHRTunerCount => new("hdhr.tuner_count");
public static ConfigElementKey ChannelsPageSize => new("pages.channels.page_size");
+7 -2
View File
@@ -1,4 +1,6 @@
namespace ErsatzTV.Core.Domain
using ErsatzTV.Core.FFmpeg;
namespace ErsatzTV.Core.Domain
{
public record FFmpegProfile
{
@@ -7,6 +9,8 @@
public int ThreadCount { get; set; }
public bool Transcode { get; set; }
public HardwareAccelerationKind HardwareAcceleration { get; set; }
public VaapiDriver VaapiDriver { get; set; }
public string VaapiDevice { get; set; }
public int ResolutionId { get; set; }
public Resolution Resolution { get; set; }
public string VideoCodec { get; set; }
@@ -39,7 +43,8 @@
AudioChannels = 2,
AudioSampleRate = 48,
NormalizeVideo = true,
NormalizeAudio = true
NormalizeAudio = true,
HardwareAcceleration = HardwareAccelerationKind.None
};
}
}
@@ -1,9 +0,0 @@
namespace ErsatzTV.Core.Errors
{
public class ChannelHasProcess : BaseError
{
public ChannelHasProcess() : base("Channel already has ffmpeg process")
{
}
}
}
@@ -0,0 +1,9 @@
namespace ErsatzTV.Core.Errors
{
public class ChannelSessionAlreadyActive : BaseError
{
public ChannelSessionAlreadyActive() : base("Channel already has HLS session")
{
}
}
}
-4
View File
@@ -7,10 +7,6 @@
</PropertyGroup>
<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="LanguageExt.Core" Version="3.4.15" />
<PackageReference Include="MediatR" Version="9.0.0" />
+1 -1
View File
@@ -1,4 +1,4 @@
namespace ErsatzTV.Core.FFmpeg
{
public record FFmpegComplexFilter(string ComplexFilter, string VideoLabel, string AudioLabel);
public record FFmpegComplexFilter(string ComplexFilter, string VideoLabel, string AudioLabel, string PixelFormat);
}
@@ -125,7 +125,7 @@ namespace ErsatzTV.Core.FFmpeg
string filter = acceleration switch
{
HardwareAccelerationKind.Qsv => "deinterlace_qsv",
HardwareAccelerationKind.Nvenc => "", // TODO: yadif_cuda support in docker
HardwareAccelerationKind.Nvenc => "yadif_cuda",
HardwareAccelerationKind.Vaapi => "deinterlace_vaapi",
_ => "yadif=1"
};
@@ -156,8 +156,8 @@ namespace ErsatzTV.Core.FFmpeg
string filter = acceleration switch
{
HardwareAccelerationKind.Qsv => $"scale_qsv=w={size.Width}:h={size.Height}",
HardwareAccelerationKind.Nvenc when _pixelFormat == "yuv420p10le" =>
$"hwdownload,format=p010le,format=nv12,hwupload,scale_npp={size.Width}:{size.Height}",
HardwareAccelerationKind.Nvenc when _pixelFormat is "yuv420p10le" =>
$"hwupload_cuda,scale_cuda={size.Width}:{size.Height}",
HardwareAccelerationKind.Nvenc => $"scale_npp={size.Width}:{size.Height}",
HardwareAccelerationKind.Vaapi => $"scale_vaapi=format=nv12:w={size.Width}:h={size.Height}",
_ => $"scale={size.Width}:{size.Height}:flags=fast_bilinear"
@@ -180,7 +180,7 @@ namespace ErsatzTV.Core.FFmpeg
string format = acceleration switch
{
HardwareAccelerationKind.Vaapi => "format=nv12|vaapi",
HardwareAccelerationKind.Nvenc when _scaleToSize.IsNone && _pixelFormat == "yuv420p10le" =>
HardwareAccelerationKind.Nvenc when _pixelFormat == "yuv420p10le" =>
"format=p010le,format=nv12",
_ => "format=nv12"
};
@@ -206,6 +206,10 @@ namespace ErsatzTV.Core.FFmpeg
ChannelWatermarkLocation.BottomLeft => $"x={horizontalMargin}:y=H-h-{verticalMargin}",
ChannelWatermarkLocation.TopLeft => $"x={horizontalMargin}:y={verticalMargin}",
ChannelWatermarkLocation.TopRight => $"x=W-w-{horizontalMargin}:y={verticalMargin}",
ChannelWatermarkLocation.TopMiddle => $"x=(W-w)/2:y={verticalMargin}",
ChannelWatermarkLocation.RightMiddle => $"x=W-w-{horizontalMargin}:y=(H-h)/2",
ChannelWatermarkLocation.BottomMiddle => $"x=(W-w)/2:y=H-h-{verticalMargin}",
ChannelWatermarkLocation.LeftMiddle => $"x={horizontalMargin}:y=(H-h)/2",
_ => $"x=W-w-{horizontalMargin}:y=H-h-{verticalMargin}"
};
@@ -234,6 +238,8 @@ namespace ErsatzTV.Core.FFmpeg
_padToSize.IfSome(size => videoFilterQueue.Add($"pad={size.Width}:{size.Height}:(ow-iw)/2:(oh-ih)/2"));
string outputPixelFormat = null;
if (usesSoftwareFilters && acceleration != HardwareAccelerationKind.None &&
string.IsNullOrWhiteSpace(watermarkOverlay))
{
@@ -245,6 +251,19 @@ namespace ErsatzTV.Core.FFmpeg
videoFilterQueue.Add(upload);
}
if (!usesSoftwareFilters && string.IsNullOrWhiteSpace(watermarkOverlay))
{
switch (acceleration, _videoEncoder, _pixelFormat)
{
case (HardwareAccelerationKind.Nvenc, "h264_nvenc", "yuv420p10le"):
outputPixelFormat = "yuv420p";
break;
case (HardwareAccelerationKind.Nvenc, "h264_nvenc", "yuv444p10le"):
outputPixelFormat = "yuv444p";
break;
}
}
bool hasAudioFilters = audioFilterQueue.Any();
if (hasAudioFilters)
{
@@ -307,7 +326,7 @@ namespace ErsatzTV.Core.FFmpeg
var filterResult = complexFilter.ToString();
return string.IsNullOrWhiteSpace(filterResult)
? Option<FFmpegComplexFilter>.None
: new FFmpegComplexFilter(filterResult, videoLabel, audioLabel);
: new FFmpegComplexFilter(filterResult, videoLabel, audioLabel, outputPixelFormat);
}
}
}
@@ -102,6 +102,22 @@ namespace ErsatzTV.Core.FFmpeg
result.VideoCodec = ffmpegProfile.VideoCodec;
result.VideoBitrate = ffmpegProfile.VideoBitrate;
result.VideoBufferSize = ffmpegProfile.VideoBufferSize;
result.VideoDecoder =
(result.HardwareAcceleration, videoStream.Codec, videoStream.PixelFormat) switch
{
(HardwareAccelerationKind.Nvenc, "h264", "yuv420p10le" or "yuv444p" or "yuv444p10le") =>
"h264",
(HardwareAccelerationKind.Nvenc, "hevc", "yuv444p" or "yuv444p10le") => "hevc",
(HardwareAccelerationKind.Nvenc, "h264", _) => "h264_cuvid",
(HardwareAccelerationKind.Nvenc, "hevc", _) => "hevc_cuvid",
(HardwareAccelerationKind.Nvenc, "mpeg2video", _) => "mpeg2_cuvid",
(HardwareAccelerationKind.Nvenc, "mpeg4", _) => "mpeg4_cuvid",
(HardwareAccelerationKind.Qsv, "h264", _) => "h264_qsv",
(HardwareAccelerationKind.Qsv, "hevc", _) => "hevc_qsv",
(HardwareAccelerationKind.Qsv, "mpeg2video", _) => "mpeg2_qsv",
_ => null
};
}
else
{
+55 -24
View File
@@ -32,13 +32,6 @@ namespace ErsatzTV.Core.FFmpeg
{
internal class FFmpegProcessBuilder
{
private static readonly Dictionary<string, string> QsvMap = new()
{
{ "h264", "h264_qsv" },
{ "hevc", "hevc_qsv" },
{ "mpeg2video", "mpeg2_qsv" }
};
private readonly List<string> _arguments = new();
private readonly string _ffmpegPath;
private readonly bool _saveReports;
@@ -46,7 +39,9 @@ namespace ErsatzTV.Core.FFmpeg
private FFmpegComplexFilterBuilder _complexFilterBuilder = new();
private bool _isConcat;
private VaapiDriver _vaapiDriver;
private string _vaapiDevice;
private HardwareAccelerationKind _hwAccel;
private string _outputPixelFormat;
public FFmpegProcessBuilder(string ffmpegPath, bool saveReports, ILogger logger)
{
@@ -55,13 +50,17 @@ namespace ErsatzTV.Core.FFmpeg
_logger = logger;
}
public FFmpegProcessBuilder WithVaapiDriver(Option<VaapiDriver> maybeVaapiDriver)
public FFmpegProcessBuilder WithVaapiDriver(VaapiDriver vaapiDriver, string vaapiDevice)
{
foreach (VaapiDriver vaapiDriver in maybeVaapiDriver)
if (vaapiDriver != VaapiDriver.Default)
{
_vaapiDriver = vaapiDriver;
}
_vaapiDevice = string.IsNullOrWhiteSpace(vaapiDevice)
? "/dev/dri/renderD128"
: vaapiDevice;
return this;
}
@@ -72,7 +71,7 @@ namespace ErsatzTV.Core.FFmpeg
return this;
}
public FFmpegProcessBuilder WithHardwareAcceleration(HardwareAccelerationKind hwAccel)
public FFmpegProcessBuilder WithHardwareAcceleration(HardwareAccelerationKind hwAccel, string pixelFormat, string encoder)
{
_hwAccel = hwAccel;
@@ -85,16 +84,24 @@ namespace ErsatzTV.Core.FFmpeg
_arguments.Add("qsv=qsv:MFX_IMPL_hw_any");
break;
case HardwareAccelerationKind.Nvenc:
string outputFormat = (encoder, pixelFormat) switch
{
("hevc_nvenc", "yuv420p10le") => "p010le",
("h264_nvenc", "yuv420p10le") => "p010le",
// ("hevc_nvenc", "yuv444p10le") => "p016le",
_ => "cuda"
};
_arguments.Add("-hwaccel");
_arguments.Add("cuda");
_arguments.Add("-hwaccel_output_format");
_arguments.Add("cuda");
_arguments.Add(outputFormat);
break;
case HardwareAccelerationKind.Vaapi:
_arguments.Add("-hwaccel");
_arguments.Add("vaapi");
_arguments.Add("-vaapi_device");
_arguments.Add("/dev/dri/renderD128");
_arguments.Add(_vaapiDevice);
_arguments.Add("-hwaccel_output_format");
_arguments.Add("vaapi");
break;
@@ -109,7 +116,14 @@ namespace ErsatzTV.Core.FFmpeg
{
if (realtimeOutput)
{
_arguments.Add("-re");
if (!_arguments.Contains("-re"))
{
_arguments.Add("-re");
}
}
else
{
_arguments.RemoveAll(s => s == "-re");
}
return this;
@@ -192,12 +206,12 @@ namespace ErsatzTV.Core.FFmpeg
return this;
}
public FFmpegProcessBuilder WithInputCodec(string input, HardwareAccelerationKind hwAccel, string codec, string pixelFormat)
public FFmpegProcessBuilder WithInputCodec(string input, string decoder, string codec, string pixelFormat)
{
if (hwAccel == HardwareAccelerationKind.Qsv && QsvMap.TryGetValue(codec, out string qsvCodec))
if (!string.IsNullOrWhiteSpace(decoder))
{
_arguments.Add("-c:v");
_arguments.Add(qsvCodec);
_arguments.Add(decoder);
}
_complexFilterBuilder = _complexFilterBuilder
@@ -310,8 +324,10 @@ namespace ErsatzTV.Core.FFmpeg
return this;
}
public FFmpegProcessBuilder WithHls(string channelNumber, MediaVersion mediaVersion)
public FFmpegProcessBuilder WithHls(string channelNumber, MediaVersion mediaVersion, bool startAtZero)
{
const int SEGMENT_SECONDS = 4;
if (!int.TryParse(mediaVersion.RFrameRate, out int frameRate))
{
string[] split = (mediaVersion.RFrameRate ?? string.Empty).Split("/");
@@ -329,15 +345,17 @@ namespace ErsatzTV.Core.FFmpeg
_arguments.AddRange(
new[]
{
"-g", $"{frameRate * 2}",
"-keyint_min", $"{frameRate * 2}",
// "-force_key_frames",
// "expr:gte(t,n_forced*2)",
"-use_wallclock_as_timestamps", "1",
"-g", $"{frameRate * SEGMENT_SECONDS}",
"-keyint_min", $"{frameRate * SEGMENT_SECONDS}",
"-force_key_frames", $"expr:gte(t,n_forced*{SEGMENT_SECONDS})",
"-f", "hls",
"-hls_time", "2",
"-hls_list_size", "10",
"-hls_time", $"{SEGMENT_SECONDS}",
"-hls_list_size", "0",
"-segment_list_flags", "+live",
"-hls_flags", "delete_segments+program_date_time+append_list+discont_start+omit_endlist",
"-hls_segment_filename",
Path.Combine(FileSystemLayout.TranscodeFolder, channelNumber, "live%06d.ts"),
"-hls_flags", "program_date_time+append_list+omit_endlist+independent_segments",
Path.Combine(FileSystemLayout.TranscodeFolder, channelNumber, "live.m3u8")
});
@@ -353,6 +371,11 @@ namespace ErsatzTV.Core.FFmpeg
"-sc_threshold", "0" // disable scene change detection
};
if (!string.IsNullOrWhiteSpace(_outputPixelFormat))
{
arguments.AddRange(new[] { "-pix_fmt", _outputPixelFormat });
}
string[] videoBitrateArgs = playbackSettings.VideoBitrate.Match(
bitrate =>
new[]
@@ -460,6 +483,11 @@ namespace ErsatzTV.Core.FFmpeg
_arguments.Add(filter.ComplexFilter);
videoLabel = filter.VideoLabel;
audioLabel = filter.AudioLabel;
if (!string.IsNullOrWhiteSpace(filter.PixelFormat))
{
_outputPixelFormat = filter.PixelFormat;
}
});
_arguments.Add("-map");
@@ -499,6 +527,9 @@ namespace ErsatzTV.Core.FFmpeg
case VaapiDriver.iHD:
startInfo.EnvironmentVariables.Add("LIBVA_DRIVER_NAME", "iHD");
break;
case VaapiDriver.RadeonSI:
startInfo.EnvironmentVariables.Add("LIBVA_DRIVER_NAME", "radeonsi");
break;
}
}
+9 -5
View File
@@ -39,7 +39,10 @@ namespace ErsatzTV.Core.FFmpeg
DateTimeOffset start,
DateTimeOffset now,
Option<ChannelWatermark> globalWatermark,
Option<VaapiDriver> maybeVaapiDriver)
VaapiDriver vaapiDriver,
string vaapiDevice,
bool startAtZero,
bool hlsRealtime)
{
MediaStream videoStream = await _ffmpegStreamSelector.SelectVideoStream(channel, version);
Option<MediaStream> maybeAudioStream = await _ffmpegStreamSelector.SelectAudioStream(channel, version);
@@ -62,13 +65,13 @@ namespace ErsatzTV.Core.FFmpeg
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath, saveReports, _logger)
.WithThreads(playbackSettings.ThreadCount)
.WithHardwareAcceleration(playbackSettings.HardwareAcceleration)
.WithVaapiDriver(maybeVaapiDriver)
.WithVaapiDriver(vaapiDriver, vaapiDevice)
.WithHardwareAcceleration(playbackSettings.HardwareAcceleration, videoStream.PixelFormat, playbackSettings.VideoCodec)
.WithQuiet()
.WithFormatFlags(playbackSettings.FormatFlags)
.WithRealtimeOutput(playbackSettings.RealtimeOutput)
.WithSeek(playbackSettings.StreamSeek)
.WithInputCodec(path, playbackSettings.HardwareAcceleration, videoStream.Codec, videoStream.PixelFormat)
.WithInputCodec(path, playbackSettings.VideoDecoder, videoStream.Codec, videoStream.PixelFormat)
.WithWatermark(maybeWatermark, maybeWatermarkPath, channel.FFmpegProfile.Resolution, isAnimated)
.WithVideoTrackTimeScale(playbackSettings.VideoTrackTimeScale)
.WithAlignedAudio(playbackSettings.AudioDuration)
@@ -118,7 +121,8 @@ namespace ErsatzTV.Core.FFmpeg
{
// HLS needs to segment and generate playlist
case StreamingMode.HttpLiveStreamingSegmenter:
return builder.WithHls(channel.Number, version)
return builder.WithHls(channel.Number, version, startAtZero)
.WithRealtimeOutput(hlsRealtime)
.Build();
default:
return builder.WithFormat("mpegts")
+6 -79
View File
@@ -1,96 +1,23 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Collections.Concurrent;
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)
public FFmpegSegmenterService()
{
if (Processes.TryGetValue(channelNumber, out ProcessAndToken processAndToken))
{
if (!processAndToken.Process.HasExited || !Processes.TryRemove(
new KeyValuePair<string, ProcessAndToken>(channelNumber, processAndToken)))
{
return true;
}
}
return false;
SessionWorkers = new ConcurrentDictionary<string, IHlsSessionWorker>();
}
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 ConcurrentDictionary<string, IHlsSessionWorker> SessionWorkers { get; }
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);
}
worker?.Touch();
}
}
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);
}
}
+111
View File
@@ -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 = int.Parse(lines[i + 2].Replace("live", string.Empty).Split('.')[0]);
output.AppendLine("#EXTM3U");
output.AppendLine("#EXT-X-VERSION:6");
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, int Sequence, string Playlist);
}
+2 -1
View File
@@ -7,6 +7,7 @@ namespace ErsatzTV.Core.FFmpeg
{
Default = 0,
iHD = 1,
i965 = 2
i965 = 2,
RadeonSI = 3
}
}
@@ -1,14 +1,11 @@
using System.Diagnostics;
using LanguageExt;
using System.Collections.Concurrent;
namespace ErsatzTV.Core.Interfaces.FFmpeg
{
public interface IFFmpegSegmenterService
{
bool ProcessExistsForChannel(string channelNumber);
bool TryAdd(string channelNumber, Process process);
ConcurrentDictionary<string, IHlsSessionWorker> SessionWorkers { get; }
void TouchChannel(string channelNumber);
void CleanUpSessions();
Unit KillAll();
}
}
@@ -0,0 +1,10 @@
using System;
namespace ErsatzTV.Core.Interfaces.FFmpeg
{
public interface IHlsSessionWorker
{
DateTimeOffset PlaylistStart { get; }
void Touch();
}
}
@@ -8,10 +8,6 @@
</PropertyGroup>
<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="Lucene.Net" Version="4.8.0-beta00014" />
<PackageReference Include="Lucene.Net.Analysis.Common" Version="4.8.0-beta00014" />
@@ -11,6 +11,7 @@ namespace ErsatzTV.Infrastructure.Health.Checks
{
public class FFmpegVersionHealthCheck : BaseHealthCheck, IFFmpegVersionHealthCheck
{
private const string BundledVersion = "N-104321-ga742ba60f1";
private readonly IConfigElementRepository _configElementRepository;
public FFmpegVersionHealthCheck(IConfigElementRepository configElementRepository)
@@ -72,17 +73,17 @@ namespace ErsatzTV.Infrastructure.Health.Checks
{
if (version.StartsWith("3."))
{
return FailResult($"{app} version {version} is too old; please install 4.3!");
return FailResult($"{app} version {version} is too old; please install 4.4!");
}
if (version.StartsWith("4.4"))
if (version.StartsWith("4.3"))
{
return FailResult($"{app} version 4.4 is known to have issues; please install 4.3!");
return WarningResult($"{app} version 4.4 is now supported, please upgrade from 4.3!");
}
if (!version.StartsWith("4.3"))
if (!version.StartsWith("4.4") && version != BundledVersion)
{
return WarningResult($"{app} version {version} is unexpected and may have problems; please install 4.3!");
return WarningResult($"{app} version {version} is unexpected and may have problems; please install 4.4!");
}
return None;
@@ -1,45 +1,47 @@
using System;
using System.Reflection;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Health;
using ErsatzTV.Core.Health.Checks;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Infrastructure.Health.Checks
{
public class VaapiDriverHealthCheck : BaseHealthCheck, IVaapiDriverHealthCheck
{
private readonly IConfigElementRepository _configElementRepository;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public VaapiDriverHealthCheck(IConfigElementRepository configElementRepository) =>
_configElementRepository = configElementRepository;
public VaapiDriverHealthCheck(IDbContextFactory<TvContext> dbContextFactory)
{
_dbContextFactory = dbContextFactory;
}
protected override string Title => "VAAPI Driver";
public async Task<HealthCheckResult> Check()
{
string version = Assembly.GetEntryAssembly()?.GetCustomAttribute<AssemblyInformationalVersionAttribute>()
?.InformationalVersion ?? "unknown";
if (!version.Contains("docker", StringComparison.OrdinalIgnoreCase) ||
!version.Contains("vaapi", StringComparison.OrdinalIgnoreCase))
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
List<FFmpegProfile> profiles = await dbContext.FFmpegProfiles
.Filter(p => p.HardwareAcceleration == HardwareAccelerationKind.Vaapi)
.ToListAsync();
if (profiles.Count == 0)
{
return NotApplicableResult();
}
Option<int> maybeVaapiDriver =
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegVaapiDriver);
var vaapiDriver = (VaapiDriver)await maybeVaapiDriver.IfNoneAsync(0);
if (vaapiDriver == VaapiDriver.Default)
{
return InfoResult(
"Settings > FFmpeg Settings > VAAPI Driver is set to Default; selecting iHD (Gen 8+) or i965 (up to Gen 9) may offer better performance");
}
return OkResult();
var defaultProfiles = profiles
.Filter(p => p.VaapiDriver == VaapiDriver.Default)
.ToList();
return defaultProfiles.Any()
? InfoResult(
$"{defaultProfiles.Count} FFmpeg Profile{(defaultProfiles.Count > 1 ? "s are" : " is")} set to use Default VAAPI Driver; selecting iHD (Gen 8+) or i965 (up to Gen 9) may offer better performance with Intel iGPU")
: OkResult();
}
}
}
@@ -0,0 +1,34 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_FFmpegProfileVaapiDriverVaapiDevice : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "VaapiDevice",
table: "FFmpegProfile",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "VaapiDriver",
table: "FFmpegProfile",
type: "INTEGER",
nullable: false,
defaultValue: 0);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "VaapiDevice",
table: "FFmpegProfile");
migrationBuilder.DropColumn(
name: "VaapiDriver",
table: "FFmpegProfile");
}
}
}
@@ -0,0 +1,19 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Update_FFmpegProfileVaapiDriverVaapiDevice : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("UPDATE FFmpegProfile SET VaapiDevice = '/dev/dri/renderD128'");
migrationBuilder.Sql(
"UPDATE FFmpegProfile SET VaapiDriver = (SELECT IFNULL((SELECT Value AS A FROM ConfigElement WHERE Key = 'ffmpeg.vaapi_driver'), 0))");
migrationBuilder.Sql("DELETE FROM ConfigElement WHERE Key = 'ffmpeg.vaapi_driver'");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
@@ -482,6 +482,12 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<bool>("Transcode")
.HasColumnType("INTEGER");
b.Property<string>("VaapiDevice")
.HasColumnType("TEXT");
b.Property<int>("VaapiDriver")
.HasColumnType("INTEGER");
b.Property<int>("VideoBitrate")
.HasColumnType("INTEGER");
@@ -52,6 +52,7 @@ namespace ErsatzTV.Infrastructure.Search
private const string DirectorField = "director";
private const string WriterField = "writer";
private const string TraktListField = "trakt_list";
private const string AlbumField = "album";
public const string MovieType = "movie";
public const string ShowType = "show";
@@ -74,7 +75,7 @@ namespace ErsatzTV.Infrastructure.Search
_initialized = false;
}
public int Version => 16;
public int Version => 17;
public Task<bool> Initialize(ILocalFileSystem localFileSystem)
{
@@ -624,6 +625,11 @@ namespace ErsatzTV.Infrastructure.Search
Field.Store.NO));
}
if (!string.IsNullOrWhiteSpace(metadata.Album))
{
doc.Add(new TextField(AlbumField, metadata.Album, Field.Store.NO));
}
if (!string.IsNullOrWhiteSpace(metadata.Plot))
{
doc.Add(new TextField(PlotField, metadata.Plot ?? string.Empty, Field.Store.NO));
+1
View File
@@ -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/=Radeon/@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>
+27 -21
View File
@@ -1,4 +1,6 @@
using System.Threading.Tasks;
using System;
using System.Diagnostics;
using System.Threading.Tasks;
using ErsatzTV.Application.Streaming.Queries;
using ErsatzTV.Extensions;
using LanguageExt;
@@ -31,25 +33,29 @@ namespace ErsatzTV.Controllers
string channelNumber,
[FromQuery]
string mode = "mixed") =>
_mediator.Send(new GetPlayoutItemProcessByChannelNumber(channelNumber, mode, false)).Map(
result =>
result.Match<IActionResult>(
process =>
{
_logger.LogDebug(
"ffmpeg arguments {FFmpegArguments}",
string.Join(" ", process.StartInfo.ArgumentList));
process.Start();
return new FileStreamResult(process.StandardOutput.BaseStream, "video/mp2t");
},
error =>
{
_logger.LogError(
"Failed to create stream for channel {ChannelNumber}: {Error}",
channelNumber,
error.Value);
return BadRequest(error.Value);
}
));
_mediator.Send(
new GetPlayoutItemProcessByChannelNumber(channelNumber, mode, DateTimeOffset.Now, false, true))
.Map(
result =>
result.Match<IActionResult>(
processModel =>
{
Process process = processModel.Process;
_logger.LogDebug(
"ffmpeg arguments {FFmpegArguments}",
string.Join(" ", process.StartInfo.ArgumentList));
process.Start();
return new FileStreamResult(process.StandardOutput.BaseStream, "video/mp2t");
},
error =>
{
_logger.LogError(
"Failed to create stream for channel {ChannelNumber}: {Error}",
channelNumber,
error.Value);
return BadRequest(error.Value);
}
));
}
}
+34 -9
View File
@@ -1,6 +1,7 @@
using System.Threading.Channels;
using System;
using System.Diagnostics;
using System.IO;
using System.Threading.Tasks;
using ErsatzTV.Application;
using ErsatzTV.Application.Channels.Queries;
using ErsatzTV.Application.Images;
using ErsatzTV.Application.Images.Queries;
@@ -9,6 +10,8 @@ using ErsatzTV.Application.Streaming.Queries;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Iptv;
using LanguageExt;
using MediatR;
@@ -22,18 +25,18 @@ namespace ErsatzTV.Controllers
[ApiExplorerSettings(IgnoreApi = true)]
public class IptvController : ControllerBase
{
private readonly ChannelWriter<IFFmpegWorkerRequest> _channel;
private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
private readonly ILogger<IptvController> _logger;
private readonly IMediator _mediator;
public IptvController(
IMediator mediator,
ILogger<IptvController> logger,
ChannelWriter<IFFmpegWorkerRequest> channel)
IFFmpegSegmenterService ffmpegSegmenterService)
{
_mediator = mediator;
_logger = logger;
_channel = channel;
_ffmpegSegmenterService = ffmpegSegmenterService;
}
[HttpGet("iptv/channels.m3u")]
@@ -53,8 +56,10 @@ namespace ErsatzTV.Controllers
_mediator.Send(new GetConcatProcessByChannelNumber(Request.Scheme, Request.Host.ToString(), channelNumber))
.Map(
result => result.Match<IActionResult>(
process =>
processModel =>
{
Process process = processModel.Process;
_logger.LogInformation("Starting ts stream for channel {ChannelNumber}", channelNumber);
// _logger.LogDebug(
// "ffmpeg concat arguments {FFmpegArguments}",
@@ -64,6 +69,26 @@ namespace ErsatzTV.Controllers
},
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")]
public async Task<IActionResult> GetHttpLiveStreamingVideo(
string channelNumber,
@@ -75,13 +100,13 @@ namespace ErsatzTV.Controllers
case "segmenter":
Either<BaseError, Unit> result = await _mediator.Send(new StartFFmpegSession(channelNumber, false));
return result.Match<IActionResult>(
_ => Redirect($"/iptv/session/{channelNumber}/live.m3u8"),
_ => Redirect($"/iptv/session/{channelNumber}/hls.m3u8"),
error =>
{
switch (error)
{
case ChannelHasProcess:
return RedirectPreserveMethod($"/iptv/session/{channelNumber}/live.m3u8");
case ChannelSessionAlreadyActive:
return RedirectPreserveMethod($"/iptv/session/{channelNumber}/hls.m3u8");
default:
_logger.LogWarning(
"Failed to start segmenter for channel {ChannelNumber}: {Error}",
-4
View File
@@ -13,10 +13,6 @@
</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="FluentValidation" Version="10.3.3" />
<PackageReference Include="FluentValidation.AspNetCore" Version="10.3.3" />
+7
View File
@@ -139,6 +139,13 @@
<MudCardContent Class="ml-3">
<div style="display: flex; flex-direction: column; height: 100%">
<MudText Typo="Typo.h4">@musicVideo.Title</MudText>
@if (!string.IsNullOrWhiteSpace(musicVideo.Album))
{
<div style="display: flex; flex-direction: row">
<MudText GutterBottom="true">Album:&nbsp;</MudText>
<MudLink Href="@(@$"album:""{musicVideo.Album}""".GetRelativeSearchQuery())">@musicVideo.Album</MudLink>
</div>
}
<MudText Style="flex-grow: 1">@musicVideo.Plot</MudText>
<div class="mt-6">
<MudButton Variant="Variant.Filled"
+28
View File
@@ -5,10 +5,14 @@
@using ErsatzTV.Application.FFmpegProfiles
@using ErsatzTV.Application.FFmpegProfiles.Commands
@using ErsatzTV.Application.FFmpegProfiles.Queries
@using ErsatzTV.Core.FFmpeg
@using Microsoft.AspNetCore.Components
@using Microsoft.Extensions.Caching.Memory
@inject NavigationManager _navigationManager
@inject ILogger<FFmpegEditor> _logger
@inject ISnackbar _snackbar
@inject IMediator _mediator
@inject IMemoryCache _memoryCache
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<EditForm EditContext="_editContext" OnSubmit="@HandleSubmitAsync">
@@ -58,6 +62,22 @@
}
</MudSelect>
</MudElement>
<MudElement HtmlTag="div" Class="mt-3">
<MudSelect Disabled="@(_model.HardwareAcceleration != HardwareAccelerationKind.Vaapi)" Label="VAAPI Driver" @bind-Value="_model.VaapiDriver" For="@(() => _model.VaapiDriver)">
@foreach (VaapiDriver driver in Enum.GetValues<VaapiDriver>())
{
<MudSelectItem Value="@driver">@driver</MudSelectItem>
}
</MudSelect>
</MudElement>
<MudElement HtmlTag="div" Class="mt-3">
<MudSelect Disabled="@(_model.HardwareAcceleration != HardwareAccelerationKind.Vaapi)" Label="VAAPI Device" @bind-Value="_model.VaapiDevice" For="@(() => _model.VaapiDevice)">
@foreach (string device in _vaapiDevices)
{
<MudSelectItem Value="@device">@device</MudSelectItem>
}
</MudSelect>
</MudElement>
<MudElement HtmlTag="div" Class="mt-3">
<MudCheckBox Disabled="@(!_model.Transcode)" Label="Normalize Video" @bind-Checked="@_model.NormalizeVideo" For="@(() => _model.NormalizeVideo)"/>
</MudElement>
@@ -107,6 +127,7 @@
private ValidationMessageStore _messageStore;
private List<ResolutionViewModel> _resolutions;
private List<string> _vaapiDevices;
protected override async Task OnParametersSetAsync()
{
@@ -126,6 +147,13 @@
_editContext = new EditContext(_model);
_messageStore = new ValidationMessageStore(_editContext);
if (!_memoryCache.TryGetValue("ffmpeg.render_devices", out List<string> vaapiDevices))
{
vaapiDevices = new List<string> { "/dev/dri/renderD128" };
}
_vaapiDevices = vaapiDevices.OrderBy(s => s).ToList();
}
private bool IsEdit => Id != 0;
+22 -6
View File
@@ -58,12 +58,24 @@
<MudSelectItem T="int?" Value="@watermark.Id">@watermark.Name</MudSelectItem>
}
</MudSelect>
<MudSelect Class="mt-3" Label="VAAPI Driver" @bind-Value="_ffmpegSettings.VaapiDriver" For="@(() => _ffmpegSettings.VaapiDriver)">
@foreach (VaapiDriver driver in Enum.GetValues<VaapiDriver>())
{
<MudSelectItem Value="@driver">@driver</MudSelectItem>
}
</MudSelect>
<MudElement HtmlTag="div" Class="mt-3">
<MudTextField T="int"
Label="HLS Segmenter Idle Timeout"
@bind-Value="_ffmpegSettings.HlsSegmenterIdleTimeout"
Validation="@(new Func<int, string>(ValidateHlsSegmenterIdleTimeout))"
Required="true"
RequiredError="HLS Segmenter idle timeout is required!"
Adornment="Adornment.End"
AdornmentText="Seconds"/>
</MudElement>
<MudElement HtmlTag="div" Class="mt-3">
<MudTextField T="int"
Label="Work-Ahead HLS Segmenter Limit"
@bind-Value="_ffmpegSettings.WorkAheadSegmenterLimit"
Validation="@(new Func<int, string>(ValidateWorkAheadSegmenterLimit))"
Required="true"
RequiredError="Work-ahead HLS Segmenter limit is required!"/>
</MudElement>
</MudForm>
</MudCardContent>
<MudCardActions>
@@ -169,6 +181,10 @@
private static string ValidatePlayoutDaysToBuild(int daysToBuild) => daysToBuild <= 0 ? "Days to build must be greater than zero" : null;
private static string ValidateHlsSegmenterIdleTimeout(int idleTimeout) => idleTimeout < 30 ? "HLS Segmenter idle timeout must be greater than or equal to 30" : null;
private static string ValidateWorkAheadSegmenterLimit(int limit) => limit < 0 ? "Work-Ahead HLS Segmenter limit must be greater than or equal to 0" : null;
private async Task LoadFFmpegProfilesAsync() =>
_ffmpegProfiles = await _mediator.Send(new GetAllFFmpegProfiles());
+5 -1
View File
@@ -56,9 +56,13 @@
For="@(() => _model.Location)"
Disabled="@(_model.Mode == ChannelWatermarkMode.None)">
<MudSelectItem Value="@(ChannelWatermarkLocation.BottomRight)">Bottom Right</MudSelectItem>
<MudSelectItem Value="@(ChannelWatermarkLocation.BottomMiddle)">Bottom Middle</MudSelectItem>
<MudSelectItem Value="@(ChannelWatermarkLocation.BottomLeft)">Bottom Left</MudSelectItem>
<MudSelectItem Value="@(ChannelWatermarkLocation.TopRight)">Top Right</MudSelectItem>
<MudSelectItem Value="@(ChannelWatermarkLocation.LeftMiddle)">Left Middle</MudSelectItem>
<MudSelectItem Value="@(ChannelWatermarkLocation.TopLeft)">Top Left</MudSelectItem>
<MudSelectItem Value="@(ChannelWatermarkLocation.TopMiddle)">Top Middle</MudSelectItem>
<MudSelectItem Value="@(ChannelWatermarkLocation.TopRight)">Top Right</MudSelectItem>
<MudSelectItem Value="@(ChannelWatermarkLocation.RightMiddle)">Right Middle</MudSelectItem>
</MudSelect>
<MudGrid Class="mt-3" Style="align-items: start" Justify="Justify.Center">
<MudItem xs="6">
@@ -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);
}
}
}
}
+1 -58
View File
@@ -1,16 +1,11 @@
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;
@@ -21,20 +16,17 @@ 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;
@@ -49,7 +41,7 @@ namespace ErsatzTV.Services
try
{
using IServiceScope scope = _serviceScopeFactory.CreateScope();
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
// IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
switch (request)
{
@@ -58,52 +50,6 @@ namespace ErsatzTV.Services
{
_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;
}
@@ -113,9 +59,6 @@ namespace ErsatzTV.Services
_logger.LogWarning(ex, "Failed to handle ffmpeg worker request");
}
}
// kill any running processes after cancellation
_ffmpegSegmenterService.KillAll();
}
}
}
@@ -1,10 +1,13 @@
using System.Runtime.InteropServices;
using System.Linq;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Runtime;
using ErsatzTV.Infrastructure.Data;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
@@ -36,6 +39,19 @@ namespace ErsatzTV.Services.RunOnce
IConfigElementRepository repo = scope.ServiceProvider.GetRequiredService<IConfigElementRepository>();
await repo.Upsert(ConfigElementKey.FFmpegSaveReports, false);
}
if (runtimeInfo != null && runtimeInfo.IsOSPlatform(OSPlatform.Linux) &&
System.IO.Directory.Exists("/dev/dri"))
{
ILocalFileSystem localFileSystem = scope.ServiceProvider.GetRequiredService<ILocalFileSystem>();
IMemoryCache memoryCache = scope.ServiceProvider.GetRequiredService<IMemoryCache>();
var devices = localFileSystem.ListFiles("/dev/dri")
.Filter(s => s.StartsWith("/dev/dri/render"))
.ToList();
memoryCache.Set("ffmpeg.render_devices", devices);
}
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+2 -1
View File
@@ -7,6 +7,7 @@ using Blazored.LocalStorage;
using Dapper;
using ErsatzTV.Application;
using ErsatzTV.Application.Channels.Queries;
using ErsatzTV.Application.Streaming;
using ErsatzTV.Application.Streaming.Commands;
using ErsatzTV.Core;
using ErsatzTV.Core.Emby;
@@ -300,6 +301,7 @@ namespace ErsatzTV
services.AddScoped<IPlexPathReplacementService, PlexPathReplacementService>();
services.AddScoped<IFFmpegStreamSelector, FFmpegStreamSelector>();
services.AddScoped<FFmpegProcessService>();
services.AddScoped<HlsSessionWorker>();
services.AddScoped<IGitHubApiClient, GitHubApiClient>();
services.AddScoped<IHtmlSanitizer, HtmlSanitizer>(
_ =>
@@ -326,7 +328,6 @@ namespace ErsatzTV
services.AddHostedService<WorkerService>();
services.AddHostedService<SchedulerService>();
services.AddHostedService<FFmpegWorkerService>();
services.AddHostedService<FFmpegSchedulerService>();
}
private void AddChannel<TMessageType>(IServiceCollection services)
@@ -2,6 +2,7 @@
using ErsatzTV.Application.FFmpegProfiles.Commands;
using ErsatzTV.Application.Resolutions;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
namespace ErsatzTV.ViewModels
{
@@ -27,6 +28,8 @@ namespace ErsatzTV.ViewModels
ThreadCount = viewModel.ThreadCount;
Transcode = viewModel.Transcode;
HardwareAcceleration = viewModel.HardwareAcceleration;
VaapiDriver = viewModel.VaapiDriver;
VaapiDevice = viewModel.VaapiDevice;
VideoBitrate = viewModel.VideoBitrate;
VideoBufferSize = viewModel.VideoBufferSize;
VideoCodec = viewModel.VideoCodec;
@@ -46,6 +49,8 @@ namespace ErsatzTV.ViewModels
public int ThreadCount { get; set; }
public bool Transcode { get; set; }
public HardwareAccelerationKind HardwareAcceleration { get; set; }
public VaapiDriver VaapiDriver { get; set; }
public string VaapiDevice { get; set; }
public int VideoBitrate { get; set; }
public int VideoBufferSize { get; set; }
public string VideoCodec { get; set; }
@@ -56,6 +61,8 @@ namespace ErsatzTV.ViewModels
ThreadCount,
Transcode,
HardwareAcceleration,
VaapiDriver,
VaapiDevice,
Resolution.Id,
NormalizeVideo,
VideoCodec,
@@ -77,6 +84,8 @@ namespace ErsatzTV.ViewModels
ThreadCount,
Transcode,
HardwareAcceleration,
VaapiDriver,
VaapiDevice,
Resolution.Id,
NormalizeVideo,
VideoCodec,
+1 -1
View File
@@ -1,6 +1,6 @@
FROM mcr.microsoft.com/dotnet/aspnet:5.0-focal-amd64 AS dotnet-runtime
FROM jrottenberg/ffmpeg:4.3-ubuntu2004 AS runtime-base
FROM jasongdove/ffmpeg:4.4-ubuntu2004 AS runtime-base
COPY --from=dotnet-runtime /usr/share/dotnet /usr/share/dotnet
RUN apt-get update && DEBIAN_FRONTEND="noninteractive" apt-get install -y libicu-dev tzdata
+1 -1
View File
@@ -23,7 +23,7 @@ ARG INFO_VERSION="unknown"
RUN dotnet publish -c release -o /app -r linux-x64 --self-contained false --no-restore /p:InformationalVersion=${INFO_VERSION}
# final stage/image
FROM jasongdove/ffmpeg:4.3-nvidia1804 AS runtime-base
FROM jasongdove/ffmpeg:4.4-nvidia2004 AS runtime-base
WORKDIR /app
EXPOSE 8409
COPY --from=build /app ./
+1 -1
View File
@@ -1,6 +1,6 @@
FROM mcr.microsoft.com/dotnet/aspnet:5.0-focal-amd64 AS dotnet-runtime
FROM jrottenberg/ffmpeg:4.3-nvidia1804 AS runtime-base
FROM jasongdove/ffmpeg-base:4.4-nvidia2004 AS runtime-base
COPY --from=dotnet-runtime /usr/share/dotnet /usr/share/dotnet
RUN apt-get update \
&& DEBIAN_FRONTEND="noninteractive" apt-get install -y libicu-dev tzdata \
+1 -1
View File
@@ -23,7 +23,7 @@ ARG INFO_VERSION="unknown"
RUN dotnet publish -c release -o /app -r linux-x64 --self-contained false --no-restore /p:InformationalVersion=${INFO_VERSION}
# final stage/image
FROM jasongdove/ffmpeg:4.3-vaapi2004 AS runtime-base
FROM jasongdove/ffmpeg:4.4-vaapi2004 AS runtime-base
WORKDIR /app
EXPOSE 8409
COPY --from=build /app ./
+3 -2
View File
@@ -1,6 +1,6 @@
FROM mcr.microsoft.com/dotnet/aspnet:5.0-focal-amd64 AS dotnet-runtime
FROM jrottenberg/ffmpeg:4.3-vaapi2004 AS runtime-base
FROM jasongdove/ffmpeg-base:4.4-vaapi2004 AS runtime-base
COPY --from=dotnet-runtime /usr/share/dotnet /usr/share/dotnet
RUN apt-get update && DEBIAN_FRONTEND="noninteractive" apt-get install -y libicu-dev \
tzdata \
@@ -12,6 +12,7 @@ RUN apt-get update && DEBIAN_FRONTEND="noninteractive" apt-get install -y libicu
build-essential \
cmake \
wget \
mesa-va-drivers \
&& mkdir /tmp/intel && cd /tmp/intel \
&& wget -O - https://github.com/intel/libva/archive/refs/tags/2.12.0.tar.gz | tar zxf - \
&& cd libva-2.12.0 \
@@ -36,4 +37,4 @@ RUN apt-get update && DEBIAN_FRONTEND="noninteractive" apt-get install -y libicu
&& apt autoremove -y \
&& rm -rf /tmp/intel \
&& rm -rf /var/lib/apt/lists/* \
&& mv /usr/lib/x86_64-linux-gnu/dri/i* /usr/local/lib/dri/
&& mv /usr/lib/x86_64-linux-gnu/dri/* /usr/local/lib/dri/
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

+3 -2
View File
@@ -10,5 +10,6 @@ Want to say thanks? Consider [sponsorship on GitHub](https://github.com/sponsors
1. [Install ErsatzTV](user-guide/install.md)
2. [Add Media Items](user-guide/add-media-items.md)
3. [Create Channels](user-guide/create-channels.md)
4. [Configure Clients](user-guide/configure-clients.md)
3. [Create Collections](user-guide/create-collections.md)
4. [Create Channels](user-guide/create-channels.md)
5. [Configure Clients](user-guide/configure-clients.md)
-23
View File
@@ -194,26 +194,3 @@ Click `Add Path Replacement` to add a new path replacement, and enter the `Emby
Click `Save Changes` after you have created all needed path replacements.
![Emby Path Replacements](../images/emby-media-source-path-replacements.png)
## Collections
ErsatzTV does not schedule individual media items; only collections of items can be scheduled.
To create a collection, find the media items you would like to include and select them by clicking the selection button in the top left of the poster:
![Poster Selection Button](../images/poster-selection-button.png)
Then, add to a collection by clicking the `Add To Collection` button at the top of the page.
![Add Selection To Collection](../images/add-selection-to-collection.png)
In the `Add To Collection` dialog, either select an existing collection for the items, or enter a new collection name to create a collection and add all of the selected items in a single step.
![Add To Collection Dialog](../images/add-to-collection-dialog.png)
---
Big Buck Bunny: (c) copyright 2008, Blender Foundation / [www.bigbuckbunny.org](https://www.bigbuckbunny.org)
Sintel: © copyright Blender Foundation | [www.sintel.org](https://www.sintel.org)
Tears of Steel: (CC) Blender Foundation | [mango.blender.org](https://mango.blender.org)
+65
View File
@@ -0,0 +1,65 @@
## Collection Types
ErsatzTV does not schedule individual media items; only collections of items can be scheduled. There are three (3) collection types:
- `Collection`: contains manually-added items
- `Smart Collection`: contains items from search results or imported Trakt Lists, and updates dynamically
- `Multi Collection`: contains other Collections and Smart Collections.
### Collections
Collections are created manually by adding individual media items to it. There is no restriction on media type. A collection can contain a single media type, eg. movies or any combination of media types.
The items to be added can be found by browsing your libraries or by using ErsatzTV's powerful Search feature. An empty collection can be created under `Lists` > `Collections` > `Add Collection`.
To create a collection, find the media items you would like to include and select them by clicking the selection button in the top left of the poster:
![Poster Selection Button](../images/poster-selection-button.png)
Then, add to a collection by clicking the `Add To Collection` button at the top of the page.
![Add Selection To Collection](../images/add-selection-to-collection.png)
In the `Add To Collection` dialog, either select an existing collection for the items, or enter a new collection name to create a collection and add all of the selected items in a single step.
![Add To Collection Dialog](../images/add-to-collection-dialog.png)
### Smart Collections
Smart Collections are created by saving searches or saving imported Trakt Lists.
#### From Searches
To create a smart collection, type a search query in the search box and press enter.
If the result shown meets your expectations, save as a Smart Collection by clicking the `Save As` button.
If it doesn't meet your expectations, refine the search query until it does and then save.
#### From Imported Trakt Lists
To import a Trakt List, navigate to the `Lists` > `Trakt Lists` page and click the `Add Trakt List` button. Trakt lists can be imported by url or by `user/list`.
- To re-download a Trakt List, simply add it again. There is no need to delete.
- Unmatched item details are written to the log.
- Trakt Lists can only be scheduled after being saved as a Smart Collection.
To see matching items from an imported Trakt list, click the search button on the row of the Trakt List.
![Trakt List Search](../images/trakt-list-search.png)
### Multi Collections
Multi Collections contain other Collections and/or Smart Collections. The primary purpose of these are to enable the scheduling of sets of Media Items in a controlled manner.
Here we are referring to Movie Boxsets, e.g. X-Men Collection, Star Trek Collection and Before... Collection as well as related movies and tv show groups such as Arrowverse, MARVEL Cinematic Universe, Worlds of DC and Battlestar Galactica (Reboot).
To Add a Multi Collection
- Navigate to `Lists` > `Collections` and click the `Add Multi Collection` button
- Give the Multi Collection a name
- Add collections and/or smart collections as required and click `Add Multi Collection` to save
---
Big Buck Bunny: (c) copyright 2008, Blender Foundation / [www.bigbuckbunny.org](https://www.bigbuckbunny.org)
Sintel: © copyright Blender Foundation | [www.sintel.org](https://www.sintel.org)
Tears of Steel: (CC) Blender Foundation | [mango.blender.org](https://mango.blender.org)
+1
View File
@@ -71,6 +71,7 @@ The following fields are available for searching artists:
The following fields are available for searching music videos:
- `title`: The music video title
- `album`: The music video album
- `genre`: The music video genre
- `library_name`: The name of the library that contains the music video
- `language`: The music video audio stream language
+1
View File
@@ -3,6 +3,7 @@ nav:
- 'Quick Start':
- 'Install ErsatzTV': 'user-guide/install.md'
- 'Add Media Items': 'user-guide/add-media-items.md'
- 'Create Collections': 'user-guide/create-collections.md'
- 'Create Channels': 'user-guide/create-channels.md'
- 'Configure Clients': 'user-guide/configure-clients.md'
- 'Advanced':