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 @@
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Playouts; |
||||
|
||||
public record SyncNextPlayout(string ChannelNumber) : IRequest, IBackgroundServiceRequest; |
||||
@ -0,0 +1,205 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -1,8 +1,6 @@
|
||||
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 @@
@@ -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 @@
@@ -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