mirror of https://github.com/ErsatzTV/ErsatzTV.git
Browse Source
* return multi variant playlist from start ffmpeg session * use ersatztv-channel session when next engine is configured * add next playout models * generate next playout json files on non-windows systemspull/2855/head
17 changed files with 1470 additions and 72 deletions
@ -0,0 +1,3 @@ |
|||||||
|
namespace ErsatzTV.Application.Playouts; |
||||||
|
|
||||||
|
public record SyncNextPlayout(string ChannelNumber) : IRequest, IBackgroundServiceRequest; |
||||||
@ -0,0 +1,205 @@ |
|||||||
|
using System.Globalization; |
||||||
|
using System.IO.Abstractions; |
||||||
|
using System.Runtime.InteropServices; |
||||||
|
using ErsatzTV.Core; |
||||||
|
using ErsatzTV.Core.Domain; |
||||||
|
using ErsatzTV.Core.Extensions; |
||||||
|
using ErsatzTV.Core.Interfaces.Metadata; |
||||||
|
using ErsatzTV.Core.Next; |
||||||
|
using ErsatzTV.Infrastructure.Data; |
||||||
|
using Microsoft.EntityFrameworkCore; |
||||||
|
using Microsoft.Extensions.Logging; |
||||||
|
|
||||||
|
namespace ErsatzTV.Application.Playouts; |
||||||
|
|
||||||
|
public partial class SyncNextPlayoutHandler( |
||||||
|
IFileSystem fileSystem, |
||||||
|
ILocalFileSystem localFileSystem, |
||||||
|
IDbContextFactory<TvContext> dbContextFactory, |
||||||
|
ILogger<SyncNextPlayoutHandler> logger) |
||||||
|
: IRequestHandler<SyncNextPlayout> |
||||||
|
{ |
||||||
|
[LibraryImport("libc", EntryPoint = "rename", SetLastError = true)] |
||||||
|
private static partial int Rename( |
||||||
|
[MarshalAs(UnmanagedType.LPUTF8Str)] |
||||||
|
string oldpath, |
||||||
|
[MarshalAs(UnmanagedType.LPUTF8Str)] |
||||||
|
string newpath |
||||||
|
); |
||||||
|
|
||||||
|
public async Task Handle(SyncNextPlayout request, CancellationToken cancellationToken) |
||||||
|
{ |
||||||
|
// TODO: NEXT: support junctions on Windows
|
||||||
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) |
||||||
|
{ |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// gen new folder name
|
||||||
|
string versionFolderName = DateTimeOffset.Now.ToUnixTimeMilliseconds().ToString(CultureInfo.InvariantCulture); |
||||||
|
|
||||||
|
string versionFolder = fileSystem.Path.Combine( |
||||||
|
FileSystemLayout.NextPlayoutsFolder, |
||||||
|
request.ChannelNumber, |
||||||
|
versionFolderName); |
||||||
|
|
||||||
|
logger.LogDebug("versioned playout folder is {Folder}", versionFolder); |
||||||
|
|
||||||
|
localFileSystem.EnsureFolderExists(versionFolder); |
||||||
|
|
||||||
|
await WriteAllJsonTo(request.ChannelNumber, versionFolder, cancellationToken); |
||||||
|
|
||||||
|
string currentFolder = fileSystem.Path.Combine( |
||||||
|
FileSystemLayout.NextPlayoutsFolder, |
||||||
|
request.ChannelNumber, |
||||||
|
"current"); |
||||||
|
|
||||||
|
// re-point symlink/junction to new folder
|
||||||
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) |
||||||
|
{ |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
string tempLink = fileSystem.Path.Combine( |
||||||
|
FileSystemLayout.NextPlayoutsFolder, |
||||||
|
request.ChannelNumber, |
||||||
|
fileSystem.Path.GetRandomFileName()); |
||||||
|
|
||||||
|
fileSystem.File.CreateSymbolicLink(tempLink, versionFolderName); |
||||||
|
_ = Rename(tempLink, currentFolder); |
||||||
|
} |
||||||
|
|
||||||
|
CleanOldVersions( |
||||||
|
fileSystem.Path.Combine(FileSystemLayout.NextPlayoutsFolder, request.ChannelNumber), |
||||||
|
currentFolder); |
||||||
|
} |
||||||
|
|
||||||
|
private async Task WriteAllJsonTo(string channelNumber, string targetFolder, CancellationToken cancellationToken) |
||||||
|
{ |
||||||
|
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); |
||||||
|
|
||||||
|
List<int> localLibraryIds = await dbContext.LocalLibraries |
||||||
|
.AsNoTracking() |
||||||
|
.Map(l => l.Id) |
||||||
|
.ToListAsync(cancellationToken); |
||||||
|
|
||||||
|
List<PlayoutItem> playoutItems = await dbContext.PlayoutItems |
||||||
|
.AsNoTracking() |
||||||
|
.Where(i => i.Playout.Channel.Number == channelNumber) |
||||||
|
.Where(i => localLibraryIds.Contains(i.MediaItem.LibraryPath.LibraryId)) |
||||||
|
.Include(i => i.MediaItem) |
||||||
|
.ThenInclude(i => (i as Episode).MediaVersions) |
||||||
|
.ThenInclude(mv => mv.MediaFiles) |
||||||
|
.Include(i => i.MediaItem) |
||||||
|
.ThenInclude(i => (i as Movie).MediaVersions) |
||||||
|
.ThenInclude(mv => mv.MediaFiles) |
||||||
|
.Include(i => i.MediaItem) |
||||||
|
.ThenInclude(i => (i as OtherVideo).MediaVersions) |
||||||
|
.ThenInclude(mv => mv.MediaFiles) |
||||||
|
.Include(i => i.MediaItem) |
||||||
|
.ThenInclude(i => (i as MusicVideo).MediaVersions) |
||||||
|
.ThenInclude(mv => mv.MediaFiles) |
||||||
|
.ToListAsync(cancellationToken); |
||||||
|
|
||||||
|
logger.LogDebug("Located {Count} local playout items", playoutItems.Count); |
||||||
|
|
||||||
|
foreach (IGrouping<DateTime, PlayoutItem> group in playoutItems.GroupBy(pi => pi.StartOffset.Date) |
||||||
|
.Where(g => g.Any())) |
||||||
|
{ |
||||||
|
var first = group.First(); |
||||||
|
var last = group.Last(); |
||||||
|
|
||||||
|
string fileName = fileSystem.Path.Combine( |
||||||
|
targetFolder, |
||||||
|
$"{first.StartOffset.ToUnixTimeMilliseconds()}_{last.FinishOffset.ToUnixTimeMilliseconds()}.json"); |
||||||
|
|
||||||
|
var playout = new Core.Next.Playout { Version = "https://ersatztv.org/playout/version/0.0.1", Items = [] }; |
||||||
|
foreach (PlayoutItem playoutItem in group) |
||||||
|
{ |
||||||
|
if (playoutItem.MediaItem is not Episode && playoutItem.MediaItem is not Movie && |
||||||
|
playoutItem.MediaItem is not OtherVideo && playoutItem.MediaItem is not MusicVideo) |
||||||
|
{ |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
string path = playoutItem.MediaItem.GetHeadVersion().MediaFiles.Head().Path; |
||||||
|
|
||||||
|
var nextPlayoutItem = new ItemElement |
||||||
|
{ |
||||||
|
Id = playoutItem.Id.ToString(CultureInfo.InvariantCulture), |
||||||
|
Start = playoutItem.StartOffset.ToString("O"), |
||||||
|
Finish = playoutItem.FinishOffset.ToString("O"), |
||||||
|
Source = new ItemSource |
||||||
|
{ |
||||||
|
SourceType = SourceType.Local, |
||||||
|
Path = path, |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
playout.Items.Add(nextPlayoutItem); |
||||||
|
} |
||||||
|
|
||||||
|
await fileSystem.File.WriteAllTextAsync(fileName, playout.ToJson(), cancellationToken); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
public void CleanOldVersions( |
||||||
|
string playoutRoot, |
||||||
|
string currentLinkPath, |
||||||
|
int keepVersions = 2, |
||||||
|
TimeSpan? gracePeriod = null) |
||||||
|
{ |
||||||
|
gracePeriod ??= TimeSpan.FromMinutes(5); |
||||||
|
|
||||||
|
string currentResolvedPath = null; |
||||||
|
if (Directory.Exists(currentLinkPath)) |
||||||
|
{ |
||||||
|
currentResolvedPath = Path.GetFullPath( |
||||||
|
Path.Combine( |
||||||
|
Path.GetDirectoryName(currentLinkPath) ?? "", |
||||||
|
Directory.ResolveLinkTarget(currentLinkPath, true)?.FullName ?? "" |
||||||
|
)); |
||||||
|
} |
||||||
|
|
||||||
|
var directories = Directory.GetDirectories(playoutRoot) |
||||||
|
.Select(d => new DirectoryInfo(d)) |
||||||
|
.Where(d => long.TryParse(d.Name, out _)) |
||||||
|
.OrderByDescending(d => d.Name) |
||||||
|
.ToList(); |
||||||
|
|
||||||
|
int keptCount = 0; |
||||||
|
|
||||||
|
foreach (var dir in directories) |
||||||
|
{ |
||||||
|
string fullDir = dir.FullName; |
||||||
|
|
||||||
|
if (fullDir.Equals(currentResolvedPath, StringComparison.OrdinalIgnoreCase)) |
||||||
|
{ |
||||||
|
keptCount++; |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
if (keptCount < keepVersions) |
||||||
|
{ |
||||||
|
keptCount++; |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
if (DateTime.Now - dir.LastWriteTime < gracePeriod) |
||||||
|
{ |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
try |
||||||
|
{ |
||||||
|
dir.Delete(recursive: true); |
||||||
|
logger.LogDebug("Cleaned up old playout version: {Folder}", dir.Name); |
||||||
|
} |
||||||
|
catch (IOException) |
||||||
|
{ |
||||||
|
// ignore errors; will be cleaned up next time through
|
||||||
|
logger.LogDebug("Skipping busy folder: {Folder}", dir.Name); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,13 @@ |
|||||||
|
using ErsatzTV.Core; |
||||||
|
|
||||||
|
namespace ErsatzTV.Application.Streaming; |
||||||
|
|
||||||
|
public record StartFFmpegNextSession( |
||||||
|
string ChannelNumber, |
||||||
|
string Mode, |
||||||
|
string Scheme, |
||||||
|
string Host, |
||||||
|
string PathBase, |
||||||
|
string AccessTokenQuery) : |
||||||
|
IRequest<Either<BaseError, string>>, |
||||||
|
IFFmpegWorkerRequest; |
||||||
@ -0,0 +1,371 @@ |
|||||||
|
using System.Globalization; |
||||||
|
using System.IO.Abstractions; |
||||||
|
using System.Reflection; |
||||||
|
using System.Runtime.InteropServices; |
||||||
|
using System.Threading.Channels; |
||||||
|
using ErsatzTV.Application.Channels; |
||||||
|
using ErsatzTV.Application.FFmpegProfiles; |
||||||
|
using ErsatzTV.Application.Graphics; |
||||||
|
using ErsatzTV.Application.Maintenance; |
||||||
|
using ErsatzTV.Core; |
||||||
|
using ErsatzTV.Core.Domain; |
||||||
|
using ErsatzTV.Core.Errors; |
||||||
|
using ErsatzTV.Core.FFmpeg; |
||||||
|
using ErsatzTV.Core.Interfaces.FFmpeg; |
||||||
|
using ErsatzTV.Core.Interfaces.Metadata; |
||||||
|
using ErsatzTV.Core.Interfaces.Repositories; |
||||||
|
using ErsatzTV.Core.Next.Config; |
||||||
|
using Microsoft.Extensions.DependencyInjection; |
||||||
|
using Microsoft.Extensions.Hosting; |
||||||
|
using Microsoft.Extensions.Logging; |
||||||
|
|
||||||
|
namespace ErsatzTV.Application.Streaming; |
||||||
|
|
||||||
|
public class StartFFmpegNextSessionHandler( |
||||||
|
IServiceScopeFactory serviceScopeFactory, |
||||||
|
IFileSystem fileSystem, |
||||||
|
ILocalFileSystem localFileSystem, |
||||||
|
IFFmpegSegmenterService ffmpegSegmenterService, |
||||||
|
IConfigElementRepository configElementRepository, |
||||||
|
IHostApplicationLifetime hostApplicationLifetime, |
||||||
|
IMediator mediator, |
||||||
|
ChannelWriter<IBackgroundServiceRequest> workerChannel, |
||||||
|
ILogger<StartFFmpegNextSessionHandler> logger, |
||||||
|
ILogger<NextSessionWorker> sessionWorkerLogger) |
||||||
|
: IRequestHandler<StartFFmpegNextSession, Either<BaseError, string>> |
||||||
|
{ |
||||||
|
|
||||||
|
public Task<Either<BaseError, string>> Handle( |
||||||
|
StartFFmpegNextSession request, |
||||||
|
CancellationToken cancellationToken) => |
||||||
|
Validate(request, cancellationToken) |
||||||
|
.MapT(validationResult => StartProcess(request, validationResult, cancellationToken)) |
||||||
|
// 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<string>, string>(identity)); |
||||||
|
#pragma warning restore VSTHRD103
|
||||||
|
|
||||||
|
private async Task<string> StartProcess( |
||||||
|
StartFFmpegNextSession request, |
||||||
|
ValidationResult validationResult, |
||||||
|
CancellationToken cancellationToken) |
||||||
|
{ |
||||||
|
Option<TimeSpan> idleTimeout = Option<TimeSpan>.None; |
||||||
|
|
||||||
|
// Option<FrameRate> targetFramerate = await mediator.Send(
|
||||||
|
// new GetChannelFramerate(request.ChannelNumber),
|
||||||
|
// cancellationToken);
|
||||||
|
|
||||||
|
// only load timeout when needed
|
||||||
|
if (validationResult.Channel.IdleBehavior is not ChannelIdleBehavior.KeepRunning) |
||||||
|
{ |
||||||
|
idleTimeout = await configElementRepository |
||||||
|
.GetValue<int>(ConfigElementKey.FFmpegSegmenterTimeout, cancellationToken) |
||||||
|
.Map(maybeTimeout => maybeTimeout.Match(i => TimeSpan.FromSeconds(i), () => TimeSpan.FromMinutes(1))); |
||||||
|
} |
||||||
|
|
||||||
|
await mediator.Send(new RefreshGraphicsElements(), cancellationToken); |
||||||
|
|
||||||
|
ChannelConfig config = await MapConfig( |
||||||
|
request.ChannelNumber, |
||||||
|
validationResult.FfmpegProfile, |
||||||
|
cancellationToken); |
||||||
|
|
||||||
|
NextSessionWorker worker = new NextSessionWorker( |
||||||
|
validationResult.ChannelBinary, |
||||||
|
config, |
||||||
|
fileSystem, |
||||||
|
localFileSystem, |
||||||
|
serviceScopeFactory, |
||||||
|
sessionWorkerLogger); |
||||||
|
|
||||||
|
ffmpegSegmenterService.AddOrUpdateWorker(request.ChannelNumber, worker); |
||||||
|
|
||||||
|
// fire and forget worker
|
||||||
|
_ = worker.Run(request.ChannelNumber, idleTimeout, hostApplicationLifetime.ApplicationStopping) |
||||||
|
.ContinueWith( |
||||||
|
_ => |
||||||
|
{ |
||||||
|
ffmpegSegmenterService.RemoveWorker(request.ChannelNumber, out IHlsSessionWorker inactiveWorker); |
||||||
|
|
||||||
|
inactiveWorker?.Dispose(); |
||||||
|
|
||||||
|
workerChannel.TryWrite(new ReleaseMemory(false)); |
||||||
|
}, |
||||||
|
TaskScheduler.Default); |
||||||
|
|
||||||
|
int initialSegmentCount = await configElementRepository |
||||||
|
.GetValue<int>(ConfigElementKey.FFmpegInitialSegmentCount, cancellationToken) |
||||||
|
.Map(maybeCount => maybeCount.Match(identity, () => 1)); |
||||||
|
|
||||||
|
await worker.WaitForPlaylistSegments(initialSegmentCount, cancellationToken); |
||||||
|
|
||||||
|
return await GetMultiVariantPlaylist(request); |
||||||
|
} |
||||||
|
|
||||||
|
private Task<Validation<BaseError, ValidationResult>> Validate( |
||||||
|
StartFFmpegNextSession request, |
||||||
|
CancellationToken cancellationToken) => |
||||||
|
SessionMustBeInactive(request) |
||||||
|
.BindT(_ => FolderMustBeEmpty(request)) |
||||||
|
.BindT(_ => ChannelBinaryMustExist()) |
||||||
|
.BindT(result => ChannelMustExist(request, result, cancellationToken)) |
||||||
|
.BindT(result => FFmpegProfileMustExist(result, cancellationToken)); |
||||||
|
|
||||||
|
private async Task<Validation<BaseError, Unit>> SessionMustBeInactive(StartFFmpegNextSession request) |
||||||
|
{ |
||||||
|
var result = Optional(ffmpegSegmenterService.TryAddWorker(request.ChannelNumber, null)) |
||||||
|
.Where(success => success) |
||||||
|
.Map(_ => Unit.Default) |
||||||
|
.ToValidation<BaseError>(new ChannelSessionAlreadyActive(await GetMultiVariantPlaylist(request))); |
||||||
|
|
||||||
|
if (result.IsFail && ffmpegSegmenterService.TryGetWorker( |
||||||
|
request.ChannelNumber, |
||||||
|
out IHlsSessionWorker worker)) |
||||||
|
{ |
||||||
|
worker?.Touch(Option<string>.None); |
||||||
|
} |
||||||
|
|
||||||
|
return result; |
||||||
|
} |
||||||
|
|
||||||
|
private Task<Validation<BaseError, Unit>> FolderMustBeEmpty(StartFFmpegNextSession 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); |
||||||
|
} |
||||||
|
|
||||||
|
private Task<Validation<BaseError, ValidationResult>> ChannelBinaryMustExist() |
||||||
|
{ |
||||||
|
string nextFolder = string.IsNullOrWhiteSpace(SystemEnvironment.NextFolder) |
||||||
|
? fileSystem.Path.GetDirectoryName(Assembly.GetEntryAssembly()?.Location) |
||||||
|
: SystemEnvironment.NextFolder; |
||||||
|
|
||||||
|
string executable = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) |
||||||
|
? "ersatztv-channel.exe" |
||||||
|
: "ersatztv-channel"; |
||||||
|
|
||||||
|
string channelBinary = fileSystem.Path.Combine(ReplaceTilde(nextFolder), executable); |
||||||
|
if (!fileSystem.Path.Exists(channelBinary)) |
||||||
|
{ |
||||||
|
return Task.FromResult<Validation<BaseError, ValidationResult>>( |
||||||
|
BaseError.New("ersatztv-channel binary does not exist!")); |
||||||
|
} |
||||||
|
|
||||||
|
return Task.FromResult<Validation<BaseError, ValidationResult>>( |
||||||
|
new ValidationResult(channelBinary, null, null)); |
||||||
|
} |
||||||
|
|
||||||
|
private async Task<Validation<BaseError, ValidationResult>> ChannelMustExist( |
||||||
|
StartFFmpegNextSession request, |
||||||
|
ValidationResult result, |
||||||
|
CancellationToken cancellationToken) |
||||||
|
{ |
||||||
|
Option<ChannelViewModel> maybeChannel = await mediator.Send( |
||||||
|
new GetChannelByNumber(request.ChannelNumber), |
||||||
|
cancellationToken); |
||||||
|
|
||||||
|
foreach (ChannelViewModel channel in maybeChannel) |
||||||
|
{ |
||||||
|
return result with { Channel = channel }; |
||||||
|
} |
||||||
|
|
||||||
|
return BaseError.New($"Channel number {request.ChannelNumber} does not exist"); |
||||||
|
} |
||||||
|
|
||||||
|
private async Task<Validation<BaseError, ValidationResult>> FFmpegProfileMustExist( |
||||||
|
ValidationResult result, |
||||||
|
CancellationToken cancellationToken) |
||||||
|
{ |
||||||
|
Option<FFmpegProfileViewModel> maybeFFmpegProfile = await mediator.Send( |
||||||
|
new GetFFmpegProfileById(result.Channel.FFmpegProfileId), |
||||||
|
cancellationToken); |
||||||
|
|
||||||
|
foreach (FFmpegProfileViewModel ffmpegProfile in maybeFFmpegProfile) |
||||||
|
{ |
||||||
|
return result with { FfmpegProfile = ffmpegProfile }; |
||||||
|
} |
||||||
|
|
||||||
|
return BaseError.New($"FFmpeg profile {result.Channel.FFmpegProfileId} not exist"); |
||||||
|
} |
||||||
|
|
||||||
|
public string ReplaceTilde(string path) |
||||||
|
{ |
||||||
|
if (!path.StartsWith('~')) |
||||||
|
{ |
||||||
|
return path; |
||||||
|
} |
||||||
|
|
||||||
|
string userFolder = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); |
||||||
|
|
||||||
|
switch (path) |
||||||
|
{ |
||||||
|
case "~": |
||||||
|
return userFolder; |
||||||
|
case not null |
||||||
|
when path.Length == 2 && |
||||||
|
(path[1] == fileSystem.Path.DirectorySeparatorChar || |
||||||
|
path[1] == fileSystem.Path.AltDirectorySeparatorChar): |
||||||
|
return userFolder + fileSystem.Path.DirectorySeparatorChar; |
||||||
|
default: |
||||||
|
return fileSystem.Path.Combine(userFolder, path[2..]); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private async Task<string> GetMultiVariantPlaylist(StartFFmpegNextSession request) |
||||||
|
{ |
||||||
|
var variantPlaylist = |
||||||
|
$"{request.Scheme}://{request.Host}{request.PathBase}/iptv/session/{request.ChannelNumber}/live.m3u8{request.AccessTokenQuery}"; |
||||||
|
|
||||||
|
Option<ChannelStreamingSpecsViewModel> maybeStreamingSpecs = |
||||||
|
await mediator.Send(new GetChannelStreamingSpecs(request.ChannelNumber)); |
||||||
|
string resolution = string.Empty; |
||||||
|
var bitrate = "10000000"; |
||||||
|
foreach (ChannelStreamingSpecsViewModel streamingSpecs in maybeStreamingSpecs) |
||||||
|
{ |
||||||
|
string videoCodec = streamingSpecs.VideoFormat switch |
||||||
|
{ |
||||||
|
FFmpegProfileVideoFormat.Av1 => "av01.0.01M.08", |
||||||
|
FFmpegProfileVideoFormat.Hevc => "hvc1.1.6.L93.B0", |
||||||
|
FFmpegProfileVideoFormat.H264 => "avc1.4D4028", |
||||||
|
_ => string.Empty |
||||||
|
}; |
||||||
|
|
||||||
|
string audioCodec = streamingSpecs.AudioFormat switch |
||||||
|
{ |
||||||
|
FFmpegProfileAudioFormat.Ac3 => "ac-3", |
||||||
|
FFmpegProfileAudioFormat.Aac or FFmpegProfileAudioFormat.AacLatm => "mp4a.40.2", |
||||||
|
_ => string.Empty |
||||||
|
}; |
||||||
|
|
||||||
|
List<string> codecStrings = []; |
||||||
|
if (!string.IsNullOrWhiteSpace(videoCodec)) |
||||||
|
{ |
||||||
|
codecStrings.Add(videoCodec); |
||||||
|
} |
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(audioCodec)) |
||||||
|
{ |
||||||
|
codecStrings.Add(audioCodec); |
||||||
|
} |
||||||
|
|
||||||
|
string codecs = codecStrings.Count > 0 ? $",CODECS=\"{string.Join(",", codecStrings)}\"" : string.Empty; |
||||||
|
resolution = $",RESOLUTION={streamingSpecs.Width}x{streamingSpecs.Height}{codecs}"; |
||||||
|
bitrate = streamingSpecs.Bitrate.ToString(CultureInfo.InvariantCulture); |
||||||
|
} |
||||||
|
|
||||||
|
return $@"#EXTM3U
|
||||||
|
#EXT-X-VERSION:3 |
||||||
|
#EXT-X-STREAM-INF:BANDWIDTH={bitrate}{resolution} |
||||||
|
{variantPlaylist}";
|
||||||
|
} |
||||||
|
|
||||||
|
private async Task<ChannelConfig> MapConfig( |
||||||
|
string channelNumber, |
||||||
|
FFmpegProfileViewModel ffmpegProfile, |
||||||
|
CancellationToken cancellationToken) |
||||||
|
{ |
||||||
|
var ffmpeg = new Ffmpeg(); |
||||||
|
|
||||||
|
Option<string> ffmpegPath = await configElementRepository.GetValue<string>( |
||||||
|
ConfigElementKey.FFmpegPath, |
||||||
|
cancellationToken); |
||||||
|
|
||||||
|
foreach (string path in ffmpegPath) |
||||||
|
{ |
||||||
|
ffmpeg.FfmpegPath = path; |
||||||
|
} |
||||||
|
|
||||||
|
Option<string> ffprobePath = await configElementRepository.GetValue<string>( |
||||||
|
ConfigElementKey.FFprobePath, |
||||||
|
cancellationToken); |
||||||
|
|
||||||
|
foreach (string path in ffprobePath) |
||||||
|
{ |
||||||
|
ffmpeg.FfprobePath = path; |
||||||
|
} |
||||||
|
|
||||||
|
var audioNormalization = new Audio |
||||||
|
{ |
||||||
|
Format = ffmpegProfile.AudioFormat switch |
||||||
|
{ |
||||||
|
FFmpegProfileAudioFormat.Ac3 => AudioFormat.Ac3, |
||||||
|
_ => AudioFormat.Aac |
||||||
|
}, |
||||||
|
BitrateKbps = ffmpegProfile.AudioBitrate, |
||||||
|
BufferKbps = ffmpegProfile.AudioBufferSize, |
||||||
|
Channels = ffmpegProfile.AudioChannels, |
||||||
|
SampleRateHz = ffmpegProfile.AudioSampleRate * 1000 |
||||||
|
}; |
||||||
|
|
||||||
|
if (ffmpegProfile.NormalizeLoudnessMode is NormalizeLoudnessMode.LoudNorm) |
||||||
|
{ |
||||||
|
audioNormalization.NormalizeLoudness = true; |
||||||
|
audioNormalization.Loudness = new LoudnessClass |
||||||
|
{ |
||||||
|
IntegratedTarget = ffmpegProfile.TargetLoudness |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
var videoNormalization = new Video |
||||||
|
{ |
||||||
|
Format = ffmpegProfile.VideoFormat switch |
||||||
|
{ |
||||||
|
FFmpegProfileVideoFormat.Hevc => VideoFormat.Hevc, |
||||||
|
_ => VideoFormat.H264 |
||||||
|
}, |
||||||
|
BitDepth = ffmpegProfile.BitDepth switch |
||||||
|
{ |
||||||
|
FFmpegProfileBitDepth.TenBit => 10, |
||||||
|
_ => 8 |
||||||
|
}, |
||||||
|
Accel = ffmpegProfile.HardwareAcceleration switch |
||||||
|
{ |
||||||
|
HardwareAccelerationKind.Nvenc => AccelEnum.Cuda, |
||||||
|
HardwareAccelerationKind.Qsv => AccelEnum.Qsv, |
||||||
|
HardwareAccelerationKind.Vaapi => AccelEnum.Vaapi, |
||||||
|
HardwareAccelerationKind.VideoToolbox => AccelEnum.Videotoolbox, |
||||||
|
_ => null |
||||||
|
}, |
||||||
|
Height = ffmpegProfile.Resolution.Height, |
||||||
|
Width = ffmpegProfile.Resolution.Width, |
||||||
|
BitrateKbps = ffmpegProfile.VideoBitrate, |
||||||
|
BufferKbps = ffmpegProfile.VideoBufferSize, |
||||||
|
// TODO: NEXT: more tonemap algorithms
|
||||||
|
TonemapAlgorithm = "linear", |
||||||
|
VaapiDevice = ffmpegProfile.VaapiDevice, |
||||||
|
VaapiDriver = ffmpegProfile.VaapiDriver switch |
||||||
|
{ |
||||||
|
VaapiDriver.i965 => VaapiDriverEnum.I965, |
||||||
|
VaapiDriver.RadeonSI => VaapiDriverEnum.Radeonsi, |
||||||
|
_ => VaapiDriverEnum.Ihd |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
string playoutFolder = fileSystem.Path.Combine(FileSystemLayout.NextPlayoutsFolder, channelNumber, "current"); |
||||||
|
|
||||||
|
return new ChannelConfig |
||||||
|
{ |
||||||
|
Playout = new Core.Next.Config.Playout |
||||||
|
{ |
||||||
|
Folder = playoutFolder |
||||||
|
}, |
||||||
|
Ffmpeg = ffmpeg, |
||||||
|
Normalization = new Normalization |
||||||
|
{ |
||||||
|
Audio = audioNormalization, |
||||||
|
Video = videoNormalization |
||||||
|
} |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
private sealed record ValidationResult( |
||||||
|
string ChannelBinary, |
||||||
|
ChannelViewModel Channel, |
||||||
|
FFmpegProfileViewModel FfmpegProfile); |
||||||
|
} |
||||||
@ -0,0 +1,157 @@ |
|||||||
|
using System.IO.Abstractions; |
||||||
|
using CliWrap; |
||||||
|
using ErsatzTV.Core; |
||||||
|
using ErsatzTV.Core.FFmpeg; |
||||||
|
using ErsatzTV.Core.Interfaces.FFmpeg; |
||||||
|
using ErsatzTV.Core.Interfaces.Metadata; |
||||||
|
using ErsatzTV.Core.Next.Config; |
||||||
|
using Microsoft.Extensions.DependencyInjection; |
||||||
|
using Microsoft.Extensions.Logging; |
||||||
|
|
||||||
|
namespace ErsatzTV.Application.Streaming; |
||||||
|
|
||||||
|
public class NextSessionWorker( |
||||||
|
string channelBinary, |
||||||
|
ChannelConfig channelConfig, |
||||||
|
IFileSystem fileSystem, |
||||||
|
ILocalFileSystem localFileSystem, |
||||||
|
IServiceScopeFactory serviceScopeFactory, |
||||||
|
ILogger<NextSessionWorker> logger) |
||||||
|
: IHlsSessionWorker |
||||||
|
{ |
||||||
|
private readonly SemaphoreSlim _slim = new(1, 1); |
||||||
|
private CancellationTokenSource _cancellationTokenSource; |
||||||
|
private IServiceScope _serviceScope = serviceScopeFactory.CreateScope(); |
||||||
|
private bool _disposedValue; |
||||||
|
private string _channelNumber; |
||||||
|
private string _workingDirectory; |
||||||
|
private string _heartbeatFileName; |
||||||
|
|
||||||
|
void IDisposable.Dispose() |
||||||
|
{ |
||||||
|
Dispose(true); |
||||||
|
GC.SuppressFinalize(this); |
||||||
|
} |
||||||
|
|
||||||
|
protected virtual void Dispose(bool disposing) |
||||||
|
{ |
||||||
|
if (!_disposedValue) |
||||||
|
{ |
||||||
|
if (disposing) |
||||||
|
{ |
||||||
|
_serviceScope.Dispose(); |
||||||
|
_serviceScope = null; |
||||||
|
} |
||||||
|
|
||||||
|
_disposedValue = true; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
public async Task Cancel(CancellationToken cancellationToken) |
||||||
|
{ |
||||||
|
logger.LogInformation("API termination request for HLS session for channel {Channel}", _channelNumber); |
||||||
|
|
||||||
|
await _slim.WaitAsync(cancellationToken); |
||||||
|
try |
||||||
|
{ |
||||||
|
await _cancellationTokenSource.CancelAsync(); |
||||||
|
} |
||||||
|
finally |
||||||
|
{ |
||||||
|
_slim.Release(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
public void Touch(Option<string> fileName) |
||||||
|
{ |
||||||
|
if (!fileSystem.File.Exists(_heartbeatFileName)) |
||||||
|
{ |
||||||
|
fileSystem.File.WriteAllBytes(_heartbeatFileName, []); |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
fileSystem.File.SetLastWriteTimeUtc(_heartbeatFileName, DateTime.UtcNow); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
public Task<Option<TrimPlaylistResult>> TrimPlaylist( |
||||||
|
DateTimeOffset filterBefore, |
||||||
|
CancellationToken cancellationToken) => |
||||||
|
throw new NotSupportedException(); |
||||||
|
|
||||||
|
public void PlayoutUpdated() |
||||||
|
{ |
||||||
|
// nothing to do here; channel binary should detect that by itself
|
||||||
|
} |
||||||
|
|
||||||
|
public HlsSessionModel GetModel() => throw new NotSupportedException(); |
||||||
|
|
||||||
|
public async Task Run( |
||||||
|
string channelNumber, |
||||||
|
Option<TimeSpan> idleTimeout, |
||||||
|
CancellationToken incomingCancellationToken) |
||||||
|
{ |
||||||
|
_cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(incomingCancellationToken); |
||||||
|
|
||||||
|
try |
||||||
|
{ |
||||||
|
_channelNumber = channelNumber; |
||||||
|
_workingDirectory = fileSystem.Path.Combine(FileSystemLayout.TranscodeFolder, _channelNumber); |
||||||
|
_heartbeatFileName = fileSystem.Path.Combine(_workingDirectory, ".heartbeat"); |
||||||
|
|
||||||
|
CommandResult commandResult = await Cli.Wrap(channelBinary) |
||||||
|
.WithArguments( |
||||||
|
["run", "--output-folder", _workingDirectory, "--number", channelNumber, "-"]) |
||||||
|
.WithStandardInputPipe(PipeSource.FromString(channelConfig.ToJson())) |
||||||
|
.WithStandardOutputPipe(PipeTarget.ToDelegate(l => logger.LogDebug("{Line}", l))) |
||||||
|
.WithStandardErrorPipe(PipeTarget.ToDelegate(l => logger.LogDebug("{Line}", l))) |
||||||
|
//.WithStandardOutputPipe(PipeTarget.ToDelegate(progressParser.ParseLine))
|
||||||
|
.WithValidation(CommandResultValidation.None) |
||||||
|
.ExecuteAsync(_cancellationTokenSource.Token); |
||||||
|
|
||||||
|
if (commandResult.ExitCode != 0) |
||||||
|
{ |
||||||
|
await _cancellationTokenSource.CancelAsync(); |
||||||
|
|
||||||
|
logger.LogError( |
||||||
|
"ErsatzTV Next session for channel {Channel} has terminated unsuccessfully with exit code {ExitCode}", |
||||||
|
_channelNumber, |
||||||
|
commandResult.ExitCode); |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
logger.LogDebug("ErsatzTV Next session has completed for channel {Channel}", _channelNumber); |
||||||
|
} |
||||||
|
} |
||||||
|
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException) |
||||||
|
{ |
||||||
|
logger.LogInformation("Terminating ErsatzTV Next session for channel {Channel}", _channelNumber); |
||||||
|
} |
||||||
|
catch (Exception ex) |
||||||
|
{ |
||||||
|
logger.LogWarning(ex, "Error running ErsatzTV Next session"); |
||||||
|
} |
||||||
|
finally |
||||||
|
{ |
||||||
|
try |
||||||
|
{ |
||||||
|
localFileSystem.EmptyFolder(_workingDirectory); |
||||||
|
} |
||||||
|
catch |
||||||
|
{ |
||||||
|
// do nothing
|
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
public async Task WaitForPlaylistSegments(int initialSegmentCount, CancellationToken cancellationToken) |
||||||
|
{ |
||||||
|
string readyFileName = fileSystem.Path.Combine(_workingDirectory, ".ready"); |
||||||
|
|
||||||
|
logger.LogDebug("Waiting for ErsatzTV Next channel to be ready"); |
||||||
|
while (!fileSystem.File.Exists(readyFileName)) |
||||||
|
{ |
||||||
|
await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -1,8 +1,6 @@ |
|||||||
namespace ErsatzTV.Core.Errors; |
namespace ErsatzTV.Core.Errors; |
||||||
|
|
||||||
public class ChannelSessionAlreadyActive : BaseError |
public class ChannelSessionAlreadyActive(string multiVariantPlaylist) : BaseError("Channel already has HLS session") |
||||||
{ |
{ |
||||||
public ChannelSessionAlreadyActive() : base("Channel already has HLS session") |
public string MultiVariantPlaylist { get; } = multiVariantPlaylist; |
||||||
{ |
|
||||||
} |
|
||||||
} |
} |
||||||
|
|||||||
@ -0,0 +1,350 @@ |
|||||||
|
// <auto-generated />
|
||||||
|
//
|
||||||
|
// To parse this JSON data, add NuGet 'Newtonsoft.Json' then do:
|
||||||
|
//
|
||||||
|
// using ErsatzTV.Core.Next.Config;
|
||||||
|
//
|
||||||
|
// var channelConfig = ChannelConfig.FromJson(jsonString);
|
||||||
|
|
||||||
|
namespace ErsatzTV.Core.Next.Config |
||||||
|
{ |
||||||
|
using System; |
||||||
|
using System.Collections.Generic; |
||||||
|
|
||||||
|
using System.Globalization; |
||||||
|
using Newtonsoft.Json; |
||||||
|
using Newtonsoft.Json.Converters; |
||||||
|
|
||||||
|
public partial class ChannelConfig |
||||||
|
{ |
||||||
|
[JsonProperty("ffmpeg")] |
||||||
|
public Ffmpeg Ffmpeg { get; set; } |
||||||
|
|
||||||
|
[JsonProperty("normalization")] |
||||||
|
public Normalization Normalization { get; set; } |
||||||
|
|
||||||
|
[JsonProperty("playout")] |
||||||
|
public Playout Playout { get; set; } |
||||||
|
} |
||||||
|
|
||||||
|
public partial class Ffmpeg |
||||||
|
{ |
||||||
|
[JsonProperty("disabled_filters", NullValueHandling = NullValueHandling.Ignore)] |
||||||
|
public List<string> DisabledFilters { get; set; } |
||||||
|
|
||||||
|
[JsonProperty("ffmpeg_path")] |
||||||
|
public string FfmpegPath { get; set; } |
||||||
|
|
||||||
|
[JsonProperty("ffprobe_path")] |
||||||
|
public string FfprobePath { get; set; } |
||||||
|
} |
||||||
|
|
||||||
|
public partial class Normalization |
||||||
|
{ |
||||||
|
[JsonProperty("audio")] |
||||||
|
public Audio Audio { get; set; } |
||||||
|
|
||||||
|
[JsonProperty("video")] |
||||||
|
public Video Video { get; set; } |
||||||
|
} |
||||||
|
|
||||||
|
public partial class Audio |
||||||
|
{ |
||||||
|
[JsonProperty("bitrate_kbps")] |
||||||
|
public long? BitrateKbps { get; set; } |
||||||
|
|
||||||
|
[JsonProperty("buffer_kbps")] |
||||||
|
public long? BufferKbps { get; set; } |
||||||
|
|
||||||
|
[JsonProperty("channels")] |
||||||
|
public long? Channels { get; set; } |
||||||
|
|
||||||
|
[JsonProperty("format")] |
||||||
|
public AudioFormat? Format { get; set; } |
||||||
|
|
||||||
|
[JsonProperty("loudness")] |
||||||
|
public LoudnessClass Loudness { get; set; } |
||||||
|
|
||||||
|
[JsonProperty("normalize_loudness", NullValueHandling = NullValueHandling.Ignore)] |
||||||
|
public bool? NormalizeLoudness { get; set; } |
||||||
|
|
||||||
|
[JsonProperty("sample_rate_hz")] |
||||||
|
public long? SampleRateHz { get; set; } |
||||||
|
} |
||||||
|
|
||||||
|
public partial class LoudnessClass |
||||||
|
{ |
||||||
|
[JsonProperty("integrated_target")] |
||||||
|
public double? IntegratedTarget { get; set; } |
||||||
|
|
||||||
|
[JsonProperty("range_target")] |
||||||
|
public double? RangeTarget { get; set; } |
||||||
|
|
||||||
|
[JsonProperty("true_peak")] |
||||||
|
public double? TruePeak { get; set; } |
||||||
|
} |
||||||
|
|
||||||
|
public partial class Video |
||||||
|
{ |
||||||
|
[JsonProperty("accel")] |
||||||
|
public AccelEnum? Accel { get; set; } |
||||||
|
|
||||||
|
[JsonProperty("bit_depth")] |
||||||
|
public long? BitDepth { get; set; } |
||||||
|
|
||||||
|
[JsonProperty("bitrate_kbps")] |
||||||
|
public long? BitrateKbps { get; set; } |
||||||
|
|
||||||
|
[JsonProperty("buffer_kbps")] |
||||||
|
public long? BufferKbps { get; set; } |
||||||
|
|
||||||
|
[JsonProperty("format")] |
||||||
|
public VideoFormat? Format { get; set; } |
||||||
|
|
||||||
|
[JsonProperty("height")] |
||||||
|
public long? Height { get; set; } |
||||||
|
|
||||||
|
[JsonProperty("tonemap_algorithm")] |
||||||
|
public string TonemapAlgorithm { get; set; } |
||||||
|
|
||||||
|
[JsonProperty("vaapi_device")] |
||||||
|
public string VaapiDevice { get; set; } |
||||||
|
|
||||||
|
[JsonProperty("vaapi_driver")] |
||||||
|
public VaapiDriverEnum? VaapiDriver { get; set; } |
||||||
|
|
||||||
|
[JsonProperty("width")] |
||||||
|
public long? Width { get; set; } |
||||||
|
} |
||||||
|
|
||||||
|
public partial class Playout |
||||||
|
{ |
||||||
|
[JsonProperty("folder")] |
||||||
|
public string Folder { get; set; } |
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// RFC3339 formatted date/time, e.g. 2026-04-13T00:24:21.527-05:00
|
||||||
|
/// </summary>
|
||||||
|
[JsonProperty("virtual_start")] |
||||||
|
public string VirtualStart { get; set; } |
||||||
|
} |
||||||
|
|
||||||
|
public enum AudioFormat { Aac, Ac3 }; |
||||||
|
|
||||||
|
public enum AccelEnum { Cuda, Qsv, Vaapi, Videotoolbox, Vulkan }; |
||||||
|
|
||||||
|
public enum VideoFormat { H264, Hevc }; |
||||||
|
|
||||||
|
public enum VaapiDriverEnum { I965, Ihd, Radeonsi }; |
||||||
|
|
||||||
|
public partial class ChannelConfig |
||||||
|
{ |
||||||
|
public static ChannelConfig FromJson(string json) => JsonConvert.DeserializeObject<ChannelConfig>(json, ErsatzTV.Core.Next.Config.Converter.Settings); |
||||||
|
} |
||||||
|
|
||||||
|
public static class Serialize |
||||||
|
{ |
||||||
|
public static string ToJson(this ChannelConfig self) => JsonConvert.SerializeObject(self, ErsatzTV.Core.Next.Config.Converter.Settings); |
||||||
|
} |
||||||
|
|
||||||
|
internal static class Converter |
||||||
|
{ |
||||||
|
public static readonly JsonSerializerSettings Settings = new JsonSerializerSettings |
||||||
|
{ |
||||||
|
MetadataPropertyHandling = MetadataPropertyHandling.Ignore, |
||||||
|
DateParseHandling = DateParseHandling.None, |
||||||
|
Converters = |
||||||
|
{ |
||||||
|
AudioFormatConverter.Singleton, |
||||||
|
AccelEnumConverter.Singleton, |
||||||
|
VideoFormatConverter.Singleton, |
||||||
|
VaapiDriverEnumConverter.Singleton, |
||||||
|
new IsoDateTimeConverter { DateTimeStyles = DateTimeStyles.AssumeUniversal } |
||||||
|
}, |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
internal class AudioFormatConverter : JsonConverter |
||||||
|
{ |
||||||
|
public override bool CanConvert(Type t) => t == typeof(AudioFormat) || t == typeof(AudioFormat?); |
||||||
|
|
||||||
|
public override object ReadJson(JsonReader reader, Type t, object existingValue, JsonSerializer serializer) |
||||||
|
{ |
||||||
|
if (reader.TokenType == JsonToken.Null) return null; |
||||||
|
var value = serializer.Deserialize<string>(reader); |
||||||
|
switch (value) |
||||||
|
{ |
||||||
|
case "aac": |
||||||
|
return AudioFormat.Aac; |
||||||
|
case "ac3": |
||||||
|
return AudioFormat.Ac3; |
||||||
|
} |
||||||
|
throw new Exception("Cannot unmarshal type AudioFormat"); |
||||||
|
} |
||||||
|
|
||||||
|
public override void WriteJson(JsonWriter writer, object untypedValue, JsonSerializer serializer) |
||||||
|
{ |
||||||
|
if (untypedValue == null) |
||||||
|
{ |
||||||
|
serializer.Serialize(writer, null); |
||||||
|
return; |
||||||
|
} |
||||||
|
var value = (AudioFormat)untypedValue; |
||||||
|
switch (value) |
||||||
|
{ |
||||||
|
case AudioFormat.Aac: |
||||||
|
serializer.Serialize(writer, "aac"); |
||||||
|
return; |
||||||
|
case AudioFormat.Ac3: |
||||||
|
serializer.Serialize(writer, "ac3"); |
||||||
|
return; |
||||||
|
} |
||||||
|
throw new Exception("Cannot marshal type AudioFormat"); |
||||||
|
} |
||||||
|
|
||||||
|
public static readonly AudioFormatConverter Singleton = new AudioFormatConverter(); |
||||||
|
} |
||||||
|
|
||||||
|
internal class AccelEnumConverter : JsonConverter |
||||||
|
{ |
||||||
|
public override bool CanConvert(Type t) => t == typeof(AccelEnum) || t == typeof(AccelEnum?); |
||||||
|
|
||||||
|
public override object ReadJson(JsonReader reader, Type t, object existingValue, JsonSerializer serializer) |
||||||
|
{ |
||||||
|
if (reader.TokenType == JsonToken.Null) return null; |
||||||
|
var value = serializer.Deserialize<string>(reader); |
||||||
|
switch (value) |
||||||
|
{ |
||||||
|
case "cuda": |
||||||
|
return AccelEnum.Cuda; |
||||||
|
case "qsv": |
||||||
|
return AccelEnum.Qsv; |
||||||
|
case "vaapi": |
||||||
|
return AccelEnum.Vaapi; |
||||||
|
case "videotoolbox": |
||||||
|
return AccelEnum.Videotoolbox; |
||||||
|
case "vulkan": |
||||||
|
return AccelEnum.Vulkan; |
||||||
|
} |
||||||
|
throw new Exception("Cannot unmarshal type AccelEnum"); |
||||||
|
} |
||||||
|
|
||||||
|
public override void WriteJson(JsonWriter writer, object untypedValue, JsonSerializer serializer) |
||||||
|
{ |
||||||
|
if (untypedValue == null) |
||||||
|
{ |
||||||
|
serializer.Serialize(writer, null); |
||||||
|
return; |
||||||
|
} |
||||||
|
var value = (AccelEnum)untypedValue; |
||||||
|
switch (value) |
||||||
|
{ |
||||||
|
case AccelEnum.Cuda: |
||||||
|
serializer.Serialize(writer, "cuda"); |
||||||
|
return; |
||||||
|
case AccelEnum.Qsv: |
||||||
|
serializer.Serialize(writer, "qsv"); |
||||||
|
return; |
||||||
|
case AccelEnum.Vaapi: |
||||||
|
serializer.Serialize(writer, "vaapi"); |
||||||
|
return; |
||||||
|
case AccelEnum.Videotoolbox: |
||||||
|
serializer.Serialize(writer, "videotoolbox"); |
||||||
|
return; |
||||||
|
case AccelEnum.Vulkan: |
||||||
|
serializer.Serialize(writer, "vulkan"); |
||||||
|
return; |
||||||
|
} |
||||||
|
throw new Exception("Cannot marshal type AccelEnum"); |
||||||
|
} |
||||||
|
|
||||||
|
public static readonly AccelEnumConverter Singleton = new AccelEnumConverter(); |
||||||
|
} |
||||||
|
|
||||||
|
internal class VideoFormatConverter : JsonConverter |
||||||
|
{ |
||||||
|
public override bool CanConvert(Type t) => t == typeof(VideoFormat) || t == typeof(VideoFormat?); |
||||||
|
|
||||||
|
public override object ReadJson(JsonReader reader, Type t, object existingValue, JsonSerializer serializer) |
||||||
|
{ |
||||||
|
if (reader.TokenType == JsonToken.Null) return null; |
||||||
|
var value = serializer.Deserialize<string>(reader); |
||||||
|
switch (value) |
||||||
|
{ |
||||||
|
case "h264": |
||||||
|
return VideoFormat.H264; |
||||||
|
case "hevc": |
||||||
|
return VideoFormat.Hevc; |
||||||
|
} |
||||||
|
throw new Exception("Cannot unmarshal type VideoFormat"); |
||||||
|
} |
||||||
|
|
||||||
|
public override void WriteJson(JsonWriter writer, object untypedValue, JsonSerializer serializer) |
||||||
|
{ |
||||||
|
if (untypedValue == null) |
||||||
|
{ |
||||||
|
serializer.Serialize(writer, null); |
||||||
|
return; |
||||||
|
} |
||||||
|
var value = (VideoFormat)untypedValue; |
||||||
|
switch (value) |
||||||
|
{ |
||||||
|
case VideoFormat.H264: |
||||||
|
serializer.Serialize(writer, "h264"); |
||||||
|
return; |
||||||
|
case VideoFormat.Hevc: |
||||||
|
serializer.Serialize(writer, "hevc"); |
||||||
|
return; |
||||||
|
} |
||||||
|
throw new Exception("Cannot marshal type VideoFormat"); |
||||||
|
} |
||||||
|
|
||||||
|
public static readonly VideoFormatConverter Singleton = new VideoFormatConverter(); |
||||||
|
} |
||||||
|
|
||||||
|
internal class VaapiDriverEnumConverter : JsonConverter |
||||||
|
{ |
||||||
|
public override bool CanConvert(Type t) => t == typeof(VaapiDriverEnum) || t == typeof(VaapiDriverEnum?); |
||||||
|
|
||||||
|
public override object ReadJson(JsonReader reader, Type t, object existingValue, JsonSerializer serializer) |
||||||
|
{ |
||||||
|
if (reader.TokenType == JsonToken.Null) return null; |
||||||
|
var value = serializer.Deserialize<string>(reader); |
||||||
|
switch (value) |
||||||
|
{ |
||||||
|
case "i965": |
||||||
|
return VaapiDriverEnum.I965; |
||||||
|
case "ihd": |
||||||
|
return VaapiDriverEnum.Ihd; |
||||||
|
case "radeonsi": |
||||||
|
return VaapiDriverEnum.Radeonsi; |
||||||
|
} |
||||||
|
throw new Exception("Cannot unmarshal type VaapiDriverEnum"); |
||||||
|
} |
||||||
|
|
||||||
|
public override void WriteJson(JsonWriter writer, object untypedValue, JsonSerializer serializer) |
||||||
|
{ |
||||||
|
if (untypedValue == null) |
||||||
|
{ |
||||||
|
serializer.Serialize(writer, null); |
||||||
|
return; |
||||||
|
} |
||||||
|
var value = (VaapiDriverEnum)untypedValue; |
||||||
|
switch (value) |
||||||
|
{ |
||||||
|
case VaapiDriverEnum.I965: |
||||||
|
serializer.Serialize(writer, "i965"); |
||||||
|
return; |
||||||
|
case VaapiDriverEnum.Ihd: |
||||||
|
serializer.Serialize(writer, "ihd"); |
||||||
|
return; |
||||||
|
case VaapiDriverEnum.Radeonsi: |
||||||
|
serializer.Serialize(writer, "radeonsi"); |
||||||
|
return; |
||||||
|
} |
||||||
|
throw new Exception("Cannot marshal type VaapiDriverEnum"); |
||||||
|
} |
||||||
|
|
||||||
|
public static readonly VaapiDriverEnumConverter Singleton = new VaapiDriverEnumConverter(); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,260 @@ |
|||||||
|
// <auto-generated />
|
||||||
|
//
|
||||||
|
// To parse this JSON data, add NuGet 'Newtonsoft.Json' then do:
|
||||||
|
//
|
||||||
|
// using ErsatzTV.Core.Next;
|
||||||
|
//
|
||||||
|
// var playout = Playout.FromJson(jsonString);
|
||||||
|
|
||||||
|
namespace ErsatzTV.Core.Next |
||||||
|
{ |
||||||
|
using System; |
||||||
|
using System.Collections.Generic; |
||||||
|
|
||||||
|
using System.Globalization; |
||||||
|
using Newtonsoft.Json; |
||||||
|
using Newtonsoft.Json.Converters; |
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A playout schedule for a single time window.
|
||||||
|
///
|
||||||
|
/// Files should be named `{start}_{finish}.json` using compact ISO 8601
|
||||||
|
/// (no separators), e.g.
|
||||||
|
/// `20260413T000000.000000000-0500_20260414T002131.620000000-0500.json`,
|
||||||
|
/// so that the channel can locate the correct file for the current time.
|
||||||
|
/// </summary>
|
||||||
|
public partial class Playout |
||||||
|
{ |
||||||
|
[JsonProperty("items")] |
||||||
|
public List<ItemElement> Items { get; set; } |
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// URI identifying the schema version, e.g. "https://ersatztv.org/playout/version/0.0.1"
|
||||||
|
/// </summary>
|
||||||
|
[JsonProperty("version")] |
||||||
|
public string Version { get; set; } |
||||||
|
} |
||||||
|
|
||||||
|
public partial class ItemElement |
||||||
|
{ |
||||||
|
/// <summary>
|
||||||
|
/// RFC3339 formatted date/time, e.g. 2026-04-13T00:24:21.527-05:00
|
||||||
|
/// </summary>
|
||||||
|
[JsonProperty("finish")] |
||||||
|
public string Finish { get; set; } |
||||||
|
|
||||||
|
[JsonProperty("id")] |
||||||
|
public string Id { get; set; } |
||||||
|
|
||||||
|
[JsonProperty("source")] |
||||||
|
public ItemSource Source { get; set; } |
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// RFC3339 formatted date/time, e.g. 2026-04-13T00:24:21.527-05:00
|
||||||
|
/// </summary>
|
||||||
|
[JsonProperty("start")] |
||||||
|
public string Start { get; set; } |
||||||
|
|
||||||
|
[JsonProperty("tracks")] |
||||||
|
public TracksClass Tracks { get; set; } |
||||||
|
} |
||||||
|
|
||||||
|
public partial class ItemSource |
||||||
|
{ |
||||||
|
[JsonProperty("in_point_ms")] |
||||||
|
public long? InPointMs { get; set; } |
||||||
|
|
||||||
|
[JsonProperty("out_point_ms")] |
||||||
|
public long? OutPointMs { get; set; } |
||||||
|
|
||||||
|
[JsonProperty("path", NullValueHandling = NullValueHandling.Ignore)] |
||||||
|
public string Path { get; set; } |
||||||
|
|
||||||
|
[JsonProperty("source_type")] |
||||||
|
public SourceType SourceType { get; set; } |
||||||
|
|
||||||
|
[JsonProperty("params", NullValueHandling = NullValueHandling.Ignore)] |
||||||
|
public string Params { get; set; } |
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Custom HTTP headers, e.g. ["Authorization: Bearer {{TOKEN}}"]
|
||||||
|
/// </summary>
|
||||||
|
[JsonProperty("headers")] |
||||||
|
public List<string> Headers { get; set; } |
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enable reconnect on failure (default: true)
|
||||||
|
/// </summary>
|
||||||
|
[JsonProperty("reconnect")] |
||||||
|
public bool? Reconnect { get; set; } |
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Max reconnect delay in seconds
|
||||||
|
/// </summary>
|
||||||
|
[JsonProperty("reconnect_delay_max")] |
||||||
|
public long? ReconnectDelayMax { get; set; } |
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Socket timeout in microseconds
|
||||||
|
/// </summary>
|
||||||
|
[JsonProperty("timeout_us")] |
||||||
|
public long? TimeoutUs { get; set; } |
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// URI template, e.g. "https://example.com/file.mkv?token={{MY_SECRET}}"
|
||||||
|
/// </summary>
|
||||||
|
[JsonProperty("uri", NullValueHandling = NullValueHandling.Ignore)] |
||||||
|
public string Uri { get; set; } |
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Custom user-agent string
|
||||||
|
/// </summary>
|
||||||
|
[JsonProperty("user_agent")] |
||||||
|
public string UserAgent { get; set; } |
||||||
|
} |
||||||
|
|
||||||
|
public partial class TracksClass |
||||||
|
{ |
||||||
|
[JsonProperty("audio")] |
||||||
|
public AudioClass Audio { get; set; } |
||||||
|
|
||||||
|
[JsonProperty("video")] |
||||||
|
public AudioClass Video { get; set; } |
||||||
|
} |
||||||
|
|
||||||
|
public partial class AudioClass |
||||||
|
{ |
||||||
|
[JsonProperty("source", NullValueHandling = NullValueHandling.Ignore)] |
||||||
|
public AudioSource Source { get; set; } |
||||||
|
|
||||||
|
[JsonProperty("stream_index", NullValueHandling = NullValueHandling.Ignore)] |
||||||
|
public long? StreamIndex { get; set; } |
||||||
|
} |
||||||
|
|
||||||
|
public partial class AudioSource |
||||||
|
{ |
||||||
|
[JsonProperty("in_point_ms")] |
||||||
|
public long? InPointMs { get; set; } |
||||||
|
|
||||||
|
[JsonProperty("out_point_ms")] |
||||||
|
public long? OutPointMs { get; set; } |
||||||
|
|
||||||
|
[JsonProperty("path", NullValueHandling = NullValueHandling.Ignore)] |
||||||
|
public string Path { get; set; } |
||||||
|
|
||||||
|
[JsonProperty("source_type")] |
||||||
|
public SourceType SourceType { get; set; } |
||||||
|
|
||||||
|
[JsonProperty("params", NullValueHandling = NullValueHandling.Ignore)] |
||||||
|
public string Params { get; set; } |
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Custom HTTP headers, e.g. ["Authorization: Bearer {{TOKEN}}"]
|
||||||
|
/// </summary>
|
||||||
|
[JsonProperty("headers")] |
||||||
|
public List<string> Headers { get; set; } |
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enable reconnect on failure (default: true)
|
||||||
|
/// </summary>
|
||||||
|
[JsonProperty("reconnect")] |
||||||
|
public bool? Reconnect { get; set; } |
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Max reconnect delay in seconds
|
||||||
|
/// </summary>
|
||||||
|
[JsonProperty("reconnect_delay_max")] |
||||||
|
public long? ReconnectDelayMax { get; set; } |
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Socket timeout in microseconds
|
||||||
|
/// </summary>
|
||||||
|
[JsonProperty("timeout_us")] |
||||||
|
public long? TimeoutUs { get; set; } |
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// URI template, e.g. "https://example.com/file.mkv?token={{MY_SECRET}}"
|
||||||
|
/// </summary>
|
||||||
|
[JsonProperty("uri", NullValueHandling = NullValueHandling.Ignore)] |
||||||
|
public string Uri { get; set; } |
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Custom user-agent string
|
||||||
|
/// </summary>
|
||||||
|
[JsonProperty("user_agent")] |
||||||
|
public string UserAgent { get; set; } |
||||||
|
} |
||||||
|
|
||||||
|
public enum SourceType { Http, Lavfi, Local }; |
||||||
|
|
||||||
|
public partial class Playout |
||||||
|
{ |
||||||
|
public static Playout FromJson(string json) => JsonConvert.DeserializeObject<Playout>(json, ErsatzTV.Core.Next.Converter.Settings); |
||||||
|
} |
||||||
|
|
||||||
|
public static class Serialize |
||||||
|
{ |
||||||
|
public static string ToJson(this Playout self) => JsonConvert.SerializeObject(self, ErsatzTV.Core.Next.Converter.Settings); |
||||||
|
} |
||||||
|
|
||||||
|
internal static class Converter |
||||||
|
{ |
||||||
|
public static readonly JsonSerializerSettings Settings = new JsonSerializerSettings |
||||||
|
{ |
||||||
|
NullValueHandling = NullValueHandling.Ignore, |
||||||
|
MetadataPropertyHandling = MetadataPropertyHandling.Ignore, |
||||||
|
DateParseHandling = DateParseHandling.None, |
||||||
|
Converters = |
||||||
|
{ |
||||||
|
SourceTypeConverter.Singleton, |
||||||
|
new IsoDateTimeConverter { DateTimeStyles = DateTimeStyles.AssumeUniversal } |
||||||
|
}, |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
internal class SourceTypeConverter : JsonConverter |
||||||
|
{ |
||||||
|
public override bool CanConvert(Type t) => t == typeof(SourceType) || t == typeof(SourceType?); |
||||||
|
|
||||||
|
public override object ReadJson(JsonReader reader, Type t, object existingValue, JsonSerializer serializer) |
||||||
|
{ |
||||||
|
if (reader.TokenType == JsonToken.Null) return null; |
||||||
|
var value = serializer.Deserialize<string>(reader); |
||||||
|
switch (value) |
||||||
|
{ |
||||||
|
case "http": |
||||||
|
return SourceType.Http; |
||||||
|
case "lavfi": |
||||||
|
return SourceType.Lavfi; |
||||||
|
case "local": |
||||||
|
return SourceType.Local; |
||||||
|
} |
||||||
|
throw new Exception("Cannot unmarshal type SourceType"); |
||||||
|
} |
||||||
|
|
||||||
|
public override void WriteJson(JsonWriter writer, object untypedValue, JsonSerializer serializer) |
||||||
|
{ |
||||||
|
if (untypedValue == null) |
||||||
|
{ |
||||||
|
serializer.Serialize(writer, null); |
||||||
|
return; |
||||||
|
} |
||||||
|
var value = (SourceType)untypedValue; |
||||||
|
switch (value) |
||||||
|
{ |
||||||
|
case SourceType.Http: |
||||||
|
serializer.Serialize(writer, "http"); |
||||||
|
return; |
||||||
|
case SourceType.Lavfi: |
||||||
|
serializer.Serialize(writer, "lavfi"); |
||||||
|
return; |
||||||
|
case SourceType.Local: |
||||||
|
serializer.Serialize(writer, "local"); |
||||||
|
return; |
||||||
|
} |
||||||
|
throw new Exception("Cannot marshal type SourceType"); |
||||||
|
} |
||||||
|
|
||||||
|
public static readonly SourceTypeConverter Singleton = new SourceTypeConverter(); |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue