Compare commits
42
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be7311e620 | ||
|
|
03be372070 | ||
|
|
d196308ee9 | ||
|
|
3d68b0f055 | ||
|
|
37e32f06ad | ||
|
|
c43ca2837d | ||
|
|
992121f308 | ||
|
|
04adbfeffa | ||
|
|
1fc905c6ad | ||
|
|
4b5dff2159 | ||
|
|
2a5edf8214 | ||
|
|
69912c8cae | ||
|
|
fd3de2d82a | ||
|
|
6ba9404752 | ||
|
|
db080375c5 | ||
|
|
9abc7ad8b7 | ||
|
|
9e531a82d7 | ||
|
|
d84bd2b948 | ||
|
|
d7d3ec1235 | ||
|
|
742ac21ad7 | ||
|
|
819b55e21f | ||
|
|
cf5718c288 | ||
|
|
adc7982955 | ||
|
|
67a6f554d0 | ||
|
|
609df217ae | ||
|
|
d3086264c7 | ||
|
|
8cd9b23787 | ||
|
|
dc5c9e42ff | ||
|
|
2dd267e4db | ||
|
|
b069a21473 | ||
|
|
6c8813ce22 | ||
|
|
b5de5e2b7f | ||
|
|
4b7da4e468 | ||
|
|
ae8e795228 | ||
|
|
334781485d | ||
|
|
27fefa1b38 | ||
|
|
fc3175591e | ||
|
|
3363d2c9d7 | ||
|
|
1d5217fa84 | ||
|
|
904cdb8780 | ||
|
|
85fee64565 | ||
|
|
13cfb9728f |
+62
-1
@@ -5,6 +5,63 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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`)
|
||||
- This mode is intended to increase client compatibility and reduce issues at program boundaries
|
||||
- If you want the temporary transcode files to be located on a particular drive, the docker path is `/root/.local/share/etv-transcode`
|
||||
- Store frame rate with media statistics; this is needed to support HLS Segmenter
|
||||
- This requires re-ingesting statistics for all media items the first time this version is launched
|
||||
|
||||
### Changed
|
||||
- Use latest iHD driver (21.2.3 vs 20.1.1) in vaapi docker images
|
||||
|
||||
### Fixed
|
||||
- Add downsampling to support transcoding 10-bit HEVC content with the h264_vaapi encoder
|
||||
- Fix updating statistics when media items are replaced
|
||||
- Fix XMLTV generation when scheduled episode is missing metadata
|
||||
|
||||
## [0.0.62-alpha] - 2021-10-05
|
||||
### Added
|
||||
- Support IMDB ids from Plex libraries, which may improve Trakt matching for some items
|
||||
|
||||
### Fixed
|
||||
- Include Specials/Season 0 `episode-num` entry in XMLTV
|
||||
- Fix some transcoding edge cases with VAAPI and pixel formats `yuv420p10le`, `yuv444p10le` and `yuv444p`
|
||||
- Update Plex movie and episode paths when they are changed within Plex
|
||||
- Always use `libx264` software encoder for error messages
|
||||
|
||||
## [0.0.61-alpha] - 2021-09-30
|
||||
### Fixed
|
||||
- Revert nvenc/cuda filter change from v60
|
||||
@@ -634,7 +691,11 @@ 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.0.61-alpha...HEAD
|
||||
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.1.2-alpha...HEAD
|
||||
[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
|
||||
[0.0.60-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.59-alpha...v0.0.60-alpha
|
||||
[0.0.59-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.58-alpha...v0.0.59-alpha
|
||||
|
||||
@@ -28,6 +28,10 @@ namespace ErsatzTV.Application.Channels.Queries
|
||||
{
|
||||
switch (mode.ToLowerInvariant())
|
||||
{
|
||||
case "segmenter":
|
||||
channel.StreamingMode = StreamingMode.HttpLiveStreamingSegmenter;
|
||||
result.Add(channel);
|
||||
break;
|
||||
case "hls-direct":
|
||||
channel.StreamingMode = StreamingMode.HttpLiveStreamingDirect;
|
||||
result.Add(channel);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace ErsatzTV.Application
|
||||
{
|
||||
public interface IFFmpegWorkerRequest
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.Streaming.Commands
|
||||
{
|
||||
public record StartFFmpegSession(string ChannelNumber, bool StartAtZero) :
|
||||
MediatR.IRequest<Either<BaseError, Unit>>,
|
||||
IFFmpegWorkerRequest;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
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;
|
||||
|
||||
namespace ErsatzTV.Application.Streaming.Commands
|
||||
{
|
||||
public class StartFFmpegSessionHandler : MediatR.IRequestHandler<StartFFmpegSession, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly ILogger<StartFFmpegSessionHandler> _logger;
|
||||
private readonly IServiceScopeFactory _serviceScopeFactory;
|
||||
private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
|
||||
public StartFFmpegSessionHandler(
|
||||
ILocalFileSystem localFileSystem,
|
||||
ILogger<StartFFmpegSessionHandler> logger,
|
||||
IServiceScopeFactory serviceScopeFactory,
|
||||
IFFmpegSegmenterService ffmpegSegmenterService,
|
||||
IConfigElementRepository configElementRepository)
|
||||
{
|
||||
_localFileSystem = localFileSystem;
|
||||
_logger = logger;
|
||||
_serviceScopeFactory = serviceScopeFactory;
|
||||
_ffmpegSegmenterService = ffmpegSegmenterService;
|
||||
_configElementRepository = configElementRepository;
|
||||
}
|
||||
|
||||
public Task<Either<BaseError, Unit>> Handle(StartFFmpegSession request, CancellationToken cancellationToken) =>
|
||||
Validate(request)
|
||||
.MapT(_ => StartProcess(request))
|
||||
// this weirdness is needed to maintain the error type (.ToEitherAsync() just gives BaseError)
|
||||
#pragma warning disable VSTHRD103
|
||||
.Bind(v => v.ToEither().MapLeft(seq => seq.Head()).MapAsync<BaseError, Task<Unit>, Unit>(identity));
|
||||
#pragma warning restore VSTHRD103
|
||||
|
||||
private async Task<Unit> StartProcess(StartFFmpegSession 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);
|
||||
|
||||
// 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) =>
|
||||
SessionMustBeInactive(request)
|
||||
.BindT(_ => FolderMustBeEmpty(request));
|
||||
|
||||
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 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)
|
||||
{
|
||||
string folder = Path.Combine(FileSystemLayout.TranscodeFolder, request.ChannelNumber);
|
||||
_logger.LogDebug("Preparing transcode folder {Folder}", folder);
|
||||
|
||||
_localFileSystem.EnsureFolderExists(folder);
|
||||
_localFileSystem.EmptyFolder(folder);
|
||||
|
||||
return Task.FromResult<Validation<BaseError, Unit>>(Unit.Default);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Application.Streaming.Commands
|
||||
{
|
||||
public record TouchFFmpegSession(string Path) : IRequest<Either<BaseError, Unit>>, IFFmpegWorkerRequest;
|
||||
}
|
||||
@@ -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,
|
||||
@@ -56,6 +55,7 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
channel.StreamingMode = request.Mode.ToLowerInvariant() switch
|
||||
{
|
||||
"hls-direct" => StreamingMode.HttpLiveStreamingDirect,
|
||||
"segmenter" => StreamingMode.HttpLiveStreamingSegmenter,
|
||||
"ts" => StreamingMode.TransportStream,
|
||||
_ => channel.StreamingMode
|
||||
};
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
using System.Diagnostics;
|
||||
using System;
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Streaming.Queries
|
||||
{
|
||||
public record FFmpegProcessRequest(string ChannelNumber, string Mode) : IRequest<Either<BaseError, Process>>;
|
||||
public record FFmpegProcessRequest
|
||||
(
|
||||
string ChannelNumber,
|
||||
string Mode,
|
||||
DateTimeOffset Now,
|
||||
bool StartAtZero,
|
||||
bool HlsRealtime) : IRequest<Either<BaseError, PlayoutItemProcessModel>>;
|
||||
}
|
||||
|
||||
@@ -1,10 +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")
|
||||
"ts",
|
||||
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,8 +1,20 @@
|
||||
namespace ErsatzTV.Application.Streaming.Queries
|
||||
using System;
|
||||
|
||||
namespace ErsatzTV.Application.Streaming.Queries
|
||||
{
|
||||
public record GetPlayoutItemProcessByChannelNumber : FFmpegProcessRequest
|
||||
{
|
||||
public GetPlayoutItemProcessByChannelNumber(string channelNumber, string mode) : base(channelNumber, mode)
|
||||
public GetPlayoutItemProcessByChannelNumber(
|
||||
string channelNumber,
|
||||
string mode,
|
||||
DateTimeOffset now,
|
||||
bool startAtZero,
|
||||
bool hlsRealtime) : base(
|
||||
channelNumber,
|
||||
mode,
|
||||
now,
|
||||
startAtZero,
|
||||
hlsRealtime)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
+36
-20
@@ -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 _,
|
||||
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,
|
||||
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,
|
||||
@@ -437,7 +418,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
"[0:0]deinterlace_vaapi,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
|
||||
"[0:0]deinterlace_vaapi,scale_vaapi=format=nv12:w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
"h264",
|
||||
@@ -451,14 +432,14 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
"[0:0]deinterlace_vaapi,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[0:0]deinterlace_vaapi,scale_vaapi=format=nv12:w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
"h264",
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
"[0:0]scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
|
||||
"[0:0]scale_vaapi=format=nv12:w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
"h264",
|
||||
@@ -472,7 +453,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
"[0:0]scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[0:0]scale_vaapi=format=nv12:w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase("mpeg4", true, false, false, "[0:0]hwupload,deinterlace_vaapi[v]", "[v]")]
|
||||
[TestCase(
|
||||
@@ -480,7 +461,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
"[0:0]hwupload,deinterlace_vaapi,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
|
||||
"[0:0]hwupload,deinterlace_vaapi,scale_vaapi=format=nv12:w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
"mpeg4",
|
||||
@@ -494,14 +475,14 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
"[0:0]hwupload,deinterlace_vaapi,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[0:0]hwupload,deinterlace_vaapi,scale_vaapi=format=nv12:w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
"mpeg4",
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
"[0:0]hwupload,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
|
||||
"[0:0]hwupload,scale_vaapi=format=nv12:w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
"mpeg4",
|
||||
@@ -515,7 +496,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
"[0:0]hwupload,scale_vaapi=w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[0:0]hwupload,scale_vaapi=format=nv12:w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[v]")]
|
||||
public void Should_Return_VAAPI_Video_Filter(
|
||||
string codec,
|
||||
|
||||
@@ -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
|
||||
");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using FluentAssertions;
|
||||
using LanguageExt;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
{
|
||||
[TestFixture]
|
||||
[Explicit]
|
||||
public class TranscodingTests
|
||||
{
|
||||
[Test]
|
||||
[Explicit]
|
||||
public void DeleteTestVideos()
|
||||
{
|
||||
foreach (string file in Directory.GetFiles(TestContext.CurrentContext.TestDirectory, "*.mkv"))
|
||||
{
|
||||
File.Delete(file);
|
||||
}
|
||||
|
||||
Assert.Pass();
|
||||
}
|
||||
|
||||
private class TestData
|
||||
{
|
||||
public static string[] InputCodecs =
|
||||
{
|
||||
"h264",
|
||||
"mpeg2video",
|
||||
"hevc",
|
||||
"mpeg4"
|
||||
};
|
||||
|
||||
public static string[] InputPixelFormats =
|
||||
{
|
||||
"yuv420p",
|
||||
"yuv420p10le",
|
||||
"yuvj420p",
|
||||
"yuv444p",
|
||||
"yuv444p10le"
|
||||
};
|
||||
|
||||
public static Resolution[] Resolutions =
|
||||
{
|
||||
new() { Width = 1920, Height = 1080 },
|
||||
new() { Width = 1280, Height = 720 }
|
||||
};
|
||||
|
||||
public static string[] SoftwareCodecs =
|
||||
{
|
||||
"libx264",
|
||||
"libx265"
|
||||
};
|
||||
|
||||
public static HardwareAccelerationKind[] NoAcceleration =
|
||||
{
|
||||
HardwareAccelerationKind.None
|
||||
};
|
||||
|
||||
public static string[] NvidiaCodecs =
|
||||
{
|
||||
"h264_nvenc",
|
||||
"hevc_nvenc"
|
||||
};
|
||||
|
||||
public static HardwareAccelerationKind[] NvidiaAcceleration =
|
||||
{
|
||||
HardwareAccelerationKind.Nvenc
|
||||
};
|
||||
|
||||
public static string[] VaapiCodecs =
|
||||
{
|
||||
"h264_vaapi",
|
||||
"hevc_vaapi"
|
||||
};
|
||||
|
||||
public static HardwareAccelerationKind[] VaapiAcceleration =
|
||||
{
|
||||
HardwareAccelerationKind.Vaapi
|
||||
};
|
||||
}
|
||||
|
||||
[Test, Combinatorial]
|
||||
public async Task Transcode(
|
||||
[ValueSource(typeof(TestData), nameof(TestData.InputCodecs))]
|
||||
string inputCodec,
|
||||
[ValueSource(typeof(TestData), nameof(TestData.InputPixelFormats))]
|
||||
string inputPixelFormat,
|
||||
[ValueSource(typeof(TestData), nameof(TestData.Resolutions))]
|
||||
Resolution profileResolution,
|
||||
// [ValueSource(typeof(TestData), nameof(TestData.SoftwareCodecs))] string profileCodec,
|
||||
// [ValueSource(typeof(TestData), nameof(TestData.NoAcceleration))] HardwareAccelerationKind profileAcceleration)
|
||||
[ValueSource(typeof(TestData), nameof(TestData.NvidiaCodecs))] string profileCodec,
|
||||
[ValueSource(typeof(TestData), nameof(TestData.NvidiaAcceleration))] HardwareAccelerationKind profileAcceleration)
|
||||
// [ValueSource(typeof(TestData), nameof(TestData.VaapiCodecs))] string profileCodec,
|
||||
// [ValueSource(typeof(TestData), nameof(TestData.VaapiAcceleration))] HardwareAccelerationKind profileAcceleration)
|
||||
{
|
||||
string name = GetStringSha256Hash(
|
||||
$"{inputCodec}_{inputPixelFormat}_{profileResolution}_{profileCodec}_{profileAcceleration}");
|
||||
|
||||
string file = Path.Combine(TestContext.CurrentContext.TestDirectory, $"{name}.mkv");
|
||||
if (!File.Exists(file))
|
||||
{
|
||||
var args =
|
||||
$"-y -f lavfi -i anullsrc=channel_layout=stereo:sample_rate=44100 -f lavfi -i testsrc=duration=1:size=1920x1080:rate=30 -c:a aac -c:v {inputCodec} -shortest -pix_fmt {inputPixelFormat} -strict -2 {file}";
|
||||
var p1 = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = "ffmpeg",
|
||||
Arguments = args
|
||||
}
|
||||
};
|
||||
|
||||
p1.Start();
|
||||
await p1.WaitForExitAsync();
|
||||
// ReSharper disable once MethodHasAsyncOverload
|
||||
p1.WaitForExit();
|
||||
p1.ExitCode.Should().Be(0);
|
||||
}
|
||||
|
||||
var service = new FFmpegProcessService(
|
||||
new FFmpegPlaybackSettingsCalculator(),
|
||||
new FakeStreamSelector(),
|
||||
new Mock<IImageCache>().Object,
|
||||
new Mock<ILogger<FFmpegProcessService>>().Object);
|
||||
|
||||
MediaVersion v = new MediaVersion();
|
||||
|
||||
var metadataRepository = new Mock<IMetadataRepository>();
|
||||
metadataRepository
|
||||
.Setup(r => r.UpdateLocalStatistics(It.IsAny<int>(), It.IsAny<MediaVersion>(), It.IsAny<bool>()))
|
||||
.Callback<int, MediaVersion, bool>((_, version, _) => v = version);
|
||||
|
||||
var localStatisticsProvider = new LocalStatisticsProvider(
|
||||
metadataRepository.Object,
|
||||
new LocalFileSystem(new Mock<ILogger<LocalFileSystem>>().Object),
|
||||
new Mock<ILogger<LocalStatisticsProvider>>().Object);
|
||||
|
||||
await localStatisticsProvider.RefreshStatistics(
|
||||
"ffprobe",
|
||||
new Movie
|
||||
{
|
||||
MediaVersions = new List<MediaVersion>
|
||||
{
|
||||
new()
|
||||
{
|
||||
MediaFiles = new List<MediaFile>
|
||||
{
|
||||
new() { Path = file }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
DateTimeOffset now = DateTimeOffset.Now;
|
||||
|
||||
Process process = await service.ForPlayoutItem(
|
||||
"ffmpeg",
|
||||
false,
|
||||
new Channel(Guid.NewGuid())
|
||||
{
|
||||
FFmpegProfile = FFmpegProfile.New("test", profileResolution) with
|
||||
{
|
||||
HardwareAcceleration = profileAcceleration,
|
||||
VideoCodec = profileCodec
|
||||
},
|
||||
StreamingMode = StreamingMode.TransportStream
|
||||
},
|
||||
v,
|
||||
file,
|
||||
now,
|
||||
now,
|
||||
None,
|
||||
VaapiDriver.Default,
|
||||
"/dev/dri/renderD128",
|
||||
false,
|
||||
false);
|
||||
|
||||
process.StartInfo.RedirectStandardError = true;
|
||||
|
||||
process.Start().Should().BeTrue();
|
||||
|
||||
process.BeginOutputReadLine();
|
||||
string error = await process.StandardError.ReadToEndAsync();
|
||||
await process.WaitForExitAsync();
|
||||
// ReSharper disable once MethodHasAsyncOverload
|
||||
process.WaitForExit();
|
||||
|
||||
string[] unsupportedMessages =
|
||||
{
|
||||
"No support for codec",
|
||||
"No usable",
|
||||
"Provided device doesn't support"
|
||||
};
|
||||
|
||||
if (profileAcceleration != HardwareAccelerationKind.None && unsupportedMessages.Any(error.Contains))
|
||||
{
|
||||
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: ffmpeg {string.Join(" ", quotedArgs)}");
|
||||
}
|
||||
else if (error.Contains("Impossible to convert between"))
|
||||
{
|
||||
IEnumerable<string> quotedArgs = process.StartInfo.ArgumentList.Map(a => $"\'{a}\'");
|
||||
Assert.Fail($"Transcode failure: ffmpeg {string.Join(" ", quotedArgs)}");
|
||||
}
|
||||
else
|
||||
{
|
||||
IEnumerable<string> quotedArgs = process.StartInfo.ArgumentList.Map(a => $"\'{a}\'");
|
||||
process.ExitCode.Should().Be(0, error + Environment.NewLine + string.Join(" ", quotedArgs));
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetStringSha256Hash(string text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
using var sha = new System.Security.Cryptography.SHA256Managed();
|
||||
byte[] textData = System.Text.Encoding.UTF8.GetBytes(text);
|
||||
byte[] hash = sha.ComputeHash(textData);
|
||||
return BitConverter.ToString(hash).Replace("-", string.Empty);
|
||||
}
|
||||
|
||||
private class FakeStreamSelector : IFFmpegStreamSelector
|
||||
{
|
||||
public Task<MediaStream> SelectVideoStream(Channel channel, MediaVersion version) =>
|
||||
version.Streams.First(s => s.MediaStreamKind == MediaStreamKind.Video).AsTask();
|
||||
|
||||
public Task<Option<MediaStream>> SelectAudioStream(Channel channel, MediaVersion version) =>
|
||||
Optional(version.Streams.First(s => s.MediaStreamKind == MediaStreamKind.Audio)).AsTask();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,8 @@ namespace ErsatzTV.Core.Tests.Fakes
|
||||
public Task<Either<BaseError, Unit>> CopyFile(string source, string destination) =>
|
||||
Task.FromResult(Right<BaseError, Unit>(Unit.Default));
|
||||
|
||||
public Unit EmptyFolder(string folder) => Unit.Default;
|
||||
|
||||
private static List<DirectoryInfo> Split(DirectoryInfo path)
|
||||
{
|
||||
var result = new List<DirectoryInfo>();
|
||||
|
||||
@@ -97,5 +97,6 @@ namespace ErsatzTV.Core.Tests.Fakes
|
||||
public Task<bool> AddDirector(EpisodeMetadata metadata, Director director) => throw new NotSupportedException();
|
||||
|
||||
public Task<bool> AddWriter(EpisodeMetadata metadata, Writer writer) => throw new NotSupportedException();
|
||||
public Task<Unit> UpdatePath(int mediaFileId, string path) => throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ namespace ErsatzTV.Core.Domain
|
||||
public TimeSpan Duration { get; set; }
|
||||
public string SampleAspectRatio { get; set; }
|
||||
public string DisplayAspectRatio { get; set; }
|
||||
public string RFrameRate { get; set; }
|
||||
public VideoScanKind VideoScanKind { get; set; }
|
||||
public DateTime DateAdded { get; set; }
|
||||
public DateTime DateUpdated { get; set; }
|
||||
|
||||
@@ -8,5 +8,7 @@ namespace ErsatzTV.Core.Domain
|
||||
public string Name { get; set; }
|
||||
public int Height { get; set; }
|
||||
public int Width { get; set; }
|
||||
|
||||
public override string ToString() => $"{Width}x{Height}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
{
|
||||
TransportStream = 1,
|
||||
HttpLiveStreamingDirect = 2,
|
||||
HttpLiveStreamingHybrid = 3
|
||||
HttpLiveStreamingHybrid = 3,
|
||||
HttpLiveStreamingSegmenter = 4
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +96,8 @@ namespace ErsatzTV.Core.Emby
|
||||
|
||||
foreach (EmbyMovie incoming in validMovies)
|
||||
{
|
||||
EmbyMovie incomingMovie = incoming;
|
||||
|
||||
decimal percentCompletion = (decimal) validMovies.IndexOf(incoming) / validMovies.Count;
|
||||
await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion));
|
||||
|
||||
@@ -122,12 +124,14 @@ namespace ErsatzTV.Core.Emby
|
||||
|
||||
updateStatistics = true;
|
||||
incoming.LibraryPathId = library.Paths.Head().Id;
|
||||
Option<EmbyMovie> updated = await _movieRepository.UpdateEmby(incoming);
|
||||
if (updated.IsSome)
|
||||
Option<EmbyMovie> maybeUpdated = await _movieRepository.UpdateEmby(incoming);
|
||||
foreach (EmbyMovie updated in maybeUpdated)
|
||||
{
|
||||
await _searchIndex.UpdateItems(
|
||||
_searchRepository,
|
||||
new List<MediaItem> { updated.ValueUnsafe() });
|
||||
new List<MediaItem> { updated });
|
||||
|
||||
incomingMovie = updated;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -174,12 +178,12 @@ namespace ErsatzTV.Core.Emby
|
||||
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath);
|
||||
Either<BaseError, bool> refreshResult =
|
||||
await _localStatisticsProvider.RefreshStatistics(ffprobePath, incoming, localPath);
|
||||
await _localStatisticsProvider.RefreshStatistics(ffprobePath, incomingMovie, localPath);
|
||||
|
||||
await refreshResult.Match(
|
||||
async _ =>
|
||||
{
|
||||
Option<MediaItem> updated = await _searchRepository.GetItemToIndex(incoming.Id);
|
||||
Option<MediaItem> updated = await _searchRepository.GetItemToIndex(incomingMovie.Id);
|
||||
if (updated.IsSome)
|
||||
{
|
||||
await _searchIndex.UpdateItems(
|
||||
|
||||
@@ -326,6 +326,7 @@ namespace ErsatzTV.Core.Emby
|
||||
{
|
||||
foreach (EmbyEpisode incoming in episodes)
|
||||
{
|
||||
EmbyEpisode incomingEpisode = incoming;
|
||||
var updateStatistics = false;
|
||||
|
||||
Option<EmbyItemEtag> maybeExisting = existingEpisodes.Find(ie => ie.ItemId == incoming.ItemId);
|
||||
@@ -349,12 +350,14 @@ namespace ErsatzTV.Core.Emby
|
||||
incoming.SeasonId = season.Id;
|
||||
incoming.LibraryPathId = library.Paths.Head().Id;
|
||||
|
||||
Option<EmbyEpisode> updated = await _televisionRepository.Update(incoming);
|
||||
if (updated.IsSome)
|
||||
Option<EmbyEpisode> maybeUpdated = await _televisionRepository.Update(incoming);
|
||||
foreach (EmbyEpisode updated in maybeUpdated)
|
||||
{
|
||||
await _searchIndex.UpdateItems(
|
||||
_searchRepository,
|
||||
new List<MediaItem> { updated.ValueUnsafe() });
|
||||
new List<MediaItem> { updated });
|
||||
|
||||
incomingEpisode = updated;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -403,7 +406,7 @@ namespace ErsatzTV.Core.Emby
|
||||
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath);
|
||||
Either<BaseError, bool> refreshResult =
|
||||
await _localStatisticsProvider.RefreshStatistics(ffprobePath, incoming, localPath);
|
||||
await _localStatisticsProvider.RefreshStatistics(ffprobePath, incomingEpisode, localPath);
|
||||
|
||||
refreshResult.Match(
|
||||
_ => { },
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ErsatzTV.Core.Errors
|
||||
{
|
||||
public class ChannelSessionAlreadyActive : BaseError
|
||||
{
|
||||
public ChannelSessionAlreadyActive() : base("Channel already has HLS session")
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,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);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
private Option<IDisplaySize> _scaleToSize = None;
|
||||
private Option<ChannelWatermark> _watermark;
|
||||
private string _pixelFormat;
|
||||
private string _videoEncoder;
|
||||
|
||||
public FFmpegComplexFilterBuilder WithHardwareAcceleration(HardwareAccelerationKind hardwareAccelerationKind)
|
||||
{
|
||||
@@ -77,6 +78,12 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegComplexFilterBuilder WithVideoEncoder(string videoEncoder)
|
||||
{
|
||||
_videoEncoder = videoEncoder;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Option<FFmpegComplexFilter> Build(int videoStreamIndex, Option<int> audioStreamIndex)
|
||||
{
|
||||
var complexFilter = new StringBuilder();
|
||||
@@ -118,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"
|
||||
};
|
||||
@@ -129,16 +136,30 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
}
|
||||
}
|
||||
|
||||
string[] h264hevc = { "h264", "hevc" };
|
||||
|
||||
if (acceleration == HardwareAccelerationKind.Vaapi && (_pixelFormat ?? string.Empty).EndsWith("p10le") &&
|
||||
h264hevc.Contains(_inputCodec)
|
||||
&& (_pixelFormat != "yuv420p10le" || _inputCodec != "hevc"))
|
||||
{
|
||||
videoFilterQueue.Add("format=p010le,format=nv12|vaapi,hwupload");
|
||||
}
|
||||
|
||||
if (acceleration == HardwareAccelerationKind.Vaapi && _pixelFormat == "yuv444p" && h264hevc.Contains(_inputCodec))
|
||||
{
|
||||
videoFilterQueue.Add("format=nv12|vaapi,hwupload");
|
||||
}
|
||||
|
||||
_scaleToSize.IfSome(
|
||||
size =>
|
||||
{
|
||||
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=w={size.Width}:h={size.Height}",
|
||||
HardwareAccelerationKind.Vaapi => $"scale_vaapi=format=nv12:w={size.Width}:h={size.Height}",
|
||||
_ => $"scale={size.Width}:{size.Height}:flags=fast_bilinear"
|
||||
};
|
||||
|
||||
@@ -159,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"
|
||||
};
|
||||
@@ -185,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}"
|
||||
};
|
||||
|
||||
@@ -213,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))
|
||||
{
|
||||
@@ -224,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)
|
||||
{
|
||||
@@ -233,6 +273,13 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
complexFilter.Append(audioLabel);
|
||||
}
|
||||
|
||||
// vaapi downsample 10bit hevc to 8bit h264
|
||||
if (acceleration == HardwareAccelerationKind.Vaapi && !videoFilterQueue.Any() &&
|
||||
_pixelFormat == "yuv420p10le" && _videoEncoder.StartsWith("h264"))
|
||||
{
|
||||
videoFilterQueue.Add("scale_vaapi=format=nv12");
|
||||
}
|
||||
|
||||
if (videoFilterQueue.Any() || !string.IsNullOrWhiteSpace(watermarkOverlay))
|
||||
{
|
||||
if (hasAudioFilters)
|
||||
@@ -279,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
result.Deinterlace = false;
|
||||
break;
|
||||
case StreamingMode.HttpLiveStreamingHybrid:
|
||||
case StreamingMode.HttpLiveStreamingSegmenter:
|
||||
case StreamingMode.TransportStream:
|
||||
result.HardwareAcceleration = ffmpegProfile.HardwareAcceleration;
|
||||
|
||||
@@ -101,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
|
||||
{
|
||||
@@ -147,8 +164,8 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
{
|
||||
ThreadCount = ffmpegProfile.ThreadCount,
|
||||
FormatFlags = CommonFormatFlags,
|
||||
VideoCodec = ffmpegProfile.VideoCodec,
|
||||
AudioCodec = ffmpegProfile.AudioCodec
|
||||
VideoCodec = "libx264",
|
||||
AudioCodec = ffmpegProfile.AudioCodec,
|
||||
};
|
||||
|
||||
private static bool NeedToScale(FFmpegProfile ffmpegProfile, MediaVersion version) =>
|
||||
|
||||
@@ -26,39 +26,41 @@ using System.Text;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using LanguageExt;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
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;
|
||||
private readonly ILogger _logger;
|
||||
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)
|
||||
public FFmpegProcessBuilder(string ffmpegPath, bool saveReports, ILogger logger)
|
||||
{
|
||||
_ffmpegPath = ffmpegPath;
|
||||
_saveReports = saveReports;
|
||||
_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;
|
||||
}
|
||||
|
||||
@@ -69,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;
|
||||
|
||||
@@ -82,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;
|
||||
@@ -106,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;
|
||||
@@ -189,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
|
||||
@@ -307,15 +324,58 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
return this;
|
||||
}
|
||||
|
||||
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("/");
|
||||
if (int.TryParse(split[0], out int left) && int.TryParse(split[1], out int right))
|
||||
{
|
||||
frameRate = (int)Math.Round(left / (double)right);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("Unable to detect framerate, using {FrameRate}", 24);
|
||||
frameRate = 24;
|
||||
}
|
||||
}
|
||||
|
||||
_arguments.AddRange(
|
||||
new[]
|
||||
{
|
||||
"-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", $"{SEGMENT_SECONDS}",
|
||||
"-hls_list_size", "0",
|
||||
"-segment_list_flags", "+live",
|
||||
"-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")
|
||||
});
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithPlaybackArgs(FFmpegPlaybackSettings playbackSettings)
|
||||
{
|
||||
var arguments = new List<string>
|
||||
{
|
||||
"-c:v", playbackSettings.VideoCodec,
|
||||
"-flags", "cgop",
|
||||
"-sc_threshold", "1000000000"
|
||||
"-sc_threshold", "0" // disable scene change detection
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_outputPixelFormat))
|
||||
{
|
||||
arguments.AddRange(new[] { "-pix_fmt", _outputPixelFormat });
|
||||
}
|
||||
|
||||
string[] videoBitrateArgs = playbackSettings.VideoBitrate.Match(
|
||||
bitrate =>
|
||||
new[]
|
||||
@@ -402,8 +462,13 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithFilterComplex(MediaStream videoStream, Option<MediaStream> maybeAudioStream)
|
||||
public FFmpegProcessBuilder WithFilterComplex(
|
||||
MediaStream videoStream,
|
||||
Option<MediaStream> maybeAudioStream,
|
||||
string videoCodec)
|
||||
{
|
||||
_complexFilterBuilder = _complexFilterBuilder.WithVideoEncoder(videoCodec);
|
||||
|
||||
int videoStreamIndex = videoStream.Index;
|
||||
Option<int> maybeIndex = maybeAudioStream.Map(ms => ms.Index);
|
||||
|
||||
@@ -418,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");
|
||||
@@ -457,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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using LanguageExt;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Core.FFmpeg
|
||||
@@ -14,16 +15,19 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
{
|
||||
private readonly IFFmpegStreamSelector _ffmpegStreamSelector;
|
||||
private readonly IImageCache _imageCache;
|
||||
private readonly ILogger<FFmpegProcessService> _logger;
|
||||
private readonly FFmpegPlaybackSettingsCalculator _playbackSettingsCalculator;
|
||||
|
||||
public FFmpegProcessService(
|
||||
FFmpegPlaybackSettingsCalculator ffmpegPlaybackSettingsService,
|
||||
IFFmpegStreamSelector ffmpegStreamSelector,
|
||||
IImageCache imageCache)
|
||||
IImageCache imageCache,
|
||||
ILogger<FFmpegProcessService> logger)
|
||||
{
|
||||
_playbackSettingsCalculator = ffmpegPlaybackSettingsService;
|
||||
_ffmpegStreamSelector = ffmpegStreamSelector;
|
||||
_imageCache = imageCache;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Process> ForPlayoutItem(
|
||||
@@ -35,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);
|
||||
@@ -56,15 +63,15 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
p => _imageCache.IsAnimated(p),
|
||||
() => Task.FromResult(false));
|
||||
|
||||
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath, saveReports)
|
||||
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath, saveReports, _logger)
|
||||
.WithThreads(playbackSettings.ThreadCount)
|
||||
.WithHardwareAcceleration(playbackSettings.HardwareAcceleration)
|
||||
.WithVaapiDriver(maybeVaapiDriver)
|
||||
.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)
|
||||
@@ -82,7 +89,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
}
|
||||
|
||||
builder = builder
|
||||
.WithFilterComplex(videoStream, maybeAudioStream);
|
||||
.WithFilterComplex(videoStream, maybeAudioStream, channel.FFmpegProfile.VideoCodec);
|
||||
},
|
||||
() =>
|
||||
{
|
||||
@@ -91,27 +98,37 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
builder = builder
|
||||
.WithDeinterlace(playbackSettings.Deinterlace)
|
||||
.WithBlackBars(channel.FFmpegProfile.Resolution)
|
||||
.WithFilterComplex(videoStream, maybeAudioStream);
|
||||
.WithFilterComplex(videoStream, maybeAudioStream, channel.FFmpegProfile.VideoCodec);
|
||||
}
|
||||
else if (playbackSettings.Deinterlace)
|
||||
{
|
||||
builder = builder.WithDeinterlace(playbackSettings.Deinterlace)
|
||||
.WithAlignedAudio(playbackSettings.AudioDuration)
|
||||
.WithFilterComplex(videoStream, maybeAudioStream);
|
||||
.WithFilterComplex(videoStream, maybeAudioStream, channel.FFmpegProfile.VideoCodec);
|
||||
}
|
||||
else
|
||||
{
|
||||
builder = builder
|
||||
.WithFilterComplex(videoStream, maybeAudioStream);
|
||||
.WithFilterComplex(videoStream, maybeAudioStream, channel.FFmpegProfile.VideoCodec);
|
||||
}
|
||||
});
|
||||
|
||||
return builder.WithPlaybackArgs(playbackSettings)
|
||||
builder = builder.WithPlaybackArgs(playbackSettings)
|
||||
.WithMetadata(channel, maybeAudioStream)
|
||||
.WithFormat("mpegts")
|
||||
.WithDuration(start + version.Duration - now)
|
||||
.WithPipe()
|
||||
.Build();
|
||||
.WithDuration(start + version.Duration - now);
|
||||
|
||||
switch (channel.StreamingMode)
|
||||
{
|
||||
// HLS needs to segment and generate playlist
|
||||
case StreamingMode.HttpLiveStreamingSegmenter:
|
||||
return builder.WithHls(channel.Number, version, startAtZero)
|
||||
.WithRealtimeOutput(hlsRealtime)
|
||||
.Build();
|
||||
default:
|
||||
return builder.WithFormat("mpegts")
|
||||
.WithPipe()
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
|
||||
public Process ForError(string ffmpegPath, Channel channel, Option<TimeSpan> duration, string errorMessage)
|
||||
@@ -121,7 +138,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
|
||||
IDisplaySize desiredResolution = channel.FFmpegProfile.Resolution;
|
||||
|
||||
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath, false)
|
||||
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath, false, _logger)
|
||||
.WithThreads(1)
|
||||
.WithQuiet()
|
||||
.WithFormatFlags(playbackSettings.FormatFlags)
|
||||
@@ -144,7 +161,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
{
|
||||
FFmpegPlaybackSettings playbackSettings = _playbackSettingsCalculator.ConcatSettings;
|
||||
|
||||
return new FFmpegProcessBuilder(ffmpegPath, saveReports)
|
||||
return new FFmpegProcessBuilder(ffmpegPath, saveReports, _logger)
|
||||
.WithThreads(1)
|
||||
.WithQuiet()
|
||||
.WithFormatFlags(playbackSettings.FormatFlags)
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.Collections.Concurrent;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
|
||||
namespace ErsatzTV.Core.FFmpeg
|
||||
{
|
||||
public class FFmpegSegmenterService : IFFmpegSegmenterService
|
||||
{
|
||||
public FFmpegSegmenterService()
|
||||
{
|
||||
SessionWorkers = new ConcurrentDictionary<string, IHlsSessionWorker>();
|
||||
}
|
||||
|
||||
public ConcurrentDictionary<string, IHlsSessionWorker> SessionWorkers { get; }
|
||||
|
||||
public void TouchChannel(string channelNumber)
|
||||
{
|
||||
if (SessionWorkers.TryGetValue(channelNumber, out IHlsSessionWorker worker))
|
||||
{
|
||||
worker?.Touch();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
{
|
||||
Default = 0,
|
||||
iHD = 1,
|
||||
i965 = 2
|
||||
i965 = 2,
|
||||
RadeonSI = 3
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,13 @@ namespace ErsatzTV.Core
|
||||
Environment.SpecialFolderOption.Create),
|
||||
"ersatztv");
|
||||
|
||||
// TODO: find a different spot for this; configurable?
|
||||
public static readonly string TranscodeFolder = Path.Combine(
|
||||
Environment.GetFolderPath(
|
||||
Environment.SpecialFolder.LocalApplicationData,
|
||||
Environment.SpecialFolderOption.Create),
|
||||
"etv-transcode");
|
||||
|
||||
public static readonly string DatabasePath = Path.Combine(AppDataFolder, "ersatztv.sqlite3");
|
||||
|
||||
public static readonly string LogDatabasePath = Path.Combine(AppDataFolder, "logs.sqlite3");
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.FFmpeg
|
||||
{
|
||||
public interface IFFmpegSegmenterService
|
||||
{
|
||||
ConcurrentDictionary<string, IHlsSessionWorker> SessionWorkers { get; }
|
||||
|
||||
void TouchChannel(string channelNumber);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using System;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.FFmpeg
|
||||
{
|
||||
public interface IHlsSessionWorker
|
||||
{
|
||||
DateTimeOffset PlaylistStart { get; }
|
||||
void Touch();
|
||||
}
|
||||
}
|
||||
@@ -15,5 +15,6 @@ namespace ErsatzTV.Core.Interfaces.Metadata
|
||||
IEnumerable<string> ListFiles(string folder);
|
||||
bool FileExists(string path);
|
||||
Task<Either<BaseError, Unit>> CopyFile(string source, string destination);
|
||||
Unit EmptyFolder(string folder);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,5 +33,6 @@ namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
Task<Option<EmbyMovie>> UpdateEmby(EmbyMovie movie);
|
||||
Task<bool> AddDirector(MovieMetadata metadata, Director director);
|
||||
Task<bool> AddWriter(MovieMetadata metadata, Writer writer);
|
||||
Task<Unit> UpdatePath(int mediaFileId, string path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,5 +51,6 @@ namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
Task<Unit> RemoveMetadata(Episode episode, EpisodeMetadata metadata);
|
||||
Task<bool> AddDirector(EpisodeMetadata metadata, Director director);
|
||||
Task<bool> AddWriter(EpisodeMetadata metadata, Writer writer);
|
||||
Task<Unit> UpdatePath(int mediaFileId, string path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,10 +200,10 @@ namespace ErsatzTV.Core.Iptv
|
||||
|
||||
if (!isSameCustomShow)
|
||||
{
|
||||
int s = Optional(episode.Season?.SeasonNumber).IfNone(0);
|
||||
int s = Optional(episode.Season?.SeasonNumber).IfNone(-1);
|
||||
// TODO: multi-episode?
|
||||
int e = episode.EpisodeMetadata.Head().EpisodeNumber;
|
||||
if (s > 0 && e > 0)
|
||||
int e = episode.EpisodeMetadata.HeadOrNone().Match(em => em.EpisodeNumber, -1);
|
||||
if (s >= 0 && e > 0)
|
||||
{
|
||||
xml.WriteStartElement("episode-num");
|
||||
xml.WriteAttributeString("system", "onscreen");
|
||||
|
||||
@@ -44,6 +44,7 @@ namespace ErsatzTV.Core.Iptv
|
||||
{
|
||||
StreamingMode.HttpLiveStreamingDirect => "m3u8?mode=hls-direct",
|
||||
StreamingMode.HttpLiveStreamingHybrid => "m3u8",
|
||||
StreamingMode.HttpLiveStreamingSegmenter => "m3u8?mode=segmenter",
|
||||
_ => "ts"
|
||||
};
|
||||
|
||||
|
||||
@@ -96,6 +96,8 @@ namespace ErsatzTV.Core.Jellyfin
|
||||
|
||||
foreach (JellyfinMovie incoming in validMovies)
|
||||
{
|
||||
JellyfinMovie incomingMovie = incoming;
|
||||
|
||||
decimal percentCompletion = (decimal) validMovies.IndexOf(incoming) / validMovies.Count;
|
||||
await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion));
|
||||
|
||||
@@ -122,12 +124,14 @@ namespace ErsatzTV.Core.Jellyfin
|
||||
|
||||
updateStatistics = true;
|
||||
incoming.LibraryPathId = library.Paths.Head().Id;
|
||||
Option<JellyfinMovie> updated = await _movieRepository.UpdateJellyfin(incoming);
|
||||
if (updated.IsSome)
|
||||
Option<JellyfinMovie> maybeUpdated = await _movieRepository.UpdateJellyfin(incoming);
|
||||
foreach (JellyfinMovie updated in maybeUpdated)
|
||||
{
|
||||
await _searchIndex.UpdateItems(
|
||||
_searchRepository,
|
||||
new List<MediaItem> { updated.ValueUnsafe() });
|
||||
new List<MediaItem> { updated });
|
||||
|
||||
incomingMovie = updated;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -174,12 +178,12 @@ namespace ErsatzTV.Core.Jellyfin
|
||||
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath);
|
||||
Either<BaseError, bool> refreshResult =
|
||||
await _localStatisticsProvider.RefreshStatistics(ffprobePath, incoming, localPath);
|
||||
await _localStatisticsProvider.RefreshStatistics(ffprobePath, incomingMovie, localPath);
|
||||
|
||||
await refreshResult.Match(
|
||||
async _ =>
|
||||
{
|
||||
Option<MediaItem> updated = await _searchRepository.GetItemToIndex(incoming.Id);
|
||||
Option<MediaItem> updated = await _searchRepository.GetItemToIndex(incomingMovie.Id);
|
||||
if (updated.IsSome)
|
||||
{
|
||||
await _searchIndex.UpdateItems(
|
||||
|
||||
@@ -327,6 +327,8 @@ namespace ErsatzTV.Core.Jellyfin
|
||||
{
|
||||
foreach (JellyfinEpisode incoming in episodes)
|
||||
{
|
||||
JellyfinEpisode incomingEpisode = incoming;
|
||||
|
||||
var updateStatistics = false;
|
||||
|
||||
Option<JellyfinItemEtag> maybeExisting = existingEpisodes.Find(ie => ie.ItemId == incoming.ItemId);
|
||||
@@ -350,12 +352,14 @@ namespace ErsatzTV.Core.Jellyfin
|
||||
incoming.SeasonId = season.Id;
|
||||
incoming.LibraryPathId = library.Paths.Head().Id;
|
||||
|
||||
Option<JellyfinEpisode> updated = await _televisionRepository.Update(incoming);
|
||||
if (updated.IsSome)
|
||||
Option<JellyfinEpisode> maybeUpdated = await _televisionRepository.Update(incoming);
|
||||
foreach (JellyfinEpisode updated in maybeUpdated)
|
||||
{
|
||||
await _searchIndex.UpdateItems(
|
||||
_searchRepository,
|
||||
new List<MediaItem> { updated.ValueUnsafe() });
|
||||
new List<MediaItem> { updated });
|
||||
|
||||
incomingEpisode = updated;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -404,7 +408,7 @@ namespace ErsatzTV.Core.Jellyfin
|
||||
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath);
|
||||
Either<BaseError, bool> refreshResult =
|
||||
await _localStatisticsProvider.RefreshStatistics(ffprobePath, incoming, localPath);
|
||||
await _localStatisticsProvider.RefreshStatistics(ffprobePath, incomingEpisode, localPath);
|
||||
|
||||
refreshResult.Match(
|
||||
_ => { },
|
||||
|
||||
@@ -5,17 +5,32 @@ using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using LanguageExt;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
public class LocalFileSystem : ILocalFileSystem
|
||||
{
|
||||
private readonly ILogger<LocalFileSystem> _logger;
|
||||
|
||||
public LocalFileSystem(ILogger<LocalFileSystem> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Unit EnsureFolderExists(string folder)
|
||||
{
|
||||
if (!Directory.Exists(folder))
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(folder);
|
||||
if (!Directory.Exists(folder))
|
||||
{
|
||||
Directory.CreateDirectory(folder);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to ensure folder exists at {Folder}", folder);
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
@@ -56,5 +71,27 @@ namespace ErsatzTV.Core.Metadata
|
||||
return BaseError.New(ex.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
public Unit EmptyFolder(string folder)
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (string file in Directory.GetFiles(folder))
|
||||
{
|
||||
File.Delete(file);
|
||||
}
|
||||
|
||||
foreach (string directory in Directory.GetDirectories(folder))
|
||||
{
|
||||
Directory.Delete(directory, true);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to empty folder at {Folder}", folder);
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,6 +188,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
version.Width = videoStream.width;
|
||||
version.Height = videoStream.height;
|
||||
version.VideoScanKind = ScanKindFromFieldOrder(videoStream.field_order);
|
||||
version.RFrameRate = videoStream.r_frame_rate;
|
||||
|
||||
var stream = new MediaStream
|
||||
{
|
||||
|
||||
@@ -156,6 +156,24 @@ namespace ErsatzTV.Core.Plex
|
||||
MediaVersion existingVersion = existing.MediaVersions.Head();
|
||||
MediaVersion incomingVersion = incoming.MediaVersions.Head();
|
||||
|
||||
foreach (MediaFile incomingFile in incomingVersion.MediaFiles.HeadOrNone())
|
||||
{
|
||||
foreach (MediaFile existingFile in existingVersion.MediaFiles.HeadOrNone())
|
||||
{
|
||||
if (incomingFile.Path != existingFile.Path)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Plex movie has moved from {OldPath} to {NewPath}",
|
||||
existingFile.Path,
|
||||
incomingFile.Path);
|
||||
|
||||
existingFile.Path = incomingFile.Path;
|
||||
|
||||
await _movieRepository.UpdatePath(existingFile.Id, incomingFile.Path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (incomingVersion.DateUpdated > existingVersion.DateUpdated || !existingVersion.Streams.Any())
|
||||
{
|
||||
string localPath = _plexPathReplacementService.GetReplacementPlexPath(
|
||||
@@ -165,7 +183,7 @@ namespace ErsatzTV.Core.Plex
|
||||
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath);
|
||||
Either<BaseError, bool> refreshResult =
|
||||
await _localStatisticsProvider.RefreshStatistics(ffprobePath, incoming, localPath);
|
||||
await _localStatisticsProvider.RefreshStatistics(ffprobePath, existing, localPath);
|
||||
|
||||
await refreshResult.Match(
|
||||
async _ =>
|
||||
|
||||
@@ -514,6 +514,24 @@ namespace ErsatzTV.Core.Plex
|
||||
{
|
||||
MediaVersion existingVersion = existing.MediaVersions.Head();
|
||||
MediaVersion incomingVersion = incoming.MediaVersions.Head();
|
||||
|
||||
foreach (MediaFile incomingFile in incomingVersion.MediaFiles.HeadOrNone())
|
||||
{
|
||||
foreach (MediaFile existingFile in existingVersion.MediaFiles.HeadOrNone())
|
||||
{
|
||||
if (incomingFile.Path != existingFile.Path)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Plex episode has moved from {OldPath} to {NewPath}",
|
||||
existingFile.Path,
|
||||
incomingFile.Path);
|
||||
|
||||
existingFile.Path = incomingFile.Path;
|
||||
|
||||
await _televisionRepository.UpdatePath(existingFile.Id, incomingFile.Path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (incomingVersion.DateUpdated > existingVersion.DateUpdated || !existingVersion.Streams.Any())
|
||||
{
|
||||
@@ -524,7 +542,7 @@ namespace ErsatzTV.Core.Plex
|
||||
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath);
|
||||
Either<BaseError, bool> refreshResult =
|
||||
await _localStatisticsProvider.RefreshStatistics(ffprobePath, incoming, localPath);
|
||||
await _localStatisticsProvider.RefreshStatistics(ffprobePath, existing, localPath);
|
||||
|
||||
await refreshResult.Match(
|
||||
async _ =>
|
||||
|
||||
@@ -133,6 +133,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
existing.Width = incoming.Width;
|
||||
existing.Height = incoming.Height;
|
||||
existing.VideoScanKind = incoming.VideoScanKind;
|
||||
existing.RFrameRate = incoming.RFrameRate;
|
||||
}
|
||||
|
||||
var toAdd = incoming.Streams.Filter(s => existing.Streams.All(es => es.Index != s.Index)).ToList();
|
||||
|
||||
@@ -774,6 +774,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
"INSERT INTO Writer (Name, MovieMetadataId) VALUES (@Name, @MetadataId)",
|
||||
new { writer.Name, MetadataId = metadata.Id }).Map(result => result > 0);
|
||||
|
||||
public Task<Unit> UpdatePath(int mediaFileId, string path) =>
|
||||
_dbConnection.ExecuteAsync(
|
||||
"UPDATE MediaFile SET Path = @Path WHERE Id = @MediaFileId",
|
||||
new { Path = path, MediaFileId = mediaFileId }).Map(_ => Unit.Default);
|
||||
|
||||
private static async Task<Either<BaseError, MediaItemScanResult<Movie>>> AddMovie(
|
||||
TvContext dbContext,
|
||||
int libraryPathId,
|
||||
|
||||
@@ -568,6 +568,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
"INSERT INTO Writer (Name, EpisodeMetadataId) VALUES (@Name, @MetadataId)",
|
||||
new { writer.Name, MetadataId = metadata.Id }).Map(result => result > 0);
|
||||
|
||||
public Task<Unit> UpdatePath(int mediaFileId, string path) =>
|
||||
_dbConnection.ExecuteAsync(
|
||||
"UPDATE MediaFile SET Path = @Path WHERE Id = @MediaFileId",
|
||||
new { Path = path, MediaFileId = mediaFileId }).Map(_ => Unit.Default);
|
||||
|
||||
public async Task<List<Episode>> GetShowItems(int showId)
|
||||
{
|
||||
IEnumerable<int> ids = await _dbConnection.QueryAsync<int>(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3287
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Add_MediaVersionRFrameRate : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "RFrameRate",
|
||||
table: "MediaVersion",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.Sql("UPDATE MediaVersion SET DateUpdated = '0001-01-01 00:00:00'");
|
||||
migrationBuilder.Sql("UPDATE LibraryFolder SET Etag = NULL");
|
||||
migrationBuilder.Sql("UPDATE EmbyMovie SET Etag = NULL");
|
||||
migrationBuilder.Sql("UPDATE EmbyShow SET Etag = NULL");
|
||||
migrationBuilder.Sql("UPDATE EmbySeason SET Etag = NULL");
|
||||
migrationBuilder.Sql("UPDATE EmbyEpisode SET Etag = NULL");
|
||||
migrationBuilder.Sql("UPDATE JellyfinMovie SET Etag = NULL");
|
||||
migrationBuilder.Sql("UPDATE JellyfinShow SET Etag = NULL");
|
||||
migrationBuilder.Sql("UPDATE JellyfinSeason SET Etag = NULL");
|
||||
migrationBuilder.Sql("UPDATE JellyfinEpisode SET Etag = NULL");
|
||||
migrationBuilder.Sql("UPDATE LibraryPath SET LastScan = '0001-01-01 00:00:00'");
|
||||
migrationBuilder.Sql("UPDATE Library SET LastScan = '0001-01-01 00:00:00'");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "RFrameRate",
|
||||
table: "MediaVersion");
|
||||
}
|
||||
}
|
||||
}
|
||||
+3293
File diff suppressed because it is too large
Load Diff
+34
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+3293
File diff suppressed because it is too large
Load Diff
+19
@@ -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");
|
||||
|
||||
@@ -809,6 +815,9 @@ namespace ErsatzTV.Infrastructure.Migrations
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("RFrameRate")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("SampleAspectRatio")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
|
||||
@@ -801,6 +801,13 @@ namespace ErsatzTV.Infrastructure.Plex
|
||||
{
|
||||
return guid;
|
||||
}
|
||||
|
||||
if (guid.StartsWith("com.plexapp.agents.imdb"))
|
||||
{
|
||||
string strip1 = guid.Replace("com.plexapp.agents.imdb://", string.Empty);
|
||||
string strip2 = strip1.Split("?").Head();
|
||||
return $"imdb://{strip2}";
|
||||
}
|
||||
|
||||
if (guid.StartsWith("com.plexapp.agents.thetvdb"))
|
||||
{
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -42,6 +42,8 @@
|
||||
<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>
|
||||
<s:Boolean x:Key="/Default/UserDictionary/Words/=strm/@EntryIndexedValue">True</s:Boolean>
|
||||
|
||||
@@ -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)).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);
|
||||
}
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Application.Channels.Queries;
|
||||
using ErsatzTV.Application.Images;
|
||||
using ErsatzTV.Application.Images.Queries;
|
||||
using ErsatzTV.Application.Streaming.Commands;
|
||||
using ErsatzTV.Application.Streaming.Queries;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Iptv;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Controllers
|
||||
{
|
||||
@@ -17,13 +25,18 @@ namespace ErsatzTV.Controllers
|
||||
[ApiExplorerSettings(IgnoreApi = true)]
|
||||
public class IptvController : ControllerBase
|
||||
{
|
||||
private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
|
||||
private readonly ILogger<IptvController> _logger;
|
||||
private readonly IMediator _mediator;
|
||||
|
||||
public IptvController(IMediator mediator, ILogger<IptvController> logger)
|
||||
public IptvController(
|
||||
IMediator mediator,
|
||||
ILogger<IptvController> logger,
|
||||
IFFmpegSegmenterService ffmpegSegmenterService)
|
||||
{
|
||||
_mediator = mediator;
|
||||
_logger = logger;
|
||||
_ffmpegSegmenterService = ffmpegSegmenterService;
|
||||
}
|
||||
|
||||
[HttpGet("iptv/channels.m3u")]
|
||||
@@ -43,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}",
|
||||
@@ -54,21 +69,65 @@ 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 Task<IActionResult> GetHttpLiveStreamingVideo(
|
||||
public async Task<IActionResult> GetHttpLiveStreamingVideo(
|
||||
string channelNumber,
|
||||
[FromQuery]
|
||||
string mode = "mixed") =>
|
||||
_mediator.Send(
|
||||
new GetHlsPlaylistByChannelNumber(
|
||||
Request.Scheme,
|
||||
Request.Host.ToString(),
|
||||
channelNumber,
|
||||
mode))
|
||||
.Map(
|
||||
result => result.Match<IActionResult>(
|
||||
playlist => Content(playlist, "application/x-mpegurl"),
|
||||
error => BadRequest(error.Value)));
|
||||
string mode = "mixed")
|
||||
{
|
||||
switch (mode)
|
||||
{
|
||||
case "segmenter":
|
||||
Either<BaseError, Unit> result = await _mediator.Send(new StartFFmpegSession(channelNumber, false));
|
||||
return result.Match<IActionResult>(
|
||||
_ => Redirect($"/iptv/session/{channelNumber}/hls.m3u8"),
|
||||
error =>
|
||||
{
|
||||
switch (error)
|
||||
{
|
||||
case ChannelSessionAlreadyActive:
|
||||
return RedirectPreserveMethod($"/iptv/session/{channelNumber}/hls.m3u8");
|
||||
default:
|
||||
_logger.LogWarning(
|
||||
"Failed to start segmenter for channel {ChannelNumber}: {Error}",
|
||||
channelNumber,
|
||||
error.ToString());
|
||||
return NotFound();
|
||||
}
|
||||
});
|
||||
default:
|
||||
return await _mediator.Send(
|
||||
new GetHlsPlaylistByChannelNumber(
|
||||
Request.Scheme,
|
||||
Request.Host.ToString(),
|
||||
channelNumber,
|
||||
mode))
|
||||
.Map(
|
||||
r => r.Match<IActionResult>(
|
||||
playlist => Content(playlist, "application/x-mpegurl"),
|
||||
error => BadRequest(error.Value)));
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("iptv/logos/{fileName}")]
|
||||
[HttpHead("iptv/logos/{fileName}.jpg")]
|
||||
|
||||
@@ -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" />
|
||||
@@ -40,7 +36,7 @@
|
||||
<PackageReference Include="Refit.HttpClientFactory" Version="6.0.94" />
|
||||
<PackageReference Include="Serilog" Version="2.10.0" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="4.1.0" />
|
||||
<PackageReference Include="Serilog.Settings.Configuration" Version="3.2.0" />
|
||||
<PackageReference Include="Serilog.Settings.Configuration" Version="3.3.0" />
|
||||
<PackageReference Include="Serilog.Sinks.SQLite" Version="5.0.0" />
|
||||
<PackageReference Include="System.IO.FileSystem.Primitives" Version="4.3.0" />
|
||||
<PackageReference Include="System.Text.Encoding.Extensions" Version="4.3.0" />
|
||||
|
||||
@@ -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: </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"
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
<MudSelectItem Value="@(StreamingMode.TransportStream)">MPEG-TS</MudSelectItem>
|
||||
<MudSelectItem Value="@(StreamingMode.HttpLiveStreamingDirect)">HLS Direct</MudSelectItem>
|
||||
<MudSelectItem Value="@(StreamingMode.HttpLiveStreamingHybrid)">HLS Hybrid</MudSelectItem>
|
||||
<MudSelectItem Value="@(StreamingMode.HttpLiveStreamingSegmenter)">HLS Segmenter</MudSelectItem>
|
||||
</MudSelect>
|
||||
<MudSelect Class="mt-3" Label="FFmpeg Profile" @bind-Value="_model.FFmpegProfileId" For="@(() => _model.FFmpegProfileId)"
|
||||
Disabled="@(_model.StreamingMode == StreamingMode.HttpLiveStreamingDirect)">
|
||||
|
||||
@@ -141,6 +141,7 @@
|
||||
private static string GetStreamingMode(StreamingMode streamingMode) => streamingMode switch {
|
||||
StreamingMode.HttpLiveStreamingDirect => "HLS Direct",
|
||||
StreamingMode.HttpLiveStreamingHybrid => "HLS Hybrid",
|
||||
StreamingMode.HttpLiveStreamingSegmenter => "HLS Segmenter",
|
||||
_ => "MPEG-TS"
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.Streaming.Commands;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Services
|
||||
{
|
||||
public class FFmpegWorkerService : BackgroundService
|
||||
{
|
||||
private readonly ChannelReader<IFFmpegWorkerRequest> _channel;
|
||||
private readonly ILogger<FFmpegWorkerService> _logger;
|
||||
private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
|
||||
private readonly IServiceScopeFactory _serviceScopeFactory;
|
||||
|
||||
public FFmpegWorkerService(
|
||||
ChannelReader<IFFmpegWorkerRequest> channel,
|
||||
IServiceScopeFactory serviceScopeFactory,
|
||||
ILogger<FFmpegWorkerService> logger,
|
||||
IFFmpegSegmenterService ffmpegSegmenterService)
|
||||
{
|
||||
_channel = channel;
|
||||
_serviceScopeFactory = serviceScopeFactory;
|
||||
_logger = logger;
|
||||
_ffmpegSegmenterService = ffmpegSegmenterService;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("FFmpeg worker service started");
|
||||
|
||||
await foreach (IFFmpegWorkerRequest request in _channel.ReadAllAsync(cancellationToken))
|
||||
{
|
||||
try
|
||||
{
|
||||
using IServiceScope scope = _serviceScopeFactory.CreateScope();
|
||||
// IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
|
||||
|
||||
switch (request)
|
||||
{
|
||||
case TouchFFmpegSession touchFFmpegSession:
|
||||
foreach (DirectoryInfo parent in Optional(Directory.GetParent(touchFFmpegSession.Path)))
|
||||
{
|
||||
_ffmpegSegmenterService.TouchChannel(parent.Name);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to handle ffmpeg worker request");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Collections.Generic;
|
||||
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 +40,18 @@ namespace ErsatzTV.Services.RunOnce
|
||||
IConfigElementRepository repo = scope.ServiceProvider.GetRequiredService<IConfigElementRepository>();
|
||||
await repo.Upsert(ConfigElementKey.FFmpegSaveReports, false);
|
||||
}
|
||||
|
||||
if (runtimeInfo != null && runtimeInfo.IsOSPlatform(OSPlatform.Linux))
|
||||
{
|
||||
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;
|
||||
|
||||
+43
-1
@@ -7,7 +7,8 @@ using Blazored.LocalStorage;
|
||||
using Dapper;
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.Channels.Queries;
|
||||
using ErsatzTV.Application.Logs.Queries;
|
||||
using ErsatzTV.Application.Streaming;
|
||||
using ErsatzTV.Application.Streaming.Commands;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Emby;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
@@ -56,10 +57,12 @@ using MediatR;
|
||||
using MediatR.Courier.DependencyInjection;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.StaticFiles;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using MudBlazor.Services;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Converters;
|
||||
@@ -76,6 +79,16 @@ namespace ErsatzTV
|
||||
|
||||
public void ConfigureServices(IServiceCollection services)
|
||||
{
|
||||
services.AddCors(
|
||||
o => o.AddPolicy(
|
||||
"AllowAll",
|
||||
builder =>
|
||||
{
|
||||
builder.AllowAnyOrigin()
|
||||
.AllowAnyMethod()
|
||||
.AllowAnyHeader();
|
||||
}));
|
||||
|
||||
services.AddControllers(
|
||||
options =>
|
||||
{
|
||||
@@ -123,6 +136,11 @@ namespace ErsatzTV
|
||||
Directory.CreateDirectory(FileSystemLayout.AppDataFolder);
|
||||
}
|
||||
|
||||
if (!Directory.Exists(FileSystemLayout.TranscodeFolder))
|
||||
{
|
||||
Directory.CreateDirectory(FileSystemLayout.TranscodeFolder);
|
||||
}
|
||||
|
||||
Log.Logger.Information("Database is at {DatabasePath}", FileSystemLayout.DatabasePath);
|
||||
|
||||
// until we add a setting for a file-specific scheme://host:port to access
|
||||
@@ -183,10 +201,30 @@ namespace ErsatzTV
|
||||
|
||||
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
|
||||
{
|
||||
app.UseCors("AllowAll");
|
||||
|
||||
// app.UseSerilogRequestLogging();
|
||||
|
||||
app.UseStaticFiles();
|
||||
|
||||
var extensionProvider = new FileExtensionContentTypeProvider();
|
||||
extensionProvider.Mappings.Add(".m3u8", "application/vnd.apple.mpegurl");
|
||||
|
||||
app.UseStaticFiles(
|
||||
new StaticFileOptions
|
||||
{
|
||||
FileProvider = new PhysicalFileProvider(FileSystemLayout.TranscodeFolder),
|
||||
RequestPath = "/iptv/session",
|
||||
ContentTypeProvider = extensionProvider,
|
||||
OnPrepareResponse = ctx =>
|
||||
{
|
||||
// Log.Logger.Information("Transcode access: {Test}", ctx.File.PhysicalPath);
|
||||
ChannelWriter<IFFmpegWorkerRequest> writer = app.ApplicationServices
|
||||
.GetRequiredService<ChannelWriter<IFFmpegWorkerRequest>>();
|
||||
writer.TryWrite(new TouchFFmpegSession(ctx.File.PhysicalPath));
|
||||
}
|
||||
});
|
||||
|
||||
app.UseRouting();
|
||||
|
||||
app.UseEndpoints(
|
||||
@@ -206,10 +244,12 @@ namespace ErsatzTV
|
||||
services.AddSingleton<ITraktApiClient, TraktApiClient>();
|
||||
services.AddSingleton<IEntityLocker, EntityLocker>();
|
||||
services.AddSingleton<ISearchIndex, SearchIndex>();
|
||||
services.AddSingleton<IFFmpegSegmenterService, FFmpegSegmenterService>();
|
||||
AddChannel<IBackgroundServiceRequest>(services);
|
||||
AddChannel<IPlexBackgroundServiceRequest>(services);
|
||||
AddChannel<IJellyfinBackgroundServiceRequest>(services);
|
||||
AddChannel<IEmbyBackgroundServiceRequest>(services);
|
||||
AddChannel<IFFmpegWorkerRequest>(services);
|
||||
|
||||
services.AddScoped<IFFmpegVersionHealthCheck, FFmpegVersionHealthCheck>();
|
||||
services.AddScoped<IFFmpegReportsHealthCheck, FFmpegReportsHealthCheck>();
|
||||
@@ -261,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>(
|
||||
_ =>
|
||||
@@ -286,6 +327,7 @@ namespace ErsatzTV
|
||||
services.AddHostedService<FFmpegLocatorService>();
|
||||
services.AddHostedService<WorkerService>();
|
||||
services.AddHostedService<SchedulerService>();
|
||||
services.AddHostedService<FFmpegWorkerService>();
|
||||
}
|
||||
|
||||
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,6 +1,6 @@
|
||||
# ErsatzTV
|
||||
|
||||
**ErsatzTV** is pre-alpha software for configuring and streaming custom live channels using your media library. The software is currently unstable and under active development.
|
||||
**ErsatzTV** is alpha software for configuring and streaming custom live channels using your media library. The software is currently unstable and under active development.
|
||||
|
||||
Documentation is available at [ersatztv.org](https://ersatztv.org/).
|
||||
|
||||
|
||||
+1
-1
@@ -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,10 +1,4 @@
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:5.0-focal-amd64 AS dotnet-runtime
|
||||
|
||||
FROM jrottenberg/ffmpeg:4.3-nvidia1804 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
|
||||
|
||||
# https://hub.docker.com/_/microsoft-dotnet
|
||||
# https://hub.docker.com/_/microsoft-dotnet
|
||||
FROM mcr.microsoft.com/dotnet/sdk:5.0 AS build
|
||||
RUN apt-get update && apt-get install -y ca-certificates
|
||||
WORKDIR /source
|
||||
@@ -29,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 runtime-base
|
||||
FROM jasongdove/ffmpeg:4.4-nvidia2004 AS runtime-base
|
||||
WORKDIR /app
|
||||
EXPOSE 8409
|
||||
COPY --from=build /app ./
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:5.0-focal-amd64 AS dotnet-runtime
|
||||
|
||||
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 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
@@ -1,10 +1,4 @@
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:5.0-focal-amd64 AS dotnet-runtime
|
||||
|
||||
FROM jrottenberg/ffmpeg:4.3-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 intel-media-va-driver-non-free libva-x11-2
|
||||
|
||||
# https://hub.docker.com/_/microsoft-dotnet
|
||||
# https://hub.docker.com/_/microsoft-dotnet
|
||||
FROM mcr.microsoft.com/dotnet/sdk:5.0 AS build
|
||||
RUN apt-get update && apt-get install -y ca-certificates
|
||||
WORKDIR /source
|
||||
@@ -29,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 runtime-base
|
||||
FROM jasongdove/ffmpeg:4.4-vaapi2004 AS runtime-base
|
||||
WORKDIR /app
|
||||
EXPOSE 8409
|
||||
COPY --from=build /app ./
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:5.0-focal-amd64 AS dotnet-runtime
|
||||
|
||||
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 \
|
||||
autoconf \
|
||||
libtool \
|
||||
libdrm-dev \
|
||||
git \
|
||||
pkg-config \
|
||||
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 \
|
||||
&& ./autogen.sh \
|
||||
&& ./configure \
|
||||
&& make -j$(nproc) \
|
||||
&& make -j$(nproc) install \
|
||||
&& cd /tmp/intel \
|
||||
&& wget -O - https://github.com/intel/gmmlib/archive/refs/tags/intel-gmmlib-21.3.1.tar.gz | tar zxf - \
|
||||
&& mv gmmlib-intel-gmmlib-21.3.1 gmmlib \
|
||||
&& cd gmmlib \
|
||||
&& mkdir build && cd build \
|
||||
&& cmake .. \
|
||||
&& make -j$(nproc) \
|
||||
&& cd /tmp/intel \
|
||||
&& git clone --depth 1 --branch intel-media-21.2.3 https://github.com/intel/media-driver \
|
||||
&& mkdir build_media && cd build_media \
|
||||
&& cmake ../media-driver \
|
||||
&& make -j$(nproc) \
|
||||
&& make install \
|
||||
&& DEBIAN_FRONTEND="noninteractive" apt-get purge -y autoconf libtool git build-essential cmake wget \
|
||||
&& apt autoremove -y \
|
||||
&& rm -rf /tmp/intel \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& mv /usr/lib/x86_64-linux-gnu/dri/* /usr/local/lib/dri/
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.0 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user