Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab1f294c1f | ||
|
|
ea08453913 | ||
|
|
87deaa6f3a | ||
|
|
9d99c19ea4 | ||
|
|
49d14b05f6 | ||
|
|
a8ba9edf2b | ||
|
|
89811a1203 | ||
|
|
534e2c4512 | ||
|
|
c1e148633d | ||
|
|
a9dff5eff7 | ||
|
|
a2da043f4b | ||
|
|
252c185562 | ||
|
|
a47987a9d7 | ||
|
|
5937211bb8 | ||
|
|
e32dbd0474 | ||
|
|
6bcc1ede2b | ||
|
|
6c9764a51e | ||
|
|
ff5438459c | ||
|
|
0c53a4509c | ||
|
|
5fd315ead8 | ||
|
|
f02b0ac345 | ||
|
|
fd83007296 | ||
|
|
70ca5bf050 | ||
|
|
eed9f60273 | ||
|
|
0e2e6cd52e | ||
|
|
c9b557f2e6 | ||
|
|
cde869f3eb | ||
|
|
90d6a59d3f | ||
|
|
b972947747 |
@@ -6,3 +6,21 @@ updates:
|
||||
interval: daily
|
||||
assignees:
|
||||
- jasongdove
|
||||
- package-ecosystem: docker
|
||||
directory: "/docker"
|
||||
schedule:
|
||||
interval: daily
|
||||
assignees:
|
||||
- jasongdove
|
||||
- package-ecosystem: docker
|
||||
directory: "/docker/nvidia"
|
||||
schedule:
|
||||
interval: daily
|
||||
assignees:
|
||||
- jasongdove
|
||||
- package-ecosystem: docker
|
||||
directory: "/docker/vaapi"
|
||||
schedule:
|
||||
interval: daily
|
||||
assignees:
|
||||
- jasongdove
|
||||
|
||||
@@ -149,6 +149,9 @@ jobs:
|
||||
- os: ubuntu-latest
|
||||
kind: linux
|
||||
target: linux-arm
|
||||
- os: ubuntu-latest
|
||||
kind: linux
|
||||
target: linux-arm64
|
||||
- os: windows-latest
|
||||
kind: windows
|
||||
target: win-x64
|
||||
@@ -169,6 +172,14 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: dotnet restore -r "${{ matrix.target}}"
|
||||
|
||||
- uses: suisei-cn/actions-download-file@v1
|
||||
if: ${{ matrix.kind }} == "windows"
|
||||
id: downloadffmpeg
|
||||
name: Download ffmpeg
|
||||
with:
|
||||
url: "https://github.com/GyanD/codexffmpeg/releases/download/5.0/ffmpeg-5.0-full_build.7z"
|
||||
target: ffmpeg/
|
||||
|
||||
- name: Build
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -184,6 +195,12 @@ jobs:
|
||||
dotnet publish ErsatzTV-Windows/ErsatzTV-Windows.csproj --framework net6.0-windows --runtime "${{ matrix.target }}" -c Release -o "$release_name" -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
fi
|
||||
|
||||
# Download ffmpeg
|
||||
if [ "${{ matrix.kind }}" == "windows" ]; then
|
||||
7z e "ffmpeg/${{ steps.downloadffmpeg.outputs.filename }}" -o"$release_name" '*.exe' -r
|
||||
rm -f "$release_name/ffplay.exe"
|
||||
fi
|
||||
|
||||
# Pack files
|
||||
if [ "${{ matrix.kind }}" == "windows" ]; then
|
||||
7z a -tzip "${release_name}.zip" "./${release_name}/*"
|
||||
|
||||
+24
-1
@@ -5,6 +5,28 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.4.1-alpha] - 2022-02-10
|
||||
### Fixed
|
||||
- Normalize smart quotes in search queries as they are unsupported by the search library
|
||||
- Fix incorrect watermark time calculations caused by working ahead in `HLS Segmenter`
|
||||
- Fix ui crash adding empty path to local library
|
||||
- Fix ui crash loading collection editor
|
||||
- Properly flag items as `File Not Found` when local library path (folder) is missing from disk
|
||||
- Fix playback bug with unknown pixel format
|
||||
- Fix playback of interlaced mpeg2video on NVIDIA, VAAPI
|
||||
|
||||
### Added
|
||||
- Include `Series` category tag for all episodes in XMLTV
|
||||
- Include movie, episode (show), music video (artist) genres as `category` tags in XMLTV
|
||||
- Add framerate normalization to `HLS Segmenter` and `MPEG-TS` streaming modes
|
||||
- Add `HLS Segmenter Initial Segment Count` setting to allow segmenter to work ahead before allowing client playback
|
||||
|
||||
### Changed
|
||||
- Intermittent watermarks will now fade in and out
|
||||
- Show collection name in some playout build error messages
|
||||
- Use hardware-accelerated filter for watermarks on NVIDIA
|
||||
- Use hardware-accelerated deinterlace for some content on NVIDIA
|
||||
|
||||
## [0.4.0-alpha] - 2022-01-29
|
||||
### Fixed
|
||||
- Fix m3u `mode` query param to properly override streaming mode for all channels
|
||||
@@ -945,7 +967,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.4.0-alpha...HEAD
|
||||
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.4.1-alpha...HEAD
|
||||
[0.4.1-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.4.0-alpha...v0.4.1-alpha
|
||||
[0.4.0-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.3.8-alpha...v0.4.0-alpha
|
||||
[0.3.8-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.3.7-alpha...v0.3.8-alpha
|
||||
[0.3.7-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.3.6-alpha...v0.3.7-alpha
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Channels.Queries;
|
||||
|
||||
public record GetChannelFramerate(string ChannelNumber) : IRequest<Option<int>>;
|
||||
@@ -0,0 +1,99 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Application.Channels.Queries;
|
||||
|
||||
public class GetChannelFramerateHandler : IRequestHandler<GetChannelFramerate, Option<int>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly ILogger<GetChannelFramerateHandler> _logger;
|
||||
|
||||
public GetChannelFramerateHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
ILogger<GetChannelFramerateHandler> logger)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Option<int>> Handle(GetChannelFramerate request, CancellationToken cancellationToken)
|
||||
{
|
||||
// TODO: expand to check everything in collection rather than what's scheduled?
|
||||
_logger.LogDebug("Checking frame rates for channel {ChannelNumber}", request.ChannelNumber);
|
||||
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
List<Playout> playouts = await dbContext.Playouts
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as Movie).MediaVersions)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as Episode).MediaVersions)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as Song).MediaVersions)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as MusicVideo).MediaVersions)
|
||||
.Include(p => p.Items)
|
||||
.ThenInclude(pi => pi.MediaItem)
|
||||
.ThenInclude(mi => (mi as OtherVideo).MediaVersions)
|
||||
.Filter(p => p.Channel.Number == request.ChannelNumber)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var frameRates = playouts.Map(p => p.Items.Map(i => i.MediaItem.GetHeadVersion()))
|
||||
.Flatten()
|
||||
.Map(mv => mv.RFrameRate)
|
||||
.ToList();
|
||||
|
||||
|
||||
var distinct = frameRates.Distinct().ToList();
|
||||
if (distinct.Count > 1)
|
||||
{
|
||||
// TODO: something more intelligent than minimum framerate?
|
||||
int result = frameRates.Map(ParseFrameRate).Min();
|
||||
_logger.LogInformation(
|
||||
"Normalizing frame rate for channel {ChannelNumber} from {Distinct} to {FrameRate}",
|
||||
request.ChannelNumber,
|
||||
distinct,
|
||||
result);
|
||||
return result;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"All content on channel {ChannelNumber} has the same frame rate of {FrameRate}; will not normalize",
|
||||
request.ChannelNumber,
|
||||
distinct[0]);
|
||||
return None;
|
||||
}
|
||||
|
||||
private int ParseFrameRate(string frameRate)
|
||||
{
|
||||
if (!int.TryParse(frameRate, out int fr))
|
||||
{
|
||||
string[] split = (frameRate ?? string.Empty).Split("/");
|
||||
if (int.TryParse(split[0], out int left) && int.TryParse(split[1], out int right))
|
||||
{
|
||||
fr = (int)Math.Round(left / (double)right);
|
||||
}
|
||||
else
|
||||
{
|
||||
fr = 24;
|
||||
}
|
||||
}
|
||||
|
||||
return fr;
|
||||
}
|
||||
}
|
||||
@@ -24,5 +24,6 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
|
||||
bool NormalizeLoudness,
|
||||
int AudioChannels,
|
||||
int AudioSampleRate,
|
||||
bool NormalizeAudio) : IRequest<Either<BaseError, CreateFFmpegProfileResult>>;
|
||||
bool NormalizeAudio,
|
||||
bool NormalizeFramerate) : IRequest<Either<BaseError, CreateFFmpegProfileResult>>;
|
||||
}
|
||||
|
||||
@@ -58,7 +58,8 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
|
||||
NormalizeLoudness = request.NormalizeLoudness,
|
||||
AudioChannels = request.AudioChannels,
|
||||
AudioSampleRate = request.AudioSampleRate,
|
||||
NormalizeAudio = request.NormalizeAudio
|
||||
NormalizeAudio = request.NormalizeAudio,
|
||||
NormalizeFramerate = request.NormalizeFramerate
|
||||
});
|
||||
|
||||
private static Validation<BaseError, string> ValidateName(CreateFFmpegProfile createFFmpegProfile) =>
|
||||
|
||||
@@ -25,5 +25,6 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
|
||||
bool NormalizeLoudness,
|
||||
int AudioChannels,
|
||||
int AudioSampleRate,
|
||||
bool NormalizeAudio) : IRequest<Either<BaseError, UpdateFFmpegProfileResult>>;
|
||||
bool NormalizeAudio,
|
||||
bool NormalizeFramerate) : IRequest<Either<BaseError, UpdateFFmpegProfileResult>>;
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
|
||||
p.AudioChannels = update.AudioChannels;
|
||||
p.AudioSampleRate = update.AudioSampleRate;
|
||||
p.NormalizeAudio = update.Transcode && update.NormalizeAudio;
|
||||
p.NormalizeFramerate = update.Transcode && update.NormalizeFramerate;
|
||||
await dbContext.SaveChangesAsync();
|
||||
return new UpdateFFmpegProfileResult(p.Id);
|
||||
}
|
||||
|
||||
@@ -119,6 +119,10 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
|
||||
ConfigElementKey.FFmpegWorkAheadSegmenters,
|
||||
request.Settings.WorkAheadSegmenterLimit);
|
||||
|
||||
await _configElementRepository.Upsert(
|
||||
ConfigElementKey.FFmpegInitialSegmentCount,
|
||||
request.Settings.InitialSegmentCount);
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,5 +23,6 @@ namespace ErsatzTV.Application.FFmpegProfiles
|
||||
bool NormalizeLoudness,
|
||||
int AudioChannels,
|
||||
int AudioSampleRate,
|
||||
bool NormalizeAudio);
|
||||
bool NormalizeAudio,
|
||||
bool NormalizeFramerate);
|
||||
}
|
||||
|
||||
@@ -11,5 +11,6 @@
|
||||
public int? GlobalFallbackFillerId { get; set; }
|
||||
public int HlsSegmenterIdleTimeout { get; set; }
|
||||
public int WorkAheadSegmenterLimit { get; set; }
|
||||
public int InitialSegmentCount { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,8 @@ namespace ErsatzTV.Application.FFmpegProfiles
|
||||
profile.NormalizeLoudness,
|
||||
profile.AudioChannels,
|
||||
profile.AudioSampleRate,
|
||||
profile.NormalizeAudio);
|
||||
profile.NormalizeAudio,
|
||||
profile.NormalizeVideo && profile.NormalizeFramerate);
|
||||
|
||||
private static ResolutionViewModel Project(Resolution resolution) =>
|
||||
new(resolution.Id, resolution.Name, resolution.Width, resolution.Height);
|
||||
|
||||
@@ -34,6 +34,8 @@ namespace ErsatzTV.Application.FFmpegProfiles.Queries
|
||||
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegSegmenterTimeout);
|
||||
Option<int> workAheadSegmenterLimit =
|
||||
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegWorkAheadSegmenters);
|
||||
Option<int> initialSegmentCount =
|
||||
await _configElementRepository.GetValue<int>(ConfigElementKey.FFmpegInitialSegmentCount);
|
||||
|
||||
var result = new FFmpegSettingsViewModel
|
||||
{
|
||||
@@ -44,6 +46,7 @@ namespace ErsatzTV.Application.FFmpegProfiles.Queries
|
||||
PreferredLanguageCode = await preferredLanguageCode.IfNoneAsync("eng"),
|
||||
HlsSegmenterIdleTimeout = await hlsSegmenterIdleTimeout.IfNoneAsync(60),
|
||||
WorkAheadSegmenterLimit = await workAheadSegmenterLimit.IfNoneAsync(1),
|
||||
InitialSegmentCount = await initialSegmentCount.IfNoneAsync(1)
|
||||
};
|
||||
|
||||
foreach (int watermarkId in watermark)
|
||||
|
||||
@@ -47,6 +47,9 @@ namespace ErsatzTV.Application.MediaCards.Queries
|
||||
.ThenInclude(i => (i as Movie).MovieMetadata)
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
.Include(c => c.MediaItems)
|
||||
.ThenInclude(i => (i as Movie).MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.Include(c => c.MediaItems)
|
||||
.ThenInclude(i => (i as Artist).ArtistMetadata)
|
||||
.ThenInclude(mvm => mvm.Artwork)
|
||||
.Include(c => c.MediaItems)
|
||||
@@ -56,6 +59,9 @@ namespace ErsatzTV.Application.MediaCards.Queries
|
||||
.ThenInclude(i => (i as MusicVideo).Artist)
|
||||
.ThenInclude(a => a.ArtistMetadata)
|
||||
.Include(c => c.MediaItems)
|
||||
.ThenInclude(i => (i as MusicVideo).MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.Include(c => c.MediaItems)
|
||||
.ThenInclude(i => (i as Show).ShowMetadata)
|
||||
.ThenInclude(sm => sm.Artwork)
|
||||
.Include(c => c.MediaItems)
|
||||
@@ -81,11 +87,20 @@ namespace ErsatzTV.Application.MediaCards.Queries
|
||||
.ThenInclude(i => (i as Episode).Season)
|
||||
.ThenInclude(s => s.SeasonMetadata)
|
||||
.Include(c => c.MediaItems)
|
||||
.ThenInclude(i => (i as Episode).MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.Include(c => c.MediaItems)
|
||||
.ThenInclude(i => (i as OtherVideo).OtherVideoMetadata)
|
||||
.ThenInclude(ovm => ovm.Artwork)
|
||||
.Include(c => c.MediaItems)
|
||||
.ThenInclude(i => (i as OtherVideo).MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.Include(c => c.MediaItems)
|
||||
.ThenInclude(i => (i as Song).SongMetadata)
|
||||
.ThenInclude(ovm => ovm.Artwork)
|
||||
.Include(c => c.MediaItems)
|
||||
.ThenInclude(i => (i as Song).MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == request.Id)
|
||||
.Map(c => c.ToEither(BaseError.New("Unable to load collection")))
|
||||
.MapT(c => ProjectToViewModel(c, maybeJellyfin, maybeEmby));
|
||||
|
||||
@@ -21,7 +21,7 @@ namespace ErsatzTV.Application.MediaCollections.Queries
|
||||
GetCollectionById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
return await dbContext.Collections
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == request.Id)
|
||||
.MapT(ProjectToViewModel);
|
||||
|
||||
@@ -5,6 +5,7 @@ using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
@@ -50,7 +51,7 @@ namespace ErsatzTV.Application.Streaming.Commands
|
||||
TimeSpan idleTimeout = await _configElementRepository
|
||||
.GetValue<int>(ConfigElementKey.FFmpegSegmenterTimeout)
|
||||
.Map(maybeTimeout => maybeTimeout.Match(i => TimeSpan.FromSeconds(i), () => TimeSpan.FromMinutes(1)));
|
||||
|
||||
|
||||
using IServiceScope scope = _serviceScopeFactory.CreateScope();
|
||||
HlsSessionWorker worker = scope.ServiceProvider.GetRequiredService<HlsSessionWorker>();
|
||||
_ffmpegSegmenterService.SessionWorkers.AddOrUpdate(request.ChannelNumber, _ => worker, (_, _) => worker);
|
||||
@@ -68,12 +69,32 @@ namespace ErsatzTV.Application.Streaming.Commands
|
||||
request.ChannelNumber,
|
||||
"live.m3u8");
|
||||
|
||||
IConfigElementRepository repo = scope.ServiceProvider.GetRequiredService<IConfigElementRepository>();
|
||||
int initialSegmentCount = await repo.GetValue<int>(ConfigElementKey.FFmpegInitialSegmentCount)
|
||||
.Map(maybeCount => maybeCount.Match(identity, () => 1));
|
||||
|
||||
await WaitForPlaylistSegments(playlistFileName, initialSegmentCount, worker);
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private static async Task WaitForPlaylistSegments(string playlistFileName, int initialSegmentCount, IHlsSessionWorker worker)
|
||||
{
|
||||
while (!File.Exists(playlistFileName))
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(100));
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
var segmentCount = 0;
|
||||
while (segmentCount < initialSegmentCount)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(200));
|
||||
|
||||
DateTimeOffset now = DateTimeOffset.Now.AddSeconds(-30);
|
||||
string[] input = await File.ReadAllLinesAsync(playlistFileName);
|
||||
TrimPlaylistResult result = HlsPlaylistFilter.TrimPlaylist(worker.PlaylistStart, now, input);
|
||||
segmentCount = result.SegmentCount;
|
||||
}
|
||||
}
|
||||
|
||||
private Task<Validation<BaseError, Unit>> Validate(StartFFmpegSession request) =>
|
||||
|
||||
@@ -5,6 +5,7 @@ using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Timers;
|
||||
using ErsatzTV.Application.Channels.Queries;
|
||||
using ErsatzTV.Application.Streaming.Queries;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
@@ -30,6 +31,7 @@ namespace ErsatzTV.Application.Streaming
|
||||
private Timer _timer;
|
||||
private readonly object _sync = new();
|
||||
private DateTimeOffset _playlistStart;
|
||||
private Option<int> _targetFramerate;
|
||||
|
||||
public HlsSessionWorker(IServiceScopeFactory serviceScopeFactory, ILogger<HlsSessionWorker> logger)
|
||||
{
|
||||
@@ -66,6 +68,13 @@ namespace ErsatzTV.Application.Streaming
|
||||
CancellationToken cancellationToken = cts.Token;
|
||||
|
||||
_logger.LogInformation("Starting HLS session for channel {Channel}", channelNumber);
|
||||
|
||||
using IServiceScope scope = _serviceScopeFactory.CreateScope();
|
||||
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
|
||||
|
||||
_targetFramerate = await mediator.Send(
|
||||
new GetChannelFramerate(channelNumber),
|
||||
cancellationToken);
|
||||
|
||||
Touch();
|
||||
_transcodedUntil = DateTimeOffset.Now;
|
||||
@@ -142,7 +151,8 @@ namespace ErsatzTV.Application.Streaming
|
||||
firstProcess ? DateTimeOffset.Now : _transcodedUntil.AddSeconds(1),
|
||||
!firstProcess,
|
||||
realtime,
|
||||
ptsOffset);
|
||||
ptsOffset,
|
||||
_targetFramerate);
|
||||
|
||||
// _logger.LogInformation("Request {@Request}", request);
|
||||
|
||||
@@ -221,16 +231,31 @@ namespace ErsatzTV.Application.Streaming
|
||||
await File.WriteAllTextAsync(playlistFileName, trimResult.Playlist, cancellationToken);
|
||||
|
||||
// delete old segments
|
||||
foreach (string file in Directory.GetFiles(
|
||||
Path.Combine(FileSystemLayout.TranscodeFolder, channelNumber),
|
||||
"*.ts"))
|
||||
var allSegments = Directory.GetFiles(
|
||||
Path.Combine(FileSystemLayout.TranscodeFolder, channelNumber),
|
||||
"live*.ts")
|
||||
.Map(
|
||||
file =>
|
||||
{
|
||||
string fileName = Path.GetFileName(file);
|
||||
var sequenceNumber = int.Parse(fileName.Replace("live", string.Empty).Split('.')[0]);
|
||||
return new Segment(file, sequenceNumber);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var toDelete = allSegments.Filter(s => s.SequenceNumber < trimResult.Sequence).ToList();
|
||||
// if (toDelete.Count > 0)
|
||||
// {
|
||||
// _logger.LogInformation(
|
||||
// "Deleting HLS segments {Min} to {Max} (less than {StartSequence})",
|
||||
// toDelete.Map(s => s.SequenceNumber).Min(),
|
||||
// toDelete.Map(s => s.SequenceNumber).Max(),
|
||||
// trimResult.Sequence);
|
||||
// }
|
||||
|
||||
foreach (Segment segment in toDelete)
|
||||
{
|
||||
string fileName = Path.GetFileName(file);
|
||||
if (fileName.StartsWith("live") && int.Parse(fileName.Replace("live", string.Empty).Split('.')[0]) <
|
||||
trimResult.Sequence)
|
||||
{
|
||||
File.Delete(file);
|
||||
}
|
||||
File.Delete(segment.File);
|
||||
}
|
||||
|
||||
_playlistStart = trimResult.PlaylistStart;
|
||||
@@ -266,5 +291,7 @@ namespace ErsatzTV.Application.Streaming
|
||||
return await repo.GetValue<int>(ConfigElementKey.FFmpegWorkAheadSegmenters)
|
||||
.Map(maybeCount => maybeCount.Match(identity, () => 1));
|
||||
}
|
||||
|
||||
private record Segment(string File, int SequenceNumber);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.Streaming.Queries
|
||||
{
|
||||
@@ -7,7 +8,8 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
DateTimeOffset Now,
|
||||
bool StartAtZero,
|
||||
bool HlsRealtime,
|
||||
long PtsOffset) : FFmpegProcessRequest(ChannelNumber,
|
||||
long PtsOffset,
|
||||
Option<int> TargetFramerate) : FFmpegProcessRequest(ChannelNumber,
|
||||
Mode,
|
||||
Now,
|
||||
StartAtZero,
|
||||
|
||||
+2
-1
@@ -159,7 +159,8 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
playoutItemWithPath.PlayoutItem.FillerKind,
|
||||
playoutItemWithPath.PlayoutItem.InPoint,
|
||||
playoutItemWithPath.PlayoutItem.OutPoint,
|
||||
request.PtsOffset);
|
||||
request.PtsOffset,
|
||||
request.TargetFramerate);
|
||||
|
||||
var result = new PlayoutItemProcessModel(process, playoutItemWithPath.PlayoutItem.FinishOffset);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using FluentAssertions;
|
||||
@@ -183,7 +184,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
ChannelWatermarkLocation.TopLeft,
|
||||
false,
|
||||
100,
|
||||
"[0:0][1:v]overlay=x=134:y=54:enable='lt(mod(mod(time(0),60*60),10*60),15)'[v]",
|
||||
"[1:v]format=yuva420p|yuva444p|yuva422p|rgba|abgr|bgra|gbrap|ya8,fade=in:st=300:d=1:alpha=1:enable='between(t,0,314)',fade=out:st=315:d=1:alpha=1:enable='between(t,301,899)',fade=in:st=900:d=1:alpha=1:enable='between(t,316,914)',fade=out:st=915:d=1:alpha=1:enable='between(t,901,1499)',fade=in:st=1500:d=1:alpha=1:enable='between(t,916,1514)',fade=out:st=1515:d=1:alpha=1:enable='between(t,1501,2099)',fade=in:st=2100:d=1:alpha=1:enable='between(t,1516,2114)',fade=out:st=2115:d=1:alpha=1:enable='between(t,2101,2699)',fade=in:st=2700:d=1:alpha=1:enable='between(t,2116,2714)',fade=out:st=2715:d=1:alpha=1:enable='between(t,2701,3300)'[wmp];[0:0][wmp]overlay=x=134:y=54,format=nv12[v]",
|
||||
"0:1",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
@@ -257,23 +258,36 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
string expectedAudioLabel,
|
||||
string expectedVideoLabel)
|
||||
{
|
||||
var watermark = new ChannelWatermark
|
||||
{
|
||||
Mode = intermittent
|
||||
? ChannelWatermarkMode.Intermittent
|
||||
: ChannelWatermarkMode.Permanent,
|
||||
DurationSeconds = intermittent ? 15 : 0,
|
||||
FrequencyMinutes = intermittent ? 10 : 0,
|
||||
Location = location,
|
||||
Size = scaled ? ChannelWatermarkSize.Scaled : ChannelWatermarkSize.ActualSize,
|
||||
WidthPercent = scaled ? 20 : 0,
|
||||
Opacity = opacity,
|
||||
HorizontalMarginPercent = 7,
|
||||
VerticalMarginPercent = 5
|
||||
};
|
||||
|
||||
Option<List<FadePoint>> maybeFadePoints = watermark.Mode == ChannelWatermarkMode.Intermittent
|
||||
? Some(
|
||||
WatermarkCalculator.CalculateFadePoints(
|
||||
new DateTimeOffset(2022, 01, 31, 12, 25, 0, TimeSpan.FromHours(-5)),
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.FromMinutes(55),
|
||||
TimeSpan.Zero,
|
||||
watermark.FrequencyMinutes,
|
||||
watermark.DurationSeconds))
|
||||
: None;
|
||||
|
||||
FFmpegComplexFilterBuilder builder = new FFmpegComplexFilterBuilder()
|
||||
.WithWatermark(
|
||||
Some(
|
||||
new ChannelWatermark
|
||||
{
|
||||
Mode = intermittent
|
||||
? ChannelWatermarkMode.Intermittent
|
||||
: ChannelWatermarkMode.Permanent,
|
||||
DurationSeconds = intermittent ? 15 : 0,
|
||||
FrequencyMinutes = intermittent ? 10 : 0,
|
||||
Location = location,
|
||||
Size = scaled ? ChannelWatermarkSize.Scaled : ChannelWatermarkSize.ActualSize,
|
||||
WidthPercent = scaled ? 20 : 0,
|
||||
Opacity = opacity,
|
||||
HorizontalMarginPercent = 7,
|
||||
VerticalMarginPercent = 5
|
||||
}),
|
||||
Some(watermark),
|
||||
maybeFadePoints,
|
||||
new Resolution { Width = 1920, Height = 1080 },
|
||||
None)
|
||||
.WithDeinterlace(deinterlace)
|
||||
@@ -290,6 +304,213 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
filter.VideoLabel.Should().Be(expectedVideoLabel);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
[TestCase(
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
ChannelWatermarkLocation.BottomLeft,
|
||||
false,
|
||||
100,
|
||||
"[0:0]scale_cuda=format=yuv420p[vt];[1:v]format=yuva420p,hwupload_cuda[wmp];[vt][wmp]overlay_cuda=x=134:y=H-h-54[v]",
|
||||
"0:1",
|
||||
"[v]",
|
||||
false)]
|
||||
[TestCase(
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
ChannelWatermarkLocation.BottomLeft,
|
||||
false,
|
||||
100,
|
||||
"[0:0]scale_cuda=1920:1080,setsar=1,hwdownload,format=nv12,format=yuv420p,hwupload_cuda[vt];[1:v]format=yuva420p,hwupload_cuda[wmp];[vt][wmp]overlay_cuda=x=134:y=H-h-54,hwupload[v]",
|
||||
"0:1",
|
||||
"[v]",
|
||||
true)]
|
||||
[TestCase(
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
ChannelWatermarkLocation.TopLeft,
|
||||
false,
|
||||
100,
|
||||
"[0:0]scale_cuda=format=yuv420p[vt];[1:v]format=yuva420p,fade=in:st=300:d=1:alpha=1:enable='between(t,0,314)',fade=out:st=315:d=1:alpha=1:enable='between(t,301,899)',fade=in:st=900:d=1:alpha=1:enable='between(t,316,914)',fade=out:st=915:d=1:alpha=1:enable='between(t,901,1499)',fade=in:st=1500:d=1:alpha=1:enable='between(t,916,1514)',fade=out:st=1515:d=1:alpha=1:enable='between(t,1501,2099)',fade=in:st=2100:d=1:alpha=1:enable='between(t,1516,2114)',fade=out:st=2115:d=1:alpha=1:enable='between(t,2101,2699)',fade=in:st=2700:d=1:alpha=1:enable='between(t,2116,2714)',fade=out:st=2715:d=1:alpha=1:enable='between(t,2701,3300)',hwupload_cuda[wmp];[vt][wmp]overlay_cuda=x=134:y=54[v]",
|
||||
"0:1",
|
||||
"[v]",
|
||||
false)]
|
||||
[TestCase(
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
ChannelWatermarkLocation.TopLeft,
|
||||
false,
|
||||
100,
|
||||
"[0:0]scale_cuda=1920:1080,setsar=1,hwdownload,format=nv12,format=yuv420p,hwupload_cuda[vt];[1:v]format=yuva420p,fade=in:st=300:d=1:alpha=1:enable='between(t,0,314)',fade=out:st=315:d=1:alpha=1:enable='between(t,301,899)',fade=in:st=900:d=1:alpha=1:enable='between(t,316,914)',fade=out:st=915:d=1:alpha=1:enable='between(t,901,1499)',fade=in:st=1500:d=1:alpha=1:enable='between(t,916,1514)',fade=out:st=1515:d=1:alpha=1:enable='between(t,1501,2099)',fade=in:st=2100:d=1:alpha=1:enable='between(t,1516,2114)',fade=out:st=2115:d=1:alpha=1:enable='between(t,2101,2699)',fade=in:st=2700:d=1:alpha=1:enable='between(t,2116,2714)',fade=out:st=2715:d=1:alpha=1:enable='between(t,2701,3300)',hwupload_cuda[wmp];[vt][wmp]overlay_cuda=x=134:y=54,hwupload[v]",
|
||||
"0:1",
|
||||
"[v]",
|
||||
true)]
|
||||
[TestCase(
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
ChannelWatermarkLocation.TopLeft,
|
||||
true,
|
||||
100,
|
||||
"[0:0]scale_cuda=format=yuv420p[vt];[1:v]format=yuva420p,scale=384:-1,hwupload_cuda[wmp];[vt][wmp]overlay_cuda=x=134:y=54[v]",
|
||||
"0:1",
|
||||
"[v]",
|
||||
false)]
|
||||
[TestCase(
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
ChannelWatermarkLocation.TopLeft,
|
||||
true,
|
||||
100,
|
||||
"[0:0]scale_cuda=1920:1080,setsar=1,hwdownload,format=nv12,format=yuv420p,hwupload_cuda[vt];[1:v]format=yuva420p,scale=384:-1,hwupload_cuda[wmp];[vt][wmp]overlay_cuda=x=134:y=54,hwupload[v]",
|
||||
"0:1",
|
||||
"[v]",
|
||||
true)]
|
||||
[TestCase(
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
ChannelWatermarkLocation.TopLeft,
|
||||
false,
|
||||
90,
|
||||
"[0:0]scale_cuda=format=yuv420p[vt];[1:v]format=yuva420p,colorchannelmixer=aa=0.90,hwupload_cuda[wmp];[vt][wmp]overlay_cuda=x=134:y=54[v]",
|
||||
"0:1",
|
||||
"[v]",
|
||||
false)]
|
||||
[TestCase(
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
ChannelWatermarkLocation.TopLeft,
|
||||
false,
|
||||
90,
|
||||
"[0:0]scale_cuda=1920:1080,setsar=1,hwdownload,format=nv12,format=yuv420p,hwupload_cuda[vt];[1:v]format=yuva420p,colorchannelmixer=aa=0.90,hwupload_cuda[wmp];[vt][wmp]overlay_cuda=x=134:y=54,hwupload[v]",
|
||||
"0:1",
|
||||
"[v]",
|
||||
true)]
|
||||
// TODO: do we need these anymore? interlaced content that isn't handled by mpeg2_cuvid?
|
||||
// [TestCase(
|
||||
// false,
|
||||
// true,
|
||||
// false,
|
||||
// ChannelWatermarkLocation.TopLeft,
|
||||
// false,
|
||||
// 100,
|
||||
// "[0:0]yadif=1[vt];[vt][1:v]overlay=x=134:y=54[v]",
|
||||
// "0:1",
|
||||
// "[v]")]
|
||||
// [TestCase(
|
||||
// false,
|
||||
// true,
|
||||
// false,
|
||||
// ChannelWatermarkLocation.TopLeft,
|
||||
// true,
|
||||
// 100,
|
||||
// "[0:0]yadif=1[vt];[1:v]scale=384:-1[wmp];[vt][wmp]overlay=x=134:y=54[v]",
|
||||
// "0:1",
|
||||
// "[v]")]
|
||||
// [TestCase(
|
||||
// true,
|
||||
// true,
|
||||
// false,
|
||||
// ChannelWatermarkLocation.TopLeft,
|
||||
// false,
|
||||
// 100,
|
||||
// "[0:1]apad=whole_dur=3300000ms[a];[0:0]yadif=1[vt];[vt][1:v]overlay=x=134:y=54[v]",
|
||||
// "[a]",
|
||||
// "[v]")]
|
||||
[TestCase(
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
ChannelWatermarkLocation.TopLeft,
|
||||
false,
|
||||
100,
|
||||
"[0:1]apad=whole_dur=3300000ms[a];[0:0]scale_cuda=format=yuv420p[vt];[1:v]format=yuva420p,hwupload_cuda[wmp];[vt][wmp]overlay_cuda=x=134:y=54[v]",
|
||||
"[a]",
|
||||
"[v]",
|
||||
false)]
|
||||
[TestCase(
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
ChannelWatermarkLocation.TopLeft,
|
||||
false,
|
||||
100,
|
||||
"[0:1]apad=whole_dur=3300000ms[a];[0:0]scale_cuda=1920:1080,setsar=1,hwdownload,format=nv12,format=yuv420p,hwupload_cuda[vt];[1:v]format=yuva420p,hwupload_cuda[wmp];[vt][wmp]overlay_cuda=x=134:y=54,hwupload[v]",
|
||||
"[a]",
|
||||
"[v]",
|
||||
true)]
|
||||
public void Should_Return_NVENC_Watermark(
|
||||
bool alignAudio,
|
||||
bool deinterlace,
|
||||
bool intermittent,
|
||||
ChannelWatermarkLocation location,
|
||||
bool scaled,
|
||||
int opacity,
|
||||
string expectedVideoFilter,
|
||||
string expectedAudioLabel,
|
||||
string expectedVideoLabel,
|
||||
bool scaledSource)
|
||||
{
|
||||
var watermark = new ChannelWatermark
|
||||
{
|
||||
Mode = intermittent
|
||||
? ChannelWatermarkMode.Intermittent
|
||||
: ChannelWatermarkMode.Permanent,
|
||||
DurationSeconds = intermittent ? 15 : 0,
|
||||
FrequencyMinutes = intermittent ? 10 : 0,
|
||||
Location = location,
|
||||
Size = scaled ? ChannelWatermarkSize.Scaled : ChannelWatermarkSize.ActualSize,
|
||||
WidthPercent = scaled ? 20 : 0,
|
||||
Opacity = opacity,
|
||||
HorizontalMarginPercent = 7,
|
||||
VerticalMarginPercent = 5
|
||||
};
|
||||
|
||||
Option<List<FadePoint>> maybeFadePoints = watermark.Mode == ChannelWatermarkMode.Intermittent
|
||||
? Some(
|
||||
WatermarkCalculator.CalculateFadePoints(
|
||||
new DateTimeOffset(2022, 01, 31, 12, 25, 0, TimeSpan.FromHours(-5)),
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.FromMinutes(55),
|
||||
TimeSpan.Zero,
|
||||
watermark.FrequencyMinutes,
|
||||
watermark.DurationSeconds))
|
||||
: None;
|
||||
|
||||
FFmpegComplexFilterBuilder builder = new FFmpegComplexFilterBuilder()
|
||||
.WithHardwareAcceleration(HardwareAccelerationKind.Nvenc)
|
||||
.WithWatermark(
|
||||
Some(watermark),
|
||||
maybeFadePoints,
|
||||
new Resolution { Width = 1920, Height = 1080 },
|
||||
None)
|
||||
.WithDeinterlace(deinterlace)
|
||||
.WithAlignedAudio(alignAudio ? Some(TimeSpan.FromMinutes(55)) : None);
|
||||
|
||||
if (scaledSource)
|
||||
{
|
||||
builder = builder.WithScaling(new Resolution { Width = 1920, Height = 1080 });
|
||||
}
|
||||
|
||||
Option<FFmpegComplexFilter> result = builder.Build(false, 0, 0, 0, 1, false);
|
||||
|
||||
result.IsSome.Should().BeTrue();
|
||||
result.IfSome(
|
||||
filter =>
|
||||
{
|
||||
filter.ComplexFilter.Should().Be(expectedVideoFilter);
|
||||
filter.AudioLabel.Should().Be(expectedAudioLabel);
|
||||
filter.VideoLabel.Should().Be(expectedVideoLabel);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
[TestCase(true, false, false, "[0:0]deinterlace_qsv[v]", "[v]")]
|
||||
|
||||
@@ -4,6 +4,7 @@ using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using FluentAssertions;
|
||||
using NUnit.Framework;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
{
|
||||
@@ -32,7 +33,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false);
|
||||
false,
|
||||
None);
|
||||
|
||||
actual.FormatFlags.Should().NotContain("+genpts");
|
||||
}
|
||||
@@ -54,7 +56,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false);
|
||||
false,
|
||||
None);
|
||||
|
||||
actual.ThreadCount.Should().Be(1);
|
||||
}
|
||||
@@ -74,7 +77,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false);
|
||||
false,
|
||||
None);
|
||||
|
||||
actual.ThreadCount.Should().Be(7);
|
||||
}
|
||||
@@ -94,7 +98,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false);
|
||||
false,
|
||||
None);
|
||||
|
||||
string[] expected = { "+genpts", "+discardcorrupt", "+igndts" };
|
||||
actual.FormatFlags.Count.Should().Be(expected.Length);
|
||||
@@ -116,7 +121,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false);
|
||||
false,
|
||||
None);
|
||||
|
||||
string[] expected = { "+genpts", "+discardcorrupt", "+igndts" };
|
||||
actual.FormatFlags.Count.Should().Be(expected.Length);
|
||||
@@ -138,7 +144,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false);
|
||||
false,
|
||||
None);
|
||||
|
||||
actual.RealtimeOutput.Should().BeTrue();
|
||||
}
|
||||
@@ -158,7 +165,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false);
|
||||
false,
|
||||
None);
|
||||
|
||||
actual.RealtimeOutput.Should().BeTrue();
|
||||
}
|
||||
@@ -180,7 +188,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
now.AddMinutes(5),
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false);
|
||||
false,
|
||||
None);
|
||||
|
||||
actual.StreamSeek.IsSome.Should().BeTrue();
|
||||
actual.StreamSeek.IfNone(TimeSpan.Zero).Should().Be(TimeSpan.FromMinutes(5));
|
||||
@@ -203,7 +212,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
now.AddMinutes(5),
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false);
|
||||
false,
|
||||
None);
|
||||
|
||||
actual.StreamSeek.IsSome.Should().BeTrue();
|
||||
actual.StreamSeek.IfNone(TimeSpan.Zero).Should().Be(TimeSpan.FromMinutes(5));
|
||||
@@ -224,7 +234,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false);
|
||||
false,
|
||||
None);
|
||||
|
||||
actual.ScaledSize.IsNone.Should().BeTrue();
|
||||
}
|
||||
@@ -251,7 +262,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false);
|
||||
false,
|
||||
None);
|
||||
|
||||
actual.ScaledSize.IsNone.Should().BeTrue();
|
||||
}
|
||||
@@ -278,7 +290,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false);
|
||||
false,
|
||||
None);
|
||||
|
||||
actual.ScaledSize.IsNone.Should().BeTrue();
|
||||
}
|
||||
@@ -305,7 +318,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false);
|
||||
false,
|
||||
None);
|
||||
|
||||
actual.ScaledSize.IsNone.Should().BeTrue();
|
||||
actual.PadToDesiredResolution.Should().BeFalse();
|
||||
@@ -334,7 +348,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false);
|
||||
false,
|
||||
None);
|
||||
|
||||
actual.ScaledSize.IsNone.Should().BeTrue();
|
||||
actual.PadToDesiredResolution.Should().BeTrue();
|
||||
@@ -362,7 +377,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false);
|
||||
false,
|
||||
None);
|
||||
|
||||
IDisplaySize scaledSize = actual.ScaledSize.IfNone(new MediaVersion { Width = 0, Height = 0 });
|
||||
scaledSize.Width.Should().Be(1280);
|
||||
@@ -393,7 +409,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false);
|
||||
false,
|
||||
None);
|
||||
|
||||
actual.ScaledSize.IsNone.Should().BeTrue();
|
||||
actual.PadToDesiredResolution.Should().BeFalse();
|
||||
@@ -422,7 +439,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false);
|
||||
false,
|
||||
None);
|
||||
|
||||
actual.ScaledSize.IsNone.Should().BeTrue();
|
||||
actual.PadToDesiredResolution.Should().BeFalse();
|
||||
@@ -452,7 +470,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false);
|
||||
false,
|
||||
None);
|
||||
|
||||
actual.ScaledSize.IsNone.Should().BeTrue();
|
||||
actual.PadToDesiredResolution.Should().BeTrue();
|
||||
@@ -485,7 +504,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false);
|
||||
false,
|
||||
None);
|
||||
|
||||
actual.ScaledSize.IsNone.Should().BeTrue();
|
||||
actual.PadToDesiredResolution.Should().BeFalse();
|
||||
@@ -518,7 +538,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false);
|
||||
false,
|
||||
None);
|
||||
|
||||
actual.ScaledSize.IsNone.Should().BeTrue();
|
||||
actual.PadToDesiredResolution.Should().BeFalse();
|
||||
@@ -550,7 +571,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false);
|
||||
false,
|
||||
None);
|
||||
|
||||
actual.ScaledSize.IsNone.Should().BeTrue();
|
||||
actual.PadToDesiredResolution.Should().BeFalse();
|
||||
@@ -583,7 +605,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false);
|
||||
false,
|
||||
None);
|
||||
|
||||
actual.ScaledSize.IsNone.Should().BeTrue();
|
||||
actual.PadToDesiredResolution.Should().BeFalse();
|
||||
@@ -618,7 +641,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false);
|
||||
false,
|
||||
None);
|
||||
|
||||
actual.ScaledSize.IsNone.Should().BeTrue();
|
||||
actual.PadToDesiredResolution.Should().BeFalse();
|
||||
@@ -651,7 +675,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false);
|
||||
false,
|
||||
None);
|
||||
|
||||
actual.ScaledSize.IsNone.Should().BeTrue();
|
||||
actual.PadToDesiredResolution.Should().BeTrue();
|
||||
@@ -683,7 +708,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false);
|
||||
false,
|
||||
None);
|
||||
|
||||
actual.ScaledSize.IsNone.Should().BeTrue();
|
||||
actual.PadToDesiredResolution.Should().BeFalse();
|
||||
@@ -714,7 +740,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false);
|
||||
false,
|
||||
None);
|
||||
|
||||
actual.ScaledSize.IsNone.Should().BeTrue();
|
||||
actual.PadToDesiredResolution.Should().BeTrue();
|
||||
@@ -747,7 +774,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false);
|
||||
false,
|
||||
None);
|
||||
|
||||
actual.ScaledSize.IsNone.Should().BeTrue();
|
||||
actual.PadToDesiredResolution.Should().BeFalse();
|
||||
@@ -776,7 +804,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false);
|
||||
false,
|
||||
None);
|
||||
|
||||
actual.AudioCodec.Should().Be("aac");
|
||||
}
|
||||
@@ -802,7 +831,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false);
|
||||
false,
|
||||
None);
|
||||
|
||||
actual.AudioCodec.Should().Be("copy");
|
||||
}
|
||||
@@ -829,7 +859,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false);
|
||||
false,
|
||||
None);
|
||||
|
||||
actual.AudioCodec.Should().Be("aac");
|
||||
}
|
||||
@@ -856,7 +887,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false);
|
||||
false,
|
||||
None);
|
||||
|
||||
actual.AudioCodec.Should().Be("copy");
|
||||
}
|
||||
@@ -884,7 +916,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false);
|
||||
false,
|
||||
None);
|
||||
|
||||
actual.AudioBitrate.IfNone(0).Should().Be(2424);
|
||||
}
|
||||
@@ -912,7 +945,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false);
|
||||
false,
|
||||
None);
|
||||
|
||||
actual.AudioBufferSize.IfNone(0).Should().Be(2424);
|
||||
}
|
||||
@@ -940,7 +974,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false);
|
||||
false,
|
||||
None);
|
||||
|
||||
actual.AudioChannels.IfNone(0).Should().Be(6);
|
||||
}
|
||||
@@ -968,7 +1003,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false);
|
||||
false,
|
||||
None);
|
||||
|
||||
actual.AudioSampleRate.IfNone(0).Should().Be(48);
|
||||
}
|
||||
@@ -995,7 +1031,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false);
|
||||
false,
|
||||
None);
|
||||
|
||||
actual.AudioChannels.IfNone(0).Should().Be(6);
|
||||
}
|
||||
@@ -1022,7 +1059,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false);
|
||||
false,
|
||||
None);
|
||||
|
||||
actual.AudioSampleRate.IfNone(0).Should().Be(48);
|
||||
}
|
||||
@@ -1050,7 +1088,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.FromMinutes(2),
|
||||
false);
|
||||
false,
|
||||
None);
|
||||
|
||||
actual.AudioDuration.IfNone(TimeSpan.MinValue).Should().Be(TimeSpan.FromMinutes(2));
|
||||
}
|
||||
@@ -1077,7 +1116,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false);
|
||||
false,
|
||||
None);
|
||||
|
||||
actual.NormalizeLoudness.Should().BeTrue();
|
||||
}
|
||||
@@ -1104,7 +1144,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false);
|
||||
false,
|
||||
None);
|
||||
|
||||
actual.NormalizeLoudness.Should().BeFalse();
|
||||
}
|
||||
@@ -1133,7 +1174,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
DateTimeOffset.Now,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false);
|
||||
false,
|
||||
None);
|
||||
|
||||
actual.HardwareAcceleration.Should().Be(HardwareAccelerationKind.Qsv);
|
||||
}
|
||||
|
||||
@@ -37,8 +37,26 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
Assert.Pass();
|
||||
}
|
||||
|
||||
public enum Padding
|
||||
{
|
||||
NoPadding,
|
||||
WithPadding
|
||||
}
|
||||
|
||||
private class TestData
|
||||
{
|
||||
public static Padding[] Paddings =
|
||||
{
|
||||
Padding.NoPadding,
|
||||
Padding.WithPadding
|
||||
};
|
||||
|
||||
public static VideoScanKind[] VideoScanKinds =
|
||||
{
|
||||
VideoScanKind.Progressive,
|
||||
VideoScanKind.Interlaced
|
||||
};
|
||||
|
||||
public static string[] InputCodecs =
|
||||
{
|
||||
"h264",
|
||||
@@ -51,9 +69,9 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
{
|
||||
"yuv420p",
|
||||
"yuv420p10le",
|
||||
"yuvj420p",
|
||||
"yuv444p",
|
||||
"yuv444p10le"
|
||||
// "yuvj420p",
|
||||
// "yuv444p",
|
||||
// "yuv444p10le"
|
||||
};
|
||||
|
||||
public static Resolution[] Resolutions =
|
||||
@@ -115,27 +133,32 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
string inputPixelFormat,
|
||||
[ValueSource(typeof(TestData), nameof(TestData.Resolutions))]
|
||||
Resolution profileResolution,
|
||||
[Values(true, false)]
|
||||
bool pad,
|
||||
[ValueSource(typeof(TestData), nameof(TestData.Paddings))]
|
||||
Padding padding,
|
||||
[ValueSource(typeof(TestData), nameof(TestData.VideoScanKinds))]
|
||||
VideoScanKind videoScanKind,
|
||||
// [ValueSource(typeof(TestData), nameof(TestData.SoftwareCodecs))] string profileCodec,
|
||||
// [ValueSource(typeof(TestData), nameof(TestData.NoAcceleration))] HardwareAccelerationKind profileAcceleration)
|
||||
// [ValueSource(typeof(TestData), nameof(TestData.NvidiaCodecs))] string profileCodec,
|
||||
// [ValueSource(typeof(TestData), nameof(TestData.NvidiaAcceleration))] HardwareAccelerationKind profileAcceleration)
|
||||
[ValueSource(typeof(TestData), nameof(TestData.NvidiaCodecs))] string profileCodec,
|
||||
[ValueSource(typeof(TestData), nameof(TestData.NvidiaAcceleration))] HardwareAccelerationKind profileAcceleration)
|
||||
// [ValueSource(typeof(TestData), nameof(TestData.VaapiCodecs))] string profileCodec,
|
||||
// [ValueSource(typeof(TestData), nameof(TestData.VaapiAcceleration))] HardwareAccelerationKind profileAcceleration)
|
||||
[ValueSource(typeof(TestData), nameof(TestData.VideoToolboxCodecs))] string profileCodec,
|
||||
[ValueSource(typeof(TestData), nameof(TestData.VideoToolboxAcceleration))] HardwareAccelerationKind profileAcceleration)
|
||||
// [ValueSource(typeof(TestData), nameof(TestData.VideoToolboxCodecs))] string profileCodec,
|
||||
// [ValueSource(typeof(TestData), nameof(TestData.VideoToolboxAcceleration))] HardwareAccelerationKind profileAcceleration)
|
||||
{
|
||||
string name = GetStringSha256Hash(
|
||||
$"{inputCodec}_{inputPixelFormat}_{pad}_{profileResolution}_{profileCodec}_{profileAcceleration}");
|
||||
$"{inputCodec}_{inputPixelFormat}_{videoScanKind}_{padding}_{profileResolution}_{profileCodec}_{profileAcceleration}");
|
||||
|
||||
string file = Path.Combine(TestContext.CurrentContext.TestDirectory, $"{name}.mkv");
|
||||
if (!File.Exists(file))
|
||||
{
|
||||
string resolution = pad ? "1920x1060" : "1920x1080";
|
||||
string resolution = padding == Padding.WithPadding ? "1920x1060" : "1920x1080";
|
||||
|
||||
string videoFilter = videoScanKind == VideoScanKind.Interlaced ? "-vf tinterlace=interleave_top,fieldorder=tff" : string.Empty;
|
||||
string flags = videoScanKind == VideoScanKind.Interlaced ? "-flags +ildct+ilme" : string.Empty;
|
||||
|
||||
var args =
|
||||
$"-y -f lavfi -i anullsrc=channel_layout=stereo:sample_rate=44100 -f lavfi -i testsrc=duration=1:size={resolution}:rate=30 -c:a aac -c:v {inputCodec} -shortest -pix_fmt {inputPixelFormat} -strict -2 {file}";
|
||||
string args =
|
||||
$"-y -f lavfi -i anoisesrc=color=brown -f lavfi -i testsrc=duration=1:size={resolution}:rate=30 {videoFilter} -c:a aac -c:v {inputCodec} -shortest -pix_fmt {inputPixelFormat} -strict -2 {flags} {file}";
|
||||
var p1 = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
@@ -215,7 +238,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
FillerKind.None,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.FromSeconds(5),
|
||||
0);
|
||||
0,
|
||||
None);
|
||||
|
||||
process.StartInfo.RedirectStandardError = true;
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using FluentAssertions;
|
||||
using NUnit.Framework;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.FFmpeg;
|
||||
|
||||
[TestFixture]
|
||||
public class WatermarkCalculatorTests
|
||||
{
|
||||
[Test]
|
||||
public void EntireVideoBetweenWatermarks_ShouldReturn_EmptyFadePointList()
|
||||
{
|
||||
List<FadePoint> actual = WatermarkCalculator.CalculateFadePoints(
|
||||
new DateTimeOffset(2022, 01, 31, 13, 34, 00, TimeSpan.FromHours(-5)),
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.FromMinutes(5),
|
||||
None,
|
||||
15,
|
||||
10);
|
||||
|
||||
actual.Should().HaveCount(0);
|
||||
}
|
||||
}
|
||||
@@ -37,5 +37,6 @@ namespace ErsatzTV.Core.Tests.Fakes
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<bool> IsCustomPlaybackOrder(int collectionId) => false.AsTask();
|
||||
public Task<Option<string>> GetNameFromKey(CollectionKey emptyCollection) => Option<string>.None.AsTask();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,24 +81,6 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
private Mock<ILocalMetadataProvider> _localMetadataProvider;
|
||||
private Mock<IImageCache> _imageCache;
|
||||
|
||||
[Test]
|
||||
public async Task Missing_Folder()
|
||||
{
|
||||
MovieFolderScanner service = GetService(
|
||||
new FakeFileEntry(Path.Combine(FakeRoot, Path.Combine("Movie (2020)", "Movie (2020).mkv")))
|
||||
);
|
||||
var libraryPath = new LibraryPath { Path = BadFakeRoot, LibraryFolders = new List<LibraryFolder>() };
|
||||
|
||||
Either<BaseError, Unit> result = await service.ScanFolder(
|
||||
libraryPath,
|
||||
FFprobePath,
|
||||
0,
|
||||
1);
|
||||
|
||||
result.IsLeft.Should().BeTrue();
|
||||
result.IfLeft(error => error.Should().BeOfType<MediaSourceInaccessible>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task NewMovie_Statistics_And_FallbackMetadata(
|
||||
[ValueSource(typeof(LocalFolderScanner), nameof(LocalFolderScanner.VideoFileExtensions))]
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
public static ConfigElementKey FFmpegGlobalFallbackFillerId => new("ffmpeg.global_fallback_filler_id");
|
||||
public static ConfigElementKey FFmpegSegmenterTimeout => new("ffmpeg.segmenter.timeout_seconds");
|
||||
public static ConfigElementKey FFmpegWorkAheadSegmenters => new("ffmpeg.segmenter.work_ahead_limit");
|
||||
public static ConfigElementKey FFmpegInitialSegmentCount => new("ffmpeg.segmenter.initial_segment_count");
|
||||
public static ConfigElementKey SearchIndexVersion => new("search_index.version");
|
||||
public static ConfigElementKey HDHRTunerCount => new("hdhr.tuner_count");
|
||||
public static ConfigElementKey ChannelsPageSize => new("pages.channels.page_size");
|
||||
|
||||
@@ -24,6 +24,7 @@ namespace ErsatzTV.Core.Domain
|
||||
public int AudioChannels { get; set; }
|
||||
public int AudioSampleRate { get; set; }
|
||||
public bool NormalizeAudio { get; set; }
|
||||
public bool NormalizeFramerate { get; set; }
|
||||
|
||||
public static FFmpegProfile New(string name, Resolution resolution) =>
|
||||
new()
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
namespace ErsatzTV.Core.Errors
|
||||
{
|
||||
public class MediaSourceInaccessible : BaseError
|
||||
{
|
||||
public MediaSourceInaccessible()
|
||||
: base("Media source is not accessible or missing")
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,8 +22,10 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
private IDisplaySize _resolution;
|
||||
private Option<IDisplaySize> _scaleToSize = None;
|
||||
private Option<ChannelWatermark> _watermark;
|
||||
private Option<List<FadePoint>> _maybeFadePoints = None;
|
||||
private Option<int> _watermarkIndex;
|
||||
private string _pixelFormat;
|
||||
private string _videoDecoder;
|
||||
private string _videoEncoder;
|
||||
private Option<string> _subtitle;
|
||||
private bool _boxBlur;
|
||||
@@ -74,6 +76,12 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegComplexFilterBuilder WithDecoder(string decoder)
|
||||
{
|
||||
_videoDecoder = decoder;
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegComplexFilterBuilder WithInputPixelFormat(Option<string> maybePixelFormat)
|
||||
{
|
||||
foreach (string pixelFormat in maybePixelFormat)
|
||||
@@ -86,10 +94,12 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
|
||||
public FFmpegComplexFilterBuilder WithWatermark(
|
||||
Option<ChannelWatermark> watermark,
|
||||
Option<List<FadePoint>> maybeFadePoints,
|
||||
IDisplaySize resolution,
|
||||
Option<int> watermarkIndex)
|
||||
{
|
||||
_watermark = watermark;
|
||||
_maybeFadePoints = maybeFadePoints;
|
||||
_resolution = resolution;
|
||||
_watermarkIndex = watermarkIndex;
|
||||
return this;
|
||||
@@ -133,29 +143,45 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
|
||||
public Option<FFmpegComplexFilter> Build(bool videoOnly, int videoInput, int videoStreamIndex, int audioInput, Option<int> audioStreamIndex, bool isSong)
|
||||
{
|
||||
// since .Contains is used on pixel format, we need it to be not null
|
||||
_pixelFormat ??= string.Empty;
|
||||
|
||||
var complexFilter = new StringBuilder();
|
||||
|
||||
var videoLabel = $"{videoInput}:{(isSong ? "v" : videoStreamIndex.ToString())}";
|
||||
string videoLabel = $"{videoInput}:{(isSong ? "v" : videoStreamIndex.ToString())}";
|
||||
string audioLabel = audioStreamIndex.Match(index => $"{audioInput}:{index}", () => "0:a");
|
||||
|
||||
HardwareAccelerationKind acceleration = _hardwareAccelerationKind.IfNone(HardwareAccelerationKind.None);
|
||||
|
||||
bool isHardwareDecode = acceleration switch
|
||||
{
|
||||
HardwareAccelerationKind.Vaapi => !isSong && _inputCodec != "mpeg4",
|
||||
|
||||
HardwareAccelerationKind.Vaapi => !isSong && _inputCodec != "mpeg4" &&
|
||||
(_deinterlace == false || !_pixelFormat.Contains("p10le")),
|
||||
|
||||
// we need an initial hwupload_cuda when only padding with these pixel formats
|
||||
HardwareAccelerationKind.Nvenc when _scaleToSize.IsNone && _padToSize.IsSome =>
|
||||
!isSong && !_pixelFormat.Contains("p10le") && !_pixelFormat.Contains("444"),
|
||||
|
||||
HardwareAccelerationKind.Nvenc => !isSong,
|
||||
HardwareAccelerationKind.Nvenc => !isSong &&
|
||||
(string.IsNullOrWhiteSpace(_videoDecoder) ||
|
||||
_videoDecoder.Contains("cuvid")),
|
||||
HardwareAccelerationKind.Qsv => !isSong,
|
||||
HardwareAccelerationKind.VideoToolbox => false,
|
||||
_ => false
|
||||
};
|
||||
|
||||
bool nvencDeinterlace = acceleration == HardwareAccelerationKind.Nvenc && _videoDecoder == "mpeg2_cuvid" &&
|
||||
_deinterlace;
|
||||
// mpeg2_cuvid will handle deinterlace and is "not" a hardware decode
|
||||
if (nvencDeinterlace)
|
||||
{
|
||||
_deinterlace = false;
|
||||
isHardwareDecode = false;
|
||||
}
|
||||
|
||||
var audioFilterQueue = new List<string>();
|
||||
var videoFilterQueue = new List<string>();
|
||||
string watermarkPreprocess = string.Empty;
|
||||
var watermarkPreprocess = new List<string>();
|
||||
string watermarkOverlay = string.Empty;
|
||||
|
||||
if (_normalizeLoudness)
|
||||
@@ -209,15 +235,17 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
|
||||
if (_deinterlace)
|
||||
{
|
||||
string filter = acceleration switch
|
||||
Option<string> maybeFilter = acceleration switch
|
||||
{
|
||||
HardwareAccelerationKind.Qsv => "deinterlace_qsv",
|
||||
HardwareAccelerationKind.Nvenc when !usesHardwareFilters && _pixelFormat.Contains("p10le") =>
|
||||
"hwupload_cuda,yadif_cuda",
|
||||
HardwareAccelerationKind.Nvenc => "yadif_cuda",
|
||||
HardwareAccelerationKind.Vaapi => "deinterlace_vaapi",
|
||||
_ => "yadif=1"
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter))
|
||||
foreach (string filter in maybeFilter)
|
||||
{
|
||||
videoFilterQueue.Add(filter);
|
||||
}
|
||||
@@ -225,9 +253,9 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
|
||||
string[] h264hevc = { "h264", "hevc" };
|
||||
|
||||
if (acceleration == HardwareAccelerationKind.Vaapi && (_pixelFormat ?? string.Empty).EndsWith("p10le") &&
|
||||
h264hevc.Contains(_inputCodec)
|
||||
&& (_pixelFormat != "yuv420p10le" || _inputCodec != "hevc"))
|
||||
if (_deinterlace == false && acceleration == HardwareAccelerationKind.Vaapi &&
|
||||
(_pixelFormat ?? string.Empty).EndsWith("p10le") &&
|
||||
h264hevc.Contains(_inputCodec) && (_pixelFormat != "yuv420p10le" || _inputCodec != "hevc"))
|
||||
{
|
||||
videoFilterQueue.Add("format=p010le,format=nv12|vaapi,hwupload");
|
||||
}
|
||||
@@ -237,66 +265,65 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
videoFilterQueue.Add("format=nv12|vaapi,hwupload");
|
||||
}
|
||||
|
||||
_scaleToSize.IfSome(
|
||||
size =>
|
||||
{
|
||||
string filter = acceleration switch
|
||||
{
|
||||
HardwareAccelerationKind.Qsv => $"scale_qsv=w={size.Width}:h={size.Height}",
|
||||
HardwareAccelerationKind.Nvenc when _pixelFormat is "yuv420p10le" =>
|
||||
$"hwupload_cuda,scale_cuda={size.Width}:{size.Height}",
|
||||
HardwareAccelerationKind.Nvenc => $"scale_cuda={size.Width}:{size.Height}",
|
||||
HardwareAccelerationKind.Vaapi => $"scale_vaapi=format=nv12:w={size.Width}:h={size.Height}",
|
||||
_ when videoOnly => $"scale={size.Width}:{size.Height}:force_original_aspect_ratio=increase,crop={size.Width}:{size.Height}",
|
||||
_ => $"scale={size.Width}:{size.Height}:flags=fast_bilinear"
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter))
|
||||
{
|
||||
videoFilterQueue.Add(filter);
|
||||
}
|
||||
});
|
||||
|
||||
bool scaleOrPad = _scaleToSize.IsSome || _padToSize.IsSome;
|
||||
bool usesSoftwareFilters = _padToSize.IsSome || _watermark.IsSome;
|
||||
bool hasFadePoints = _maybeFadePoints.Map(fp => fp.Count).IfNone(0) > 0;
|
||||
|
||||
if (scaleOrPad && _boxBlur == false)
|
||||
{
|
||||
videoFilterQueue.Add("setsar=1");
|
||||
}
|
||||
|
||||
var softwareFilterQueue = new List<string>();
|
||||
if (usesSoftwareFilters)
|
||||
{
|
||||
if (acceleration != HardwareAccelerationKind.None && (isHardwareDecode || usesHardwareFilters))
|
||||
{
|
||||
videoFilterQueue.Add("hwdownload");
|
||||
string format = acceleration switch
|
||||
Option<string> maybeFormat = acceleration switch
|
||||
{
|
||||
HardwareAccelerationKind.Vaapi => "format=nv12|vaapi",
|
||||
HardwareAccelerationKind.Nvenc when _padToSize.IsNone || nvencDeinterlace => None,
|
||||
HardwareAccelerationKind.Nvenc when _pixelFormat == "yuv420p10le" =>
|
||||
"format=p010le,format=nv12",
|
||||
HardwareAccelerationKind.Qsv when isSong => "format=nv12,format=yuv420p",
|
||||
_ when isSong => "format=yuv420p",
|
||||
_ => "format=nv12"
|
||||
};
|
||||
videoFilterQueue.Add(format);
|
||||
|
||||
foreach (string format in maybeFormat)
|
||||
{
|
||||
softwareFilterQueue.Add("hwdownload");
|
||||
softwareFilterQueue.Add(format);
|
||||
}
|
||||
|
||||
if (nvencDeinterlace)
|
||||
{
|
||||
softwareFilterQueue.Add("hwdownload");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (_boxBlur)
|
||||
{
|
||||
videoFilterQueue.Add("boxblur=40");
|
||||
softwareFilterQueue.Add("boxblur=40");
|
||||
}
|
||||
|
||||
if (videoOnly)
|
||||
{
|
||||
videoFilterQueue.Add("deband");
|
||||
softwareFilterQueue.Add("deband");
|
||||
}
|
||||
|
||||
foreach (ChannelWatermark watermark in _watermark)
|
||||
{
|
||||
string enable = watermark.Mode == ChannelWatermarkMode.Intermittent
|
||||
? $":enable='lt(mod(mod(time(0),60*60),{watermark.FrequencyMinutes}*60),{watermark.DurationSeconds})'"
|
||||
: string.Empty;
|
||||
Option<string> maybeFormats = acceleration switch
|
||||
{
|
||||
// overlay_cuda only supports alpha with yuva420p
|
||||
HardwareAccelerationKind.Nvenc => "yuva420p",
|
||||
|
||||
_ when watermark.Opacity != 100 || hasFadePoints =>
|
||||
"yuva420p|yuva444p|yuva422p|rgba|abgr|bgra|gbrap|ya8",
|
||||
|
||||
_ => None
|
||||
};
|
||||
|
||||
foreach (string formats in maybeFormats)
|
||||
{
|
||||
watermarkPreprocess.Add($"format={formats}");
|
||||
}
|
||||
|
||||
double horizontalMargin = Math.Round(watermark.HorizontalMarginPercent / 100.0 * _resolution.Width);
|
||||
double verticalMargin = Math.Round(watermark.VerticalMarginPercent / 100.0 * _resolution.Height);
|
||||
@@ -313,49 +340,47 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
_ => $"x=W-w-{horizontalMargin}:y=H-h-{verticalMargin}"
|
||||
};
|
||||
|
||||
if (watermark.Opacity != 100)
|
||||
{
|
||||
double opacity = watermark.Opacity / 100.0;
|
||||
watermarkPreprocess.Add($"colorchannelmixer=aa={opacity:F2}");
|
||||
}
|
||||
|
||||
if (watermark.Size == ChannelWatermarkSize.Scaled)
|
||||
{
|
||||
double width = Math.Round(watermark.WidthPercent / 100.0 * _resolution.Width);
|
||||
watermarkPreprocess = $"scale={width}:-1";
|
||||
watermarkPreprocess.Add($"scale={width}:-1");
|
||||
}
|
||||
|
||||
if (watermark.Opacity != 100)
|
||||
|
||||
foreach (List<FadePoint> fadePoints in _maybeFadePoints)
|
||||
{
|
||||
const string FORMATS = "yuva420p|yuva444p|yuva422p|rgba|abgr|bgra|gbrap|ya8";
|
||||
string join = string.Empty;
|
||||
double opacity = watermark.Opacity / 100.0;
|
||||
if (!string.IsNullOrWhiteSpace(watermarkPreprocess))
|
||||
{
|
||||
join = ",";
|
||||
}
|
||||
|
||||
watermarkPreprocess = $"format={FORMATS},colorchannelmixer=aa={opacity:F2}{join}{watermarkPreprocess}";
|
||||
watermarkPreprocess.AddRange(fadePoints.Map(fp => fp.ToFilter()));
|
||||
}
|
||||
|
||||
watermarkOverlay = $"overlay={position}{enable}";
|
||||
if (acceleration == HardwareAccelerationKind.Nvenc)
|
||||
{
|
||||
watermarkPreprocess.Add("hwupload_cuda");
|
||||
}
|
||||
|
||||
watermarkOverlay = acceleration switch
|
||||
{
|
||||
HardwareAccelerationKind.Nvenc => $"overlay_cuda={position}",
|
||||
_ => $"overlay={position}"
|
||||
};
|
||||
|
||||
if (hasFadePoints && acceleration != HardwareAccelerationKind.Nvenc)
|
||||
{
|
||||
watermarkOverlay += "," + acceleration switch
|
||||
{
|
||||
HardwareAccelerationKind.Vaapi => "format=nv12|vaapi",
|
||||
_ when isSong => "format=yuv420p",
|
||||
_ => "format=nv12"
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_padToSize.IfSome(size => videoFilterQueue.Add($"pad={size.Width}:{size.Height}:(ow-iw)/2:(oh-ih)/2"));
|
||||
|
||||
foreach (string subtitle in _subtitle)
|
||||
{
|
||||
videoFilterQueue.Add(subtitle);
|
||||
}
|
||||
|
||||
string outputPixelFormat = null;
|
||||
|
||||
if (usesSoftwareFilters && acceleration != HardwareAccelerationKind.None &&
|
||||
string.IsNullOrWhiteSpace(watermarkOverlay))
|
||||
{
|
||||
string upload = acceleration switch
|
||||
{
|
||||
HardwareAccelerationKind.Qsv => "hwupload=extra_hw_frames=64",
|
||||
_ => "hwupload"
|
||||
};
|
||||
videoFilterQueue.Add(upload);
|
||||
}
|
||||
|
||||
if (!usesSoftwareFilters && string.IsNullOrWhiteSpace(watermarkOverlay))
|
||||
{
|
||||
switch (acceleration, _videoEncoder, _pixelFormat)
|
||||
@@ -369,6 +394,90 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
}
|
||||
}
|
||||
|
||||
string outputFormat = (_videoEncoder, _pixelFormat) switch
|
||||
{
|
||||
("hevc_nvenc", "yuv420p10le") => "p010le",
|
||||
("h264_nvenc", "yuv420p10le") => "p010le",
|
||||
_ => null
|
||||
};
|
||||
|
||||
_scaleToSize.IfSome(
|
||||
size =>
|
||||
{
|
||||
string filter = acceleration switch
|
||||
{
|
||||
HardwareAccelerationKind.Qsv => $"scale_qsv=w={size.Width}:h={size.Height}",
|
||||
HardwareAccelerationKind.Nvenc when _watermark.IsSome && _scaleToSize.IsNone =>
|
||||
$"format=yuv420p,hwupload_cuda,scale_cuda={size.Width}:{size.Height}",
|
||||
HardwareAccelerationKind.Nvenc when _watermark.IsSome && _padToSize.IsNone =>
|
||||
$"scale_cuda={size.Width}:{size.Height}",
|
||||
HardwareAccelerationKind.Nvenc when _watermark.IsNone && !string.IsNullOrEmpty(outputFormat) =>
|
||||
$"scale_cuda={size.Width}:{size.Height}:format={outputFormat}",
|
||||
HardwareAccelerationKind.Nvenc when _pixelFormat is "yuv420p10le" && usesHardwareFilters == false =>
|
||||
$"hwupload_cuda,scale_cuda={size.Width}:{size.Height}",
|
||||
HardwareAccelerationKind.Nvenc => $"scale_cuda={size.Width}:{size.Height}",
|
||||
HardwareAccelerationKind.Vaapi => $"scale_vaapi=format=nv12:w={size.Width}:h={size.Height}",
|
||||
_ when videoOnly => $"scale={size.Width}:{size.Height}:force_original_aspect_ratio=increase,crop={size.Width}:{size.Height}",
|
||||
_ => $"scale={size.Width}:{size.Height}:flags=fast_bilinear"
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter))
|
||||
{
|
||||
videoFilterQueue.Add(filter);
|
||||
}
|
||||
});
|
||||
|
||||
if (scaleOrPad && _boxBlur == false)
|
||||
{
|
||||
if (acceleration == HardwareAccelerationKind.Nvenc)
|
||||
{
|
||||
if (!isHardwareDecode && !string.IsNullOrWhiteSpace(outputPixelFormat))
|
||||
{
|
||||
videoFilterQueue.Add($"hwdownload,format={outputPixelFormat}");
|
||||
}
|
||||
}
|
||||
|
||||
videoFilterQueue.Add("setsar=1");
|
||||
}
|
||||
|
||||
videoFilterQueue.AddRange(softwareFilterQueue);
|
||||
|
||||
_padToSize.IfSome(size => videoFilterQueue.Add($"pad={size.Width}:{size.Height}:(ow-iw)/2:(oh-ih)/2"));
|
||||
|
||||
if (acceleration == HardwareAccelerationKind.Nvenc && _watermark.IsSome)
|
||||
{
|
||||
if (_scaleToSize.IsSome)
|
||||
{
|
||||
videoFilterQueue.Add("hwdownload,format=nv12,format=yuv420p");
|
||||
videoFilterQueue.Add("hwupload_cuda");
|
||||
}
|
||||
else if (_padToSize.IsNone)
|
||||
{
|
||||
videoFilterQueue.Add("scale_cuda=format=yuv420p");
|
||||
}
|
||||
else
|
||||
{
|
||||
videoFilterQueue.Add("format=yuv420p");
|
||||
videoFilterQueue.Add("hwupload_cuda");
|
||||
}
|
||||
}
|
||||
|
||||
foreach (string subtitle in _subtitle)
|
||||
{
|
||||
videoFilterQueue.Add(subtitle);
|
||||
}
|
||||
|
||||
if (usesSoftwareFilters && acceleration != HardwareAccelerationKind.None &&
|
||||
string.IsNullOrWhiteSpace(watermarkOverlay))
|
||||
{
|
||||
string upload = acceleration switch
|
||||
{
|
||||
HardwareAccelerationKind.Qsv => "hwupload=extra_hw_frames=64",
|
||||
_ => "hwupload"
|
||||
};
|
||||
videoFilterQueue.Add(upload);
|
||||
}
|
||||
|
||||
bool hasAudioFilters = audioFilterQueue.Any();
|
||||
if (hasAudioFilters)
|
||||
{
|
||||
@@ -412,9 +521,10 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
watermarkLabel = $"[{audioInput+1}:{index}]";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(watermarkPreprocess))
|
||||
if (watermarkPreprocess.Count > 0)
|
||||
{
|
||||
complexFilter.Append($"{watermarkLabel}{watermarkPreprocess}[wmp];");
|
||||
var joined = string.Join(",", watermarkPreprocess);
|
||||
complexFilter.Append($"{watermarkLabel}{joined}[wmp];");
|
||||
watermarkLabel = "[wmp]";
|
||||
}
|
||||
|
||||
@@ -430,6 +540,9 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
case (true, HardwareAccelerationKind.Nvenc):
|
||||
complexFilter.Append(",hwupload_cuda");
|
||||
break;
|
||||
// no need to upload since we're already in the GPU with overlay_cuda
|
||||
case (_, HardwareAccelerationKind.Nvenc) when scaleOrPad == false && _watermark.IsSome:
|
||||
break;
|
||||
case (_, HardwareAccelerationKind.Qsv):
|
||||
complexFilter.Append(",format=yuv420p,hwupload=extra_hw_frames=64");
|
||||
break;
|
||||
|
||||
@@ -28,5 +28,6 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
public bool Deinterlace { get; set; }
|
||||
public Option<int> VideoTrackTimeScale { get; set; }
|
||||
public bool NormalizeLoudness { get; set; }
|
||||
public Option<int> FrameRate { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +58,8 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
DateTimeOffset now,
|
||||
TimeSpan inPoint,
|
||||
TimeSpan outPoint,
|
||||
bool hlsRealtime)
|
||||
bool hlsRealtime,
|
||||
Option<int> targetFramerate)
|
||||
{
|
||||
var result = new FFmpegPlaybackSettings
|
||||
{
|
||||
@@ -113,6 +114,11 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
|
||||
if (ffmpegProfile.Transcode && ffmpegProfile.NormalizeVideo)
|
||||
{
|
||||
if (ffmpegProfile.NormalizeFramerate)
|
||||
{
|
||||
result.FrameRate = targetFramerate;
|
||||
}
|
||||
|
||||
result.VideoTrackTimeScale = 90000;
|
||||
}
|
||||
|
||||
|
||||
@@ -214,30 +214,62 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithFrameRate(Option<int> frameRate)
|
||||
{
|
||||
foreach (int fr in frameRate)
|
||||
{
|
||||
_arguments.Add("-r");
|
||||
_arguments.Add($"{fr}");
|
||||
|
||||
_arguments.Add("-vsync");
|
||||
_arguments.Add("1");
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithWatermark(
|
||||
Option<WatermarkOptions> watermarkOptions,
|
||||
Option<List<FadePoint>> maybeFadePoints,
|
||||
IDisplaySize resolution)
|
||||
{
|
||||
foreach (WatermarkOptions options in watermarkOptions)
|
||||
ChannelWatermarkMode maybeWatermarkMode = watermarkOptions.Map(wmo => wmo.Watermark.Map(wm => wm.Mode)).Flatten()
|
||||
.IfNone(ChannelWatermarkMode.None);
|
||||
|
||||
// skip watermark if intermittent and no fade points
|
||||
if (maybeWatermarkMode != ChannelWatermarkMode.None &&
|
||||
(maybeWatermarkMode != ChannelWatermarkMode.Intermittent ||
|
||||
maybeFadePoints.Map(fp => fp.Count > 0).IfNone(false)))
|
||||
{
|
||||
foreach (string path in options.ImagePath)
|
||||
foreach (WatermarkOptions options in watermarkOptions)
|
||||
{
|
||||
if (options.IsAnimated)
|
||||
foreach (string path in options.ImagePath)
|
||||
{
|
||||
_arguments.Add("-ignore_loop");
|
||||
_arguments.Add("0");
|
||||
if (options.IsAnimated)
|
||||
{
|
||||
_arguments.Add("-ignore_loop");
|
||||
_arguments.Add("0");
|
||||
}
|
||||
|
||||
// when we have fade points, we need to loop the static watermark image
|
||||
else if (maybeFadePoints.Map(fp => fp.Count).IfNone(0) > 0)
|
||||
{
|
||||
_arguments.Add("-stream_loop");
|
||||
_arguments.Add("-1");
|
||||
}
|
||||
|
||||
_arguments.Add("-i");
|
||||
_arguments.Add(path);
|
||||
|
||||
_complexFilterBuilder = _complexFilterBuilder.WithWatermark(
|
||||
options.Watermark,
|
||||
maybeFadePoints,
|
||||
resolution,
|
||||
options.ImageStreamIndex);
|
||||
}
|
||||
|
||||
_arguments.Add("-i");
|
||||
_arguments.Add(path);
|
||||
|
||||
_complexFilterBuilder = _complexFilterBuilder.WithWatermark(
|
||||
options.Watermark,
|
||||
resolution,
|
||||
options.ImageStreamIndex);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -254,7 +286,8 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
string audioPath,
|
||||
string decoder,
|
||||
Option<string> codec,
|
||||
Option<string> pixelFormat)
|
||||
Option<string> pixelFormat,
|
||||
bool deinterlace)
|
||||
{
|
||||
if (audioPath == videoPath)
|
||||
{
|
||||
@@ -274,6 +307,15 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
{
|
||||
_arguments.Add("-c:v");
|
||||
_arguments.Add(decoder);
|
||||
|
||||
if (decoder == "mpeg2_cuvid" && deinterlace)
|
||||
{
|
||||
_arguments.Add("-deint");
|
||||
_arguments.Add("2");
|
||||
}
|
||||
|
||||
_complexFilterBuilder = _complexFilterBuilder
|
||||
.WithDecoder(decoder);
|
||||
}
|
||||
|
||||
_complexFilterBuilder = _complexFilterBuilder
|
||||
@@ -388,30 +430,15 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithHls(string channelNumber, Option<MediaVersion> mediaVersion, long ptsOffset, Option<int> maybeTimeScale)
|
||||
public FFmpegProcessBuilder WithHls(
|
||||
string channelNumber,
|
||||
Option<MediaVersion> mediaVersion,
|
||||
long ptsOffset,
|
||||
Option<int> maybeTimeScale,
|
||||
Option<int> maybeFrameRate)
|
||||
{
|
||||
const int SEGMENT_SECONDS = 4;
|
||||
|
||||
var frameRate = 24;
|
||||
|
||||
foreach (MediaVersion version in mediaVersion)
|
||||
{
|
||||
if (!int.TryParse(version.RFrameRate, out int fr))
|
||||
{
|
||||
string[] split = (version.RFrameRate ?? string.Empty).Split("/");
|
||||
if (int.TryParse(split[0], out int left) && int.TryParse(split[1], out int right))
|
||||
{
|
||||
fr = (int)Math.Round(left / (double)right);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("Unable to detect framerate, using {FrameRate}", 24);
|
||||
fr = 24;
|
||||
}
|
||||
}
|
||||
|
||||
frameRate = fr;
|
||||
}
|
||||
int frameRate = maybeFrameRate.IfNone(GetFrameRateFromMediaVersion(mediaVersion));
|
||||
|
||||
foreach (int timescale in maybeTimeScale)
|
||||
{
|
||||
@@ -616,14 +643,14 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
}
|
||||
});
|
||||
|
||||
_arguments.Add("-map");
|
||||
_arguments.Add(videoLabel);
|
||||
|
||||
foreach (string _ in audioPath)
|
||||
{
|
||||
_arguments.Add("-map");
|
||||
_arguments.Add(audioLabel);
|
||||
}
|
||||
|
||||
_arguments.Add("-map");
|
||||
_arguments.Add(videoLabel);
|
||||
|
||||
return this;
|
||||
}
|
||||
@@ -692,5 +719,31 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
StartInfo = startInfo
|
||||
};
|
||||
}
|
||||
|
||||
private int GetFrameRateFromMediaVersion(Option<MediaVersion> mediaVersion)
|
||||
{
|
||||
var frameRate = 24;
|
||||
|
||||
foreach (MediaVersion version in mediaVersion)
|
||||
{
|
||||
if (!int.TryParse(version.RFrameRate, out int fr))
|
||||
{
|
||||
string[] split = (version.RFrameRate ?? string.Empty).Split("/");
|
||||
if (int.TryParse(split[0], out int left) && int.TryParse(split[1], out int right))
|
||||
{
|
||||
fr = (int)Math.Round(left / (double)right);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("Unable to detect framerate, using {FrameRate}", 24);
|
||||
fr = 24;
|
||||
}
|
||||
}
|
||||
|
||||
frameRate = fr;
|
||||
}
|
||||
|
||||
return frameRate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
@@ -52,7 +53,8 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
FillerKind fillerKind,
|
||||
TimeSpan inPoint,
|
||||
TimeSpan outPoint,
|
||||
long ptsOffset)
|
||||
long ptsOffset,
|
||||
Option<int> targetFramerate)
|
||||
{
|
||||
MediaStream videoStream = await _ffmpegStreamSelector.SelectVideoStream(channel, videoVersion);
|
||||
Option<MediaStream> maybeAudioStream = await _ffmpegStreamSelector.SelectAudioStream(channel, audioVersion);
|
||||
@@ -67,11 +69,34 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
now,
|
||||
inPoint,
|
||||
outPoint,
|
||||
hlsRealtime);
|
||||
hlsRealtime,
|
||||
targetFramerate);
|
||||
|
||||
Option<WatermarkOptions> watermarkOptions =
|
||||
await GetWatermarkOptions(channel, globalWatermark, videoVersion, None, None);
|
||||
|
||||
Option<List<FadePoint>> maybeFadePoints = watermarkOptions
|
||||
.Map(o => o.Watermark)
|
||||
.Flatten()
|
||||
.Where(wm => wm.Mode == ChannelWatermarkMode.Intermittent)
|
||||
.Map(
|
||||
wm =>
|
||||
WatermarkCalculator.CalculateFadePoints(
|
||||
start,
|
||||
inPoint,
|
||||
outPoint,
|
||||
playbackSettings.StreamSeek,
|
||||
wm.FrequencyMinutes,
|
||||
wm.DurationSeconds));
|
||||
|
||||
// foreach (List<FadePoint> fadePoints in maybeFadePoints)
|
||||
// {
|
||||
// foreach (FadePoint fadePoint in fadePoints)
|
||||
// {
|
||||
// _logger.LogDebug("Fade point filter: {FadePointFilter}", fadePoint.ToFilter());
|
||||
// }
|
||||
// }
|
||||
|
||||
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath, saveReports, _logger)
|
||||
.WithThreads(playbackSettings.ThreadCount)
|
||||
.WithVaapiDriver(vaapiDriver, vaapiDevice)
|
||||
@@ -89,8 +114,10 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
audioPath,
|
||||
playbackSettings.VideoDecoder,
|
||||
videoStream.Codec,
|
||||
videoStream.PixelFormat)
|
||||
.WithWatermark(watermarkOptions, channel.FFmpegProfile.Resolution)
|
||||
videoStream.PixelFormat,
|
||||
playbackSettings.Deinterlace)
|
||||
.WithWatermark(watermarkOptions, maybeFadePoints, channel.FFmpegProfile.Resolution)
|
||||
.WithFrameRate(playbackSettings.FrameRate)
|
||||
.WithVideoTrackTimeScale(playbackSettings.VideoTrackTimeScale)
|
||||
.WithAlignedAudio(videoPath == audioPath ? playbackSettings.AudioDuration : Option<TimeSpan>.None)
|
||||
.WithNormalizeLoudness(playbackSettings.NormalizeLoudness);
|
||||
@@ -159,7 +186,12 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
{
|
||||
// HLS needs to segment and generate playlist
|
||||
case StreamingMode.HttpLiveStreamingSegmenter:
|
||||
return builder.WithHls(channel.Number, videoVersion, ptsOffset, playbackSettings.VideoTrackTimeScale)
|
||||
return builder.WithHls(
|
||||
channel.Number,
|
||||
videoVersion,
|
||||
ptsOffset,
|
||||
playbackSettings.VideoTrackTimeScale,
|
||||
playbackSettings.FrameRate)
|
||||
.Build();
|
||||
default:
|
||||
return builder.WithFormat("mpegts")
|
||||
@@ -223,7 +255,12 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
{
|
||||
// HLS needs to segment and generate playlist
|
||||
case StreamingMode.HttpLiveStreamingSegmenter:
|
||||
return builder.WithHls(channel.Number, None, ptsOffset, playbackSettings.VideoTrackTimeScale)
|
||||
return builder.WithHls(
|
||||
channel.Number,
|
||||
None,
|
||||
ptsOffset,
|
||||
playbackSettings.VideoTrackTimeScale,
|
||||
playbackSettings.FrameRate)
|
||||
.Build();
|
||||
default:
|
||||
return builder.WithFormat("mpegts")
|
||||
@@ -338,14 +375,15 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
DateTimeOffset.UnixEpoch,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.Zero,
|
||||
false);
|
||||
false,
|
||||
Option<int>.None);
|
||||
|
||||
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath, false, _logger)
|
||||
.WithThreads(1)
|
||||
.WithQuiet()
|
||||
.WithFormatFlags(playbackSettings.FormatFlags)
|
||||
.WithSongInput(videoPath, videoStream.Codec, videoStream.PixelFormat, boxBlur)
|
||||
.WithWatermark(watermarkOptions, channel.FFmpegProfile.Resolution)
|
||||
.WithWatermark(watermarkOptions, None, channel.FFmpegProfile.Resolution)
|
||||
.WithSubtitleFile(subtitleFile);
|
||||
|
||||
foreach (IDisplaySize scaledSize in scalePlaybackSettings.ScaledSize)
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
|
||||
namespace ErsatzTV.Core.FFmpeg;
|
||||
|
||||
public abstract record FadePoint(TimeSpan Time, string InOut)
|
||||
{
|
||||
public TimeSpan EnableStart { get; set; }
|
||||
public TimeSpan EnableFinish { get; set; }
|
||||
|
||||
public string ToFilter()
|
||||
{
|
||||
var startTime = Time.TotalSeconds.ToString(NumberFormatInfo.InvariantInfo);
|
||||
var enableStart = EnableStart.TotalSeconds.ToString(NumberFormatInfo.InvariantInfo);
|
||||
var enableFinish = EnableFinish.TotalSeconds.ToString(NumberFormatInfo.InvariantInfo);
|
||||
|
||||
return $"fade={InOut}:st={startTime}:d=1:alpha=1:enable='between(t,{enableStart},{enableFinish})'";
|
||||
}
|
||||
}
|
||||
|
||||
public record FadeInPoint(TimeSpan Time) : FadePoint(Time, "in");
|
||||
|
||||
public record FadeOutPoint(TimeSpan Time) : FadePoint(Time, "out");
|
||||
@@ -95,12 +95,13 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
i += 3;
|
||||
}
|
||||
|
||||
if (endWithDiscontinuity)
|
||||
var playlist = output.ToString();
|
||||
if (endWithDiscontinuity && !playlist.EndsWith($"#EXT-X-DISCONTINUITY{Environment.NewLine}"))
|
||||
{
|
||||
output.AppendLine("#EXT-X-DISCONTINUITY");
|
||||
playlist += "#EXT-X-DISCONTINUITY" + Environment.NewLine;
|
||||
}
|
||||
|
||||
return new TrimPlaylistResult(nextPlaylistStart, startSequence, output.ToString());
|
||||
return new TrimPlaylistResult(nextPlaylistStart, startSequence, playlist, segments);
|
||||
}
|
||||
|
||||
public static TrimPlaylistResult TrimPlaylistWithDiscontinuity(
|
||||
@@ -112,5 +113,5 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
}
|
||||
}
|
||||
|
||||
public record TrimPlaylistResult(DateTimeOffset PlaylistStart, int Sequence, string Playlist);
|
||||
public record TrimPlaylistResult(DateTimeOffset PlaylistStart, int Sequence, string Playlist, int SegmentCount);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Core.FFmpeg;
|
||||
|
||||
public static class WatermarkCalculator
|
||||
{
|
||||
public static List<FadePoint> CalculateFadePoints(
|
||||
DateTimeOffset itemStartTime,
|
||||
TimeSpan inPoint,
|
||||
TimeSpan outPoint,
|
||||
Option<TimeSpan> streamSeek,
|
||||
int frequencyMinutes,
|
||||
int durationSeconds)
|
||||
{
|
||||
var result = new List<FadePoint>();
|
||||
|
||||
TimeSpan duration = outPoint - inPoint;
|
||||
DateTimeOffset itemFinishTime = itemStartTime + duration;
|
||||
|
||||
DateTimeOffset start = itemStartTime.AddMinutes(-16);
|
||||
|
||||
// find the next whole minute
|
||||
if (start.Second > 0 || start.Millisecond > 0)
|
||||
{
|
||||
start = start.AddMinutes(1);
|
||||
start = start.AddSeconds(-start.Second);
|
||||
start = start.AddMilliseconds(-start.Millisecond);
|
||||
}
|
||||
|
||||
DateTimeOffset finish = itemFinishTime;
|
||||
|
||||
// find the previous whole minute
|
||||
if (finish.Second > 0 || finish.Millisecond > 0)
|
||||
{
|
||||
finish = finish.AddSeconds(-finish.Second);
|
||||
finish = finish.AddMilliseconds(-finish.Millisecond);
|
||||
}
|
||||
|
||||
DateTimeOffset current = start;
|
||||
while (current <= finish)
|
||||
{
|
||||
current = current.AddMinutes(1);
|
||||
if (current.Minute % frequencyMinutes == 0)
|
||||
{
|
||||
TimeSpan fadeInTime = inPoint + (current - itemStartTime);
|
||||
|
||||
result.Add(new FadeInPoint(fadeInTime));
|
||||
result.Add(new FadeOutPoint(fadeInTime.Add(TimeSpan.FromSeconds(durationSeconds))));
|
||||
}
|
||||
}
|
||||
|
||||
// if we're seeking, subtract the seek from each item and return that
|
||||
foreach (TimeSpan ss in streamSeek)
|
||||
{
|
||||
result = result.Map(fp => fp with { Time = fp.Time - ss }).ToList();
|
||||
}
|
||||
|
||||
// trim points that have already passed
|
||||
result.RemoveAll(fp => fp.Time < TimeSpan.Zero);
|
||||
|
||||
// trim points that are past the end
|
||||
result.RemoveAll(fp => fp.Time >= outPoint);
|
||||
|
||||
if (result.Any())
|
||||
{
|
||||
for (var i = 0; i < result.Count; i++)
|
||||
{
|
||||
result[i].EnableStart = i == 0 ? TimeSpan.Zero : result[i - 1].Time.Add(TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
for (var i = 0; i < result.Count; i++)
|
||||
{
|
||||
result[i].EnableFinish = i == result.Count - 1
|
||||
? outPoint
|
||||
: result[i + 1].Time.Subtract(TimeSpan.FromSeconds(1));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,8 @@ namespace ErsatzTV.Core.Interfaces.FFmpeg
|
||||
FillerKind fillerKind,
|
||||
TimeSpan inPoint,
|
||||
TimeSpan outPoint,
|
||||
long ptsOffset);
|
||||
long ptsOffset,
|
||||
Option<int> targetFramerate);
|
||||
|
||||
Task<Process> ForError(
|
||||
string ffmpegPath,
|
||||
|
||||
@@ -18,5 +18,6 @@ namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
Task<List<int>> PlayoutIdsUsingMultiCollection(int multiCollectionId);
|
||||
Task<List<int>> PlayoutIdsUsingSmartCollection(int smartCollectionId);
|
||||
Task<bool> IsCustomPlaybackOrder(int collectionId);
|
||||
Task<Option<string>> GetNameFromKey(CollectionKey emptyCollection);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Emby;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
using LanguageExt;
|
||||
using LanguageExt.UnsafeValueAccess;
|
||||
using Serilog;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
@@ -158,15 +157,20 @@ namespace ErsatzTV.Core.Iptv
|
||||
xml.WriteString(metadata.Year.Value.ToString());
|
||||
xml.WriteEndElement(); // date
|
||||
}
|
||||
}
|
||||
|
||||
xml.WriteStartElement("category");
|
||||
xml.WriteAttributeString("lang", "en");
|
||||
xml.WriteString("Movie");
|
||||
xml.WriteEndElement(); // category
|
||||
xml.WriteStartElement("category");
|
||||
xml.WriteAttributeString("lang", "en");
|
||||
xml.WriteString("Movie");
|
||||
xml.WriteEndElement(); // category
|
||||
|
||||
foreach (Genre genre in Optional(metadata.Genres).Flatten())
|
||||
{
|
||||
xml.WriteStartElement("category");
|
||||
xml.WriteAttributeString("lang", "en");
|
||||
xml.WriteString(genre.Name);
|
||||
xml.WriteEndElement(); // category
|
||||
}
|
||||
|
||||
foreach (MovieMetadata metadata in movie.MovieMetadata.HeadOrNone())
|
||||
{
|
||||
string poster = Optional(metadata.Artwork).Flatten()
|
||||
.Filter(a => a.ArtworkKind == ArtworkKind.Poster)
|
||||
.HeadOrNone()
|
||||
@@ -191,15 +195,35 @@ namespace ErsatzTV.Core.Iptv
|
||||
xml.WriteString(metadata.Year.Value.ToString());
|
||||
xml.WriteEndElement(); // date
|
||||
}
|
||||
}
|
||||
|
||||
xml.WriteStartElement("category");
|
||||
xml.WriteAttributeString("lang", "en");
|
||||
xml.WriteString("Music");
|
||||
xml.WriteEndElement(); // category
|
||||
xml.WriteStartElement("category");
|
||||
xml.WriteAttributeString("lang", "en");
|
||||
xml.WriteString("Music");
|
||||
xml.WriteEndElement(); // category
|
||||
|
||||
// music video genres
|
||||
foreach (Genre genre in Optional(metadata.Genres).Flatten())
|
||||
{
|
||||
xml.WriteStartElement("category");
|
||||
xml.WriteAttributeString("lang", "en");
|
||||
xml.WriteString(genre.Name);
|
||||
xml.WriteEndElement(); // category
|
||||
}
|
||||
|
||||
// artist genres
|
||||
Option<ArtistMetadata> maybeMetadata =
|
||||
Optional(musicVideo.Artist?.ArtistMetadata.HeadOrNone()).Flatten();
|
||||
foreach (ArtistMetadata artistMetadata in maybeMetadata)
|
||||
{
|
||||
foreach (Genre genre in Optional(artistMetadata.Genres).Flatten())
|
||||
{
|
||||
xml.WriteStartElement("category");
|
||||
xml.WriteAttributeString("lang", "en");
|
||||
xml.WriteString(genre.Name);
|
||||
xml.WriteEndElement(); // category
|
||||
}
|
||||
}
|
||||
|
||||
foreach (MusicVideoMetadata metadata in musicVideo.MusicVideoMetadata.HeadOrNone())
|
||||
{
|
||||
string thumbnail = Optional(metadata.Artwork).Flatten()
|
||||
.Filter(a => a.ArtworkKind == ArtworkKind.Thumbnail)
|
||||
.HeadOrNone()
|
||||
@@ -213,7 +237,7 @@ namespace ErsatzTV.Core.Iptv
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (!hasCustomTitle && displayItem.MediaItem is Song song)
|
||||
{
|
||||
xml.WriteStartElement("category");
|
||||
@@ -241,9 +265,21 @@ namespace ErsatzTV.Core.Iptv
|
||||
{
|
||||
Option<ShowMetadata> maybeMetadata =
|
||||
Optional(episode.Season?.Show?.ShowMetadata.HeadOrNone()).Flatten();
|
||||
if (maybeMetadata.IsSome)
|
||||
foreach (ShowMetadata metadata in maybeMetadata)
|
||||
{
|
||||
ShowMetadata metadata = maybeMetadata.ValueUnsafe();
|
||||
xml.WriteStartElement("category");
|
||||
xml.WriteAttributeString("lang", "en");
|
||||
xml.WriteString("Series");
|
||||
xml.WriteEndElement(); // category
|
||||
|
||||
foreach (Genre genre in Optional(metadata.Genres).Flatten())
|
||||
{
|
||||
xml.WriteStartElement("category");
|
||||
xml.WriteAttributeString("lang", "en");
|
||||
xml.WriteString(genre.Name);
|
||||
xml.WriteEndElement(); // category
|
||||
}
|
||||
|
||||
string artwork = Optional(metadata.Artwork).Flatten()
|
||||
.Filter(a => a.ArtworkKind == ArtworkKind.Thumbnail)
|
||||
.HeadOrNone()
|
||||
|
||||
@@ -23,7 +23,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(folder))
|
||||
if (folder != null && !Directory.Exists(folder))
|
||||
{
|
||||
Directory.CreateDirectory(folder);
|
||||
}
|
||||
@@ -42,11 +42,39 @@ namespace ErsatzTV.Core.Metadata
|
||||
public bool IsLibraryPathAccessible(LibraryPath libraryPath) =>
|
||||
Directory.Exists(libraryPath.Path);
|
||||
|
||||
public IEnumerable<string> ListSubdirectories(string folder) =>
|
||||
Try(Directory.EnumerateDirectories(folder)).IfFail(new List<string>());
|
||||
public IEnumerable<string> ListSubdirectories(string folder)
|
||||
{
|
||||
if (Directory.Exists(folder))
|
||||
{
|
||||
try
|
||||
{
|
||||
return Directory.EnumerateDirectories(folder);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
public IEnumerable<string> ListFiles(string folder) =>
|
||||
Try(Directory.EnumerateFiles(folder, "*", SearchOption.TopDirectoryOnly)).IfFail(new List<string>());
|
||||
return new List<string>();
|
||||
}
|
||||
|
||||
public IEnumerable<string> ListFiles(string folder)
|
||||
{
|
||||
if (Directory.Exists(folder))
|
||||
{
|
||||
try
|
||||
{
|
||||
return Directory.EnumerateFiles(folder, "*", SearchOption.TopDirectoryOnly);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
return new List<string>();
|
||||
}
|
||||
|
||||
public bool FileExists(string path) => File.Exists(path);
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
@@ -73,11 +72,6 @@ namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
decimal progressSpread = progressMax - progressMin;
|
||||
|
||||
if (!_localFileSystem.IsLibraryPathAccessible(libraryPath))
|
||||
{
|
||||
return new MediaSourceInaccessible();
|
||||
}
|
||||
|
||||
var foldersCompleted = 0;
|
||||
|
||||
var folderQueue = new Queue<string>();
|
||||
|
||||
@@ -4,7 +4,6 @@ using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
@@ -74,11 +73,6 @@ namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
decimal progressSpread = progressMax - progressMin;
|
||||
|
||||
if (!_localFileSystem.IsLibraryPathAccessible(libraryPath))
|
||||
{
|
||||
return new MediaSourceInaccessible();
|
||||
}
|
||||
|
||||
var allArtistFolders = _localFileSystem.ListSubdirectories(libraryPath.Path)
|
||||
.Filter(ShouldIncludeFolder)
|
||||
.OrderBy(identity)
|
||||
|
||||
@@ -4,7 +4,6 @@ using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
@@ -71,11 +70,6 @@ namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
decimal progressSpread = progressMax - progressMin;
|
||||
|
||||
if (!_localFileSystem.IsLibraryPathAccessible(libraryPath))
|
||||
{
|
||||
return new MediaSourceInaccessible();
|
||||
}
|
||||
|
||||
var foldersCompleted = 0;
|
||||
|
||||
var folderQueue = new Queue<string>();
|
||||
|
||||
@@ -4,7 +4,6 @@ using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
@@ -73,11 +72,6 @@ namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
decimal progressSpread = progressMax - progressMin;
|
||||
|
||||
if (!_localFileSystem.IsLibraryPathAccessible(libraryPath))
|
||||
{
|
||||
return new MediaSourceInaccessible();
|
||||
}
|
||||
|
||||
var foldersCompleted = 0;
|
||||
|
||||
var folderQueue = new Queue<string>();
|
||||
|
||||
@@ -4,7 +4,6 @@ using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
@@ -73,11 +72,6 @@ namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
decimal progressSpread = progressMax - progressMin;
|
||||
|
||||
if (!_localFileSystem.IsLibraryPathAccessible(libraryPath))
|
||||
{
|
||||
return new MediaSourceInaccessible();
|
||||
}
|
||||
|
||||
var allShowFolders = _localFileSystem.ListSubdirectories(libraryPath.Path)
|
||||
.Filter(ShouldIncludeFolder)
|
||||
.OrderBy(identity)
|
||||
|
||||
@@ -73,9 +73,23 @@ namespace ErsatzTV.Core.Scheduling
|
||||
Option<CollectionKey> maybeEmptyCollection = await CheckForEmptyCollections(collectionMediaItems);
|
||||
foreach (CollectionKey emptyCollection in maybeEmptyCollection)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Unable to rebuild playout; collection {@CollectionKey} has no valid items!",
|
||||
emptyCollection);
|
||||
Option<string> maybeName = await _mediaCollectionRepository.GetNameFromKey(emptyCollection);
|
||||
if (maybeName.IsSome)
|
||||
{
|
||||
foreach (string name in maybeName)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Unable to rebuild playout; {CollectionType} {CollectionName} has no valid items!",
|
||||
emptyCollection.CollectionType,
|
||||
name);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError(
|
||||
"Unable to rebuild playout; collection {@CollectionKey} has no valid items!",
|
||||
emptyCollection);
|
||||
}
|
||||
|
||||
return playout;
|
||||
}
|
||||
|
||||
@@ -70,18 +70,36 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.Include(c => c.Playouts)
|
||||
.ThenInclude(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Episode).Season)
|
||||
.ThenInclude(s => s.Show)
|
||||
.ThenInclude(s => s.ShowMetadata)
|
||||
.ThenInclude(em => em.Genres)
|
||||
.Include(c => c.Playouts)
|
||||
.ThenInclude(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Movie).MovieMetadata)
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
.Include(c => c.Playouts)
|
||||
.ThenInclude(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as Movie).MovieMetadata)
|
||||
.ThenInclude(mm => mm.Genres)
|
||||
.Include(c => c.Playouts)
|
||||
.ThenInclude(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
.Include(c => c.Playouts)
|
||||
.ThenInclude(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mvm => mvm.Genres)
|
||||
.Include(c => c.Playouts)
|
||||
.ThenInclude(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
.ThenInclude(i => (i as MusicVideo).Artist)
|
||||
.ThenInclude(a => a.ArtistMetadata)
|
||||
.ThenInclude(am => am.Genres)
|
||||
.Include(c => c.Playouts)
|
||||
.ThenInclude(p => p.Items)
|
||||
.ThenInclude(i => i.MediaItem)
|
||||
|
||||
@@ -355,6 +355,37 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
@"SELECT IFNULL(MIN(UseCustomPlaybackOrder), 0) FROM Collection WHERE Id = @CollectionId",
|
||||
new { CollectionId = collectionId });
|
||||
|
||||
public async Task<Option<string>> GetNameFromKey(CollectionKey emptyCollection)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
return emptyCollection.CollectionType switch
|
||||
{
|
||||
ProgramScheduleItemCollectionType.Artist => await dbContext.Artists.Include(a => a.ArtistMetadata)
|
||||
.SelectOneAsync(a => a.Id, a => a.Id == emptyCollection.MediaItemId.Value)
|
||||
.MapT(a => a.ArtistMetadata.Head().Title),
|
||||
ProgramScheduleItemCollectionType.Collection => await dbContext.Collections
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == emptyCollection.CollectionId.Value)
|
||||
.MapT(c => c.Name),
|
||||
ProgramScheduleItemCollectionType.MultiCollection => await dbContext.MultiCollections
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == emptyCollection.MultiCollectionId.Value)
|
||||
.MapT(c => c.Name),
|
||||
ProgramScheduleItemCollectionType.SmartCollection => await dbContext.SmartCollections
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == emptyCollection.SmartCollectionId.Value)
|
||||
.MapT(c => c.Name),
|
||||
ProgramScheduleItemCollectionType.TelevisionSeason => await dbContext.Seasons
|
||||
.Include(s => s.SeasonMetadata)
|
||||
.Include(s => s.Show)
|
||||
.ThenInclude(s => s.ShowMetadata)
|
||||
.SelectOneAsync(a => a.Id, a => a.Id == emptyCollection.MediaItemId.Value)
|
||||
.MapT(s => $"{s.Show.ShowMetadata.Head().Title} Season {s.SeasonNumber}"),
|
||||
ProgramScheduleItemCollectionType.TelevisionShow => await dbContext.Shows.Include(s => s.ShowMetadata)
|
||||
.SelectOneAsync(a => a.Id, a => a.Id == emptyCollection.MediaItemId.Value)
|
||||
.MapT(s => s.ShowMetadata.Head().Title),
|
||||
_ => None
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<List<Movie>> GetMovieItems(TvContext dbContext, int collectionId)
|
||||
{
|
||||
IEnumerable<int> ids = await _dbConnection.QueryAsync<int>(
|
||||
|
||||
@@ -159,7 +159,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
|
||||
public async Task<List<MusicVideoMetadata>> GetPagedMusicVideos(int artistId, int pageNumber, int pageSize)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
return await dbContext.MusicVideoMetadata
|
||||
.AsNoTracking()
|
||||
.Include(m => m.Artwork)
|
||||
|
||||
@@ -12,18 +12,19 @@
|
||||
<PackageReference Include="Lucene.Net" Version="4.8.0-beta00015" />
|
||||
<PackageReference Include="Lucene.Net.Analysis.Common" Version="4.8.0-beta00015" />
|
||||
<PackageReference Include="Lucene.Net.QueryParser" Version="4.8.0-beta00015" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="6.0.1" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.1">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="6.0.2" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.1" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.2" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.0.64">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Refit" Version="6.2.16" />
|
||||
<PackageReference Include="Refit.Newtonsoft.Json" Version="6.2.16" />
|
||||
<PackageReference Include="Refit" Version="6.3.2" />
|
||||
<PackageReference Include="Refit.Newtonsoft.Json" Version="6.3.2" />
|
||||
<PackageReference Include="Refit.Xml" Version="6.3.2" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="1.0.4" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
Generated
+3861
File diff suppressed because it is too large
Load Diff
+26
@@ -0,0 +1,26 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Add_FFmpegProfile_NormalizeFramerate : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "NormalizeFramerate",
|
||||
table: "FFmpegProfile",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: true);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "NormalizeFramerate",
|
||||
table: "FFmpegProfile");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -505,6 +505,9 @@ namespace ErsatzTV.Infrastructure.Migrations
|
||||
b.Property<bool>("NormalizeAudio")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("NormalizeFramerate")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("NormalizeLoudness")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
@@ -645,7 +648,7 @@ namespace ErsatzTV.Infrastructure.Migrations
|
||||
|
||||
b.HasIndex("SongMetadataId");
|
||||
|
||||
b.ToTable("Genre");
|
||||
b.ToTable("Genre", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ErsatzTV.Core.Domain.JellyfinConnection", b =>
|
||||
@@ -1052,7 +1055,7 @@ namespace ErsatzTV.Infrastructure.Migrations
|
||||
|
||||
b.HasIndex("ArtistMetadataId");
|
||||
|
||||
b.ToTable("Mood");
|
||||
b.ToTable("Mood", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ErsatzTV.Core.Domain.MovieMetadata", b =>
|
||||
@@ -1160,7 +1163,7 @@ namespace ErsatzTV.Infrastructure.Migrations
|
||||
|
||||
b.HasIndex("SmartCollectionId");
|
||||
|
||||
b.ToTable("MultiCollectionSmartItem");
|
||||
b.ToTable("MultiCollectionSmartItem", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ErsatzTV.Core.Domain.MusicVideoMetadata", b =>
|
||||
@@ -1768,7 +1771,7 @@ namespace ErsatzTV.Infrastructure.Migrations
|
||||
|
||||
b.HasIndex("ArtistMetadataId");
|
||||
|
||||
b.ToTable("Style");
|
||||
b.ToTable("Style", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ErsatzTV.Core.Domain.Tag", b =>
|
||||
@@ -1822,7 +1825,7 @@ namespace ErsatzTV.Infrastructure.Migrations
|
||||
|
||||
b.HasIndex("SongMetadataId");
|
||||
|
||||
b.ToTable("Tag");
|
||||
b.ToTable("Tag", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ErsatzTV.Core.Domain.TraktList", b =>
|
||||
@@ -2843,7 +2846,7 @@ namespace ErsatzTV.Infrastructure.Migrations
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.OwnsOne("ErsatzTV.Core.Domain.PlayoutAnchor", "Anchor", b1 =>
|
||||
b.OwnsOne("ErsatzTV.Core.Domain.Playout.Anchor#ErsatzTV.Core.Domain.PlayoutAnchor", "Anchor", b1 =>
|
||||
{
|
||||
b1.Property<int>("PlayoutId")
|
||||
.HasColumnType("INTEGER");
|
||||
@@ -2946,7 +2949,7 @@ namespace ErsatzTV.Infrastructure.Migrations
|
||||
.WithMany()
|
||||
.HasForeignKey("SmartCollectionId");
|
||||
|
||||
b.OwnsOne("ErsatzTV.Core.Domain.CollectionEnumeratorState", "EnumeratorState", b1 =>
|
||||
b.OwnsOne("ErsatzTV.Core.Domain.PlayoutProgramScheduleAnchor.EnumeratorState#ErsatzTV.Core.Domain.CollectionEnumeratorState", "EnumeratorState", b1 =>
|
||||
{
|
||||
b1.Property<int>("PlayoutProgramScheduleAnchorId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
@@ -34,7 +34,14 @@ namespace ErsatzTV.Controllers
|
||||
[FromQuery]
|
||||
string mode = "mixed") =>
|
||||
_mediator.Send(
|
||||
new GetPlayoutItemProcessByChannelNumber(channelNumber, mode, DateTimeOffset.Now, false, true, 0))
|
||||
new GetPlayoutItemProcessByChannelNumber(
|
||||
channelNumber,
|
||||
mode,
|
||||
DateTimeOffset.Now,
|
||||
false,
|
||||
true,
|
||||
0,
|
||||
Option<int>.None))
|
||||
.Map(
|
||||
result =>
|
||||
result.Match<IActionResult>(
|
||||
|
||||
@@ -92,6 +92,11 @@ namespace ErsatzTV.Controllers
|
||||
{
|
||||
string[] input = await System.IO.File.ReadAllLinesAsync(fileName);
|
||||
|
||||
// _logger.LogInformation(
|
||||
// "Trimming playlist: {PlaylistStart} {FilterBefore}",
|
||||
// worker.PlaylistStart,
|
||||
// now);
|
||||
|
||||
TrimPlaylistResult result = HlsPlaylistFilter.TrimPlaylist(worker.PlaylistStart, now, input);
|
||||
return Content(result.Playlist, "application/vnd.apple.mpegurl");
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Blazored.LocalStorage" Version="4.1.5" />
|
||||
<PackageReference Include="Blazored.LocalStorage" Version="4.2.0" />
|
||||
<PackageReference Include="FluentValidation" Version="10.3.6" />
|
||||
<PackageReference Include="FluentValidation.AspNetCore" Version="10.3.6" />
|
||||
<PackageReference Include="HtmlSanitizer" Version="7.1.475" />
|
||||
@@ -19,8 +19,8 @@
|
||||
<PackageReference Include="Markdig" Version="0.27.0" />
|
||||
<PackageReference Include="MediatR.Courier.DependencyInjection" Version="5.0.0" />
|
||||
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="6.0.1" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.1">
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="6.0.2" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
@@ -28,10 +28,10 @@
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="MudBlazor" Version="6.0.5" />
|
||||
<PackageReference Include="MudBlazor" Version="6.0.6" />
|
||||
<PackageReference Include="NaturalSort.Extension" Version="3.2.0" />
|
||||
<PackageReference Include="PPioli.FluentValidation.Blazor" Version="5.0.0" />
|
||||
<PackageReference Include="Refit.HttpClientFactory" Version="6.2.16" />
|
||||
<PackageReference Include="Refit.HttpClientFactory" Version="6.3.2" />
|
||||
<PackageReference Include="Serilog" Version="2.10.0" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="4.1.0" />
|
||||
<PackageReference Include="Serilog.Settings.Configuration" Version="3.3.0" />
|
||||
|
||||
@@ -43,7 +43,9 @@ namespace ErsatzTV.Extensions
|
||||
|
||||
public static EncodedQueryResult EncodeQuery(this string query)
|
||||
{
|
||||
string encoded = Uri.EscapeDataString(query);
|
||||
string normalizedQuery = Normalize(query);
|
||||
|
||||
string encoded = Uri.EscapeDataString(normalizedQuery);
|
||||
|
||||
// TODO: remove this on dotnet 6
|
||||
// see https://github.com/dotnet/aspnetcore/pull/26769
|
||||
@@ -54,5 +56,13 @@ namespace ErsatzTV.Extensions
|
||||
}
|
||||
|
||||
public record EncodedQueryResult(string Key, string Value);
|
||||
|
||||
private static string Normalize(string s)
|
||||
{
|
||||
// normalize single and double quotes
|
||||
return !string.IsNullOrEmpty(s)
|
||||
? s.Replace('\u2018', '\'').Replace('\u2019', '\'').Replace('\u201c', '\"').Replace('\u201d', '\"')
|
||||
: s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +81,9 @@
|
||||
<MudElement HtmlTag="div" Class="mt-3">
|
||||
<MudCheckBox Disabled="@(!_model.Transcode)" Label="Normalize Video" @bind-Checked="@_model.NormalizeVideo" For="@(() => _model.NormalizeVideo)"/>
|
||||
</MudElement>
|
||||
<MudElement HtmlTag="div" Class="mt-3">
|
||||
<MudCheckBox Disabled="@(!_model.Transcode)" Label="Normalize Frame Rate" @bind-Checked="@_model.NormalizeFramerate" For="@(() => _model.NormalizeFramerate)"/>
|
||||
</MudElement>
|
||||
</MudItem>
|
||||
<MudItem>
|
||||
<MudText Typo="Typo.h6">Audio</MudText>
|
||||
|
||||
@@ -188,7 +188,7 @@
|
||||
|
||||
private void AddLibraryPath()
|
||||
{
|
||||
if (_model.Paths.All(p => NormalizePath(p.Path) != NormalizePath(_newPath.Path)))
|
||||
if (!string.IsNullOrWhiteSpace(_newPath.Path) && _model.Paths.All(p => NormalizePath(p.Path) != NormalizePath(_newPath.Path)))
|
||||
{
|
||||
_model.HasChanges = true;
|
||||
_model.Paths.Add(new LocalLibraryPathEditViewModel
|
||||
|
||||
@@ -94,6 +94,14 @@
|
||||
Required="true"
|
||||
RequiredError="Work-ahead HLS Segmenter limit is required!"/>
|
||||
</MudElement>
|
||||
<MudElement HtmlTag="div" Class="mt-3">
|
||||
<MudTextField T="int"
|
||||
Label="HLS Segmenter Initial Segment Count"
|
||||
@bind-Value="_ffmpegSettings.InitialSegmentCount"
|
||||
Validation="@(new Func<int, string>(ValidateInitialSegmentCount))"
|
||||
Required="true"
|
||||
RequiredError="HLS Segmenter initial segment count is required!"/>
|
||||
</MudElement>
|
||||
</MudForm>
|
||||
</MudCardContent>
|
||||
<MudCardActions>
|
||||
@@ -206,6 +214,8 @@
|
||||
|
||||
private static string ValidateWorkAheadSegmenterLimit(int limit) => limit < 0 ? "Work-Ahead HLS Segmenter limit must be greater than or equal to 0" : null;
|
||||
|
||||
private static string ValidateInitialSegmentCount(int count) => count < 1 ? "HLS Segmenter initial segment count must be greater than or equal to 1" : null;
|
||||
|
||||
private async Task LoadFFmpegProfilesAsync() =>
|
||||
_ffmpegProfiles = await _mediator.Send(new GetAllFFmpegProfiles());
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ namespace ErsatzTV.ViewModels
|
||||
Name = viewModel.Name;
|
||||
NormalizeAudio = viewModel.NormalizeAudio;
|
||||
NormalizeVideo = viewModel.NormalizeVideo;
|
||||
NormalizeFramerate = viewModel.NormalizeFramerate;
|
||||
Resolution = viewModel.Resolution;
|
||||
ThreadCount = viewModel.ThreadCount;
|
||||
Transcode = viewModel.Transcode;
|
||||
@@ -45,6 +46,7 @@ namespace ErsatzTV.ViewModels
|
||||
public string Name { get; set; }
|
||||
public bool NormalizeAudio { get; set; }
|
||||
public bool NormalizeVideo { get; set; }
|
||||
public bool NormalizeFramerate { get; set; }
|
||||
public ResolutionViewModel Resolution { get; set; }
|
||||
public int ThreadCount { get; set; }
|
||||
public bool Transcode { get; set; }
|
||||
@@ -74,7 +76,8 @@ namespace ErsatzTV.ViewModels
|
||||
NormalizeLoudness,
|
||||
AudioChannels,
|
||||
AudioSampleRate,
|
||||
NormalizeAudio
|
||||
NormalizeAudio,
|
||||
NormalizeFramerate
|
||||
);
|
||||
|
||||
public UpdateFFmpegProfile ToUpdate() =>
|
||||
@@ -97,7 +100,8 @@ namespace ErsatzTV.ViewModels
|
||||
NormalizeLoudness,
|
||||
AudioChannels,
|
||||
AudioSampleRate,
|
||||
NormalizeAudio
|
||||
NormalizeAudio,
|
||||
NormalizeFramerate
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,3 +17,6 @@ cd "$REPO_ROOT" || exit
|
||||
cp -R "$REPO_ROOT/ErsatzTV-macOS/build/Release/ErsatzTV-macOS.app" "$APP_NAME"
|
||||
|
||||
cp -a "$PUBLISH_OUTPUT_DIRECTORY" "$APP_NAME/Contents/MacOS"
|
||||
|
||||
chmod +x "$APP_NAME/Contents/MacOS/ErsatzTV"
|
||||
chmod +x "$APP_NAME/Contents/MacOS/ErsatzTV-macOS"
|
||||
|
||||
Reference in New Issue
Block a user