Compare commits

...
Author SHA1 Message Date
Jason Dove 67a6f554d0 rename v0.0.63-alpha v0.1.0-alpha [no ci] 2021-10-08 18:44:49 -05:00
Jason Dove 609df217ae update changelog for release 63 [no ci] 2021-10-08 18:39:03 -05:00
Jason DoveandGitHub d3086264c7 unraid doc fixes (#405) 2021-10-08 18:31:22 -05:00
Jason DoveandGitHub 8cd9b23787 fix transcode folder preparation (#404) 2021-10-08 15:29:19 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
dc5c9e42ff Bump Serilog.Settings.Configuration from 3.2.0 to 3.3.0 (#403)
Bumps [Serilog.Settings.Configuration](https://github.com/serilog/serilog-settings-configuration) from 3.2.0 to 3.3.0.
- [Release notes](https://github.com/serilog/serilog-settings-configuration/releases)
- [Changelog](https://github.com/serilog/serilog-settings-configuration/blob/dev/CHANGES.md)
- [Commits](https://github.com/serilog/serilog-settings-configuration/commits)

---
updated-dependencies:
- dependency-name: Serilog.Settings.Configuration
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-10-08 09:09:57 -05:00
Jason DoveandGitHub 2dd267e4db fix xmltv generation with missing episode metadata (#402) 2021-10-07 22:31:46 -05:00
Jason DoveandGitHub b069a21473 allow hls segmenter to run before framerate is known (#401) 2021-10-07 22:07:54 -05:00
Jason DoveandGitHub 6c8813ce22 add hls segmenter streaming mode (#400)
* hls segmenter wip

* log message

* close unused transcode sessions after 2 minutes

* use frame rate for 2s keyframes in hls segmenter

* add frame rate to media version

* fix segmenter framerate calculation

* automatically restart hls segmenter with next scheduled item

* cleanup

* update changelog

* decrease segmenter start delay
2021-10-07 21:42:29 -05:00
Jason DoveandGitHub b5de5e2b7f fix statistic updates (#399) 2021-10-07 19:56:41 -05:00
Jason DoveandGitHub 4b7da4e468 speed up builds by using base images (#398) 2021-10-06 06:06:13 -05:00
Jason DoveandGitHub ae8e795228 vaapi downsample 10bit hevc 8bit h264 (#397)
* vaapi downsample 10bit hevc to 8bit h264

* update changelog
2021-10-05 22:16:57 -05:00
Jason DoveandGitHub 334781485d use latest intel driver in vaapi docker images (#396)
* compile latest iHD driver

* cleanup to reduce image size

* update changelog
2021-10-05 20:24:26 -05:00
64 changed files with 4119 additions and 95 deletions
+18 -1
View File
@@ -5,6 +5,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
## [0.1.0-alpha] - 2021-10-08
### Added
- Add *experimental* streaming mode `HLS Segmenter` (most similar to `HLS Hybrid`)
- This mode is intended to increase client compatibility and reduce issues at program boundaries
- If you want the temporary transcode files to be located on a particular drive, the docker path is `/root/.local/share/etv-transcode`
- Store frame rate with media statistics; this is needed to support HLS Segmenter
- This requires re-ingesting statistics for all media items the first time this version is launched
### Changed
- Use latest iHD driver (21.2.3 vs 20.1.1) in vaapi docker images
### Fixed
- Add downsampling to support transcoding 10-bit HEVC content with the h264_vaapi encoder
- Fix updating statistics when media items are replaced
- Fix XMLTV generation when scheduled episode is missing metadata
## [0.0.62-alpha] - 2021-10-05
### Added
- Support IMDB ids from Plex libraries, which may improve Trakt matching for some items
@@ -644,7 +660,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.0.62-alpha...HEAD
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.1.0-alpha...HEAD
[0.1.0-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.62-alpha...v0.1.0-alpha
[0.0.62-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.61-alpha...v0.0.62-alpha
[0.0.61-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.60-alpha...v0.0.61-alpha
[0.0.60-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.59-alpha...v0.0.60-alpha
@@ -28,6 +28,10 @@ namespace ErsatzTV.Application.Channels.Queries
{
switch (mode.ToLowerInvariant())
{
case "segmenter":
channel.StreamingMode = StreamingMode.HttpLiveStreamingSegmenter;
result.Add(channel);
break;
case "hls-direct":
channel.StreamingMode = StreamingMode.HttpLiveStreamingDirect;
result.Add(channel);
@@ -0,0 +1,6 @@
namespace ErsatzTV.Application
{
public interface IFFmpegWorkerRequest
{
}
}
@@ -0,0 +1,9 @@
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Application.Streaming.Commands
{
public record CleanUpFFmpegSessions : IRequest<Either<BaseError, Unit>>, IFFmpegWorkerRequest;
}
@@ -0,0 +1,27 @@
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;
}
}
}
@@ -0,0 +1,9 @@
using ErsatzTV.Core;
using LanguageExt;
namespace ErsatzTV.Application.Streaming.Commands
{
public record StartFFmpegSession(string ChannelNumber, bool StartAtZero) :
MediatR.IRequest<Either<BaseError, Unit>>,
IFFmpegWorkerRequest;
}
@@ -0,0 +1,75 @@
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.Logging;
using static LanguageExt.Prelude;
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 IFFmpegSegmenterService _ffmpegSegmenterService;
private readonly ILocalFileSystem _localFileSystem;
public StartFFmpegSessionHandler(
IFFmpegSegmenterService ffmpegSegmenterService,
ILocalFileSystem localFileSystem,
ChannelWriter<IFFmpegWorkerRequest> channel,
ILogger<StartFFmpegSessionHandler> logger)
{
_ffmpegSegmenterService = ffmpegSegmenterService;
_localFileSystem = localFileSystem;
_channel = channel;
_logger = logger;
}
public Task<Either<BaseError, Unit>> Handle(StartFFmpegSession request, CancellationToken cancellationToken) =>
Validate(request)
.MapT(_ => StartProcess(request))
// this weirdness is needed to maintain the error type (.ToEitherAsync() just gives BaseError)
#pragma warning disable VSTHRD103
.Bind(v => v.ToEither().MapLeft(seq => seq.Head()).MapAsync<BaseError, Task<Unit>, Unit>(identity));
#pragma warning restore VSTHRD103
private async Task<Unit> StartProcess(StartFFmpegSession request)
{
await _channel.WriteAsync(request);
// TODO: find some other way to let ffmpeg get ahead
await Task.Delay(TimeSpan.FromSeconds(5));
return Unit.Default;
}
private Task<Validation<BaseError, Unit>> Validate(StartFFmpegSession request) =>
ProcessMustNotExist(request)
.BindT(_ => FolderMustBeEmpty(request));
private Task<Validation<BaseError, Unit>> ProcessMustNotExist(StartFFmpegSession request) =>
Optional(_ffmpegSegmenterService.ProcessExistsForChannel(request.ChannelNumber))
.Filter(exists => exists == false)
.Map(_ => Unit.Default)
.ToValidation<BaseError>(new ChannelHasProcess())
.AsTask();
private Task<Validation<BaseError, Unit>> FolderMustBeEmpty(StartFFmpegSession request)
{
string folder = Path.Combine(FileSystemLayout.TranscodeFolder, request.ChannelNumber);
_logger.LogDebug("Preparing transcode folder {Folder}", folder);
_localFileSystem.EnsureFolderExists(folder);
_localFileSystem.EmptyFolder(folder);
return Task.FromResult<Validation<BaseError, Unit>>(Unit.Default);
}
}
}
@@ -0,0 +1,9 @@
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Application.Streaming.Commands
{
public record TouchFFmpegSession(string Path) : IRequest<Either<BaseError, Unit>>, IFFmpegWorkerRequest;
}
@@ -56,6 +56,7 @@ namespace ErsatzTV.Application.Streaming.Queries
channel.StreamingMode = request.Mode.ToLowerInvariant() switch
{
"hls-direct" => StreamingMode.HttpLiveStreamingDirect,
"segmenter" => StreamingMode.HttpLiveStreamingSegmenter,
"ts" => StreamingMode.TransportStream,
_ => channel.StreamingMode
};
@@ -5,5 +5,6 @@ using MediatR;
namespace ErsatzTV.Application.Streaming.Queries
{
public record FFmpegProcessRequest(string ChannelNumber, string Mode) : IRequest<Either<BaseError, Process>>;
public record FFmpegProcessRequest
(string ChannelNumber, string Mode, bool StartAtZero) : IRequest<Either<BaseError, Process>>;
}
@@ -4,7 +4,8 @@
{
public GetConcatProcessByChannelNumber(string scheme, string host, string channelNumber) : base(
channelNumber,
"ts")
"ts",
false)
{
Scheme = scheme;
Host = host;
@@ -2,7 +2,10 @@
{
public record GetPlayoutItemProcessByChannelNumber : FFmpegProcessRequest
{
public GetPlayoutItemProcessByChannelNumber(string channelNumber, string mode) : base(channelNumber, mode)
public GetPlayoutItemProcessByChannelNumber(string channelNumber, string mode, bool startAtZero) : base(
channelNumber,
mode,
startAtZero)
{
}
}
@@ -50,7 +50,7 @@ namespace ErsatzTV.Application.Streaming.Queries
protected override async Task<Either<BaseError, Process>> GetProcess(
TvContext dbContext,
GetPlayoutItemProcessByChannelNumber _,
GetPlayoutItemProcessByChannelNumber request,
Channel channel,
string ffmpegPath)
{
@@ -111,7 +111,7 @@ namespace ErsatzTV.Application.Streaming.Queries
version,
playoutItemWithPath.Path,
playoutItemWithPath.PlayoutItem.StartOffset,
now,
request.StartAtZero ? playoutItemWithPath.PlayoutItem.StartOffset : now,
maybeGlobalWatermark,
maybeVaapiDriver));
},
@@ -134,7 +134,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
var service = new FFmpegProcessService(
new FFmpegPlaybackSettingsCalculator(),
new FakeStreamSelector(),
new Mock<IImageCache>().Object);
new Mock<IImageCache>().Object,
new Mock<ILogger<FFmpegProcessService>>().Object);
MediaVersion v = new MediaVersion();
@@ -145,7 +146,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
var localStatisticsProvider = new LocalStatisticsProvider(
metadataRepository.Object,
new LocalFileSystem(),
new LocalFileSystem(new Mock<ILogger<LocalFileSystem>>().Object),
new Mock<ILogger<LocalStatisticsProvider>>().Object);
await localStatisticsProvider.RefreshStatistics(
@@ -59,6 +59,8 @@ namespace ErsatzTV.Core.Tests.Fakes
public Task<Either<BaseError, Unit>> CopyFile(string source, string destination) =>
Task.FromResult(Right<BaseError, Unit>(Unit.Default));
public Unit EmptyFolder(string folder) => Unit.Default;
private static List<DirectoryInfo> Split(DirectoryInfo path)
{
var result = new List<DirectoryInfo>();
@@ -13,6 +13,7 @@ namespace ErsatzTV.Core.Domain
public TimeSpan Duration { get; set; }
public string SampleAspectRatio { get; set; }
public string DisplayAspectRatio { get; set; }
public string RFrameRate { get; set; }
public VideoScanKind VideoScanKind { get; set; }
public DateTime DateAdded { get; set; }
public DateTime DateUpdated { get; set; }
+2 -1
View File
@@ -4,6 +4,7 @@
{
TransportStream = 1,
HttpLiveStreamingDirect = 2,
HttpLiveStreamingHybrid = 3
HttpLiveStreamingHybrid = 3,
HttpLiveStreamingSegmenter = 4
}
}
@@ -96,6 +96,8 @@ namespace ErsatzTV.Core.Emby
foreach (EmbyMovie incoming in validMovies)
{
EmbyMovie incomingMovie = incoming;
decimal percentCompletion = (decimal) validMovies.IndexOf(incoming) / validMovies.Count;
await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion));
@@ -122,12 +124,14 @@ namespace ErsatzTV.Core.Emby
updateStatistics = true;
incoming.LibraryPathId = library.Paths.Head().Id;
Option<EmbyMovie> updated = await _movieRepository.UpdateEmby(incoming);
if (updated.IsSome)
Option<EmbyMovie> maybeUpdated = await _movieRepository.UpdateEmby(incoming);
foreach (EmbyMovie updated in maybeUpdated)
{
await _searchIndex.UpdateItems(
_searchRepository,
new List<MediaItem> { updated.ValueUnsafe() });
new List<MediaItem> { updated });
incomingMovie = updated;
}
}
catch (Exception ex)
@@ -174,12 +178,12 @@ namespace ErsatzTV.Core.Emby
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath);
Either<BaseError, bool> refreshResult =
await _localStatisticsProvider.RefreshStatistics(ffprobePath, incoming, localPath);
await _localStatisticsProvider.RefreshStatistics(ffprobePath, incomingMovie, localPath);
await refreshResult.Match(
async _ =>
{
Option<MediaItem> updated = await _searchRepository.GetItemToIndex(incoming.Id);
Option<MediaItem> updated = await _searchRepository.GetItemToIndex(incomingMovie.Id);
if (updated.IsSome)
{
await _searchIndex.UpdateItems(
@@ -326,6 +326,7 @@ namespace ErsatzTV.Core.Emby
{
foreach (EmbyEpisode incoming in episodes)
{
EmbyEpisode incomingEpisode = incoming;
var updateStatistics = false;
Option<EmbyItemEtag> maybeExisting = existingEpisodes.Find(ie => ie.ItemId == incoming.ItemId);
@@ -349,12 +350,14 @@ namespace ErsatzTV.Core.Emby
incoming.SeasonId = season.Id;
incoming.LibraryPathId = library.Paths.Head().Id;
Option<EmbyEpisode> updated = await _televisionRepository.Update(incoming);
if (updated.IsSome)
Option<EmbyEpisode> maybeUpdated = await _televisionRepository.Update(incoming);
foreach (EmbyEpisode updated in maybeUpdated)
{
await _searchIndex.UpdateItems(
_searchRepository,
new List<MediaItem> { updated.ValueUnsafe() });
new List<MediaItem> { updated });
incomingEpisode = updated;
}
}
catch (Exception ex)
@@ -403,7 +406,7 @@ namespace ErsatzTV.Core.Emby
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath);
Either<BaseError, bool> refreshResult =
await _localStatisticsProvider.RefreshStatistics(ffprobePath, incoming, localPath);
await _localStatisticsProvider.RefreshStatistics(ffprobePath, incomingEpisode, localPath);
refreshResult.Match(
_ => { },
@@ -0,0 +1,9 @@
namespace ErsatzTV.Core.Errors
{
public class ChannelHasProcess : BaseError
{
public ChannelHasProcess() : base("Channel already has ffmpeg process")
{
}
}
}
@@ -21,6 +21,7 @@ namespace ErsatzTV.Core.FFmpeg
private Option<IDisplaySize> _scaleToSize = None;
private Option<ChannelWatermark> _watermark;
private string _pixelFormat;
private string _videoEncoder;
public FFmpegComplexFilterBuilder WithHardwareAcceleration(HardwareAccelerationKind hardwareAccelerationKind)
{
@@ -77,6 +78,12 @@ namespace ErsatzTV.Core.FFmpeg
return this;
}
public FFmpegComplexFilterBuilder WithVideoEncoder(string videoEncoder)
{
_videoEncoder = videoEncoder;
return this;
}
public Option<FFmpegComplexFilter> Build(int videoStreamIndex, Option<int> audioStreamIndex)
{
var complexFilter = new StringBuilder();
@@ -247,6 +254,13 @@ namespace ErsatzTV.Core.FFmpeg
complexFilter.Append(audioLabel);
}
// vaapi downsample 10bit hevc to 8bit h264
if (acceleration == HardwareAccelerationKind.Vaapi && !videoFilterQueue.Any() &&
_pixelFormat == "yuv420p10le" && _videoEncoder.StartsWith("h264"))
{
videoFilterQueue.Add("scale_vaapi=format=nv12");
}
if (videoFilterQueue.Any() || !string.IsNullOrWhiteSpace(watermarkOverlay))
{
if (hasAudioFilters)
@@ -70,6 +70,7 @@ namespace ErsatzTV.Core.FFmpeg
result.Deinterlace = false;
break;
case StreamingMode.HttpLiveStreamingHybrid:
case StreamingMode.HttpLiveStreamingSegmenter:
case StreamingMode.TransportStream:
result.HardwareAcceleration = ffmpegProfile.HardwareAcceleration;
+45 -3
View File
@@ -26,6 +26,7 @@ using System.Text;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
using LanguageExt;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Core.FFmpeg
{
@@ -41,15 +42,17 @@ namespace ErsatzTV.Core.FFmpeg
private readonly List<string> _arguments = new();
private readonly string _ffmpegPath;
private readonly bool _saveReports;
private readonly ILogger _logger;
private FFmpegComplexFilterBuilder _complexFilterBuilder = new();
private bool _isConcat;
private VaapiDriver _vaapiDriver;
private HardwareAccelerationKind _hwAccel;
public FFmpegProcessBuilder(string ffmpegPath, bool saveReports)
public FFmpegProcessBuilder(string ffmpegPath, bool saveReports, ILogger logger)
{
_ffmpegPath = ffmpegPath;
_saveReports = saveReports;
_logger = logger;
}
public FFmpegProcessBuilder WithVaapiDriver(Option<VaapiDriver> maybeVaapiDriver)
@@ -307,13 +310,47 @@ namespace ErsatzTV.Core.FFmpeg
return this;
}
public FFmpegProcessBuilder WithHls(string channelNumber, MediaVersion mediaVersion)
{
if (!int.TryParse(mediaVersion.RFrameRate, out int frameRate))
{
string[] split = (mediaVersion.RFrameRate ?? string.Empty).Split("/");
if (int.TryParse(split[0], out int left) && int.TryParse(split[1], out int right))
{
frameRate = (int)Math.Round(left / (double)right);
}
else
{
_logger.LogInformation("Unable to detect framerate, using {FrameRate}", 24);
frameRate = 24;
}
}
_arguments.AddRange(
new[]
{
"-g", $"{frameRate * 2}",
"-keyint_min", $"{frameRate * 2}",
// "-force_key_frames",
// "expr:gte(t,n_forced*2)",
"-f", "hls",
"-hls_time", "2",
"-hls_list_size", "10",
"-segment_list_flags", "+live",
"-hls_flags", "delete_segments+program_date_time+append_list+discont_start+omit_endlist",
Path.Combine(FileSystemLayout.TranscodeFolder, channelNumber, "live.m3u8")
});
return this;
}
public FFmpegProcessBuilder WithPlaybackArgs(FFmpegPlaybackSettings playbackSettings)
{
var arguments = new List<string>
{
"-c:v", playbackSettings.VideoCodec,
"-flags", "cgop",
"-sc_threshold", "1000000000"
"-sc_threshold", "0" // disable scene change detection
};
string[] videoBitrateArgs = playbackSettings.VideoBitrate.Match(
@@ -402,8 +439,13 @@ namespace ErsatzTV.Core.FFmpeg
return this;
}
public FFmpegProcessBuilder WithFilterComplex(MediaStream videoStream, Option<MediaStream> maybeAudioStream)
public FFmpegProcessBuilder WithFilterComplex(
MediaStream videoStream,
Option<MediaStream> maybeAudioStream,
string videoCodec)
{
_complexFilterBuilder = _complexFilterBuilder.WithVideoEncoder(videoCodec);
int videoStreamIndex = videoStream.Index;
Option<int> maybeIndex = maybeAudioStream.Map(ms => ms.Index);
+26 -13
View File
@@ -6,6 +6,7 @@ using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images;
using LanguageExt;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.FFmpeg
@@ -14,16 +15,19 @@ namespace ErsatzTV.Core.FFmpeg
{
private readonly IFFmpegStreamSelector _ffmpegStreamSelector;
private readonly IImageCache _imageCache;
private readonly ILogger<FFmpegProcessService> _logger;
private readonly FFmpegPlaybackSettingsCalculator _playbackSettingsCalculator;
public FFmpegProcessService(
FFmpegPlaybackSettingsCalculator ffmpegPlaybackSettingsService,
IFFmpegStreamSelector ffmpegStreamSelector,
IImageCache imageCache)
IImageCache imageCache,
ILogger<FFmpegProcessService> logger)
{
_playbackSettingsCalculator = ffmpegPlaybackSettingsService;
_ffmpegStreamSelector = ffmpegStreamSelector;
_imageCache = imageCache;
_logger = logger;
}
public async Task<Process> ForPlayoutItem(
@@ -56,7 +60,7 @@ namespace ErsatzTV.Core.FFmpeg
p => _imageCache.IsAnimated(p),
() => Task.FromResult(false));
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath, saveReports)
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath, saveReports, _logger)
.WithThreads(playbackSettings.ThreadCount)
.WithHardwareAcceleration(playbackSettings.HardwareAcceleration)
.WithVaapiDriver(maybeVaapiDriver)
@@ -82,7 +86,7 @@ namespace ErsatzTV.Core.FFmpeg
}
builder = builder
.WithFilterComplex(videoStream, maybeAudioStream);
.WithFilterComplex(videoStream, maybeAudioStream, channel.FFmpegProfile.VideoCodec);
},
() =>
{
@@ -91,27 +95,36 @@ namespace ErsatzTV.Core.FFmpeg
builder = builder
.WithDeinterlace(playbackSettings.Deinterlace)
.WithBlackBars(channel.FFmpegProfile.Resolution)
.WithFilterComplex(videoStream, maybeAudioStream);
.WithFilterComplex(videoStream, maybeAudioStream, channel.FFmpegProfile.VideoCodec);
}
else if (playbackSettings.Deinterlace)
{
builder = builder.WithDeinterlace(playbackSettings.Deinterlace)
.WithAlignedAudio(playbackSettings.AudioDuration)
.WithFilterComplex(videoStream, maybeAudioStream);
.WithFilterComplex(videoStream, maybeAudioStream, channel.FFmpegProfile.VideoCodec);
}
else
{
builder = builder
.WithFilterComplex(videoStream, maybeAudioStream);
.WithFilterComplex(videoStream, maybeAudioStream, channel.FFmpegProfile.VideoCodec);
}
});
return builder.WithPlaybackArgs(playbackSettings)
builder = builder.WithPlaybackArgs(playbackSettings)
.WithMetadata(channel, maybeAudioStream)
.WithFormat("mpegts")
.WithDuration(start + version.Duration - now)
.WithPipe()
.Build();
.WithDuration(start + version.Duration - now);
switch (channel.StreamingMode)
{
// HLS needs to segment and generate playlist
case StreamingMode.HttpLiveStreamingSegmenter:
return builder.WithHls(channel.Number, version)
.Build();
default:
return builder.WithFormat("mpegts")
.WithPipe()
.Build();
}
}
public Process ForError(string ffmpegPath, Channel channel, Option<TimeSpan> duration, string errorMessage)
@@ -121,7 +134,7 @@ namespace ErsatzTV.Core.FFmpeg
IDisplaySize desiredResolution = channel.FFmpegProfile.Resolution;
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath, false)
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath, false, _logger)
.WithThreads(1)
.WithQuiet()
.WithFormatFlags(playbackSettings.FormatFlags)
@@ -144,7 +157,7 @@ namespace ErsatzTV.Core.FFmpeg
{
FFmpegPlaybackSettings playbackSettings = _playbackSettingsCalculator.ConcatSettings;
return new FFmpegProcessBuilder(ffmpegPath, saveReports)
return new FFmpegProcessBuilder(ffmpegPath, saveReports, _logger)
.WithThreads(1)
.WithQuiet()
.WithFormatFlags(playbackSettings.FormatFlags)
@@ -0,0 +1,96 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
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)
{
if (Processes.TryGetValue(channelNumber, out ProcessAndToken processAndToken))
{
if (!processAndToken.Process.HasExited || !Processes.TryRemove(
new KeyValuePair<string, ProcessAndToken>(channelNumber, processAndToken)))
{
return true;
}
}
return false;
}
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 void TouchChannel(string channelNumber)
{
if (Processes.TryGetValue(channelNumber, out ProcessAndToken processAndToken))
{
ProcessAndToken newValue = processAndToken with { LastAccess = DateTimeOffset.Now };
if (!Processes.TryUpdate(channelNumber, newValue, processAndToken))
{
_logger.LogWarning("Failed to update last access for channel {Channel}", channelNumber);
}
}
}
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);
}
}
+7
View File
@@ -11,6 +11,13 @@ namespace ErsatzTV.Core
Environment.SpecialFolderOption.Create),
"ersatztv");
// TODO: find a different spot for this; configurable?
public static readonly string TranscodeFolder = Path.Combine(
Environment.GetFolderPath(
Environment.SpecialFolder.LocalApplicationData,
Environment.SpecialFolderOption.Create),
"etv-transcode");
public static readonly string DatabasePath = Path.Combine(AppDataFolder, "ersatztv.sqlite3");
public static readonly string LogDatabasePath = Path.Combine(AppDataFolder, "logs.sqlite3");
@@ -0,0 +1,14 @@
using System.Diagnostics;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.FFmpeg
{
public interface IFFmpegSegmenterService
{
bool ProcessExistsForChannel(string channelNumber);
bool TryAdd(string channelNumber, Process process);
void TouchChannel(string channelNumber);
void CleanUpSessions();
Unit KillAll();
}
}
@@ -15,5 +15,6 @@ namespace ErsatzTV.Core.Interfaces.Metadata
IEnumerable<string> ListFiles(string folder);
bool FileExists(string path);
Task<Either<BaseError, Unit>> CopyFile(string source, string destination);
Unit EmptyFolder(string folder);
}
}
+1 -1
View File
@@ -202,7 +202,7 @@ namespace ErsatzTV.Core.Iptv
{
int s = Optional(episode.Season?.SeasonNumber).IfNone(-1);
// TODO: multi-episode?
int e = episode.EpisodeMetadata.Head().EpisodeNumber;
int e = episode.EpisodeMetadata.HeadOrNone().Match(em => em.EpisodeNumber, -1);
if (s >= 0 && e > 0)
{
xml.WriteStartElement("episode-num");
+1
View File
@@ -44,6 +44,7 @@ namespace ErsatzTV.Core.Iptv
{
StreamingMode.HttpLiveStreamingDirect => "m3u8?mode=hls-direct",
StreamingMode.HttpLiveStreamingHybrid => "m3u8",
StreamingMode.HttpLiveStreamingSegmenter => "m3u8?mode=segmenter",
_ => "ts"
};
@@ -96,6 +96,8 @@ namespace ErsatzTV.Core.Jellyfin
foreach (JellyfinMovie incoming in validMovies)
{
JellyfinMovie incomingMovie = incoming;
decimal percentCompletion = (decimal) validMovies.IndexOf(incoming) / validMovies.Count;
await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion));
@@ -122,12 +124,14 @@ namespace ErsatzTV.Core.Jellyfin
updateStatistics = true;
incoming.LibraryPathId = library.Paths.Head().Id;
Option<JellyfinMovie> updated = await _movieRepository.UpdateJellyfin(incoming);
if (updated.IsSome)
Option<JellyfinMovie> maybeUpdated = await _movieRepository.UpdateJellyfin(incoming);
foreach (JellyfinMovie updated in maybeUpdated)
{
await _searchIndex.UpdateItems(
_searchRepository,
new List<MediaItem> { updated.ValueUnsafe() });
new List<MediaItem> { updated });
incomingMovie = updated;
}
}
catch (Exception ex)
@@ -174,12 +178,12 @@ namespace ErsatzTV.Core.Jellyfin
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath);
Either<BaseError, bool> refreshResult =
await _localStatisticsProvider.RefreshStatistics(ffprobePath, incoming, localPath);
await _localStatisticsProvider.RefreshStatistics(ffprobePath, incomingMovie, localPath);
await refreshResult.Match(
async _ =>
{
Option<MediaItem> updated = await _searchRepository.GetItemToIndex(incoming.Id);
Option<MediaItem> updated = await _searchRepository.GetItemToIndex(incomingMovie.Id);
if (updated.IsSome)
{
await _searchIndex.UpdateItems(
@@ -327,6 +327,8 @@ namespace ErsatzTV.Core.Jellyfin
{
foreach (JellyfinEpisode incoming in episodes)
{
JellyfinEpisode incomingEpisode = incoming;
var updateStatistics = false;
Option<JellyfinItemEtag> maybeExisting = existingEpisodes.Find(ie => ie.ItemId == incoming.ItemId);
@@ -350,12 +352,14 @@ namespace ErsatzTV.Core.Jellyfin
incoming.SeasonId = season.Id;
incoming.LibraryPathId = library.Paths.Head().Id;
Option<JellyfinEpisode> updated = await _televisionRepository.Update(incoming);
if (updated.IsSome)
Option<JellyfinEpisode> maybeUpdated = await _televisionRepository.Update(incoming);
foreach (JellyfinEpisode updated in maybeUpdated)
{
await _searchIndex.UpdateItems(
_searchRepository,
new List<MediaItem> { updated.ValueUnsafe() });
new List<MediaItem> { updated });
incomingEpisode = updated;
}
}
catch (Exception ex)
@@ -404,7 +408,7 @@ namespace ErsatzTV.Core.Jellyfin
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath);
Either<BaseError, bool> refreshResult =
await _localStatisticsProvider.RefreshStatistics(ffprobePath, incoming, localPath);
await _localStatisticsProvider.RefreshStatistics(ffprobePath, incomingEpisode, localPath);
refreshResult.Match(
_ => { },
+39 -2
View File
@@ -5,17 +5,32 @@ using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using LanguageExt;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.Metadata
{
public class LocalFileSystem : ILocalFileSystem
{
private readonly ILogger<LocalFileSystem> _logger;
public LocalFileSystem(ILogger<LocalFileSystem> logger)
{
_logger = logger;
}
public Unit EnsureFolderExists(string folder)
{
if (!Directory.Exists(folder))
try
{
Directory.CreateDirectory(folder);
if (!Directory.Exists(folder))
{
Directory.CreateDirectory(folder);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to ensure folder exists at {Folder}", folder);
}
return Unit.Default;
@@ -56,5 +71,27 @@ namespace ErsatzTV.Core.Metadata
return BaseError.New(ex.ToString());
}
}
public Unit EmptyFolder(string folder)
{
try
{
foreach (string file in Directory.GetFiles(folder))
{
File.Delete(file);
}
foreach (string directory in Directory.GetDirectories(folder))
{
Directory.Delete(directory, true);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to empty folder at {Folder}", folder);
}
return Unit.Default;
}
}
}
@@ -188,6 +188,7 @@ namespace ErsatzTV.Core.Metadata
version.Width = videoStream.width;
version.Height = videoStream.height;
version.VideoScanKind = ScanKindFromFieldOrder(videoStream.field_order);
version.RFrameRate = videoStream.r_frame_rate;
var stream = new MediaStream
{
@@ -183,7 +183,7 @@ namespace ErsatzTV.Core.Plex
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath);
Either<BaseError, bool> refreshResult =
await _localStatisticsProvider.RefreshStatistics(ffprobePath, incoming, localPath);
await _localStatisticsProvider.RefreshStatistics(ffprobePath, existing, localPath);
await refreshResult.Match(
async _ =>
@@ -542,7 +542,7 @@ namespace ErsatzTV.Core.Plex
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath);
Either<BaseError, bool> refreshResult =
await _localStatisticsProvider.RefreshStatistics(ffprobePath, incoming, localPath);
await _localStatisticsProvider.RefreshStatistics(ffprobePath, existing, localPath);
await refreshResult.Match(
async _ =>
@@ -133,6 +133,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
existing.Width = incoming.Width;
existing.Height = incoming.Height;
existing.VideoScanKind = incoming.VideoScanKind;
existing.RFrameRate = incoming.RFrameRate;
}
var toAdd = incoming.Streams.Filter(s => existing.Streams.All(es => es.Index != s.Index)).ToList();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,36 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_MediaVersionRFrameRate : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "RFrameRate",
table: "MediaVersion",
type: "TEXT",
nullable: true);
migrationBuilder.Sql("UPDATE MediaVersion SET DateUpdated = '0001-01-01 00:00:00'");
migrationBuilder.Sql("UPDATE LibraryFolder SET Etag = NULL");
migrationBuilder.Sql("UPDATE EmbyMovie SET Etag = NULL");
migrationBuilder.Sql("UPDATE EmbyShow SET Etag = NULL");
migrationBuilder.Sql("UPDATE EmbySeason SET Etag = NULL");
migrationBuilder.Sql("UPDATE EmbyEpisode SET Etag = NULL");
migrationBuilder.Sql("UPDATE JellyfinMovie SET Etag = NULL");
migrationBuilder.Sql("UPDATE JellyfinShow SET Etag = NULL");
migrationBuilder.Sql("UPDATE JellyfinSeason SET Etag = NULL");
migrationBuilder.Sql("UPDATE JellyfinEpisode SET Etag = NULL");
migrationBuilder.Sql("UPDATE LibraryPath SET LastScan = '0001-01-01 00:00:00'");
migrationBuilder.Sql("UPDATE Library SET LastScan = '0001-01-01 00:00:00'");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "RFrameRate",
table: "MediaVersion");
}
}
}
@@ -809,6 +809,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("RFrameRate")
.HasColumnType("TEXT");
b.Property<string>("SampleAspectRatio")
.HasColumnType("TEXT");
+1
View File
@@ -42,6 +42,7 @@
<s:Boolean x:Key="/Default/UserDictionary/Words/=playout/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Playouts/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=probesize/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Segmenter/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=setsar/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=showtitle/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=strm/@EntryIndexedValue">True</s:Boolean>
+1 -1
View File
@@ -31,7 +31,7 @@ namespace ErsatzTV.Controllers
string channelNumber,
[FromQuery]
string mode = "mixed") =>
_mediator.Send(new GetPlayoutItemProcessByChannelNumber(channelNumber, mode)).Map(
_mediator.Send(new GetPlayoutItemProcessByChannelNumber(channelNumber, mode, false)).Map(
result =>
result.Match<IActionResult>(
process =>
+47 -13
View File
@@ -1,15 +1,20 @@
using System.Threading.Channels;
using System.Threading.Tasks;
using ErsatzTV.Application;
using ErsatzTV.Application.Channels.Queries;
using ErsatzTV.Application.Images;
using ErsatzTV.Application.Images.Queries;
using ErsatzTV.Application.Streaming.Commands;
using ErsatzTV.Application.Streaming.Queries;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Iptv;
using LanguageExt;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Controllers
{
@@ -17,13 +22,18 @@ namespace ErsatzTV.Controllers
[ApiExplorerSettings(IgnoreApi = true)]
public class IptvController : ControllerBase
{
private readonly ChannelWriter<IFFmpegWorkerRequest> _channel;
private readonly ILogger<IptvController> _logger;
private readonly IMediator _mediator;
public IptvController(IMediator mediator, ILogger<IptvController> logger)
public IptvController(
IMediator mediator,
ILogger<IptvController> logger,
ChannelWriter<IFFmpegWorkerRequest> channel)
{
_mediator = mediator;
_logger = logger;
_channel = channel;
}
[HttpGet("iptv/channels.m3u")]
@@ -55,20 +65,44 @@ namespace ErsatzTV.Controllers
error => BadRequest(error.Value)));
[HttpGet("iptv/channel/{channelNumber}.m3u8")]
public Task<IActionResult> GetHttpLiveStreamingVideo(
public async Task<IActionResult> GetHttpLiveStreamingVideo(
string channelNumber,
[FromQuery]
string mode = "mixed") =>
_mediator.Send(
new GetHlsPlaylistByChannelNumber(
Request.Scheme,
Request.Host.ToString(),
channelNumber,
mode))
.Map(
result => result.Match<IActionResult>(
playlist => Content(playlist, "application/x-mpegurl"),
error => BadRequest(error.Value)));
string mode = "mixed")
{
switch (mode)
{
case "segmenter":
Either<BaseError, Unit> result = await _mediator.Send(new StartFFmpegSession(channelNumber, false));
return result.Match<IActionResult>(
_ => Redirect($"/iptv/session/{channelNumber}/live.m3u8"),
error =>
{
switch (error)
{
case ChannelHasProcess:
return RedirectPreserveMethod($"/iptv/session/{channelNumber}/live.m3u8");
default:
_logger.LogWarning(
"Failed to start segmenter for channel {ChannelNumber}: {Error}",
channelNumber,
error.ToString());
return NotFound();
}
});
default:
return await _mediator.Send(
new GetHlsPlaylistByChannelNumber(
Request.Scheme,
Request.Host.ToString(),
channelNumber,
mode))
.Map(
r => r.Match<IActionResult>(
playlist => Content(playlist, "application/x-mpegurl"),
error => BadRequest(error.Value)));
}
}
[HttpGet("iptv/logos/{fileName}")]
[HttpHead("iptv/logos/{fileName}.jpg")]
+1 -1
View File
@@ -40,7 +40,7 @@
<PackageReference Include="Refit.HttpClientFactory" Version="6.0.94" />
<PackageReference Include="Serilog" Version="2.10.0" />
<PackageReference Include="Serilog.AspNetCore" Version="4.1.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="3.2.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="3.3.0" />
<PackageReference Include="Serilog.Sinks.SQLite" Version="5.0.0" />
<PackageReference Include="System.IO.FileSystem.Primitives" Version="4.3.0" />
<PackageReference Include="System.Text.Encoding.Extensions" Version="4.3.0" />
+1
View File
@@ -32,6 +32,7 @@
<MudSelectItem Value="@(StreamingMode.TransportStream)">MPEG-TS</MudSelectItem>
<MudSelectItem Value="@(StreamingMode.HttpLiveStreamingDirect)">HLS Direct</MudSelectItem>
<MudSelectItem Value="@(StreamingMode.HttpLiveStreamingHybrid)">HLS Hybrid</MudSelectItem>
<MudSelectItem Value="@(StreamingMode.HttpLiveStreamingSegmenter)">HLS Segmenter</MudSelectItem>
</MudSelect>
<MudSelect Class="mt-3" Label="FFmpeg Profile" @bind-Value="_model.FFmpegProfileId" For="@(() => _model.FFmpegProfileId)"
Disabled="@(_model.StreamingMode == StreamingMode.HttpLiveStreamingDirect)">
+1
View File
@@ -141,6 +141,7 @@
private static string GetStreamingMode(StreamingMode streamingMode) => streamingMode switch {
StreamingMode.HttpLiveStreamingDirect => "HLS Direct",
StreamingMode.HttpLiveStreamingHybrid => "HLS Hybrid",
StreamingMode.HttpLiveStreamingSegmenter => "HLS Segmenter",
_ => "MPEG-TS"
};
@@ -0,0 +1,34 @@
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);
}
}
}
}
+121
View File
@@ -0,0 +1,121 @@
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;
using static LanguageExt.Prelude;
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;
}
protected override async Task ExecuteAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("FFmpeg worker service started");
await foreach (IFFmpegWorkerRequest request in _channel.ReadAllAsync(cancellationToken))
{
try
{
using IServiceScope scope = _serviceScopeFactory.CreateScope();
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
switch (request)
{
case TouchFFmpegSession touchFFmpegSession:
foreach (DirectoryInfo parent in Optional(Directory.GetParent(touchFFmpegSession.Path)))
{
_ffmpegSegmenterService.TouchChannel(parent.Name);
}
break;
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;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to handle ffmpeg worker request");
}
}
// kill any running processes after cancellation
_ffmpegSegmenterService.KillAll();
}
}
}
+42 -1
View File
@@ -7,7 +7,7 @@ using Blazored.LocalStorage;
using Dapper;
using ErsatzTV.Application;
using ErsatzTV.Application.Channels.Queries;
using ErsatzTV.Application.Logs.Queries;
using ErsatzTV.Application.Streaming.Commands;
using ErsatzTV.Core;
using ErsatzTV.Core.Emby;
using ErsatzTV.Core.FFmpeg;
@@ -56,10 +56,12 @@ using MediatR;
using MediatR.Courier.DependencyInjection;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.StaticFiles;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using MudBlazor.Services;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
@@ -76,6 +78,16 @@ namespace ErsatzTV
public void ConfigureServices(IServiceCollection services)
{
services.AddCors(
o => o.AddPolicy(
"AllowAll",
builder =>
{
builder.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader();
}));
services.AddControllers(
options =>
{
@@ -123,6 +135,11 @@ namespace ErsatzTV
Directory.CreateDirectory(FileSystemLayout.AppDataFolder);
}
if (!Directory.Exists(FileSystemLayout.TranscodeFolder))
{
Directory.CreateDirectory(FileSystemLayout.TranscodeFolder);
}
Log.Logger.Information("Database is at {DatabasePath}", FileSystemLayout.DatabasePath);
// until we add a setting for a file-specific scheme://host:port to access
@@ -183,10 +200,30 @@ namespace ErsatzTV
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
app.UseCors("AllowAll");
// app.UseSerilogRequestLogging();
app.UseStaticFiles();
var extensionProvider = new FileExtensionContentTypeProvider();
extensionProvider.Mappings.Add(".m3u8", "application/vnd.apple.mpegurl");
app.UseStaticFiles(
new StaticFileOptions
{
FileProvider = new PhysicalFileProvider(FileSystemLayout.TranscodeFolder),
RequestPath = "/iptv/session",
ContentTypeProvider = extensionProvider,
OnPrepareResponse = ctx =>
{
// Log.Logger.Information("Transcode access: {Test}", ctx.File.PhysicalPath);
ChannelWriter<IFFmpegWorkerRequest> writer = app.ApplicationServices
.GetRequiredService<ChannelWriter<IFFmpegWorkerRequest>>();
writer.TryWrite(new TouchFFmpegSession(ctx.File.PhysicalPath));
}
});
app.UseRouting();
app.UseEndpoints(
@@ -206,10 +243,12 @@ namespace ErsatzTV
services.AddSingleton<ITraktApiClient, TraktApiClient>();
services.AddSingleton<IEntityLocker, EntityLocker>();
services.AddSingleton<ISearchIndex, SearchIndex>();
services.AddSingleton<IFFmpegSegmenterService, FFmpegSegmenterService>();
AddChannel<IBackgroundServiceRequest>(services);
AddChannel<IPlexBackgroundServiceRequest>(services);
AddChannel<IJellyfinBackgroundServiceRequest>(services);
AddChannel<IEmbyBackgroundServiceRequest>(services);
AddChannel<IFFmpegWorkerRequest>(services);
services.AddScoped<IFFmpegVersionHealthCheck, FFmpegVersionHealthCheck>();
services.AddScoped<IFFmpegReportsHealthCheck, FFmpegReportsHealthCheck>();
@@ -286,6 +325,8 @@ namespace ErsatzTV
services.AddHostedService<FFmpegLocatorService>();
services.AddHostedService<WorkerService>();
services.AddHostedService<SchedulerService>();
services.AddHostedService<FFmpegWorkerService>();
services.AddHostedService<FFmpegSchedulerService>();
}
private void AddChannel<TMessageType>(IServiceCollection services)
+1 -1
View File
@@ -1,6 +1,6 @@
# ErsatzTV
**ErsatzTV** is pre-alpha software for configuring and streaming custom live channels using your media library. The software is currently unstable and under active development.
**ErsatzTV** is alpha software for configuring and streaming custom live channels using your media library. The software is currently unstable and under active development.
Documentation is available at [ersatztv.org](https://ersatztv.org/).
+2 -8
View File
@@ -1,10 +1,4 @@
FROM mcr.microsoft.com/dotnet/aspnet:5.0-focal-amd64 AS dotnet-runtime
FROM jrottenberg/ffmpeg:4.3-nvidia1804 AS runtime-base
COPY --from=dotnet-runtime /usr/share/dotnet /usr/share/dotnet
RUN apt-get update && DEBIAN_FRONTEND="noninteractive" apt-get install -y libicu-dev tzdata
# https://hub.docker.com/_/microsoft-dotnet
# https://hub.docker.com/_/microsoft-dotnet
FROM mcr.microsoft.com/dotnet/sdk:5.0 AS build
RUN apt-get update && apt-get install -y ca-certificates
WORKDIR /source
@@ -29,7 +23,7 @@ ARG INFO_VERSION="unknown"
RUN dotnet publish -c release -o /app -r linux-x64 --self-contained false --no-restore /p:InformationalVersion=${INFO_VERSION}
# final stage/image
FROM runtime-base
FROM jasongdove/ffmpeg:4.3-nvidia1804 AS runtime-base
WORKDIR /app
EXPOSE 8409
COPY --from=build /app ./
+7
View File
@@ -0,0 +1,7 @@
FROM mcr.microsoft.com/dotnet/aspnet:5.0-focal-amd64 AS dotnet-runtime
FROM jrottenberg/ffmpeg:4.3-nvidia1804 AS runtime-base
COPY --from=dotnet-runtime /usr/share/dotnet /usr/share/dotnet
RUN apt-get update \
&& DEBIAN_FRONTEND="noninteractive" apt-get install -y libicu-dev tzdata \
&& rm -rf /var/lib/apt/lists/*
+2 -8
View File
@@ -1,10 +1,4 @@
FROM mcr.microsoft.com/dotnet/aspnet:5.0-focal-amd64 AS dotnet-runtime
FROM jrottenberg/ffmpeg:4.3-vaapi2004 AS runtime-base
COPY --from=dotnet-runtime /usr/share/dotnet /usr/share/dotnet
RUN apt-get update && DEBIAN_FRONTEND="noninteractive" apt-get install -y libicu-dev tzdata intel-media-va-driver-non-free libva-x11-2
# https://hub.docker.com/_/microsoft-dotnet
# https://hub.docker.com/_/microsoft-dotnet
FROM mcr.microsoft.com/dotnet/sdk:5.0 AS build
RUN apt-get update && apt-get install -y ca-certificates
WORKDIR /source
@@ -29,7 +23,7 @@ ARG INFO_VERSION="unknown"
RUN dotnet publish -c release -o /app -r linux-x64 --self-contained false --no-restore /p:InformationalVersion=${INFO_VERSION}
# final stage/image
FROM runtime-base
FROM jasongdove/ffmpeg:4.3-vaapi2004 AS runtime-base
WORKDIR /app
EXPOSE 8409
COPY --from=build /app ./
+39
View File
@@ -0,0 +1,39 @@
FROM mcr.microsoft.com/dotnet/aspnet:5.0-focal-amd64 AS dotnet-runtime
FROM jrottenberg/ffmpeg:4.3-vaapi2004 AS runtime-base
COPY --from=dotnet-runtime /usr/share/dotnet /usr/share/dotnet
RUN apt-get update && DEBIAN_FRONTEND="noninteractive" apt-get install -y libicu-dev \
tzdata \
autoconf \
libtool \
libdrm-dev \
git \
pkg-config \
build-essential \
cmake \
wget \
&& mkdir /tmp/intel && cd /tmp/intel \
&& wget -O - https://github.com/intel/libva/archive/refs/tags/2.12.0.tar.gz | tar zxf - \
&& cd libva-2.12.0 \
&& ./autogen.sh \
&& ./configure \
&& make -j$(nproc) \
&& make -j$(nproc) install \
&& cd /tmp/intel \
&& wget -O - https://github.com/intel/gmmlib/archive/refs/tags/intel-gmmlib-21.3.1.tar.gz | tar zxf - \
&& mv gmmlib-intel-gmmlib-21.3.1 gmmlib \
&& cd gmmlib \
&& mkdir build && cd build \
&& cmake .. \
&& make -j$(nproc) \
&& cd /tmp/intel \
&& git clone --depth 1 --branch intel-media-21.2.3 https://github.com/intel/media-driver \
&& mkdir build_media && cd build_media \
&& cmake ../media-driver \
&& make -j$(nproc) \
&& make install \
&& DEBIAN_FRONTEND="noninteractive" apt-get purge -y autoconf libtool git build-essential cmake wget \
&& apt autoremove -y \
&& rm -rf /tmp/intel \
&& rm -rf /var/lib/apt/lists/* \
&& mv /usr/lib/x86_64-linux-gnu/dri/i* /usr/local/lib/dri/
Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 191 KiB

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 602 KiB

After

Width:  |  Height:  |  Size: 510 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 315 KiB

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+2
View File
@@ -15,6 +15,8 @@ For all clients, the `M3U` and/or the `XMLTV` urls are needed and can be copied
A [Plex Pass](https://www.plex.tv/plex-pass/) is required for ErsatzTV to work with Plex.
The only channel streaming mode supported directly by Plex clients is `MPEG-TS`.
### Add DVR
From Plex Settings, click `Live TV & DVR` and `Set Up Plex DVR` to add a new DVR.
+2 -1
View File
@@ -8,11 +8,12 @@ Channel numbers can be whole numbers or can contain one decimal, like `500` or `
### Streaming Mode
Three streaming modes are currently supported: `MPEG-TS` (Transport Stream), `HLS Direct` (HTTP Live Streaming Direct) and `HLS Hybrid` (HTTP Live Streaming Hybrid).
Four streaming modes are currently supported: `MPEG-TS` (Transport Stream), `HLS Direct` (HTTP Live Streaming Direct), `HLS Hybrid` (HTTP Live Streaming Hybrid) and `HLS Segmenter` (HTTP Live Streaming Segmenter).
* `MPEG-TS` transcodes content and supports watermarks, though some clients will have issues at program boundaries
* `HLS Direct` does not transcode content and can perform better on low power systems, but does not support watermarks and some clients will have issues at program boundaries
* `HLS Hybrid` transcodes content and supports watermarks, and can perform very well at program boundaries with some clients
* `HLS Segmenter` is currently *experimental*, and is the only "proper" HLS mode. It transcodes content, supports watermarks and offers the best performance at program boundaries.
### FFmpeg Profile
+22 -13
View File
@@ -51,27 +51,36 @@ docker run -d \
### Unraid Docker
1. Install the Commuinity Applications plugin by pasting the following URL in the Plugins / Install Plugin section of UnRAID
1. Install the Commuinity Applications plugin by pasting the following URL in the Plugins / Install Plugin section of UnRAID
```
https://raw.githubusercontent.com/Squidly271/community.applications/master/plugins/community.applications.plg
```
```
https://raw.githubusercontent.com/Squidly271/community.applications/master/plugins/community.applications.plg
```
![Community Applications Install](../images/unraid-install-community-apps.png)
![Community Applications Install](../images/unraid-install-community-apps.png)
2. Click on the `Apps` tab in UnRAID, search for `ersatztv`, and click the `install` button.
![Search Community Applications ErsatzTV](../images/unraid-docker-install-ersatz.png)
![Search Community Applications ErsatzTV](../images/unraid-docker-install-ersatz.png)
3. Choose an appropriate branch (Use `vaapi` for video acceleration for AMD GPUs and Intel CPUs with QuickSync, use `nvidia` for video acceleration for nVidia GPUs)
![ErsatzTV Docker branches](../images/unraid-docker-ersatz-branches.png)
<div align="center">`VAAPI` and `nVidia` branches are for hardware acceleration. See [latest release tags](install.md#latest-release-tags)</div>
4. Map your path to shared media
![ErsatzTV Docker branches](../images/unraid-docker-ersatz-branches.png)
`VAAPI` and `nVidia` branches are for hardware acceleration. See [latest release tags](install.md#latest-release-tags)
![Media Mapping](../images/unraid-docker-media-map.png)
5. <B>OPTIONAL</B> In advanced view, add extra parameters for video acceleration. **NOTE** The [nVidia-Driver plugin](https://forums.unraid.net/topic/98978-plugin-nvidia-driver/) must be installed prior to this step.
4. Map your path to ErsatzTV configuration data
![Adding video acceleration options](../images/unraid-docker-ersatz-hwtranscoding.png)
6. Open your browser to `http://[docker-ip]:8409` (First run may take a few minutes to be available.)
![Media Mapping](../images/unraid-path-to-configuration-data.png)
5. Add another path for each media share
![Media Mapping](../images/unraid-path-to-media-share.png)
6. **OPTIONAL** In advanced view, add extra parameters for video acceleration. **NOTE** The [nVidia-Driver plugin](https://forums.unraid.net/topic/98978-plugin-nvidia-driver/) must be installed prior to this step.
![Adding video acceleration options](../images/unraid-docker-ersatz-hwtranscoding.png)
7. Open your browser to `http://[docker-ip]:8409` (First run may take a few minutes to be available.)
## Windows