Compare commits

...
Author SHA1 Message Date
Jason Dove db080375c5 update changelog for release v0.1.1-alpha [no ci] 2021-10-10 12:54:46 -05:00
Jason Dove 9abc7ad8b7 try to fix tests on windows 2021-10-10 12:39:08 -05:00
Jason DoveandGitHub 9e531a82d7 add some hls playlist filter tests (#411) 2021-10-10 12:33:02 -05:00
Jason DoveandGitHub d84bd2b948 upgrade nvidia docker image (#410) 2021-10-10 11:45:02 -05:00
Jason DoveandGitHub d7d3ec1235 add music video album to search index (#409)
* add music video album to search index

* update search docs
2021-10-10 10:28:35 -05:00
Jason Dove 742ac21ad7 update collection docs [no docker] 2021-10-10 07:53:29 -05:00
Jason DoveandGitHub 819b55e21f increase max hls segments (#408) 2021-10-10 06:47:24 -05:00
Jason DoveandGitHub cf5718c288 rework hls segmenter (#407)
* rework hls segmenter to start more quickly

* don't use realtime encoding for hls until we're at least a minute ahead

* ugly but functional playlist filtering
2021-10-09 22:46:38 -05:00
Jason DoveandGitHub adc7982955 reduce initial hls segmenter delay (#406) 2021-10-09 10:26:57 -05:00
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
81 changed files with 4735 additions and 193 deletions
+28 -1
View File
@@ -5,6 +5,31 @@ 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`)
- 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 +669,9 @@ 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.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
[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);
@@ -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">
@@ -0,0 +1,6 @@
namespace ErsatzTV.Application
{
public interface IFFmpegWorkerRequest
{
}
}
+2 -2
View File
@@ -1,5 +1,4 @@
using System;
using System.Linq;
using System.Linq;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Emby;
@@ -101,6 +100,7 @@ namespace ErsatzTV.Application.MediaCards
musicVideoMetadata.MusicVideo.Artist.ArtistMetadata.Head().Title,
musicVideoMetadata.SortTitle,
musicVideoMetadata.Plot,
musicVideoMetadata.Album,
GetThumbnail(musicVideoMetadata, None, None));
internal static ArtistCardViewModel ProjectToViewModel(ArtistMetadata artistMetadata) =>
@@ -7,6 +7,7 @@
string Subtitle,
string SortTitle,
string Plot,
string Album,
string Poster) : MediaCardViewModel(
MusicVideoId,
Title,
@@ -0,0 +1,9 @@
using ErsatzTV.Core;
using LanguageExt;
namespace ErsatzTV.Application.Streaming.Commands
{
public record StartFFmpegSession(string ChannelNumber, bool StartAtZero) :
MediatR.IRequest<Either<BaseError, Unit>>,
IFFmpegWorkerRequest;
}
@@ -0,0 +1,102 @@
using System;
using System.IO;
using System.Threading;
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;
namespace ErsatzTV.Application.Streaming.Commands
{
public class StartFFmpegSessionHandler : MediatR.IRequestHandler<StartFFmpegSession, Either<BaseError, Unit>>
{
private readonly ILogger<StartFFmpegSessionHandler> _logger;
private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
private readonly ILocalFileSystem _localFileSystem;
public StartFFmpegSessionHandler(
ILocalFileSystem localFileSystem,
ILogger<StartFFmpegSessionHandler> logger,
IServiceScopeFactory serviceScopeFactory,
IFFmpegSegmenterService ffmpegSegmenterService)
{
_localFileSystem = localFileSystem;
_logger = logger;
_serviceScopeFactory = serviceScopeFactory;
_ffmpegSegmenterService = ffmpegSegmenterService;
}
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)
{
using IServiceScope scope = _serviceScopeFactory.CreateScope();
HlsSessionWorker worker = scope.ServiceProvider.GetRequiredService<HlsSessionWorker>();
_ffmpegSegmenterService.SessionWorkers.AddOrUpdate(request.ChannelNumber, _ => worker, (_, _) => worker);
// fire and forget worker
_ = worker.Run(request.ChannelNumber)
.ContinueWith(
_ => _ffmpegSegmenterService.SessionWorkers.TryRemove(
request.ChannelNumber,
out IHlsSessionWorker _),
TaskScheduler.Default);
string playlistFileName = Path.Combine(
FileSystemLayout.TranscodeFolder,
request.ChannelNumber,
"live.m3u8");
while (!File.Exists(playlistFileName))
{
await Task.Delay(TimeSpan.FromMilliseconds(100));
}
return Unit.Default;
}
private Task<Validation<BaseError, Unit>> Validate(StartFFmpegSession request) =>
SessionMustBeInactive(request)
.BindT(_ => FolderMustBeEmpty(request));
private Task<Validation<BaseError, Unit>> SessionMustBeInactive(StartFFmpegSession request)
{
var result = Optional(_ffmpegSegmenterService.SessionWorkers.TryAdd(request.ChannelNumber, null))
.Filter(success => success)
.Map(_ => Unit.Default)
.ToValidation<BaseError>(new ChannelSessionAlreadyActive());
if (result.IsFail && _ffmpegSegmenterService.SessionWorkers.TryGetValue(
request.ChannelNumber,
out IHlsSessionWorker worker))
{
worker?.Touch();
}
return result.AsTask();
}
private Task<Validation<BaseError, Unit>> FolderMustBeEmpty(StartFFmpegSession request)
{
string folder = Path.Combine(FileSystemLayout.TranscodeFolder, request.ChannelNumber);
_logger.LogDebug("Preparing transcode folder {Folder}", folder);
_localFileSystem.EnsureFolderExists(folder);
_localFileSystem.EmptyFolder(folder);
return Task.FromResult<Validation<BaseError, Unit>>(Unit.Default);
}
}
}
@@ -0,0 +1,9 @@
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Application.Streaming.Commands
{
public record TouchFFmpegSession(string Path) : IRequest<Either<BaseError, Unit>>, IFFmpegWorkerRequest;
}
@@ -0,0 +1,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,
@@ -56,6 +55,7 @@ namespace ErsatzTV.Application.Streaming.Queries
channel.StreamingMode = request.Mode.ToLowerInvariant() switch
{
"hls-direct" => StreamingMode.HttpLiveStreamingDirect,
"segmenter" => StreamingMode.HttpLiveStreamingSegmenter,
"ts" => StreamingMode.TransportStream,
_ => channel.StreamingMode
};
@@ -1,9 +1,15 @@
using System.Diagnostics;
using System;
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.Streaming.Queries
{
public record FFmpegProcessRequest(string ChannelNumber, string Mode) : IRequest<Either<BaseError, Process>>;
public record FFmpegProcessRequest
(
string ChannelNumber,
string Mode,
DateTimeOffset Now,
bool StartAtZero,
bool HlsRealtime) : IRequest<Either<BaseError, PlayoutItemProcessModel>>;
}
@@ -1,10 +1,15 @@
namespace ErsatzTV.Application.Streaming.Queries
using System;
namespace ErsatzTV.Application.Streaming.Queries
{
public record GetConcatProcessByChannelNumber : FFmpegProcessRequest
{
public GetConcatProcessByChannelNumber(string scheme, string host, string channelNumber) : base(
channelNumber,
"ts")
"ts",
DateTimeOffset.Now,
false,
true)
{
Scheme = scheme;
Host = host;
@@ -1,4 +1,5 @@
using System.Diagnostics;
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using ErsatzTV.Core;
@@ -27,7 +28,7 @@ namespace ErsatzTV.Application.Streaming.Queries
_runtimeInfo = runtimeInfo;
}
protected override async Task<Either<BaseError, Process>> GetProcess(
protected override async Task<Either<BaseError, PlayoutItemProcessModel>> GetProcess(
TvContext dbContext,
GetConcatProcessByChannelNumber request,
Channel channel,
@@ -37,12 +38,14 @@ namespace ErsatzTV.Application.Streaming.Queries
.GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
.Map(result => result.IfNone(false));
return _ffmpegProcessService.ConcatChannel(
Process process = _ffmpegProcessService.ConcatChannel(
ffmpegPath,
saveReports,
channel,
request.Scheme,
request.Host);
return new PlayoutItemProcessModel(process, DateTimeOffset.MaxValue);
}
}
}
@@ -1,8 +1,20 @@
namespace ErsatzTV.Application.Streaming.Queries
using System;
namespace ErsatzTV.Application.Streaming.Queries
{
public record GetPlayoutItemProcessByChannelNumber : FFmpegProcessRequest
{
public GetPlayoutItemProcessByChannelNumber(string channelNumber, string mode) : base(channelNumber, mode)
public GetPlayoutItemProcessByChannelNumber(
string channelNumber,
string mode,
DateTimeOffset now,
bool startAtZero,
bool hlsRealtime) : base(
channelNumber,
mode,
now,
startAtZero,
hlsRealtime)
{
}
}
@@ -48,13 +48,14 @@ namespace ErsatzTV.Application.Streaming.Queries
_runtimeInfo = runtimeInfo;
}
protected override async Task<Either<BaseError, Process>> GetProcess(
protected override async Task<Either<BaseError, PlayoutItemProcessModel>> GetProcess(
TvContext dbContext,
GetPlayoutItemProcessByChannelNumber _,
GetPlayoutItemProcessByChannelNumber request,
Channel channel,
string ffmpegPath)
{
DateTimeOffset now = DateTimeOffset.Now;
DateTimeOffset now = request.Now;
Either<BaseError, PlayoutItemWithPath> maybePlayoutItem = await dbContext.PlayoutItems
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Episode).MediaVersions)
@@ -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,
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
");
}
}
}
@@ -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(
@@ -183,7 +184,9 @@ namespace ErsatzTV.Core.Tests.FFmpeg
now,
now,
None,
None);
None,
false,
false);
process.StartInfo.RedirectStandardError = true;
@@ -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 ChannelSessionAlreadyActive : BaseError
{
public ChannelSessionAlreadyActive() : base("Channel already has HLS session")
{
}
}
}
-4
View File
@@ -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" />
@@ -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;
+57 -4
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)
@@ -106,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;
@@ -307,13 +317,51 @@ namespace ErsatzTV.Core.FFmpeg
return this;
}
public FFmpegProcessBuilder WithHls(string channelNumber, MediaVersion mediaVersion, bool startAtZero)
{
const int SEGMENT_SECONDS = 4;
if (!int.TryParse(mediaVersion.RFrameRate, out int frameRate))
{
string[] split = (mediaVersion.RFrameRate ?? string.Empty).Split("/");
if (int.TryParse(split[0], out int left) && int.TryParse(split[1], out int right))
{
frameRate = (int)Math.Round(left / (double)right);
}
else
{
_logger.LogInformation("Unable to detect framerate, using {FrameRate}", 24);
frameRate = 24;
}
}
_arguments.AddRange(
new[]
{
"-use_wallclock_as_timestamps", "1",
"-g", $"{frameRate * SEGMENT_SECONDS}",
"-keyint_min", $"{frameRate * SEGMENT_SECONDS}",
"-force_key_frames", $"expr:gte(t,n_forced*{SEGMENT_SECONDS})",
"-f", "hls",
"-hls_time", $"{SEGMENT_SECONDS}",
"-hls_list_size", "0",
"-segment_list_flags", "+live",
"-hls_segment_filename",
Path.Combine(FileSystemLayout.TranscodeFolder, channelNumber, "live%06d.ts"),
"-hls_flags", "program_date_time+append_list+omit_endlist+independent_segments",
Path.Combine(FileSystemLayout.TranscodeFolder, channelNumber, "live.m3u8")
});
return this;
}
public FFmpegProcessBuilder WithPlaybackArgs(FFmpegPlaybackSettings playbackSettings)
{
var arguments = new List<string>
{
"-c:v", playbackSettings.VideoCodec,
"-flags", "cgop",
"-sc_threshold", "1000000000"
"-sc_threshold", "0" // disable scene change detection
};
string[] videoBitrateArgs = playbackSettings.VideoBitrate.Match(
@@ -402,8 +450,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);
+30 -14
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(
@@ -35,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);
@@ -56,7 +62,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 +88,7 @@ namespace ErsatzTV.Core.FFmpeg
}
builder = builder
.WithFilterComplex(videoStream, maybeAudioStream);
.WithFilterComplex(videoStream, maybeAudioStream, channel.FFmpegProfile.VideoCodec);
},
() =>
{
@@ -91,27 +97,37 @@ namespace ErsatzTV.Core.FFmpeg
builder = builder
.WithDeinterlace(playbackSettings.Deinterlace)
.WithBlackBars(channel.FFmpegProfile.Resolution)
.WithFilterComplex(videoStream, maybeAudioStream);
.WithFilterComplex(videoStream, maybeAudioStream, channel.FFmpegProfile.VideoCodec);
}
else if (playbackSettings.Deinterlace)
{
builder = builder.WithDeinterlace(playbackSettings.Deinterlace)
.WithAlignedAudio(playbackSettings.AudioDuration)
.WithFilterComplex(videoStream, maybeAudioStream);
.WithFilterComplex(videoStream, maybeAudioStream, channel.FFmpegProfile.VideoCodec);
}
else
{
builder = builder
.WithFilterComplex(videoStream, maybeAudioStream);
.WithFilterComplex(videoStream, maybeAudioStream, channel.FFmpegProfile.VideoCodec);
}
});
return builder.WithPlaybackArgs(playbackSettings)
builder = builder.WithPlaybackArgs(playbackSettings)
.WithMetadata(channel, maybeAudioStream)
.WithFormat("mpegts")
.WithDuration(start + version.Duration - now)
.WithPipe()
.Build();
.WithDuration(start + version.Duration - now);
switch (channel.StreamingMode)
{
// HLS needs to segment and generate playlist
case StreamingMode.HttpLiveStreamingSegmenter:
return builder.WithHls(channel.Number, version, startAtZero)
.WithRealtimeOutput(hlsRealtime)
.Build();
default:
return builder.WithFormat("mpegts")
.WithPipe()
.Build();
}
}
public Process ForError(string ffmpegPath, Channel channel, Option<TimeSpan> duration, string errorMessage)
@@ -121,7 +137,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 +160,7 @@ namespace ErsatzTV.Core.FFmpeg
{
FFmpegPlaybackSettings playbackSettings = _playbackSettingsCalculator.ConcatSettings;
return new FFmpegProcessBuilder(ffmpegPath, saveReports)
return new FFmpegProcessBuilder(ffmpegPath, saveReports, _logger)
.WithThreads(1)
.WithQuiet()
.WithFormatFlags(playbackSettings.FormatFlags)
@@ -0,0 +1,23 @@
using System.Collections.Concurrent;
using ErsatzTV.Core.Interfaces.FFmpeg;
namespace ErsatzTV.Core.FFmpeg
{
public class FFmpegSegmenterService : IFFmpegSegmenterService
{
public FFmpegSegmenterService()
{
SessionWorkers = new ConcurrentDictionary<string, IHlsSessionWorker>();
}
public ConcurrentDictionary<string, IHlsSessionWorker> SessionWorkers { get; }
public void TouchChannel(string channelNumber)
{
if (SessionWorkers.TryGetValue(channelNumber, out IHlsSessionWorker worker))
{
worker?.Touch();
}
}
}
}
+111
View File
@@ -0,0 +1,111 @@
using System;
using System.Text;
namespace ErsatzTV.Core.FFmpeg
{
public class HlsPlaylistFilter
{
public static TrimPlaylistResult TrimPlaylist(
DateTimeOffset playlistStart,
DateTimeOffset filterBefore,
string[] lines,
int maxSegments = 10,
bool endWithDiscontinuity = false)
{
DateTimeOffset currentTime = playlistStart;
DateTimeOffset nextPlaylistStart = DateTimeOffset.MaxValue;
var discontinuitySequence = 0;
var startSequence = 0;
var output = new StringBuilder();
var started = false;
var i = 0;
var segments = 0;
while (!lines[i].StartsWith("#EXTINF:"))
{
if (lines[i].StartsWith("#EXT-X-DISCONTINUITY-SEQUENCE"))
{
discontinuitySequence = int.Parse(lines[i].Split(':')[1]);
}
i++;
}
while (i < lines.Length)
{
if (segments >= maxSegments)
{
break;
}
string line = lines[i];
// _logger.LogInformation("Line: {Line}", line);
if (line.StartsWith("#EXT-X-DISCONTINUITY"))
{
if (started)
{
output.AppendLine("#EXT-X-DISCONTINUITY");
}
else
{
discontinuitySequence++;
}
i++;
continue;
}
var duration = TimeSpan.FromSeconds(double.Parse(lines[i].TrimEnd(',').Split(':')[1]));
if (currentTime < filterBefore)
{
currentTime += duration;
i += 3;
continue;
}
nextPlaylistStart = currentTime < nextPlaylistStart ? currentTime : nextPlaylistStart;
if (!started)
{
startSequence = int.Parse(lines[i + 2].Replace("live", string.Empty).Split('.')[0]);
output.AppendLine("#EXTM3U");
output.AppendLine("#EXT-X-VERSION:6");
output.AppendLine("#EXT-X-TARGETDURATION:4");
output.AppendLine($"#EXT-X-MEDIA-SEQUENCE:{startSequence}");
output.AppendLine($"#EXT-X-DISCONTINUITY-SEQUENCE:{discontinuitySequence}");
output.AppendLine("#EXT-X-INDEPENDENT-SEGMENTS");
output.AppendLine("#EXT-X-DISCONTINUITY");
started = true;
}
output.AppendLine(lines[i]);
string offset = currentTime.ToString("zzz").Replace(":", string.Empty);
output.AppendLine($"#EXT-X-PROGRAM-DATE-TIME:{currentTime:yyyy-MM-ddTHH:mm:ss.fff}{offset}");
output.AppendLine(lines[i + 2]);
currentTime += duration;
segments++;
i += 3;
}
if (endWithDiscontinuity)
{
output.AppendLine("#EXT-X-DISCONTINUITY");
}
return new TrimPlaylistResult(nextPlaylistStart, startSequence, output.ToString());
}
public static TrimPlaylistResult TrimPlaylistWithDiscontinuity(
DateTimeOffset playlistStart,
DateTimeOffset filterBefore,
string[] lines)
{
return TrimPlaylist(playlistStart, filterBefore, lines, int.MaxValue, true);
}
}
public record TrimPlaylistResult(DateTimeOffset PlaylistStart, int Sequence, string Playlist);
}
+7
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,11 @@
using System.Collections.Concurrent;
namespace ErsatzTV.Core.Interfaces.FFmpeg
{
public interface IFFmpegSegmenterService
{
ConcurrentDictionary<string, IHlsSessionWorker> SessionWorkers { get; }
void TouchChannel(string channelNumber);
}
}
@@ -0,0 +1,10 @@
using System;
namespace ErsatzTV.Core.Interfaces.FFmpeg
{
public interface IHlsSessionWorker
{
DateTimeOffset PlaylistStart { get; }
void Touch();
}
}
@@ -15,5 +15,6 @@ namespace ErsatzTV.Core.Interfaces.Metadata
IEnumerable<string> ListFiles(string folder);
bool FileExists(string path);
Task<Either<BaseError, Unit>> CopyFile(string source, string destination);
Unit EmptyFolder(string folder);
}
}
+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();
@@ -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" />
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");
@@ -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
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>
+27 -21
View File
@@ -1,4 +1,6 @@
using System.Threading.Tasks;
using System;
using System.Diagnostics;
using System.Threading.Tasks;
using ErsatzTV.Application.Streaming.Queries;
using ErsatzTV.Extensions;
using LanguageExt;
@@ -31,25 +33,29 @@ namespace ErsatzTV.Controllers
string channelNumber,
[FromQuery]
string mode = "mixed") =>
_mediator.Send(new GetPlayoutItemProcessByChannelNumber(channelNumber, mode)).Map(
result =>
result.Match<IActionResult>(
process =>
{
_logger.LogDebug(
"ffmpeg arguments {FFmpegArguments}",
string.Join(" ", process.StartInfo.ArgumentList));
process.Start();
return new FileStreamResult(process.StandardOutput.BaseStream, "video/mp2t");
},
error =>
{
_logger.LogError(
"Failed to create stream for channel {ChannelNumber}: {Error}",
channelNumber,
error.Value);
return BadRequest(error.Value);
}
));
_mediator.Send(
new GetPlayoutItemProcessByChannelNumber(channelNumber, mode, DateTimeOffset.Now, false, true))
.Map(
result =>
result.Match<IActionResult>(
processModel =>
{
Process process = processModel.Process;
_logger.LogDebug(
"ffmpeg arguments {FFmpegArguments}",
string.Join(" ", process.StartInfo.ArgumentList));
process.Start();
return new FileStreamResult(process.StandardOutput.BaseStream, "video/mp2t");
},
error =>
{
_logger.LogError(
"Failed to create stream for channel {ChannelNumber}: {Error}",
channelNumber,
error.Value);
return BadRequest(error.Value);
}
));
}
}
+73 -14
View File
@@ -1,15 +1,23 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Threading.Tasks;
using ErsatzTV.Application.Channels.Queries;
using ErsatzTV.Application.Images;
using ErsatzTV.Application.Images.Queries;
using ErsatzTV.Application.Streaming.Commands;
using ErsatzTV.Application.Streaming.Queries;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Iptv;
using LanguageExt;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Controllers
{
@@ -17,13 +25,18 @@ namespace ErsatzTV.Controllers
[ApiExplorerSettings(IgnoreApi = true)]
public class IptvController : ControllerBase
{
private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
private readonly ILogger<IptvController> _logger;
private readonly IMediator _mediator;
public IptvController(IMediator mediator, ILogger<IptvController> logger)
public IptvController(
IMediator mediator,
ILogger<IptvController> logger,
IFFmpegSegmenterService ffmpegSegmenterService)
{
_mediator = mediator;
_logger = logger;
_ffmpegSegmenterService = ffmpegSegmenterService;
}
[HttpGet("iptv/channels.m3u")]
@@ -43,8 +56,10 @@ namespace ErsatzTV.Controllers
_mediator.Send(new GetConcatProcessByChannelNumber(Request.Scheme, Request.Host.ToString(), channelNumber))
.Map(
result => result.Match<IActionResult>(
process =>
processModel =>
{
Process process = processModel.Process;
_logger.LogInformation("Starting ts stream for channel {ChannelNumber}", channelNumber);
// _logger.LogDebug(
// "ffmpeg concat arguments {FFmpegArguments}",
@@ -54,21 +69,65 @@ namespace ErsatzTV.Controllers
},
error => BadRequest(error.Value)));
[HttpGet("iptv/session/{channelNumber}/hls.m3u8")]
public async Task<IActionResult> GetLivePlaylist(string channelNumber)
{
if (_ffmpegSegmenterService.SessionWorkers.TryGetValue(channelNumber, out IHlsSessionWorker worker))
{
DateTimeOffset now = DateTimeOffset.Now.AddSeconds(-30);
string fileName = Path.Combine(FileSystemLayout.TranscodeFolder, channelNumber, "live.m3u8");
if (System.IO.File.Exists(fileName))
{
string[] input = await System.IO.File.ReadAllLinesAsync(fileName);
TrimPlaylistResult result = HlsPlaylistFilter.TrimPlaylist(worker.PlaylistStart, now, input);
return Content(result.Playlist, "application/vnd.apple.mpegurl");
}
}
return NotFound();
}
[HttpGet("iptv/channel/{channelNumber}.m3u8")]
public Task<IActionResult> GetHttpLiveStreamingVideo(
public async Task<IActionResult> GetHttpLiveStreamingVideo(
string channelNumber,
[FromQuery]
string mode = "mixed") =>
_mediator.Send(
new GetHlsPlaylistByChannelNumber(
Request.Scheme,
Request.Host.ToString(),
channelNumber,
mode))
.Map(
result => result.Match<IActionResult>(
playlist => Content(playlist, "application/x-mpegurl"),
error => BadRequest(error.Value)));
string mode = "mixed")
{
switch (mode)
{
case "segmenter":
Either<BaseError, Unit> result = await _mediator.Send(new StartFFmpegSession(channelNumber, false));
return result.Match<IActionResult>(
_ => Redirect($"/iptv/session/{channelNumber}/hls.m3u8"),
error =>
{
switch (error)
{
case ChannelSessionAlreadyActive:
return RedirectPreserveMethod($"/iptv/session/{channelNumber}/hls.m3u8");
default:
_logger.LogWarning(
"Failed to start segmenter for channel {ChannelNumber}: {Error}",
channelNumber,
error.ToString());
return NotFound();
}
});
default:
return await _mediator.Send(
new GetHlsPlaylistByChannelNumber(
Request.Scheme,
Request.Host.ToString(),
channelNumber,
mode))
.Map(
r => r.Match<IActionResult>(
playlist => Content(playlist, "application/x-mpegurl"),
error => BadRequest(error.Value)));
}
}
[HttpGet("iptv/logos/{fileName}")]
[HttpHead("iptv/logos/{fileName}.jpg")]
+1 -5
View File
@@ -13,10 +13,6 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="AsyncFixer" Version="1.5.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Blazored.LocalStorage" Version="4.1.5" />
<PackageReference Include="FluentValidation" Version="10.3.3" />
<PackageReference Include="FluentValidation.AspNetCore" Version="10.3.3" />
@@ -40,7 +36,7 @@
<PackageReference Include="Refit.HttpClientFactory" Version="6.0.94" />
<PackageReference Include="Serilog" Version="2.10.0" />
<PackageReference Include="Serilog.AspNetCore" Version="4.1.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="3.2.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="3.3.0" />
<PackageReference Include="Serilog.Sinks.SQLite" Version="5.0.0" />
<PackageReference Include="System.IO.FileSystem.Primitives" Version="4.3.0" />
<PackageReference Include="System.Text.Encoding.Extensions" Version="4.3.0" />
+7
View File
@@ -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:&nbsp;</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
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"
};
+64
View File
@@ -0,0 +1,64 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using ErsatzTV.Application;
using ErsatzTV.Application.Streaming.Commands;
using ErsatzTV.Core.Interfaces.FFmpeg;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
namespace ErsatzTV.Services
{
public class FFmpegWorkerService : BackgroundService
{
private readonly ChannelReader<IFFmpegWorkerRequest> _channel;
private readonly ILogger<FFmpegWorkerService> _logger;
private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
private readonly IServiceScopeFactory _serviceScopeFactory;
public FFmpegWorkerService(
ChannelReader<IFFmpegWorkerRequest> channel,
IServiceScopeFactory serviceScopeFactory,
ILogger<FFmpegWorkerService> logger,
IFFmpegSegmenterService ffmpegSegmenterService)
{
_channel = channel;
_serviceScopeFactory = serviceScopeFactory;
_logger = logger;
_ffmpegSegmenterService = ffmpegSegmenterService;
}
protected override async Task ExecuteAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("FFmpeg worker service started");
await foreach (IFFmpegWorkerRequest request in _channel.ReadAllAsync(cancellationToken))
{
try
{
using IServiceScope scope = _serviceScopeFactory.CreateScope();
// IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
switch (request)
{
case TouchFFmpegSession touchFFmpegSession:
foreach (DirectoryInfo parent in Optional(Directory.GetParent(touchFFmpegSession.Path)))
{
_ffmpegSegmenterService.TouchChannel(parent.Name);
}
break;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to handle ffmpeg worker request");
}
}
}
}
}
+43 -1
View File
@@ -7,7 +7,8 @@ using Blazored.LocalStorage;
using Dapper;
using ErsatzTV.Application;
using ErsatzTV.Application.Channels.Queries;
using ErsatzTV.Application.Logs.Queries;
using ErsatzTV.Application.Streaming;
using ErsatzTV.Application.Streaming.Commands;
using ErsatzTV.Core;
using ErsatzTV.Core.Emby;
using ErsatzTV.Core.FFmpeg;
@@ -56,10 +57,12 @@ using MediatR;
using MediatR.Courier.DependencyInjection;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.StaticFiles;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using MudBlazor.Services;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
@@ -76,6 +79,16 @@ namespace ErsatzTV
public void ConfigureServices(IServiceCollection services)
{
services.AddCors(
o => o.AddPolicy(
"AllowAll",
builder =>
{
builder.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader();
}));
services.AddControllers(
options =>
{
@@ -123,6 +136,11 @@ namespace ErsatzTV
Directory.CreateDirectory(FileSystemLayout.AppDataFolder);
}
if (!Directory.Exists(FileSystemLayout.TranscodeFolder))
{
Directory.CreateDirectory(FileSystemLayout.TranscodeFolder);
}
Log.Logger.Information("Database is at {DatabasePath}", FileSystemLayout.DatabasePath);
// until we add a setting for a file-specific scheme://host:port to access
@@ -183,10 +201,30 @@ namespace ErsatzTV
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
app.UseCors("AllowAll");
// app.UseSerilogRequestLogging();
app.UseStaticFiles();
var extensionProvider = new FileExtensionContentTypeProvider();
extensionProvider.Mappings.Add(".m3u8", "application/vnd.apple.mpegurl");
app.UseStaticFiles(
new StaticFileOptions
{
FileProvider = new PhysicalFileProvider(FileSystemLayout.TranscodeFolder),
RequestPath = "/iptv/session",
ContentTypeProvider = extensionProvider,
OnPrepareResponse = ctx =>
{
// Log.Logger.Information("Transcode access: {Test}", ctx.File.PhysicalPath);
ChannelWriter<IFFmpegWorkerRequest> writer = app.ApplicationServices
.GetRequiredService<ChannelWriter<IFFmpegWorkerRequest>>();
writer.TryWrite(new TouchFFmpegSession(ctx.File.PhysicalPath));
}
});
app.UseRouting();
app.UseEndpoints(
@@ -206,10 +244,12 @@ namespace ErsatzTV
services.AddSingleton<ITraktApiClient, TraktApiClient>();
services.AddSingleton<IEntityLocker, EntityLocker>();
services.AddSingleton<ISearchIndex, SearchIndex>();
services.AddSingleton<IFFmpegSegmenterService, FFmpegSegmenterService>();
AddChannel<IBackgroundServiceRequest>(services);
AddChannel<IPlexBackgroundServiceRequest>(services);
AddChannel<IJellyfinBackgroundServiceRequest>(services);
AddChannel<IEmbyBackgroundServiceRequest>(services);
AddChannel<IFFmpegWorkerRequest>(services);
services.AddScoped<IFFmpegVersionHealthCheck, FFmpegVersionHealthCheck>();
services.AddScoped<IFFmpegReportsHealthCheck, FFmpegReportsHealthCheck>();
@@ -261,6 +301,7 @@ namespace ErsatzTV
services.AddScoped<IPlexPathReplacementService, PlexPathReplacementService>();
services.AddScoped<IFFmpegStreamSelector, FFmpegStreamSelector>();
services.AddScoped<FFmpegProcessService>();
services.AddScoped<HlsSessionWorker>();
services.AddScoped<IGitHubApiClient, GitHubApiClient>();
services.AddScoped<IHtmlSanitizer, HtmlSanitizer>(
_ =>
@@ -286,6 +327,7 @@ namespace ErsatzTV
services.AddHostedService<FFmpegLocatorService>();
services.AddHostedService<WorkerService>();
services.AddHostedService<SchedulerService>();
services.AddHostedService<FFmpegWorkerService>();
}
private void AddChannel<TMessageType>(IServiceCollection services)
+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-nvidia2004 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-nvidia2004 AS runtime-base
COPY --from=dotnet-runtime /usr/share/dotnet /usr/share/dotnet
RUN apt-get update \
&& DEBIAN_FRONTEND="noninteractive" apt-get install -y libicu-dev tzdata \
&& rm -rf /var/lib/apt/lists/*
+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.

After

Width:  |  Height:  |  Size: 6.0 KiB

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

+3 -2
View File
@@ -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)
-23
View File
@@ -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.
![Emby Path Replacements](../images/emby-media-source-path-replacements.png)
## 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:
![Poster Selection Button](../images/poster-selection-button.png)
Then, add to a collection by clicking the `Add To Collection` button at the top of the page.
![Add Selection To Collection](../images/add-selection-to-collection.png)
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.
![Add To Collection Dialog](../images/add-to-collection-dialog.png)
---
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)
+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
+65
View File
@@ -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:
![Poster Selection Button](../images/poster-selection-button.png)
Then, add to a collection by clicking the `Add To Collection` button at the top of the page.
![Add Selection To Collection](../images/add-selection-to-collection.png)
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.
![Add To Collection Dialog](../images/add-to-collection-dialog.png)
### 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.
![Trakt List Search](../images/trakt-list-search.png)
### 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)
+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
+1
View File
@@ -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
+1
View File
@@ -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':