Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db080375c5 | ||
|
|
9abc7ad8b7 | ||
|
|
9e531a82d7 | ||
|
|
d84bd2b948 | ||
|
|
d7d3ec1235 | ||
|
|
742ac21ad7 | ||
|
|
819b55e21f | ||
|
|
cf5718c288 | ||
|
|
adc7982955 |
+11
-1
@@ -5,6 +5,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.1.1-alpha] - 2021-10-10
|
||||
### Added
|
||||
- Add music video album to search index
|
||||
- This requires rebuilding the search index and search results may be empty or incomplete until the rebuild is complete
|
||||
|
||||
### Changed
|
||||
- Remove forced initial delay from `HLS Segmenter` streaming mode
|
||||
- Upgrade nvidia docker image from 18.04 to 20.04
|
||||
|
||||
## [0.1.0-alpha] - 2021-10-08
|
||||
### Added
|
||||
- Add *experimental* streaming mode `HLS Segmenter` (most similar to `HLS Hybrid`)
|
||||
@@ -660,7 +669,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- Initial release to facilitate testing outside of Docker.
|
||||
|
||||
|
||||
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.1.0-alpha...HEAD
|
||||
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.1.1-alpha...HEAD
|
||||
[0.1.1-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.1.0-alpha...v0.1.1-alpha
|
||||
[0.1.0-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.62-alpha...v0.1.0-alpha
|
||||
[0.0.62-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.61-alpha...v0.0.62-alpha
|
||||
[0.0.61-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.60-alpha...v0.0.61-alpha
|
||||
|
||||
@@ -7,10 +7,6 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AsyncFixer" Version="1.5.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="MediatR" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="5.0.0" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.0.63">
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Linq;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Emby;
|
||||
@@ -101,6 +100,7 @@ namespace ErsatzTV.Application.MediaCards
|
||||
musicVideoMetadata.MusicVideo.Artist.ArtistMetadata.Head().Title,
|
||||
musicVideoMetadata.SortTitle,
|
||||
musicVideoMetadata.Plot,
|
||||
musicVideoMetadata.Album,
|
||||
GetThumbnail(musicVideoMetadata, None, None));
|
||||
|
||||
internal static ArtistCardViewModel ProjectToViewModel(ArtistMetadata artistMetadata) =>
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
string Subtitle,
|
||||
string SortTitle,
|
||||
string Plot,
|
||||
string Album,
|
||||
string Poster) : MediaCardViewModel(
|
||||
MusicVideoId,
|
||||
Title,
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Application.Streaming.Commands
|
||||
{
|
||||
public record CleanUpFFmpegSessions : IRequest<Either<BaseError, Unit>>, IFFmpegWorkerRequest;
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Application.Streaming.Commands
|
||||
{
|
||||
public class CleanUpFFmpegSessionsHandler : IRequestHandler<CleanUpFFmpegSessions, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly ChannelWriter<IFFmpegWorkerRequest> _channel;
|
||||
|
||||
public CleanUpFFmpegSessionsHandler(ChannelWriter<IFFmpegWorkerRequest> channel)
|
||||
{
|
||||
_channel = channel;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>>
|
||||
Handle(CleanUpFFmpegSessions request, CancellationToken cancellationToken)
|
||||
{
|
||||
await _channel.WriteAsync(request, cancellationToken);
|
||||
return Unit.Default;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using LanguageExt;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
@@ -15,21 +15,21 @@ namespace ErsatzTV.Application.Streaming.Commands
|
||||
{
|
||||
public class StartFFmpegSessionHandler : MediatR.IRequestHandler<StartFFmpegSession, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly ChannelWriter<IFFmpegWorkerRequest> _channel;
|
||||
private readonly ILogger<StartFFmpegSessionHandler> _logger;
|
||||
private readonly IServiceScopeFactory _serviceScopeFactory;
|
||||
private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
|
||||
public StartFFmpegSessionHandler(
|
||||
IFFmpegSegmenterService ffmpegSegmenterService,
|
||||
ILocalFileSystem localFileSystem,
|
||||
ChannelWriter<IFFmpegWorkerRequest> channel,
|
||||
ILogger<StartFFmpegSessionHandler> logger)
|
||||
ILogger<StartFFmpegSessionHandler> logger,
|
||||
IServiceScopeFactory serviceScopeFactory,
|
||||
IFFmpegSegmenterService ffmpegSegmenterService)
|
||||
{
|
||||
_ffmpegSegmenterService = ffmpegSegmenterService;
|
||||
_localFileSystem = localFileSystem;
|
||||
_channel = channel;
|
||||
_logger = logger;
|
||||
_serviceScopeFactory = serviceScopeFactory;
|
||||
_ffmpegSegmenterService = ffmpegSegmenterService;
|
||||
}
|
||||
|
||||
public Task<Either<BaseError, Unit>> Handle(StartFFmpegSession request, CancellationToken cancellationToken) =>
|
||||
@@ -42,24 +42,51 @@ namespace ErsatzTV.Application.Streaming.Commands
|
||||
|
||||
private async Task<Unit> StartProcess(StartFFmpegSession request)
|
||||
{
|
||||
await _channel.WriteAsync(request);
|
||||
using IServiceScope scope = _serviceScopeFactory.CreateScope();
|
||||
HlsSessionWorker worker = scope.ServiceProvider.GetRequiredService<HlsSessionWorker>();
|
||||
_ffmpegSegmenterService.SessionWorkers.AddOrUpdate(request.ChannelNumber, _ => worker, (_, _) => worker);
|
||||
|
||||
// TODO: find some other way to let ffmpeg get ahead
|
||||
await Task.Delay(TimeSpan.FromSeconds(5));
|
||||
// fire and forget worker
|
||||
_ = worker.Run(request.ChannelNumber)
|
||||
.ContinueWith(
|
||||
_ => _ffmpegSegmenterService.SessionWorkers.TryRemove(
|
||||
request.ChannelNumber,
|
||||
out IHlsSessionWorker _),
|
||||
TaskScheduler.Default);
|
||||
|
||||
string playlistFileName = Path.Combine(
|
||||
FileSystemLayout.TranscodeFolder,
|
||||
request.ChannelNumber,
|
||||
"live.m3u8");
|
||||
|
||||
while (!File.Exists(playlistFileName))
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(100));
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private Task<Validation<BaseError, Unit>> Validate(StartFFmpegSession request) =>
|
||||
ProcessMustNotExist(request)
|
||||
SessionMustBeInactive(request)
|
||||
.BindT(_ => FolderMustBeEmpty(request));
|
||||
|
||||
private Task<Validation<BaseError, Unit>> ProcessMustNotExist(StartFFmpegSession request) =>
|
||||
Optional(_ffmpegSegmenterService.ProcessExistsForChannel(request.ChannelNumber))
|
||||
.Filter(exists => exists == false)
|
||||
private Task<Validation<BaseError, Unit>> SessionMustBeInactive(StartFFmpegSession request)
|
||||
{
|
||||
var result = Optional(_ffmpegSegmenterService.SessionWorkers.TryAdd(request.ChannelNumber, null))
|
||||
.Filter(success => success)
|
||||
.Map(_ => Unit.Default)
|
||||
.ToValidation<BaseError>(new ChannelHasProcess())
|
||||
.AsTask();
|
||||
.ToValidation<BaseError>(new ChannelSessionAlreadyActive());
|
||||
|
||||
if (result.IsFail && _ffmpegSegmenterService.SessionWorkers.TryGetValue(
|
||||
request.ChannelNumber,
|
||||
out IHlsSessionWorker worker))
|
||||
{
|
||||
worker?.Touch();
|
||||
}
|
||||
|
||||
return result.AsTask();
|
||||
}
|
||||
|
||||
private Task<Validation<BaseError, Unit>> FolderMustBeEmpty(StartFFmpegSession request)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Timers;
|
||||
using ErsatzTV.Application.Streaming.Queries;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Timer = System.Timers.Timer;
|
||||
|
||||
namespace ErsatzTV.Application.Streaming
|
||||
{
|
||||
public class HlsSessionWorker : IHlsSessionWorker
|
||||
{
|
||||
private readonly IServiceScopeFactory _serviceScopeFactory;
|
||||
private readonly ILogger<HlsSessionWorker> _logger;
|
||||
private DateTimeOffset _lastAccess;
|
||||
private DateTimeOffset _transcodedUntil;
|
||||
private readonly Timer _timer = new(TimeSpan.FromMinutes(2).TotalMilliseconds) { AutoReset = false };
|
||||
private readonly object _sync = new();
|
||||
private DateTimeOffset _playlistStart;
|
||||
|
||||
public HlsSessionWorker(IServiceScopeFactory serviceScopeFactory, ILogger<HlsSessionWorker> logger)
|
||||
{
|
||||
_serviceScopeFactory = serviceScopeFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public DateTimeOffset PlaylistStart => _playlistStart;
|
||||
|
||||
public void Touch()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_lastAccess = DateTimeOffset.Now;
|
||||
|
||||
_timer.Stop();
|
||||
_timer.Start();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task Run(string channelNumber)
|
||||
{
|
||||
var cts = new CancellationTokenSource();
|
||||
void Cancel(object o, ElapsedEventArgs e) => cts.Cancel();
|
||||
|
||||
try
|
||||
{
|
||||
_timer.Elapsed += Cancel;
|
||||
|
||||
CancellationToken cancellationToken = cts.Token;
|
||||
|
||||
_logger.LogInformation("Starting HLS session for channel {Channel}", channelNumber);
|
||||
|
||||
Touch();
|
||||
_transcodedUntil = DateTimeOffset.Now;
|
||||
_playlistStart = _transcodedUntil;
|
||||
|
||||
// start initial transcode WITHOUT realtime throttle
|
||||
if (!await Transcode(channelNumber, true, false, cancellationToken))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// TODO: configurable? 5 minutes?
|
||||
if (DateTimeOffset.Now - _lastAccess > TimeSpan.FromMinutes(2))
|
||||
{
|
||||
_logger.LogInformation("Stopping idle HLS session for channel {Channel}", channelNumber);
|
||||
return;
|
||||
}
|
||||
|
||||
var transcodedBuffer = TimeSpan.FromSeconds(
|
||||
Math.Max(0, _transcodedUntil.Subtract(DateTimeOffset.Now).TotalSeconds));
|
||||
if (transcodedBuffer <= TimeSpan.FromMinutes(1))
|
||||
{
|
||||
// only use realtime encoding when we're at least 30 seconds ahead
|
||||
bool realtime = transcodedBuffer >= TimeSpan.FromSeconds(30);
|
||||
if (!await Transcode(channelNumber, false, realtime, cancellationToken))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
await TrimAndDelete(channelNumber, cancellationToken);
|
||||
await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_timer.Elapsed -= Cancel;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> Transcode(string channelNumber, bool firstProcess, bool realtime, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using IServiceScope scope = _serviceScopeFactory.CreateScope();
|
||||
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
|
||||
|
||||
var request = new GetPlayoutItemProcessByChannelNumber(
|
||||
channelNumber,
|
||||
"segmenter",
|
||||
firstProcess ? DateTimeOffset.Now : _transcodedUntil.AddSeconds(1),
|
||||
!firstProcess,
|
||||
realtime);
|
||||
|
||||
// _logger.LogInformation("Request {@Request}", request);
|
||||
|
||||
Either<BaseError, PlayoutItemProcessModel> result = await mediator.Send(request, cancellationToken);
|
||||
|
||||
// _logger.LogInformation("Result {Result}", result.ToString());
|
||||
|
||||
foreach (BaseError error in result.LeftAsEnumerable())
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Failed to create process for HLS session on channel {Channel}: {Error}",
|
||||
channelNumber,
|
||||
error.ToString());
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (PlayoutItemProcessModel processModel in result.RightAsEnumerable())
|
||||
{
|
||||
await TrimAndDelete(channelNumber, cancellationToken);
|
||||
|
||||
Process process = processModel.Process;
|
||||
|
||||
_logger.LogDebug(
|
||||
"ffmpeg hls arguments {FFmpegArguments}",
|
||||
string.Join(" ", process.StartInfo.ArgumentList));
|
||||
|
||||
process.Start();
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(cancellationToken);
|
||||
process.WaitForExit();
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
_logger.LogInformation("Terminating HLS process for channel {Channel}", channelNumber);
|
||||
process.Kill();
|
||||
process.WaitForExit();
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.LogInformation("HLS process has completed for channel {Channel}", channelNumber);
|
||||
|
||||
_transcodedUntil = processModel.Until;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error transcoding channel {Channel}", channelNumber);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task TrimAndDelete(string channelNumber, CancellationToken cancellationToken)
|
||||
{
|
||||
string playlistFileName = Path.Combine(
|
||||
FileSystemLayout.TranscodeFolder,
|
||||
channelNumber,
|
||||
"live.m3u8");
|
||||
|
||||
if (File.Exists(playlistFileName))
|
||||
{
|
||||
// trim playlist and insert discontinuity before appending with new ffmpeg process
|
||||
string[] lines = await File.ReadAllLinesAsync(playlistFileName, cancellationToken);
|
||||
TrimPlaylistResult trimResult = HlsPlaylistFilter.TrimPlaylistWithDiscontinuity(
|
||||
_playlistStart,
|
||||
DateTimeOffset.Now.AddMinutes(-1),
|
||||
lines);
|
||||
await File.WriteAllTextAsync(playlistFileName, trimResult.Playlist, cancellationToken);
|
||||
|
||||
// delete old segments
|
||||
foreach (string file in Directory.GetFiles(
|
||||
Path.Combine(FileSystemLayout.TranscodeFolder, channelNumber),
|
||||
"*.ts"))
|
||||
{
|
||||
string fileName = Path.GetFileName(file);
|
||||
if (fileName.StartsWith("live") && int.Parse(fileName.Replace("live", string.Empty).Split('.')[0]) <
|
||||
trimResult.Sequence)
|
||||
{
|
||||
File.Delete(file);
|
||||
}
|
||||
}
|
||||
|
||||
_playlistStart = trimResult.PlaylistStart;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace ErsatzTV.Application.Streaming
|
||||
{
|
||||
public record PlayoutItemProcessModel(Process Process, DateTimeOffset Until);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@@ -14,7 +13,7 @@ using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Application.Streaming.Queries
|
||||
{
|
||||
public abstract class FFmpegProcessHandler<T> : IRequestHandler<T, Either<BaseError, Process>>
|
||||
public abstract class FFmpegProcessHandler<T> : IRequestHandler<T, Either<BaseError, PlayoutItemProcessModel>>
|
||||
where T : FFmpegProcessRequest
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
@@ -22,16 +21,16 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
protected FFmpegProcessHandler(IDbContextFactory<TvContext> dbContextFactory) =>
|
||||
_dbContextFactory = dbContextFactory;
|
||||
|
||||
public async Task<Either<BaseError, Process>> Handle(T request, CancellationToken cancellationToken)
|
||||
public async Task<Either<BaseError, PlayoutItemProcessModel>> Handle(T request, CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
Validation<BaseError, Tuple<Channel, string>> validation = await Validate(dbContext, request);
|
||||
return await validation.Match(
|
||||
tuple => GetProcess(dbContext, request, tuple.Item1, tuple.Item2),
|
||||
error => Task.FromResult<Either<BaseError, Process>>(error.Join()));
|
||||
error => Task.FromResult<Either<BaseError, PlayoutItemProcessModel>>(error.Join()));
|
||||
}
|
||||
|
||||
protected abstract Task<Either<BaseError, Process>> GetProcess(
|
||||
protected abstract Task<Either<BaseError, PlayoutItemProcessModel>> GetProcess(
|
||||
TvContext dbContext,
|
||||
T request,
|
||||
Channel channel,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Diagnostics;
|
||||
using System;
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
@@ -6,5 +6,10 @@ using MediatR;
|
||||
namespace ErsatzTV.Application.Streaming.Queries
|
||||
{
|
||||
public record FFmpegProcessRequest
|
||||
(string ChannelNumber, string Mode, bool StartAtZero) : IRequest<Either<BaseError, Process>>;
|
||||
(
|
||||
string ChannelNumber,
|
||||
string Mode,
|
||||
DateTimeOffset Now,
|
||||
bool StartAtZero,
|
||||
bool HlsRealtime) : IRequest<Either<BaseError, PlayoutItemProcessModel>>;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
namespace ErsatzTV.Application.Streaming.Queries
|
||||
using System;
|
||||
|
||||
namespace ErsatzTV.Application.Streaming.Queries
|
||||
{
|
||||
public record GetConcatProcessByChannelNumber : FFmpegProcessRequest
|
||||
{
|
||||
public GetConcatProcessByChannelNumber(string scheme, string host, string channelNumber) : base(
|
||||
channelNumber,
|
||||
"ts",
|
||||
false)
|
||||
DateTimeOffset.Now,
|
||||
false,
|
||||
true)
|
||||
{
|
||||
Scheme = scheme;
|
||||
Host = host;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Diagnostics;
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
@@ -27,7 +28,7 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
_runtimeInfo = runtimeInfo;
|
||||
}
|
||||
|
||||
protected override async Task<Either<BaseError, Process>> GetProcess(
|
||||
protected override async Task<Either<BaseError, PlayoutItemProcessModel>> GetProcess(
|
||||
TvContext dbContext,
|
||||
GetConcatProcessByChannelNumber request,
|
||||
Channel channel,
|
||||
@@ -37,12 +38,14 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
.GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
|
||||
.Map(result => result.IfNone(false));
|
||||
|
||||
return _ffmpegProcessService.ConcatChannel(
|
||||
Process process = _ffmpegProcessService.ConcatChannel(
|
||||
ffmpegPath,
|
||||
saveReports,
|
||||
channel,
|
||||
request.Scheme,
|
||||
request.Host);
|
||||
|
||||
return new PlayoutItemProcessModel(process, DateTimeOffset.MaxValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
namespace ErsatzTV.Application.Streaming.Queries
|
||||
using System;
|
||||
|
||||
namespace ErsatzTV.Application.Streaming.Queries
|
||||
{
|
||||
public record GetPlayoutItemProcessByChannelNumber : FFmpegProcessRequest
|
||||
{
|
||||
public GetPlayoutItemProcessByChannelNumber(string channelNumber, string mode, bool startAtZero) : base(
|
||||
public GetPlayoutItemProcessByChannelNumber(
|
||||
string channelNumber,
|
||||
string mode,
|
||||
DateTimeOffset now,
|
||||
bool startAtZero,
|
||||
bool hlsRealtime) : base(
|
||||
channelNumber,
|
||||
mode,
|
||||
startAtZero)
|
||||
now,
|
||||
startAtZero,
|
||||
hlsRealtime)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
+35
-16
@@ -48,13 +48,14 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
_runtimeInfo = runtimeInfo;
|
||||
}
|
||||
|
||||
protected override async Task<Either<BaseError, Process>> GetProcess(
|
||||
protected override async Task<Either<BaseError, PlayoutItemProcessModel>> GetProcess(
|
||||
TvContext dbContext,
|
||||
GetPlayoutItemProcessByChannelNumber request,
|
||||
Channel channel,
|
||||
string ffmpegPath)
|
||||
{
|
||||
DateTimeOffset now = DateTimeOffset.Now;
|
||||
DateTimeOffset now = request.Now;
|
||||
|
||||
Either<BaseError, PlayoutItemWithPath> maybePlayoutItem = await dbContext.PlayoutItems
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(mi => (mi as Episode).MediaVersions)
|
||||
@@ -103,17 +104,22 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
.GetValue<int>(ConfigElementKey.FFmpegVaapiDriver)
|
||||
.MapT(i => (VaapiDriver)i);
|
||||
|
||||
return Right<BaseError, Process>(
|
||||
await _ffmpegProcessService.ForPlayoutItem(
|
||||
ffmpegPath,
|
||||
saveReports,
|
||||
channel,
|
||||
version,
|
||||
playoutItemWithPath.Path,
|
||||
playoutItemWithPath.PlayoutItem.StartOffset,
|
||||
request.StartAtZero ? playoutItemWithPath.PlayoutItem.StartOffset : now,
|
||||
maybeGlobalWatermark,
|
||||
maybeVaapiDriver));
|
||||
Process process = await _ffmpegProcessService.ForPlayoutItem(
|
||||
ffmpegPath,
|
||||
saveReports,
|
||||
channel,
|
||||
version,
|
||||
playoutItemWithPath.Path,
|
||||
playoutItemWithPath.PlayoutItem.StartOffset,
|
||||
request.StartAtZero ? playoutItemWithPath.PlayoutItem.StartOffset : now,
|
||||
maybeGlobalWatermark,
|
||||
maybeVaapiDriver,
|
||||
request.StartAtZero,
|
||||
request.HlsRealtime);
|
||||
|
||||
var result = new PlayoutItemProcessModel(process, playoutItemWithPath.PlayoutItem.FinishOffset);
|
||||
|
||||
return Right<BaseError, PlayoutItemProcessModel>(result);
|
||||
},
|
||||
async error =>
|
||||
{
|
||||
@@ -132,16 +138,21 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
.MapT(pi => pi.StartOffset - now),
|
||||
() => Option<TimeSpan>.None.AsTask());
|
||||
|
||||
DateTimeOffset finish = maybeDuration.Match(d => now.Add(d), () => now);
|
||||
|
||||
switch (error)
|
||||
{
|
||||
case UnableToLocatePlayoutItem:
|
||||
if (channel.FFmpegProfile.Transcode)
|
||||
{
|
||||
return _ffmpegProcessService.ForError(
|
||||
Process errorProcess = _ffmpegProcessService.ForError(
|
||||
ffmpegPath,
|
||||
channel,
|
||||
maybeDuration,
|
||||
"Channel is Offline");
|
||||
|
||||
|
||||
return new PlayoutItemProcessModel(errorProcess, finish);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -153,7 +164,13 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
case PlayoutItemDoesNotExistOnDisk:
|
||||
if (channel.FFmpegProfile.Transcode)
|
||||
{
|
||||
return _ffmpegProcessService.ForError(ffmpegPath, channel, maybeDuration, error.Value);
|
||||
Process errorProcess = _ffmpegProcessService.ForError(
|
||||
ffmpegPath,
|
||||
channel,
|
||||
maybeDuration,
|
||||
error.Value);
|
||||
|
||||
return new PlayoutItemProcessModel(errorProcess, finish);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -165,11 +182,13 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
default:
|
||||
if (channel.FFmpegProfile.Transcode)
|
||||
{
|
||||
return _ffmpegProcessService.ForError(
|
||||
Process errorProcess = _ffmpegProcessService.ForError(
|
||||
ffmpegPath,
|
||||
channel,
|
||||
maybeDuration,
|
||||
"Channel is Offline");
|
||||
|
||||
return new PlayoutItemProcessModel(errorProcess, finish);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -6,10 +6,6 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AsyncFixer" Version="1.5.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="FluentAssertions" Version="6.1.0" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="3.4.15" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="5.0.2" />
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
using System;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using FluentAssertions;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
{
|
||||
[TestFixture]
|
||||
public class HlsPlaylistFilterTests
|
||||
{
|
||||
[Test]
|
||||
public void HlsPlaylistFilter_ShouldRewriteProgramDateTime()
|
||||
{
|
||||
var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5));
|
||||
string[] input = @"#EXTM3U
|
||||
#EXT-X-VERSION:6
|
||||
#EXT-X-TARGETDURATION:4
|
||||
#EXT-X-MEDIA-SEQUENCE:1137
|
||||
#EXT-X-INDEPENDENT-SEGMENTS
|
||||
#EXT-X-DISCONTINUITY
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:49.320-0500
|
||||
live001137.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:53.320-0500
|
||||
live001138.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500
|
||||
live001139.ts".Split(Environment.NewLine);
|
||||
|
||||
TrimPlaylistResult result = HlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(-30), input);
|
||||
|
||||
result.PlaylistStart.Should().Be(start);
|
||||
result.Sequence.Should().Be(1137);
|
||||
result.Playlist.Should().Be(
|
||||
@"#EXTM3U
|
||||
#EXT-X-VERSION:6
|
||||
#EXT-X-TARGETDURATION:4
|
||||
#EXT-X-MEDIA-SEQUENCE:1137
|
||||
#EXT-X-DISCONTINUITY-SEQUENCE:0
|
||||
#EXT-X-INDEPENDENT-SEGMENTS
|
||||
#EXT-X-DISCONTINUITY
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:00.000-0500
|
||||
live001137.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:04.000-0500
|
||||
live001138.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:08.000-0500
|
||||
live001139.ts
|
||||
");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void HlsPlaylistFilter_ShouldLimitSegments()
|
||||
{
|
||||
var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5));
|
||||
string[] input = @"#EXTM3U
|
||||
#EXT-X-VERSION:6
|
||||
#EXT-X-TARGETDURATION:4
|
||||
#EXT-X-MEDIA-SEQUENCE:1137
|
||||
#EXT-X-INDEPENDENT-SEGMENTS
|
||||
#EXT-X-DISCONTINUITY
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:49.320-0500
|
||||
live001137.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:53.320-0500
|
||||
live001138.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500
|
||||
live001139.ts".Split(Environment.NewLine);
|
||||
|
||||
TrimPlaylistResult result = HlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(-30), input, 2);
|
||||
|
||||
result.PlaylistStart.Should().Be(start);
|
||||
result.Sequence.Should().Be(1137);
|
||||
result.Playlist.Should().Be(
|
||||
@"#EXTM3U
|
||||
#EXT-X-VERSION:6
|
||||
#EXT-X-TARGETDURATION:4
|
||||
#EXT-X-MEDIA-SEQUENCE:1137
|
||||
#EXT-X-DISCONTINUITY-SEQUENCE:0
|
||||
#EXT-X-INDEPENDENT-SEGMENTS
|
||||
#EXT-X-DISCONTINUITY
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:00.000-0500
|
||||
live001137.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:04.000-0500
|
||||
live001138.ts
|
||||
");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void HlsPlaylistFilter_ShouldAddDiscontinuity()
|
||||
{
|
||||
var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5));
|
||||
string[] input = @"#EXTM3U
|
||||
#EXT-X-VERSION:6
|
||||
#EXT-X-TARGETDURATION:4
|
||||
#EXT-X-MEDIA-SEQUENCE:1137
|
||||
#EXT-X-INDEPENDENT-SEGMENTS
|
||||
#EXT-X-DISCONTINUITY
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:49.320-0500
|
||||
live001137.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:53.320-0500
|
||||
live001138.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500
|
||||
live001139.ts".Split(Environment.NewLine);
|
||||
|
||||
TrimPlaylistResult result = HlsPlaylistFilter.TrimPlaylist(
|
||||
start,
|
||||
start.AddSeconds(-30),
|
||||
input,
|
||||
int.MaxValue,
|
||||
true);
|
||||
|
||||
result.PlaylistStart.Should().Be(start);
|
||||
result.Sequence.Should().Be(1137);
|
||||
result.Playlist.Should().Be(
|
||||
@"#EXTM3U
|
||||
#EXT-X-VERSION:6
|
||||
#EXT-X-TARGETDURATION:4
|
||||
#EXT-X-MEDIA-SEQUENCE:1137
|
||||
#EXT-X-DISCONTINUITY-SEQUENCE:0
|
||||
#EXT-X-INDEPENDENT-SEGMENTS
|
||||
#EXT-X-DISCONTINUITY
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:00.000-0500
|
||||
live001137.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:04.000-0500
|
||||
live001138.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:08.000-0500
|
||||
live001139.ts
|
||||
#EXT-X-DISCONTINUITY
|
||||
");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void HlsPlaylistFilter_ShouldFilterOldSegments()
|
||||
{
|
||||
var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5));
|
||||
string[] input = @"#EXTM3U
|
||||
#EXT-X-VERSION:6
|
||||
#EXT-X-TARGETDURATION:4
|
||||
#EXT-X-MEDIA-SEQUENCE:1137
|
||||
#EXT-X-INDEPENDENT-SEGMENTS
|
||||
#EXT-X-DISCONTINUITY
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:49.320-0500
|
||||
live001137.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:53.320-0500
|
||||
live001138.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500
|
||||
live001139.ts".Split(Environment.NewLine);
|
||||
|
||||
TrimPlaylistResult result = HlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(6), input);
|
||||
|
||||
result.PlaylistStart.Should().Be(start.AddSeconds(8));
|
||||
result.Sequence.Should().Be(1139);
|
||||
result.Playlist.Should().Be(
|
||||
@"#EXTM3U
|
||||
#EXT-X-VERSION:6
|
||||
#EXT-X-TARGETDURATION:4
|
||||
#EXT-X-MEDIA-SEQUENCE:1139
|
||||
#EXT-X-DISCONTINUITY-SEQUENCE:0
|
||||
#EXT-X-INDEPENDENT-SEGMENTS
|
||||
#EXT-X-DISCONTINUITY
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:08.000-0500
|
||||
live001139.ts
|
||||
");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void HlsPlaylistFilter_ShouldFilterOldDiscontinuity()
|
||||
{
|
||||
var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5));
|
||||
string[] input = @"#EXTM3U
|
||||
#EXT-X-VERSION:6
|
||||
#EXT-X-TARGETDURATION:4
|
||||
#EXT-X-MEDIA-SEQUENCE:1137
|
||||
#EXT-X-INDEPENDENT-SEGMENTS
|
||||
#EXT-X-DISCONTINUITY
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:49.320-0500
|
||||
live001137.ts
|
||||
#EXT-X-DISCONTINUITY
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:53.320-0500
|
||||
live001138.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500
|
||||
live001139.ts".Split(Environment.NewLine);
|
||||
|
||||
TrimPlaylistResult result = HlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(6), input);
|
||||
|
||||
result.PlaylistStart.Should().Be(start.AddSeconds(8));
|
||||
result.Sequence.Should().Be(1139);
|
||||
result.Playlist.Should().Be(
|
||||
@"#EXTM3U
|
||||
#EXT-X-VERSION:6
|
||||
#EXT-X-TARGETDURATION:4
|
||||
#EXT-X-MEDIA-SEQUENCE:1139
|
||||
#EXT-X-DISCONTINUITY-SEQUENCE:1
|
||||
#EXT-X-INDEPENDENT-SEGMENTS
|
||||
#EXT-X-DISCONTINUITY
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:08.000-0500
|
||||
live001139.ts
|
||||
");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -184,7 +184,9 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
now,
|
||||
now,
|
||||
None,
|
||||
None);
|
||||
None,
|
||||
false,
|
||||
false);
|
||||
|
||||
process.StartInfo.RedirectStandardError = true;
|
||||
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
namespace ErsatzTV.Core.Errors
|
||||
{
|
||||
public class ChannelHasProcess : BaseError
|
||||
{
|
||||
public ChannelHasProcess() : base("Channel already has ffmpeg process")
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ErsatzTV.Core.Errors
|
||||
{
|
||||
public class ChannelSessionAlreadyActive : BaseError
|
||||
{
|
||||
public ChannelSessionAlreadyActive() : base("Channel already has HLS session")
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,6 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AsyncFixer" Version="1.5.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Flurl" Version="3.0.2" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="3.4.15" />
|
||||
<PackageReference Include="MediatR" Version="9.0.0" />
|
||||
|
||||
@@ -109,7 +109,14 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
{
|
||||
if (realtimeOutput)
|
||||
{
|
||||
_arguments.Add("-re");
|
||||
if (!_arguments.Contains("-re"))
|
||||
{
|
||||
_arguments.Add("-re");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_arguments.RemoveAll(s => s == "-re");
|
||||
}
|
||||
|
||||
return this;
|
||||
@@ -310,8 +317,10 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithHls(string channelNumber, MediaVersion mediaVersion)
|
||||
public FFmpegProcessBuilder WithHls(string channelNumber, MediaVersion mediaVersion, bool startAtZero)
|
||||
{
|
||||
const int SEGMENT_SECONDS = 4;
|
||||
|
||||
if (!int.TryParse(mediaVersion.RFrameRate, out int frameRate))
|
||||
{
|
||||
string[] split = (mediaVersion.RFrameRate ?? string.Empty).Split("/");
|
||||
@@ -329,15 +338,17 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
_arguments.AddRange(
|
||||
new[]
|
||||
{
|
||||
"-g", $"{frameRate * 2}",
|
||||
"-keyint_min", $"{frameRate * 2}",
|
||||
// "-force_key_frames",
|
||||
// "expr:gte(t,n_forced*2)",
|
||||
"-use_wallclock_as_timestamps", "1",
|
||||
"-g", $"{frameRate * SEGMENT_SECONDS}",
|
||||
"-keyint_min", $"{frameRate * SEGMENT_SECONDS}",
|
||||
"-force_key_frames", $"expr:gte(t,n_forced*{SEGMENT_SECONDS})",
|
||||
"-f", "hls",
|
||||
"-hls_time", "2",
|
||||
"-hls_list_size", "10",
|
||||
"-hls_time", $"{SEGMENT_SECONDS}",
|
||||
"-hls_list_size", "0",
|
||||
"-segment_list_flags", "+live",
|
||||
"-hls_flags", "delete_segments+program_date_time+append_list+discont_start+omit_endlist",
|
||||
"-hls_segment_filename",
|
||||
Path.Combine(FileSystemLayout.TranscodeFolder, channelNumber, "live%06d.ts"),
|
||||
"-hls_flags", "program_date_time+append_list+omit_endlist+independent_segments",
|
||||
Path.Combine(FileSystemLayout.TranscodeFolder, channelNumber, "live.m3u8")
|
||||
});
|
||||
|
||||
|
||||
@@ -39,7 +39,9 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
DateTimeOffset start,
|
||||
DateTimeOffset now,
|
||||
Option<ChannelWatermark> globalWatermark,
|
||||
Option<VaapiDriver> maybeVaapiDriver)
|
||||
Option<VaapiDriver> maybeVaapiDriver,
|
||||
bool startAtZero,
|
||||
bool hlsRealtime)
|
||||
{
|
||||
MediaStream videoStream = await _ffmpegStreamSelector.SelectVideoStream(channel, version);
|
||||
Option<MediaStream> maybeAudioStream = await _ffmpegStreamSelector.SelectAudioStream(channel, version);
|
||||
@@ -118,7 +120,8 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
{
|
||||
// HLS needs to segment and generate playlist
|
||||
case StreamingMode.HttpLiveStreamingSegmenter:
|
||||
return builder.WithHls(channel.Number, version)
|
||||
return builder.WithHls(channel.Number, version, startAtZero)
|
||||
.WithRealtimeOutput(hlsRealtime)
|
||||
.Build();
|
||||
default:
|
||||
return builder.WithFormat("mpegts")
|
||||
|
||||
@@ -1,96 +1,23 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Collections.Concurrent;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using LanguageExt;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Core.FFmpeg
|
||||
{
|
||||
public class FFmpegSegmenterService : IFFmpegSegmenterService
|
||||
{
|
||||
private static readonly ConcurrentDictionary<string, ProcessAndToken> Processes = new();
|
||||
|
||||
private readonly ILogger<FFmpegSegmenterService> _logger;
|
||||
|
||||
public FFmpegSegmenterService(ILogger<FFmpegSegmenterService> logger) => _logger = logger;
|
||||
|
||||
public bool ProcessExistsForChannel(string channelNumber)
|
||||
public FFmpegSegmenterService()
|
||||
{
|
||||
if (Processes.TryGetValue(channelNumber, out ProcessAndToken processAndToken))
|
||||
{
|
||||
if (!processAndToken.Process.HasExited || !Processes.TryRemove(
|
||||
new KeyValuePair<string, ProcessAndToken>(channelNumber, processAndToken)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
SessionWorkers = new ConcurrentDictionary<string, IHlsSessionWorker>();
|
||||
}
|
||||
|
||||
public bool TryAdd(string channelNumber, Process process)
|
||||
{
|
||||
var cts = new CancellationTokenSource();
|
||||
var processAndToken = new ProcessAndToken(process, cts, DateTimeOffset.Now);
|
||||
if (Processes.TryAdd(channelNumber, processAndToken))
|
||||
{
|
||||
CancellationToken token = cts.Token;
|
||||
token.Register(process.Kill);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
public ConcurrentDictionary<string, IHlsSessionWorker> SessionWorkers { get; }
|
||||
|
||||
public void TouchChannel(string channelNumber)
|
||||
{
|
||||
if (Processes.TryGetValue(channelNumber, out ProcessAndToken processAndToken))
|
||||
if (SessionWorkers.TryGetValue(channelNumber, out IHlsSessionWorker worker))
|
||||
{
|
||||
ProcessAndToken newValue = processAndToken with { LastAccess = DateTimeOffset.Now };
|
||||
if (!Processes.TryUpdate(channelNumber, newValue, processAndToken))
|
||||
{
|
||||
_logger.LogWarning("Failed to update last access for channel {Channel}", channelNumber);
|
||||
}
|
||||
worker?.Touch();
|
||||
}
|
||||
}
|
||||
|
||||
public void CleanUpSessions()
|
||||
{
|
||||
foreach ((string key, (_, CancellationTokenSource cts, DateTimeOffset lastAccess)) in Processes.ToList())
|
||||
{
|
||||
// TODO: configure this time span? 5 min?
|
||||
if (DateTimeOffset.Now.Subtract(lastAccess) > TimeSpan.FromMinutes(2))
|
||||
{
|
||||
_logger.LogDebug("Cleaning up ffmpeg session for channel {Channel}", key);
|
||||
|
||||
cts.Cancel();
|
||||
Processes.TryRemove(key, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Unit KillAll()
|
||||
{
|
||||
foreach ((string key, ProcessAndToken processAndToken) in Processes.ToList())
|
||||
{
|
||||
try
|
||||
{
|
||||
processAndToken.TokenSource.Cancel();
|
||||
Processes.TryRemove(key, out _);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogInformation(ex, "Error killing process");
|
||||
}
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private record ProcessAndToken(Process Process, CancellationTokenSource TokenSource, DateTimeOffset LastAccess);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
using System;
|
||||
using System.Text;
|
||||
|
||||
namespace ErsatzTV.Core.FFmpeg
|
||||
{
|
||||
public class HlsPlaylistFilter
|
||||
{
|
||||
public static TrimPlaylistResult TrimPlaylist(
|
||||
DateTimeOffset playlistStart,
|
||||
DateTimeOffset filterBefore,
|
||||
string[] lines,
|
||||
int maxSegments = 10,
|
||||
bool endWithDiscontinuity = false)
|
||||
{
|
||||
DateTimeOffset currentTime = playlistStart;
|
||||
DateTimeOffset nextPlaylistStart = DateTimeOffset.MaxValue;
|
||||
|
||||
var discontinuitySequence = 0;
|
||||
var startSequence = 0;
|
||||
var output = new StringBuilder();
|
||||
var started = false;
|
||||
var i = 0;
|
||||
var segments = 0;
|
||||
while (!lines[i].StartsWith("#EXTINF:"))
|
||||
{
|
||||
if (lines[i].StartsWith("#EXT-X-DISCONTINUITY-SEQUENCE"))
|
||||
{
|
||||
discontinuitySequence = int.Parse(lines[i].Split(':')[1]);
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
while (i < lines.Length)
|
||||
{
|
||||
if (segments >= maxSegments)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
string line = lines[i];
|
||||
// _logger.LogInformation("Line: {Line}", line);
|
||||
if (line.StartsWith("#EXT-X-DISCONTINUITY"))
|
||||
{
|
||||
if (started)
|
||||
{
|
||||
output.AppendLine("#EXT-X-DISCONTINUITY");
|
||||
}
|
||||
else
|
||||
{
|
||||
discontinuitySequence++;
|
||||
}
|
||||
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var duration = TimeSpan.FromSeconds(double.Parse(lines[i].TrimEnd(',').Split(':')[1]));
|
||||
if (currentTime < filterBefore)
|
||||
{
|
||||
currentTime += duration;
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
|
||||
nextPlaylistStart = currentTime < nextPlaylistStart ? currentTime : nextPlaylistStart;
|
||||
|
||||
if (!started)
|
||||
{
|
||||
startSequence = int.Parse(lines[i + 2].Replace("live", string.Empty).Split('.')[0]);
|
||||
|
||||
output.AppendLine("#EXTM3U");
|
||||
output.AppendLine("#EXT-X-VERSION:6");
|
||||
output.AppendLine("#EXT-X-TARGETDURATION:4");
|
||||
output.AppendLine($"#EXT-X-MEDIA-SEQUENCE:{startSequence}");
|
||||
output.AppendLine($"#EXT-X-DISCONTINUITY-SEQUENCE:{discontinuitySequence}");
|
||||
output.AppendLine("#EXT-X-INDEPENDENT-SEGMENTS");
|
||||
output.AppendLine("#EXT-X-DISCONTINUITY");
|
||||
|
||||
started = true;
|
||||
}
|
||||
|
||||
output.AppendLine(lines[i]);
|
||||
string offset = currentTime.ToString("zzz").Replace(":", string.Empty);
|
||||
output.AppendLine($"#EXT-X-PROGRAM-DATE-TIME:{currentTime:yyyy-MM-ddTHH:mm:ss.fff}{offset}");
|
||||
output.AppendLine(lines[i + 2]);
|
||||
|
||||
currentTime += duration;
|
||||
segments++;
|
||||
i += 3;
|
||||
}
|
||||
|
||||
if (endWithDiscontinuity)
|
||||
{
|
||||
output.AppendLine("#EXT-X-DISCONTINUITY");
|
||||
}
|
||||
|
||||
return new TrimPlaylistResult(nextPlaylistStart, startSequence, output.ToString());
|
||||
}
|
||||
|
||||
public static TrimPlaylistResult TrimPlaylistWithDiscontinuity(
|
||||
DateTimeOffset playlistStart,
|
||||
DateTimeOffset filterBefore,
|
||||
string[] lines)
|
||||
{
|
||||
return TrimPlaylist(playlistStart, filterBefore, lines, int.MaxValue, true);
|
||||
}
|
||||
}
|
||||
|
||||
public record TrimPlaylistResult(DateTimeOffset PlaylistStart, int Sequence, string Playlist);
|
||||
}
|
||||
@@ -1,14 +1,11 @@
|
||||
using System.Diagnostics;
|
||||
using LanguageExt;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.FFmpeg
|
||||
{
|
||||
public interface IFFmpegSegmenterService
|
||||
{
|
||||
bool ProcessExistsForChannel(string channelNumber);
|
||||
bool TryAdd(string channelNumber, Process process);
|
||||
ConcurrentDictionary<string, IHlsSessionWorker> SessionWorkers { get; }
|
||||
|
||||
void TouchChannel(string channelNumber);
|
||||
void CleanUpSessions();
|
||||
Unit KillAll();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
using System;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.FFmpeg
|
||||
{
|
||||
public interface IHlsSessionWorker
|
||||
{
|
||||
DateTimeOffset PlaylistStart { get; }
|
||||
void Touch();
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,6 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AsyncFixer" Version="1.5.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Dapper" Version="2.0.90" />
|
||||
<PackageReference Include="Lucene.Net" Version="4.8.0-beta00014" />
|
||||
<PackageReference Include="Lucene.Net.Analysis.Common" Version="4.8.0-beta00014" />
|
||||
|
||||
@@ -52,6 +52,7 @@ namespace ErsatzTV.Infrastructure.Search
|
||||
private const string DirectorField = "director";
|
||||
private const string WriterField = "writer";
|
||||
private const string TraktListField = "trakt_list";
|
||||
private const string AlbumField = "album";
|
||||
|
||||
public const string MovieType = "movie";
|
||||
public const string ShowType = "show";
|
||||
@@ -74,7 +75,7 @@ namespace ErsatzTV.Infrastructure.Search
|
||||
_initialized = false;
|
||||
}
|
||||
|
||||
public int Version => 16;
|
||||
public int Version => 17;
|
||||
|
||||
public Task<bool> Initialize(ILocalFileSystem localFileSystem)
|
||||
{
|
||||
@@ -624,6 +625,11 @@ namespace ErsatzTV.Infrastructure.Search
|
||||
Field.Store.NO));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(metadata.Album))
|
||||
{
|
||||
doc.Add(new TextField(AlbumField, metadata.Album, Field.Store.NO));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(metadata.Plot))
|
||||
{
|
||||
doc.Add(new TextField(PlotField, metadata.Plot ?? string.Empty, Field.Store.NO));
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using System.Threading.Tasks;
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Application.Streaming.Queries;
|
||||
using ErsatzTV.Extensions;
|
||||
using LanguageExt;
|
||||
@@ -31,25 +33,29 @@ namespace ErsatzTV.Controllers
|
||||
string channelNumber,
|
||||
[FromQuery]
|
||||
string mode = "mixed") =>
|
||||
_mediator.Send(new GetPlayoutItemProcessByChannelNumber(channelNumber, mode, false)).Map(
|
||||
result =>
|
||||
result.Match<IActionResult>(
|
||||
process =>
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"ffmpeg arguments {FFmpegArguments}",
|
||||
string.Join(" ", process.StartInfo.ArgumentList));
|
||||
process.Start();
|
||||
return new FileStreamResult(process.StandardOutput.BaseStream, "video/mp2t");
|
||||
},
|
||||
error =>
|
||||
{
|
||||
_logger.LogError(
|
||||
"Failed to create stream for channel {ChannelNumber}: {Error}",
|
||||
channelNumber,
|
||||
error.Value);
|
||||
return BadRequest(error.Value);
|
||||
}
|
||||
));
|
||||
_mediator.Send(
|
||||
new GetPlayoutItemProcessByChannelNumber(channelNumber, mode, DateTimeOffset.Now, false, true))
|
||||
.Map(
|
||||
result =>
|
||||
result.Match<IActionResult>(
|
||||
processModel =>
|
||||
{
|
||||
Process process = processModel.Process;
|
||||
|
||||
_logger.LogDebug(
|
||||
"ffmpeg arguments {FFmpegArguments}",
|
||||
string.Join(" ", process.StartInfo.ArgumentList));
|
||||
process.Start();
|
||||
return new FileStreamResult(process.StandardOutput.BaseStream, "video/mp2t");
|
||||
},
|
||||
error =>
|
||||
{
|
||||
_logger.LogError(
|
||||
"Failed to create stream for channel {ChannelNumber}: {Error}",
|
||||
channelNumber,
|
||||
error.Value);
|
||||
return BadRequest(error.Value);
|
||||
}
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Threading.Channels;
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.Channels.Queries;
|
||||
using ErsatzTV.Application.Images;
|
||||
using ErsatzTV.Application.Images.Queries;
|
||||
@@ -9,6 +10,8 @@ using ErsatzTV.Application.Streaming.Queries;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Iptv;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
@@ -22,18 +25,18 @@ namespace ErsatzTV.Controllers
|
||||
[ApiExplorerSettings(IgnoreApi = true)]
|
||||
public class IptvController : ControllerBase
|
||||
{
|
||||
private readonly ChannelWriter<IFFmpegWorkerRequest> _channel;
|
||||
private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
|
||||
private readonly ILogger<IptvController> _logger;
|
||||
private readonly IMediator _mediator;
|
||||
|
||||
public IptvController(
|
||||
IMediator mediator,
|
||||
ILogger<IptvController> logger,
|
||||
ChannelWriter<IFFmpegWorkerRequest> channel)
|
||||
IFFmpegSegmenterService ffmpegSegmenterService)
|
||||
{
|
||||
_mediator = mediator;
|
||||
_logger = logger;
|
||||
_channel = channel;
|
||||
_ffmpegSegmenterService = ffmpegSegmenterService;
|
||||
}
|
||||
|
||||
[HttpGet("iptv/channels.m3u")]
|
||||
@@ -53,8 +56,10 @@ namespace ErsatzTV.Controllers
|
||||
_mediator.Send(new GetConcatProcessByChannelNumber(Request.Scheme, Request.Host.ToString(), channelNumber))
|
||||
.Map(
|
||||
result => result.Match<IActionResult>(
|
||||
process =>
|
||||
processModel =>
|
||||
{
|
||||
Process process = processModel.Process;
|
||||
|
||||
_logger.LogInformation("Starting ts stream for channel {ChannelNumber}", channelNumber);
|
||||
// _logger.LogDebug(
|
||||
// "ffmpeg concat arguments {FFmpegArguments}",
|
||||
@@ -64,6 +69,26 @@ namespace ErsatzTV.Controllers
|
||||
},
|
||||
error => BadRequest(error.Value)));
|
||||
|
||||
[HttpGet("iptv/session/{channelNumber}/hls.m3u8")]
|
||||
public async Task<IActionResult> GetLivePlaylist(string channelNumber)
|
||||
{
|
||||
if (_ffmpegSegmenterService.SessionWorkers.TryGetValue(channelNumber, out IHlsSessionWorker worker))
|
||||
{
|
||||
DateTimeOffset now = DateTimeOffset.Now.AddSeconds(-30);
|
||||
|
||||
string fileName = Path.Combine(FileSystemLayout.TranscodeFolder, channelNumber, "live.m3u8");
|
||||
if (System.IO.File.Exists(fileName))
|
||||
{
|
||||
string[] input = await System.IO.File.ReadAllLinesAsync(fileName);
|
||||
|
||||
TrimPlaylistResult result = HlsPlaylistFilter.TrimPlaylist(worker.PlaylistStart, now, input);
|
||||
return Content(result.Playlist, "application/vnd.apple.mpegurl");
|
||||
}
|
||||
}
|
||||
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
[HttpGet("iptv/channel/{channelNumber}.m3u8")]
|
||||
public async Task<IActionResult> GetHttpLiveStreamingVideo(
|
||||
string channelNumber,
|
||||
@@ -75,13 +100,13 @@ namespace ErsatzTV.Controllers
|
||||
case "segmenter":
|
||||
Either<BaseError, Unit> result = await _mediator.Send(new StartFFmpegSession(channelNumber, false));
|
||||
return result.Match<IActionResult>(
|
||||
_ => Redirect($"/iptv/session/{channelNumber}/live.m3u8"),
|
||||
_ => Redirect($"/iptv/session/{channelNumber}/hls.m3u8"),
|
||||
error =>
|
||||
{
|
||||
switch (error)
|
||||
{
|
||||
case ChannelHasProcess:
|
||||
return RedirectPreserveMethod($"/iptv/session/{channelNumber}/live.m3u8");
|
||||
case ChannelSessionAlreadyActive:
|
||||
return RedirectPreserveMethod($"/iptv/session/{channelNumber}/hls.m3u8");
|
||||
default:
|
||||
_logger.LogWarning(
|
||||
"Failed to start segmenter for channel {ChannelNumber}: {Error}",
|
||||
|
||||
@@ -13,10 +13,6 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AsyncFixer" Version="1.5.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Blazored.LocalStorage" Version="4.1.5" />
|
||||
<PackageReference Include="FluentValidation" Version="10.3.3" />
|
||||
<PackageReference Include="FluentValidation.AspNetCore" Version="10.3.3" />
|
||||
|
||||
@@ -139,6 +139,13 @@
|
||||
<MudCardContent Class="ml-3">
|
||||
<div style="display: flex; flex-direction: column; height: 100%">
|
||||
<MudText Typo="Typo.h4">@musicVideo.Title</MudText>
|
||||
@if (!string.IsNullOrWhiteSpace(musicVideo.Album))
|
||||
{
|
||||
<div style="display: flex; flex-direction: row">
|
||||
<MudText GutterBottom="true">Album: </MudText>
|
||||
<MudLink Href="@(@$"album:""{musicVideo.Album}""".GetRelativeSearchQuery())">@musicVideo.Album</MudLink>
|
||||
</div>
|
||||
}
|
||||
<MudText Style="flex-grow: 1">@musicVideo.Plot</MudText>
|
||||
<div class="mt-6">
|
||||
<MudButton Variant="Variant.Filled"
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.Streaming.Commands;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Services
|
||||
{
|
||||
public class FFmpegSchedulerService : BackgroundService
|
||||
{
|
||||
private readonly ILogger<FFmpegSchedulerService> _logger;
|
||||
private readonly ChannelWriter<IFFmpegWorkerRequest> _workerChannel;
|
||||
|
||||
public FFmpegSchedulerService(
|
||||
ChannelWriter<IFFmpegWorkerRequest> workerChannel,
|
||||
ILogger<FFmpegSchedulerService> logger)
|
||||
{
|
||||
_workerChannel = workerChannel;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
await _workerChannel.WriteAsync(new CleanUpFFmpegSessions(), cancellationToken);
|
||||
await Task.Delay(TimeSpan.FromMinutes(1), cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,11 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.Streaming.Commands;
|
||||
using ErsatzTV.Application.Streaming.Queries;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -21,20 +16,17 @@ namespace ErsatzTV.Services
|
||||
public class FFmpegWorkerService : BackgroundService
|
||||
{
|
||||
private readonly ChannelReader<IFFmpegWorkerRequest> _channel;
|
||||
private readonly ChannelWriter<IFFmpegWorkerRequest> _channelWriter;
|
||||
private readonly ILogger<FFmpegWorkerService> _logger;
|
||||
private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
|
||||
private readonly IServiceScopeFactory _serviceScopeFactory;
|
||||
|
||||
public FFmpegWorkerService(
|
||||
ChannelReader<IFFmpegWorkerRequest> channel,
|
||||
ChannelWriter<IFFmpegWorkerRequest> channelWriter,
|
||||
IServiceScopeFactory serviceScopeFactory,
|
||||
ILogger<FFmpegWorkerService> logger,
|
||||
IFFmpegSegmenterService ffmpegSegmenterService)
|
||||
{
|
||||
_channel = channel;
|
||||
_channelWriter = channelWriter;
|
||||
_serviceScopeFactory = serviceScopeFactory;
|
||||
_logger = logger;
|
||||
_ffmpegSegmenterService = ffmpegSegmenterService;
|
||||
@@ -49,7 +41,7 @@ namespace ErsatzTV.Services
|
||||
try
|
||||
{
|
||||
using IServiceScope scope = _serviceScopeFactory.CreateScope();
|
||||
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
|
||||
// IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
|
||||
|
||||
switch (request)
|
||||
{
|
||||
@@ -58,52 +50,6 @@ namespace ErsatzTV.Services
|
||||
{
|
||||
_ffmpegSegmenterService.TouchChannel(parent.Name);
|
||||
}
|
||||
break;
|
||||
case CleanUpFFmpegSessions:
|
||||
_ffmpegSegmenterService.CleanUpSessions();
|
||||
break;
|
||||
case StartFFmpegSession startFFmpegSession:
|
||||
_logger.LogInformation(
|
||||
"Starting ffmpeg session for channel {Channel}",
|
||||
startFFmpegSession.ChannelNumber);
|
||||
|
||||
if (!_ffmpegSegmenterService.ProcessExistsForChannel(startFFmpegSession.ChannelNumber))
|
||||
{
|
||||
var req = new GetPlayoutItemProcessByChannelNumber(
|
||||
startFFmpegSession.ChannelNumber,
|
||||
"segmenter",
|
||||
startFFmpegSession.StartAtZero);
|
||||
Either<BaseError, Process> maybeProcess = await mediator.Send(req, cancellationToken);
|
||||
maybeProcess.Match(
|
||||
process =>
|
||||
{
|
||||
if (_ffmpegSegmenterService.TryAdd(startFFmpegSession.ChannelNumber, process))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"ffmpeg hls arguments {FFmpegArguments}",
|
||||
string.Join(" ", process.StartInfo.ArgumentList));
|
||||
|
||||
process.Start();
|
||||
process.EnableRaisingEvents = true;
|
||||
process.Exited += (_, _) =>
|
||||
{
|
||||
if (process.ExitCode == 0)
|
||||
{
|
||||
_channelWriter.TryWrite(
|
||||
new StartFFmpegSession(startFFmpegSession.ChannelNumber, true));
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"hls segmenter for channel {Channel} exited with code {ExitCode}",
|
||||
startFFmpegSession.ChannelNumber,
|
||||
process.ExitCode);
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
_ => { });
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
@@ -113,9 +59,6 @@ namespace ErsatzTV.Services
|
||||
_logger.LogWarning(ex, "Failed to handle ffmpeg worker request");
|
||||
}
|
||||
}
|
||||
|
||||
// kill any running processes after cancellation
|
||||
_ffmpegSegmenterService.KillAll();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -7,6 +7,7 @@ using Blazored.LocalStorage;
|
||||
using Dapper;
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.Channels.Queries;
|
||||
using ErsatzTV.Application.Streaming;
|
||||
using ErsatzTV.Application.Streaming.Commands;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Emby;
|
||||
@@ -300,6 +301,7 @@ namespace ErsatzTV
|
||||
services.AddScoped<IPlexPathReplacementService, PlexPathReplacementService>();
|
||||
services.AddScoped<IFFmpegStreamSelector, FFmpegStreamSelector>();
|
||||
services.AddScoped<FFmpegProcessService>();
|
||||
services.AddScoped<HlsSessionWorker>();
|
||||
services.AddScoped<IGitHubApiClient, GitHubApiClient>();
|
||||
services.AddScoped<IHtmlSanitizer, HtmlSanitizer>(
|
||||
_ =>
|
||||
@@ -326,7 +328,6 @@ namespace ErsatzTV
|
||||
services.AddHostedService<WorkerService>();
|
||||
services.AddHostedService<SchedulerService>();
|
||||
services.AddHostedService<FFmpegWorkerService>();
|
||||
services.AddHostedService<FFmpegSchedulerService>();
|
||||
}
|
||||
|
||||
private void AddChannel<TMessageType>(IServiceCollection services)
|
||||
|
||||
@@ -23,7 +23,7 @@ ARG INFO_VERSION="unknown"
|
||||
RUN dotnet publish -c release -o /app -r linux-x64 --self-contained false --no-restore /p:InformationalVersion=${INFO_VERSION}
|
||||
|
||||
# final stage/image
|
||||
FROM jasongdove/ffmpeg:4.3-nvidia1804 AS runtime-base
|
||||
FROM jasongdove/ffmpeg:4.3-nvidia2004 AS runtime-base
|
||||
WORKDIR /app
|
||||
EXPOSE 8409
|
||||
COPY --from=build /app ./
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:5.0-focal-amd64 AS dotnet-runtime
|
||||
|
||||
FROM jrottenberg/ffmpeg:4.3-nvidia1804 AS runtime-base
|
||||
FROM jrottenberg/ffmpeg:4.3-nvidia2004 AS runtime-base
|
||||
COPY --from=dotnet-runtime /usr/share/dotnet /usr/share/dotnet
|
||||
RUN apt-get update \
|
||||
&& DEBIAN_FRONTEND="noninteractive" apt-get install -y libicu-dev tzdata \
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.0 KiB |
+3
-2
@@ -10,5 +10,6 @@ Want to say thanks? Consider [sponsorship on GitHub](https://github.com/sponsors
|
||||
|
||||
1. [Install ErsatzTV](user-guide/install.md)
|
||||
2. [Add Media Items](user-guide/add-media-items.md)
|
||||
3. [Create Channels](user-guide/create-channels.md)
|
||||
4. [Configure Clients](user-guide/configure-clients.md)
|
||||
3. [Create Collections](user-guide/create-collections.md)
|
||||
4. [Create Channels](user-guide/create-channels.md)
|
||||
5. [Configure Clients](user-guide/configure-clients.md)
|
||||
|
||||
@@ -194,26 +194,3 @@ Click `Add Path Replacement` to add a new path replacement, and enter the `Emby
|
||||
Click `Save Changes` after you have created all needed path replacements.
|
||||
|
||||

|
||||
|
||||
## Collections
|
||||
|
||||
ErsatzTV does not schedule individual media items; only collections of items can be scheduled.
|
||||
To create a collection, find the media items you would like to include and select them by clicking the selection button in the top left of the poster:
|
||||
|
||||

|
||||
|
||||
Then, add to a collection by clicking the `Add To Collection` button at the top of the page.
|
||||
|
||||

|
||||
|
||||
In the `Add To Collection` dialog, either select an existing collection for the items, or enter a new collection name to create a collection and add all of the selected items in a single step.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
Big Buck Bunny: (c) copyright 2008, Blender Foundation / [www.bigbuckbunny.org](https://www.bigbuckbunny.org)
|
||||
|
||||
Sintel: © copyright Blender Foundation | [www.sintel.org](https://www.sintel.org)
|
||||
|
||||
Tears of Steel: (CC) Blender Foundation | [mango.blender.org](https://mango.blender.org)
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
## Collection Types
|
||||
|
||||
ErsatzTV does not schedule individual media items; only collections of items can be scheduled. There are three (3) collection types:
|
||||
|
||||
- `Collection`: contains manually-added items
|
||||
- `Smart Collection`: contains items from search results or imported Trakt Lists, and updates dynamically
|
||||
- `Multi Collection`: contains other Collections and Smart Collections.
|
||||
|
||||
### Collections
|
||||
|
||||
Collections are created manually by adding individual media items to it. There is no restriction on media type. A collection can contain a single media type, eg. movies or any combination of media types.
|
||||
The items to be added can be found by browsing your libraries or by using ErsatzTV's powerful Search feature. An empty collection can be created under `Lists` > `Collections` > `Add Collection`.
|
||||
|
||||
To create a collection, find the media items you would like to include and select them by clicking the selection button in the top left of the poster:
|
||||
|
||||

|
||||
|
||||
Then, add to a collection by clicking the `Add To Collection` button at the top of the page.
|
||||
|
||||

|
||||
|
||||
In the `Add To Collection` dialog, either select an existing collection for the items, or enter a new collection name to create a collection and add all of the selected items in a single step.
|
||||
|
||||

|
||||
|
||||
### Smart Collections
|
||||
|
||||
Smart Collections are created by saving searches or saving imported Trakt Lists.
|
||||
|
||||
#### From Searches
|
||||
|
||||
To create a smart collection, type a search query in the search box and press enter.
|
||||
If the result shown meets your expectations, save as a Smart Collection by clicking the `Save As` button.
|
||||
If it doesn't meet your expectations, refine the search query until it does and then save.
|
||||
|
||||
#### From Imported Trakt Lists
|
||||
|
||||
To import a Trakt List, navigate to the `Lists` > `Trakt Lists` page and click the `Add Trakt List` button. Trakt lists can be imported by url or by `user/list`.
|
||||
|
||||
- To re-download a Trakt List, simply add it again. There is no need to delete.
|
||||
- Unmatched item details are written to the log.
|
||||
- Trakt Lists can only be scheduled after being saved as a Smart Collection.
|
||||
|
||||
To see matching items from an imported Trakt list, click the search button on the row of the Trakt List.
|
||||
|
||||

|
||||
|
||||
### Multi Collections
|
||||
|
||||
Multi Collections contain other Collections and/or Smart Collections. The primary purpose of these are to enable the scheduling of sets of Media Items in a controlled manner.
|
||||
Here we are referring to Movie Boxsets, e.g. X-Men Collection, Star Trek Collection and Before... Collection as well as related movies and tv show groups such as Arrowverse, MARVEL Cinematic Universe, Worlds of DC and Battlestar Galactica (Reboot).
|
||||
|
||||
To Add a Multi Collection
|
||||
|
||||
- Navigate to `Lists` > `Collections` and click the `Add Multi Collection` button
|
||||
- Give the Multi Collection a name
|
||||
- Add collections and/or smart collections as required and click `Add Multi Collection` to save
|
||||
|
||||
---
|
||||
|
||||
Big Buck Bunny: (c) copyright 2008, Blender Foundation / [www.bigbuckbunny.org](https://www.bigbuckbunny.org)
|
||||
|
||||
Sintel: © copyright Blender Foundation | [www.sintel.org](https://www.sintel.org)
|
||||
|
||||
Tears of Steel: (CC) Blender Foundation | [mango.blender.org](https://mango.blender.org)
|
||||
@@ -71,6 +71,7 @@ The following fields are available for searching artists:
|
||||
The following fields are available for searching music videos:
|
||||
|
||||
- `title`: The music video title
|
||||
- `album`: The music video album
|
||||
- `genre`: The music video genre
|
||||
- `library_name`: The name of the library that contains the music video
|
||||
- `language`: The music video audio stream language
|
||||
|
||||
@@ -3,6 +3,7 @@ nav:
|
||||
- 'Quick Start':
|
||||
- 'Install ErsatzTV': 'user-guide/install.md'
|
||||
- 'Add Media Items': 'user-guide/add-media-items.md'
|
||||
- 'Create Collections': 'user-guide/create-collections.md'
|
||||
- 'Create Channels': 'user-guide/create-channels.md'
|
||||
- 'Configure Clients': 'user-guide/configure-clients.md'
|
||||
- 'Advanced':
|
||||
|
||||
Reference in New Issue
Block a user