Compare commits

...
Author SHA1 Message Date
Jason Dove ab1f294c1f update changelog for release v0.4.1-alpha 2022-02-10 20:49:26 -06:00
Jason DoveandGitHub ea08453913 vaapi improvements (#629)
* fix interlaced video with vaapi

* downgrade imagesharp to fix blurhash generation

* fix ui crash loading collection editor
2022-02-10 20:19:59 -06:00
Jason DoveandGitHub 87deaa6f3a nvidia improvements (#628) 2022-02-10 15:39:45 -06:00
Jason DoveandGitHub 9d99c19ea4 fix playback with unknown pixel format (#627) 2022-02-10 08:37:25 -06:00
Jason DoveandGitHub 49d14b05f6 update more dependencies (#626) 2022-02-09 11:08:49 -06:00
Jason DoveandGitHub a8ba9edf2b update dependencies (#624)
* update dependencies

* include refit xml serializer
2022-02-09 10:10:12 -06:00
Jason DoveandGitHub 89811a1203 wait for one segment by default (#617) 2022-02-07 12:44:12 -06:00
Jason DoveandGitHub 534e2c4512 add hls segmenter initial segment count (#616) 2022-02-07 12:18:58 -06:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
c1e148633d Bump Blazored.LocalStorage from 4.1.5 to 4.2.0 (#614)
Bumps [Blazored.LocalStorage](https://github.com/Blazored/LocalStorage) from 4.1.5 to 4.2.0.
- [Release notes](https://github.com/Blazored/LocalStorage/releases)
- [Commits](https://github.com/Blazored/LocalStorage/compare/v4.1.5...v4.2.0)

---
updated-dependencies:
- dependency-name: Blazored.LocalStorage
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-02-07 03:10:34 -06:00
Jason DoveandGitHub a9dff5eff7 properly flag local missing folders (#615) 2022-02-07 02:39:24 -06:00
Jason Dove a2da043f4b try to fix mac permission issues 2022-02-06 17:45:28 -06:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
252c185562 Bump MudBlazor from 6.0.5 to 6.0.6 (#609)
Bumps [MudBlazor](https://github.com/MudBlazor/MudBlazor) from 6.0.5 to 6.0.6.
- [Release notes](https://github.com/MudBlazor/MudBlazor/releases)
- [Changelog](https://github.com/MudBlazor/MudBlazor/blob/dev/CHANGELOG.md)
- [Commits](https://github.com/MudBlazor/MudBlazor/compare/v6.0.5...v6.0.6)

---
updated-dependencies:
- dependency-name: MudBlazor
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

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

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-02-05 18:20:45 -06:00
Jason DoveandGitHub a47987a9d7 revert windows trimming (#613)
* Revert "disable trimming in docker"

This reverts commit 5937211bb8.

* Revert "try to reduce windows artifact size"

This reverts commit e32dbd0474.
2022-02-05 18:09:02 -06:00
Jason Dove 5937211bb8 disable trimming in docker 2022-02-05 13:41:47 -06:00
Jason Dove e32dbd0474 try to reduce windows artifact size 2022-02-05 13:34:21 -06:00
Jason Dove 6bcc1ede2b try again 2022-02-05 11:25:14 -06:00
Jason Dove 6c9764a51e try a different method for downloading ffmpeg 2022-02-05 11:19:56 -06:00
Jason Dove ff5438459c try to bundle ffmpeg with windows artifacts 2022-02-05 11:09:01 -06:00
Jason DoveandGitHub 0c53a4509c show collection name in some error messages (#612) 2022-02-04 20:17:17 -06:00
Jason DoveandGitHub 5fd315ead8 change framerate normalization method (#611) 2022-02-04 14:39:27 -06:00
Jason DoveandGitHub f02b0ac345 re-introduce framerate normalization (#610) 2022-02-04 12:57:40 -06:00
Jason Dove fd83007296 try to fix watermark on vaapi 2022-02-01 17:50:47 -06:00
Jason DoveandGitHub 70ca5bf050 fix bug with watermark and short content (#608) 2022-01-31 14:07:12 -06:00
Jason DoveandGitHub eed9f60273 fade in and fade out intermittent watermarks (#607)
* first pass at fading in/out overlay

* fix tests

* update changelog
2022-01-31 12:31:56 -06:00
Jason Dove 0e2e6cd52e build linux-arm64 artifacts 2022-01-31 01:07:03 -06:00
Jason DoveandGitHub c9b557f2e6 more xmltv category improvements (#606) 2022-01-30 17:58:45 -06:00
Jason Dove cde869f3eb enable docker dependency scanning [no build] 2022-01-30 11:50:16 -06:00
Jason DoveandGitHub 90d6a59d3f normalize smart quotes in search queries (#605) 2022-01-30 11:09:32 -06:00
Jason DoveandGitHub b972947747 xmltv category improvements (#604) 2022-01-30 10:56:53 -06:00
Jason Dove 17bc988b49 update changelog for release v0.4.0-alpha [no ci] 2022-01-29 18:22:07 -06:00
Jason Dove 749eea836b update install docs for tray apps (win, mac) [no build] 2022-01-29 18:18:58 -06:00
Jason DoveandGitHub 37c52c4cb4 update docs and dependencies (#603) 2022-01-29 18:08:05 -06:00
Jason DoveandGitHub 33ba58aa68 add windows launcher (#602) 2022-01-29 17:58:11 -06:00
Jason DoveandGitHub 5f6043e593 index added date (#601) 2022-01-29 14:47:58 -06:00
Jason Dove 96e95a21fb update changelog [no ci] 2022-01-29 12:34:10 -06:00
Jason DoveandGitHub 9168fd6bf2 write text file logs (#600) 2022-01-29 12:31:22 -06:00
Jason Dove 14413f62a7 properly sent content root on macos 2022-01-29 11:49:38 -06:00
Jason Dove 34c71a0c12 try to fix static resource loading 2022-01-29 10:20:03 -06:00
Jason Dove a487e7fe15 use absolute paths in bundle script 2022-01-28 22:11:50 -06:00
Jason Dove cd4ea42597 fix bundle script 2022-01-28 22:05:20 -06:00
Jason Dove a3d42145f7 update macos submodule 2022-01-28 21:57:16 -06:00
Jason Dove 261cf5052a fetch submodules for mac build 2022-01-28 21:48:29 -06:00
Jason Dove de9af2f0f6 first pass at native macos app 2022-01-28 21:41:26 -06:00
Jason DoveandGitHub 8d4e18ed2f update mac app icon (#599) 2022-01-28 19:46:34 -06:00
Jason DoveandGitHub 1ee01c1d78 fix hls timestamps (#598) 2022-01-27 23:51:18 -06:00
Jason DoveandGitHub 7de50dd916 minor hls segmenter improvements (#593) 2022-01-26 20:12:29 -06:00
Jason DoveandGitHub 744fd3beaa link file not found health check to trash (#592)
* update dependencies

* fix file not found health check
2022-01-26 08:37:40 -06:00
Jason DoveandGitHub 861c95e1bd fix m3u mode override (#590) 2022-01-25 18:36:54 -06:00
93 changed files with 5694 additions and 453 deletions
+18
View File
@@ -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
+28 -4
View File
@@ -44,6 +44,7 @@ jobs:
uses: actions/checkout@v2
with:
fetch-depth: 0
submodules: true
- name: Setup .NET Core
uses: actions/setup-dotnet@v1
@@ -75,8 +76,9 @@ jobs:
- name: Bundle
shell: bash
run: |
plutil -replace CFBundleShortVersionString -string "${{ inputs.info_version }}" scripts/macOS/Info.plist
plutil -replace CFBundleVersion -string "${{ inputs.info_version }}" scripts/macOS/Info.plist
brew install coreutils
plutil -replace CFBundleShortVersionString -string "${{ inputs.info_version }}" ErsatzTV-macOS/ErsatzTV-macOS/Info.plist
plutil -replace CFBundleVersion -string "${{ inputs.info_version }}" ErsatzTV-macOS/ErsatzTV-macOS/Info.plist
scripts/macOS/bundle.sh
- name: Sign
@@ -147,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
@@ -167,16 +172,35 @@ 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: |
# Define some variables for things we need
release_name="ErsatzTV-${{ inputs.release_version }}-${{ matrix.target }}"
echo "RELEASE_NAME=${release_name}" >> $GITHUB_ENV
# Build everything
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net6.0 --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
# Build Windows launcher
if [ "${{ matrix.kind }}" == "windows" ]; then
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}/*"
+3
View File
@@ -0,0 +1,3 @@
[submodule "ErsatzTV-macOS"]
path = ErsatzTV-macOS
url = git@github.com:jasongdove/ErsatzTV-macOS.git
+46 -1
View File
@@ -4,6 +4,49 @@ All notable changes to this project will be documented in this file.
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
- `segmenter` for `HLS Segmenter`
- `hls-direct` for `HLS Direct`
- `ts` for `MPEG-TS`
- `ts-legacy` for `MPEG-TS (Legacy)`
- omitting the `mode` parameter returns each channel as configured
- Link `File Not Found` health check to `Trash` page to allow deletion
- Fix `HLS Segmenter` streaming mode with multiple ffmpeg-based clients
- Jellyfin (web) and TiviMate (Android) were specifically tested
### Added
- Hide console window on macOS and Windows; tray menu can be used to access UI, logs and to stop the app
- Also write logs to text files in the `logs` config subfolder
- Add `added_date` to search index
- This requires rebuilding the search index and search results may be empty or incomplete until the rebuild is complete
- Add `added_inthelast`, `added_notinthelast` search field for relative added date queries
- Syntax is a number and a unit (days, weeks, months, years) like `1 week` or `2 years`
## [0.3.8-alpha] - 2022-01-23
### Fixed
- Fix issue preventing some versions of ffmpeg (usually 4.4.x) from streaming MPEG-TS (Legacy) channels at all
@@ -924,7 +967,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Initial release to facilitate testing outside of Docker.
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.3.8-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.3.6-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.3.5-alpha...v0.3.6-alpha
+33
View File
@@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net6.0-windows</TargetFramework>
<RootNamespace>ErsatzTV_Windows</RootNamespace>
<Nullable>enable</Nullable>
<UseWindowsForms>true</UseWindowsForms>
<ImplicitUsings>enable</ImplicitUsings>
<ApplicationIcon>Ersatztv.ico</ApplicationIcon>
</PropertyGroup>
<ItemGroup>
<Content Include="Ersatztv.ico">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Asmichi.ChildProcess" Version="0.11.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ErsatzTV.Core\ErsatzTV.Core.csproj" />
</ItemGroup>
<ItemGroup>
<Compile Update="Program.cs">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Compile>
</ItemGroup>
</Project>
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+14
View File
@@ -0,0 +1,14 @@
namespace ErsatzTV_Windows;
public static class Program
{
/// <summary>
/// The main entry point for the application.
/// </summary>
[STAThread]
public static void Main()
{
ApplicationConfiguration.Initialize();
Application.Run(new TrayApplicationContext());
}
}
@@ -0,0 +1,77 @@
using ErsatzTV.Core;
using System.Diagnostics;
using Asmichi.ProcessManagement;
using System.Reflection;
namespace ErsatzTV_Windows;
public class TrayApplicationContext : ApplicationContext
{
private readonly NotifyIcon _trayIcon;
private readonly IChildProcess? _childProcess;
public TrayApplicationContext()
{
_trayIcon = new NotifyIcon
{
Icon = new Icon("./Ersatztv.ico"),
ContextMenuStrip = new ContextMenuStrip(),
Visible = true
};
AddMenuItem("Launch Web UI", LaunchWebUI);
AddMenuItem("Show Logs", ShowLogs);
_trayIcon.ContextMenuStrip.Items.Add(new ToolStripSeparator());
AddMenuItem("Exit", Exit);
string folder = AppContext.BaseDirectory;
string exe = Path.Combine(folder, "ErsatzTV.exe");
if (File.Exists(exe))
{
var si = new ChildProcessStartInfo(exe);
_childProcess = ChildProcess.Start(si);
}
}
private void AddMenuItem(string name, EventHandler action)
{
var item = new ToolStripMenuItem(name);
item.Click += action;
_trayIcon.ContextMenuStrip.Items.Add(item);
}
private void LaunchWebUI(object? sender, EventArgs e)
{
var process = new Process();
process.StartInfo.UseShellExecute = true;
process.StartInfo.FileName = "http://localhost:8409";
process.Start();
}
private void ShowLogs(object? sender, EventArgs e)
{
if (!Directory.Exists(FileSystemLayout.LogsFolder))
{
Directory.CreateDirectory(FileSystemLayout.LogsFolder);
}
var process = new Process();
process.StartInfo.UseShellExecute = true;
process.StartInfo.FileName = FileSystemLayout.LogsFolder;
process.Start();
}
protected override void Dispose(bool disposing)
{
_childProcess?.Dispose();
base.Dispose(disposing);
}
private void Exit(object? sender, EventArgs e)
{
// Hide tray icon, otherwise it will remain shown until user mouses over it
_trayIcon.Visible = false;
Application.Exit();
}
}
+1
Submodule ErsatzTV-macOS added at 2f3ee16f11
@@ -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;
}
}
@@ -36,10 +36,14 @@ namespace ErsatzTV.Application.Channels.Queries
channel.StreamingMode = StreamingMode.HttpLiveStreamingDirect;
result.Add(channel);
break;
case "ts":
case "ts-legacy":
channel.StreamingMode = StreamingMode.TransportStream;
result.Add(channel);
break;
case "ts":
channel.StreamingMode = StreamingMode.TransportStreamHybrid;
result.Add(channel);
break;
default:
result.Add(channel);
break;
@@ -6,7 +6,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MediatR" Version="9.0.0" />
<PackageReference Include="MediatR" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="6.0.0" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.0.64">
<PrivateAssets>all</PrivateAssets>
@@ -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) =>
@@ -1,9 +1,11 @@
using System;
using System.Diagnostics;
using System.IO;
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;
@@ -29,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)
{
@@ -65,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;
@@ -132,12 +142,17 @@ namespace ErsatzTV.Application.Streaming
using IServiceScope scope = _serviceScopeFactory.CreateScope();
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
long ptsOffset = await GetPtsOffset(mediator, channelNumber, cancellationToken);
// _logger.LogInformation("PTS offset: {PtsOffset}", ptsOffset);
var request = new GetPlayoutItemProcessByChannelNumber(
channelNumber,
"segmenter",
firstProcess ? DateTimeOffset.Now : _transcodedUntil.AddSeconds(1),
!firstProcess,
realtime);
realtime,
ptsOffset,
_targetFramerate);
// _logger.LogInformation("Request {@Request}", request);
@@ -216,21 +231,58 @@ 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;
}
}
private async Task<long> GetPtsOffset(IMediator mediator, string channelNumber, CancellationToken cancellationToken)
{
var directory = new DirectoryInfo(Path.Combine(FileSystemLayout.TranscodeFolder, channelNumber));
Option<FileInfo> lastSegment =
Optional(directory.GetFiles("*.ts").OrderByDescending(f => f.Name).FirstOrDefault());
long result = 0;
foreach (FileInfo segment in lastSegment)
{
Either<BaseError, PtsAndDuration> queryResult = await mediator.Send(
new GetLastPtsDuration(segment.FullName),
cancellationToken);
foreach (PtsAndDuration ptsAndDuration in queryResult.RightToSeq())
{
result = ptsAndDuration.Pts + ptsAndDuration.Duration;
}
}
return result;
}
private async Task<int> GetWorkAheadLimit()
{
@@ -239,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);
}
}
@@ -0,0 +1,12 @@
namespace ErsatzTV.Application.Streaming;
public record PtsAndDuration(long Pts, long Duration)
{
public static PtsAndDuration From(string ffprobeLine)
{
string[] split = ffprobeLine.Split("|");
var left = long.Parse(split[0]);
var right = long.Parse(split[1]);
return new PtsAndDuration(left, right);
}
}
@@ -11,5 +11,6 @@ namespace ErsatzTV.Application.Streaming.Queries
string Mode,
DateTimeOffset Now,
bool StartAtZero,
bool HlsRealtime) : IRequest<Either<BaseError, PlayoutItemProcessModel>>;
bool HlsRealtime,
long PtsOffset) : IRequest<Either<BaseError, PlayoutItemProcessModel>>;
}
@@ -9,7 +9,8 @@ namespace ErsatzTV.Application.Streaming.Queries
"ts-legacy",
DateTimeOffset.Now,
false,
true)
true,
0)
{
Scheme = scheme;
Host = host;
@@ -0,0 +1,7 @@
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.Streaming.Queries;
public record GetLastPtsDuration(string FileName) : IRequest<Either<BaseError, PtsAndDuration>>;
@@ -0,0 +1,87 @@
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.Streaming.Queries;
public class GetLastPtsDurationHandler : IRequestHandler<GetLastPtsDuration, Either<BaseError, PtsAndDuration>>
{
private readonly IConfigElementRepository _configElementRepository;
public GetLastPtsDurationHandler(IConfigElementRepository configElementRepository)
{
_configElementRepository = configElementRepository;
}
public async Task<Either<BaseError, PtsAndDuration>> Handle(
GetLastPtsDuration request,
CancellationToken cancellationToken)
{
Validation<BaseError, RequestParameters> validation = await Validate(request);
return await validation.Match(
Handle,
error => Task.FromResult<Either<BaseError, PtsAndDuration>>(error.Join()));
}
private async Task<Validation<BaseError, RequestParameters>> Validate(GetLastPtsDuration request) =>
await ValidateFFprobePath()
.MapT(
ffprobePath => new RequestParameters(
request.FileName,
ffprobePath));
private async Task<Either<BaseError, PtsAndDuration>> Handle(RequestParameters parameters)
{
var startInfo = new ProcessStartInfo
{
FileName = parameters.FFprobePath,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8
};
startInfo.ArgumentList.Add("-v");
startInfo.ArgumentList.Add("0");
startInfo.ArgumentList.Add("-show_entries");
startInfo.ArgumentList.Add("packet=pts,duration");
startInfo.ArgumentList.Add("-of");
startInfo.ArgumentList.Add("compact=p=0:nk=1");
startInfo.ArgumentList.Add("-read_intervals");
startInfo.ArgumentList.Add("-999999");
startInfo.ArgumentList.Add(parameters.FileName);
var probe = new Process
{
StartInfo = startInfo
};
probe.Start();
return await probe.StandardOutput.ReadToEndAsync().MapAsync<string, Either<BaseError, PtsAndDuration>>(
async output =>
{
await probe.WaitForExitAsync();
return probe.ExitCode == 0
? PtsAndDuration.From(output.Split("\n").Filter(s => !string.IsNullOrWhiteSpace(s)).Last().Trim())
: BaseError.New($"FFprobe at {parameters.FFprobePath} exited with code {probe.ExitCode}");
});
}
private Task<Validation<BaseError, string>> ValidateFFprobePath() =>
_configElementRepository.GetValue<string>(ConfigElementKey.FFprobePath)
.FilterT(File.Exists)
.Map(
ffprobePath =>
ffprobePath.ToValidation<BaseError>("FFprobe path does not exist on the file system"));
private record RequestParameters(string FileName, string FFprobePath);
}
@@ -1,21 +1,18 @@
using System;
using LanguageExt;
namespace ErsatzTV.Application.Streaming.Queries
{
public record GetPlayoutItemProcessByChannelNumber : FFmpegProcessRequest
{
public GetPlayoutItemProcessByChannelNumber(
string channelNumber,
string mode,
DateTimeOffset now,
bool startAtZero,
bool hlsRealtime) : base(
channelNumber,
mode,
now,
startAtZero,
hlsRealtime)
{
}
}
public record GetPlayoutItemProcessByChannelNumber(string ChannelNumber,
string Mode,
DateTimeOffset Now,
bool StartAtZero,
bool HlsRealtime,
long PtsOffset,
Option<int> TargetFramerate) : FFmpegProcessRequest(ChannelNumber,
Mode,
Now,
StartAtZero,
HlsRealtime,
PtsOffset);
}
@@ -158,7 +158,9 @@ namespace ErsatzTV.Application.Streaming.Queries
request.HlsRealtime,
playoutItemWithPath.PlayoutItem.FillerKind,
playoutItemWithPath.PlayoutItem.InPoint,
playoutItemWithPath.PlayoutItem.OutPoint);
playoutItemWithPath.PlayoutItem.OutPoint,
request.PtsOffset,
request.TargetFramerate);
var result = new PlayoutItemProcessModel(process, playoutItemWithPath.PlayoutItem.FinishOffset);
@@ -193,7 +195,8 @@ namespace ErsatzTV.Application.Streaming.Queries
channel,
maybeDuration,
"Channel is Offline",
request.HlsRealtime);
request.HlsRealtime,
request.PtsOffset);
return new PlayoutItemProcessModel(errorProcess, finish);
}
@@ -212,7 +215,8 @@ namespace ErsatzTV.Application.Streaming.Queries
channel,
maybeDuration,
error.Value,
request.HlsRealtime);
request.HlsRealtime,
request.PtsOffset);
return new PlayoutItemProcessModel(errorProcess, finish);
}
@@ -231,7 +235,8 @@ namespace ErsatzTV.Application.Streaming.Queries
channel,
maybeDuration,
"Channel is Offline",
request.HlsRealtime);
request.HlsRealtime,
request.PtsOffset);
return new PlayoutItemProcessModel(errorProcess, finish);
}
@@ -9,7 +9,8 @@ namespace ErsatzTV.Application.Streaming.Queries
"ts",
DateTimeOffset.Now,
false,
true)
true,
0)
{
Scheme = scheme;
Host = host;
@@ -6,7 +6,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.3.0" />
<PackageReference Include="FluentAssertions" Version="6.4.0" />
<PackageReference Include="LanguageExt.Core" Version="4.0.3" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" />
@@ -16,7 +16,7 @@
</PackageReference>
<PackageReference Include="Moq" Version="4.16.1" />
<PackageReference Include="NUnit" Version="3.13.2" />
<PackageReference Include="NUnit3TestAdapter" Version="4.2.0" />
<PackageReference Include="NUnit3TestAdapter" Version="4.2.1" />
<PackageReference Include="Serilog" Version="2.10.0" />
<PackageReference Include="Serilog.Extensions.Logging" Version="3.1.0" />
<PackageReference Include="Serilog.Sinks.Debug" Version="2.0.0" />
@@ -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
{
@@ -16,6 +17,27 @@ namespace ErsatzTV.Core.Tests.FFmpeg
private readonly FFmpegPlaybackSettingsCalculator _calculator;
public CalculateSettings() => _calculator = new FFmpegPlaybackSettingsCalculator();
[Test]
public void Should_Not_GenPts_ForHlsSegmenter()
{
FFmpegProfile ffmpegProfile = TestProfile();
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.HttpLiveStreamingSegmenter,
ffmpegProfile,
new MediaVersion(),
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero,
false,
None);
actual.FormatFlags.Should().NotContain("+genpts");
}
[Test]
public void Should_Not_UseSpecifiedThreadCount_ForTransportStream()
@@ -34,7 +56,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero,
false);
false,
None);
actual.ThreadCount.Should().Be(1);
}
@@ -54,7 +77,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero,
false);
false,
None);
actual.ThreadCount.Should().Be(7);
}
@@ -74,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);
@@ -96,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);
@@ -118,7 +144,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero,
false);
false,
None);
actual.RealtimeOutput.Should().BeTrue();
}
@@ -138,7 +165,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero,
false);
false,
None);
actual.RealtimeOutput.Should().BeTrue();
}
@@ -160,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));
@@ -183,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));
@@ -204,7 +234,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero,
false);
false,
None);
actual.ScaledSize.IsNone.Should().BeTrue();
}
@@ -231,7 +262,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero,
false);
false,
None);
actual.ScaledSize.IsNone.Should().BeTrue();
}
@@ -258,7 +290,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero,
false);
false,
None);
actual.ScaledSize.IsNone.Should().BeTrue();
}
@@ -285,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();
@@ -314,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();
@@ -342,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);
@@ -373,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();
@@ -402,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();
@@ -432,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();
@@ -465,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();
@@ -498,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();
@@ -530,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();
@@ -563,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();
@@ -598,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();
@@ -631,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();
@@ -663,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();
@@ -694,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();
@@ -727,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();
@@ -756,7 +804,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero,
false);
false,
None);
actual.AudioCodec.Should().Be("aac");
}
@@ -782,7 +831,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero,
false);
false,
None);
actual.AudioCodec.Should().Be("copy");
}
@@ -809,7 +859,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero,
false);
false,
None);
actual.AudioCodec.Should().Be("aac");
}
@@ -836,7 +887,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero,
false);
false,
None);
actual.AudioCodec.Should().Be("copy");
}
@@ -864,7 +916,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero,
false);
false,
None);
actual.AudioBitrate.IfNone(0).Should().Be(2424);
}
@@ -892,7 +945,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero,
false);
false,
None);
actual.AudioBufferSize.IfNone(0).Should().Be(2424);
}
@@ -920,7 +974,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero,
false);
false,
None);
actual.AudioChannels.IfNone(0).Should().Be(6);
}
@@ -948,7 +1003,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero,
false);
false,
None);
actual.AudioSampleRate.IfNone(0).Should().Be(48);
}
@@ -975,7 +1031,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero,
false);
false,
None);
actual.AudioChannels.IfNone(0).Should().Be(6);
}
@@ -1002,7 +1059,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero,
false);
false,
None);
actual.AudioSampleRate.IfNone(0).Should().Be(48);
}
@@ -1030,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));
}
@@ -1057,7 +1116,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero,
false);
false,
None);
actual.NormalizeLoudness.Should().BeTrue();
}
@@ -1084,7 +1144,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero,
false);
false,
None);
actual.NormalizeLoudness.Should().BeFalse();
}
@@ -1113,7 +1174,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero,
false);
false,
None);
actual.HardwareAcceleration.Should().Be(HardwareAccelerationKind.Qsv);
}
+39 -14
View File
@@ -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
@@ -214,7 +237,9 @@ namespace ErsatzTV.Core.Tests.FFmpeg
false,
FillerKind.None,
TimeSpan.Zero,
TimeSpan.FromSeconds(5));
TimeSpan.FromSeconds(5),
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))]
+1
View File
@@ -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");
+1
View File
@@ -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")
{
}
}
}
+2 -2
View File
@@ -6,9 +6,9 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Flurl" Version="3.0.2" />
<PackageReference Include="Flurl" Version="3.0.4" />
<PackageReference Include="LanguageExt.Core" Version="4.0.3" />
<PackageReference Include="MediatR" Version="9.0.0" />
<PackageReference Include="MediatR" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Http" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="6.0.0" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.0.64">
@@ -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; }
}
}
@@ -36,6 +36,12 @@ namespace ErsatzTV.Core.FFmpeg
"+igndts"
};
private static readonly List<string> SegmenterFormatFlags = new()
{
"+discardcorrupt",
"+igndts"
};
public FFmpegPlaybackSettings ConcatSettings => new()
{
ThreadCount = 1,
@@ -52,11 +58,16 @@ namespace ErsatzTV.Core.FFmpeg
DateTimeOffset now,
TimeSpan inPoint,
TimeSpan outPoint,
bool hlsRealtime)
bool hlsRealtime,
Option<int> targetFramerate)
{
var result = new FFmpegPlaybackSettings
{
FormatFlags = CommonFormatFlags,
FormatFlags = streamingMode switch
{
StreamingMode.HttpLiveStreamingSegmenter => SegmenterFormatFlags,
_ => CommonFormatFlags,
},
RealtimeOutput = streamingMode switch
{
StreamingMode.HttpLiveStreamingSegmenter => hlsRealtime,
@@ -103,6 +114,11 @@ namespace ErsatzTV.Core.FFmpeg
if (ffmpegProfile.Transcode && ffmpegProfile.NormalizeVideo)
{
if (ffmpegProfile.NormalizeFramerate)
{
result.FrameRate = targetFramerate;
}
result.VideoTrackTimeScale = 90000;
}
+99 -39
View File
@@ -21,6 +21,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
@@ -213,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;
}
@@ -253,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)
{
@@ -273,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
@@ -387,35 +430,25 @@ namespace ErsatzTV.Core.FFmpeg
return this;
}
public FFmpegProcessBuilder WithHls(string channelNumber, Option<MediaVersion> mediaVersion)
public FFmpegProcessBuilder WithHls(
string channelNumber,
Option<MediaVersion> mediaVersion,
long ptsOffset,
Option<int> maybeTimeScale,
Option<int> maybeFrameRate)
{
const int SEGMENT_SECONDS = 4;
int frameRate = maybeFrameRate.IfNone(GetFrameRateFromMediaVersion(mediaVersion));
var frameRate = 24;
foreach (MediaVersion version in mediaVersion)
foreach (int timescale in maybeTimeScale)
{
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;
_arguments.Add("-output_ts_offset");
_arguments.Add($"{(ptsOffset / (double)timescale).ToString(NumberFormatInfo.InvariantInfo)}");
}
_arguments.AddRange(
new[]
{
"-use_wallclock_as_timestamps", "1",
"-g", $"{frameRate * SEGMENT_SECONDS}",
"-keyint_min", $"{frameRate * SEGMENT_SECONDS}",
"-force_key_frames", $"expr:gte(t,n_forced*{SEGMENT_SECONDS})",
@@ -425,7 +458,8 @@ namespace ErsatzTV.Core.FFmpeg
"-segment_list_flags", "+live",
"-hls_segment_filename",
Path.Combine(FileSystemLayout.TranscodeFolder, channelNumber, "live%06d.ts"),
"-hls_flags", "program_date_time+append_list+omit_endlist+independent_segments",
"-hls_flags", "program_date_time+append_list+discont_start+omit_endlist+independent_segments",
"-mpegts_flags", "+initial_discontinuity",
Path.Combine(FileSystemLayout.TranscodeFolder, channelNumber, "live.m3u8")
});
@@ -609,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;
}
@@ -685,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;
}
}
}
+49 -9
View File
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Threading.Tasks;
@@ -51,7 +52,9 @@ namespace ErsatzTV.Core.FFmpeg
bool hlsRealtime,
FillerKind fillerKind,
TimeSpan inPoint,
TimeSpan outPoint)
TimeSpan outPoint,
long ptsOffset,
Option<int> targetFramerate)
{
MediaStream videoStream = await _ffmpegStreamSelector.SelectVideoStream(channel, videoVersion);
Option<MediaStream> maybeAudioStream = await _ffmpegStreamSelector.SelectAudioStream(channel, audioVersion);
@@ -66,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)
@@ -88,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);
@@ -158,7 +186,12 @@ namespace ErsatzTV.Core.FFmpeg
{
// HLS needs to segment and generate playlist
case StreamingMode.HttpLiveStreamingSegmenter:
return builder.WithHls(channel.Number, videoVersion)
return builder.WithHls(
channel.Number,
videoVersion,
ptsOffset,
playbackSettings.VideoTrackTimeScale,
playbackSettings.FrameRate)
.Build();
default:
return builder.WithFormat("mpegts")
@@ -173,7 +206,8 @@ namespace ErsatzTV.Core.FFmpeg
Channel channel,
Option<TimeSpan> duration,
string errorMessage,
bool hlsRealtime)
bool hlsRealtime,
long ptsOffset)
{
FFmpegPlaybackSettings playbackSettings =
_playbackSettingsCalculator.CalculateErrorSettings(channel.FFmpegProfile);
@@ -221,7 +255,12 @@ namespace ErsatzTV.Core.FFmpeg
{
// HLS needs to segment and generate playlist
case StreamingMode.HttpLiveStreamingSegmenter:
return builder.WithHls(channel.Number, None)
return builder.WithHls(
channel.Number,
None,
ptsOffset,
playbackSettings.VideoTrackTimeScale,
playbackSettings.FrameRate)
.Build();
default:
return builder.WithFormat("mpegts")
@@ -336,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)
+23
View File
@@ -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");
+5 -4
View File
@@ -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;
}
}
+3
View File
@@ -18,9 +18,12 @@ namespace ErsatzTV.Core
Environment.SpecialFolderOption.Create),
"etv-transcode");
public static readonly string LogsFolder = Path.Combine(AppDataFolder, "logs");
public static readonly string DatabasePath = Path.Combine(AppDataFolder, "ersatztv.sqlite3");
public static readonly string LogDatabasePath = Path.Combine(AppDataFolder, "logs.sqlite3");
public static readonly string LogFilePath = Path.Combine(LogsFolder, "ersatztv.log");
public static readonly string LegacyImageCacheFolder = Path.Combine(AppDataFolder, "cache", "images");
public static readonly string ResourcesCacheFolder = Path.Combine(AppDataFolder, "cache", "resources");
@@ -27,14 +27,17 @@ namespace ErsatzTV.Core.Interfaces.FFmpeg
bool hlsRealtime,
FillerKind fillerKind,
TimeSpan inPoint,
TimeSpan outPoint);
TimeSpan outPoint,
long ptsOffset,
Option<int> targetFramerate);
Task<Process> ForError(
string ffmpegPath,
Channel channel,
Option<TimeSpan> duration,
string errorMessage,
bool hlsRealtime);
bool hlsRealtime,
long ptsOffset);
Process ConcatChannel(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host);
@@ -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);
}
}
+54 -18
View File
@@ -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()
+33 -5
View File
@@ -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)
+17 -3
View File
@@ -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.1.15" />
<PackageReference Include="Refit.Newtonsoft.Json" Version="6.1.15" />
<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>
@@ -68,7 +68,7 @@ public class FileNotFoundHealthCheck : BaseHealthCheck, IFileNotFoundHealthCheck
return WarningResult(
$"There are {all.Count} files that do not exist on disk, including the following: {files}",
$"/search?query=state%3AFileNotFound");
"/media/trash");
}
return OkResult();
@@ -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");
@@ -60,6 +60,21 @@ namespace ErsatzTV.Infrastructure.Search
return base.GetRangeQuery("release_date", "00000000", dateString, false, false);
}
if (field == "added_inthelast" && CustomQueryParser.ParseStart(queryText, out DateTime addedStart))
{
var todayString = DateTime.Today.ToString("yyyyMMdd");
var dateString = addedStart.ToString("yyyyMMdd");
return base.GetRangeQuery("added_date", dateString, todayString, true, true);
}
if (field == "added_notinthelast" && CustomQueryParser.ParseStart(queryText, out DateTime addedFinish))
{
var dateString = addedFinish.ToString("yyyyMMdd");
return base.GetRangeQuery("added_date", "00000000", dateString, false, false);
}
return base.GetFieldQuery(field, queryText, slop);
}
@@ -57,6 +57,21 @@ namespace ErsatzTV.Infrastructure.Search
return base.GetRangeQuery("release_date", "00000000", dateString, false, false);
}
if (field == "added_inthelast" && ParseStart(queryText, out DateTime addedStart))
{
var todayString = DateTime.Today.ToString("yyyyMMdd");
var dateString = addedStart.ToString("yyyyMMdd");
return base.GetRangeQuery("added_date", dateString, todayString, true, true);
}
if (field == "added_notinthelast" && ParseStart(queryText, out DateTime addedFinish))
{
var dateString = addedFinish.ToString("yyyyMMdd");
return base.GetRangeQuery("added_date", "00000000", dateString, false, false);
}
return base.GetFieldQuery(field, queryText, slop);
}
+18 -1
View File
@@ -43,6 +43,7 @@ namespace ErsatzTV.Infrastructure.Search
private const string TitleAndYearField = "title_and_year";
private const string JumpLetterField = "jump_letter";
private const string ReleaseDateField = "release_date";
private const string AddedDateField = "added_date";
private const string StudioField = "studio";
private const string LanguageField = "language";
private const string StyleField = "style";
@@ -80,7 +81,7 @@ namespace ErsatzTV.Infrastructure.Search
_initialized = false;
}
public int Version => 19;
public int Version => 20;
public Task<bool> Initialize(ILocalFileSystem localFileSystem)
{
@@ -342,6 +343,8 @@ namespace ErsatzTV.Infrastructure.Search
Field.Store.NO));
}
doc.Add(new StringField(AddedDateField, metadata.DateAdded.ToString("yyyyMMdd"), Field.Store.NO));
if (!string.IsNullOrWhiteSpace(metadata.Plot))
{
doc.Add(new TextField(PlotField, metadata.Plot ?? string.Empty, Field.Store.NO));
@@ -468,6 +471,8 @@ namespace ErsatzTV.Infrastructure.Search
Field.Store.NO));
}
doc.Add(new StringField(AddedDateField, metadata.DateAdded.ToString("yyyyMMdd"), Field.Store.NO));
if (!string.IsNullOrWhiteSpace(metadata.Plot))
{
doc.Add(new TextField(PlotField, metadata.Plot ?? string.Empty, Field.Store.NO));
@@ -558,6 +563,8 @@ namespace ErsatzTV.Infrastructure.Search
Field.Store.NO));
}
doc.Add(new StringField(AddedDateField, metadata.DateAdded.ToString("yyyyMMdd"), Field.Store.NO));
foreach (TraktListItem item in season.TraktListItems)
{
doc.Add(new StringField(TraktListField, item.TraktList.TraktId.ToString(), Field.Store.NO));
@@ -597,6 +604,8 @@ namespace ErsatzTV.Infrastructure.Search
List<string> languages = await searchRepository.GetLanguagesForArtist(artist);
await AddLanguages(searchRepository, doc, languages);
doc.Add(new StringField(AddedDateField, metadata.DateAdded.ToString("yyyyMMdd"), Field.Store.NO));
foreach (Genre genre in metadata.Genres)
{
doc.Add(new TextField(GenreField, genre.Name, Field.Store.NO));
@@ -659,6 +668,8 @@ namespace ErsatzTV.Infrastructure.Search
metadata.ReleaseDate.Value.ToString("yyyyMMdd"),
Field.Store.NO));
}
doc.Add(new StringField(AddedDateField, metadata.DateAdded.ToString("yyyyMMdd"), Field.Store.NO));
if (!string.IsNullOrWhiteSpace(metadata.Album))
{
@@ -748,6 +759,8 @@ namespace ErsatzTV.Infrastructure.Search
metadata.ReleaseDate.Value.ToString("yyyyMMdd"),
Field.Store.NO));
}
doc.Add(new StringField(AddedDateField, metadata.DateAdded.ToString("yyyyMMdd"), Field.Store.NO));
if (!string.IsNullOrWhiteSpace(metadata.Plot))
{
@@ -827,6 +840,8 @@ namespace ErsatzTV.Infrastructure.Search
{
doc.Add(new Int32Field(MinutesField, (int)Math.Ceiling(version.Duration.TotalMinutes), Field.Store.NO));
}
doc.Add(new StringField(AddedDateField, metadata.DateAdded.ToString("yyyyMMdd"), Field.Store.NO));
foreach (Tag tag in metadata.Tags)
{
@@ -871,6 +886,8 @@ namespace ErsatzTV.Infrastructure.Search
{
doc.Add(new Int32Field(MinutesField, (int)Math.Ceiling(version.Duration.TotalMinutes), Field.Store.NO));
}
doc.Add(new StringField(AddedDateField, metadata.DateAdded.ToString("yyyyMMdd"), Field.Store.NO));
if (!string.IsNullOrWhiteSpace(metadata.Album))
{
+9 -2
View File
@@ -27,14 +27,21 @@ namespace ErsatzTV.Controllers
public Task<IActionResult> GetConcatPlaylist(string channelNumber) =>
_mediator.Send(new GetConcatPlaylistByChannelNumber(Request.Scheme, Request.Host.ToString(), channelNumber))
.ToActionResult();
[HttpGet("ffmpeg/stream/{channelNumber}")]
public Task<IActionResult> GetStream(
string channelNumber,
[FromQuery]
string mode = "mixed") =>
_mediator.Send(
new GetPlayoutItemProcessByChannelNumber(channelNumber, mode, DateTimeOffset.Now, false, true))
new GetPlayoutItemProcessByChannelNumber(
channelNumber,
mode,
DateTimeOffset.Now,
false,
true,
0,
Option<int>.None))
.Map(
result =>
result.Match<IActionResult>(
+5
View File
@@ -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");
}
+10 -11
View File
@@ -8,20 +8,19 @@
<ItemGroup>
<Folder Include="Resources" />
<Folder Include="wwwroot\css\bootstrap" />
</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="6.0.453" />
<PackageReference Include="HtmlSanitizer" Version="7.1.475" />
<PackageReference Include="LanguageExt.Core" Version="4.0.3" />
<PackageReference Include="Markdig" Version="0.26.0" />
<PackageReference Include="MediatR.Courier.DependencyInjection" Version="4.0.0" />
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="9.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="6.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.1">
<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.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
@@ -29,10 +28,10 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="MudBlazor" Version="6.0.4" />
<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.1.15" />
<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" />
+11 -1
View File
@@ -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;
}
}
}
+3
View File
@@ -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>
+1 -1
View File
@@ -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
+10
View File
@@ -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());
+12 -11
View File
@@ -11,23 +11,22 @@ namespace ErsatzTV
{
public class Program
{
private static readonly string BasePath;
static Program()
{
var executablePath = System.Diagnostics.Process.GetCurrentProcess().MainModule.FileName;
var executable = Path.GetFileNameWithoutExtension(executablePath);
string executablePath = System.Diagnostics.Process.GetCurrentProcess().MainModule.FileName;
string executable = Path.GetFileNameWithoutExtension(executablePath);
IConfigurationBuilder builder = new ConfigurationBuilder();
if ("dotnet".Equals(executable, StringComparison.InvariantCultureIgnoreCase))
{
builder = builder.SetBasePath(Path.GetDirectoryName(typeof(Program).Assembly.Location));
}
else
{
builder = builder.SetBasePath(Path.GetDirectoryName(executablePath));
}
BasePath = Path.GetDirectoryName(
"dotnet".Equals(executable, StringComparison.InvariantCultureIgnoreCase)
? typeof(Program).Assembly.Location
: executablePath);
Configuration = builder
.SetBasePath(BasePath)
.AddJsonFile("appsettings.json", false, true)
.AddJsonFile(
$"appsettings.{Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production"}.json",
@@ -44,6 +43,7 @@ namespace ErsatzTV
.ReadFrom.Configuration(Configuration)
.Enrich.FromLogContext()
.WriteTo.SQLite(FileSystemLayout.LogDatabasePath, retentionPeriod: TimeSpan.FromDays(1))
.WriteTo.File(FileSystemLayout.LogFilePath, rollingInterval: RollingInterval.Day)
.CreateLogger();
try
@@ -67,7 +67,8 @@ namespace ErsatzTV
.ConfigureWebHostDefaults(
webBuilder => webBuilder.UseStartup<Startup>()
.UseConfiguration(Configuration)
.UseKestrel(options => options.AddServerHeader = false))
.UseKestrel(options => options.AddServerHeader = false)
.UseContentRoot(BasePath))
.UseSerilog();
}
}
@@ -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
);
}
}
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+21
View File
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.0.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1"
id="svg8" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 500 500"
style="enable-background:new 0 0 500 500;" xml:space="preserve">
<style type="text/css">
.st0{fill:#C0C000;}
.st1{font-family:'Roboto-Bold';}
.st2{font-size:144.6417px;}
.st3{fill:#00C0C0;}
.st4{font-size:312.4454px;}
.st5{fill:#00C000;}
</style>
<sodipodi:namedview bordercolor="#666666" borderopacity="1.0" fit-margin-bottom="0" fit-margin-left="0" fit-margin-right="0" fit-margin-top="0" id="base" inkscape:current-layer="layer1" inkscape:cx="199.98633" inkscape:cy="92.251514" inkscape:document-rotation="0" inkscape:document-units="mm" inkscape:pageopacity="0.0" inkscape:pageshadow="2" inkscape:window-height="1364" inkscape:window-maximized="1" inkscape:window-width="3408" inkscape:window-x="14" inkscape:window-y="36" inkscape:zoom="0.49497475" pagecolor="#ffffff" showgrid="false">
</sodipodi:namedview>
<g>
<text transform="matrix(1 0 0 1 47.5005 181.2363)" class="st0 st1 st2">Ersatz</text>
<text transform="matrix(1 0 0 1 53.6772 427.7119)"><tspan x="0" y="0" class="st3 st1 st4">T</tspan><tspan x="193.3" y="0" class="st5 st1 st4">V</tspan></text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

-17
View File
@@ -1,17 +0,0 @@
#!/bin/bash
IFS='.' read -ra ADDR <<< "$1"
ICONSET=${ADDR[0]}.iconset
mkdir $ICONSET
sips -z 16 16 $1 --out $ICONSET/icon_16x16.png
sips -z 32 32 $1 --out $ICONSET/icon_16x16@2x.png
sips -z 32 32 $1 --out $ICONSET/icon_32x32.png
sips -z 64 64 $1 --out $ICONSET/icon_32x32@2x.png
sips -z 128 128 $1 --out $ICONSET/icon_128x128.png
sips -z 256 256 $1 --out $ICONSET/icon_128x128@2x.png
sips -z 256 256 $1 --out $ICONSET/icon_256x256.png
sips -z 512 512 $1 --out $ICONSET/icon_256x256@2x.png
sips -z 512 512 $1 --out $ICONSET/icon_512x512.png
cp $1 $ICONSET/icon_512x512@2x.png
iconutil -c icns $ICONSET
rm -R $ICONSET
+6 -6
View File
@@ -93,17 +93,17 @@ As with the docker development tags, the development builds are updated with eve
1. Create a folder `ersatztv` at your preferred install location.
2. Download and extract the [latest release from GitHub](https://github.com/jasongdove/ErsatzTV/releases) to the `ersatztv` folder.
3. Run `ErsatzTV.exe`
4. Open your browser to `http://[server-ip]:8409`
3. Run `ErsatzTV-Windows.exe`
4. Use the tray menu to open the UI, view logs or exit
## MacOS
### Manual Installation
1. Create a folder `ersatztv` at your preferred install location.
2. Download and extract the [latest release from GitHub](https://github.com/jasongdove/ErsatzTV/releases) to the `ersatztv` folder.
3. Run `ErsatzTV`
4. Open your browser to `http://[server-ip]:8409`
1. Download the .dmg from the [latest release from GitHub](https://github.com/jasongdove/ErsatzTV/releases)
2. Open the .dmg and drag `ErsatzTV` to the `Applications` folder
3. Double-click the `ErsatzTV` application
4. Use the tray menu to open the UI, view logs or exit
## Linux
+9
View File
@@ -24,6 +24,7 @@ The following fields are available for searching movies:
- `content_rating`: The movie content rating (case-sensitive)
- `language`: The movie audio stream language
- `release_date`: The movie release date (YYYYMMDD)
- `added_date`: The date the movie was added to ErsatzTV (YYYYMMDD)
- `minutes`: the rounded-up whole number duration of the movie in minutes
- `type`: Always `movie`
@@ -41,6 +42,7 @@ The following fields are available for searching shows:
- `content_rating`: The show content rating (case-sensitive)
- `language`: The show audio stream language
- `release_date`: The show release date (YYYYMMDD)
- `added_date`: The date the show was added to ErsatzTV (YYYYMMDD)
- `type`: Always `show`
### Episodes
@@ -54,6 +56,7 @@ The following fields are available for searching episodes:
- `library_name`: The name of the library that contains the episode
- `language`: The episode audio stream language
- `release_date`: The episode release date (YYYYMMDD)
- `added_date`: The date the episode was added to ErsatzTV (YYYYMMDD)
- `minutes`: the rounded-up whole number duration of the episode in minutes
- `type`: Always `episode`
@@ -65,6 +68,7 @@ The following fields are available for searching artists:
- `genre`: The artist genre
- `style`: The artist style
- `mood`: The artist mood
- `added_date`: The date the artist was added to ErsatzTV (YYYYMMDD)
- `library_name`: The name of the library that contains the artist
- `type`: Always `artist`
@@ -79,6 +83,7 @@ The following fields are available for searching music videos:
- `library_name`: The name of the library that contains the music video
- `language`: The music video audio stream language
- `release_date`: The music video release date (YYYYMMDD)
- `added_date`: The date the music video was added to ErsatzTV (YYYYMMDD)
- `minutes`: the rounded-up whole number duration of the music video in minutes
- `type`: Always `music_video`
@@ -89,6 +94,7 @@ The following fields are available for searching other videos:
- `title`: The filename of the video (without extension)
- `tag`: All of the video's parent folders
- `minutes`: the rounded-up whole number duration of the video in minutes
- `added_date`: The date the other video was added to ErsatzTV (YYYYMMDD)
- `type`: Always `other_video`
### Songs
@@ -101,6 +107,7 @@ The following fields are available for searching songs:
- `genre`: The song genre
- `tag`: All of the song's parent folders
- `minutes`: the rounded-up whole number duration of the song in minutes
- `added_date`: The date the song was added to ErsatzTV (YYYYMMDD)
- `type`: Always `song`
## Special Search Fields
@@ -108,6 +115,8 @@ The following fields are available for searching songs:
- `released_inthelast`: For any media type that supports `release_date`, `released_inthelast` takes a number and a unit (days, weeks, months, years) and returns items released between the specified time ago and now
- `released_notinthelast`: For any media type that supports `release_date`, `released_notinthelast` takes a number and a unit (days, weeks, months, years) and returns items released before the specified time ago
- `released_onthisday`: For any media type that supports `release_date`, `released_onthisday` takes any value (ignored) and will return items released on this month number and day number in previous years
- `added_inthelast`: For any media type that supports `added_date`, `added_inthelast` takes a number and a unit (days, weeks, months, years) and returns items added to ErsatzTV between the specified time ago and now
- `added_notinthelast`: For any media type that supports `added_date`, `added_notinthelast` takes a number and a unit (days, weeks, months, years) and returns items added to ErsatzTV before the specified time ago
## Sample Searches
-26
View File
@@ -1,26 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDisplayName</key>
<string>ErsatzTV</string>
<key>CFBundleExecutable</key>
<string>launcher.sh</string>
<key>CFBundleIconFile</key>
<string>ErsatzTV.icns</string>
<key>CFBundleIdentifier</key>
<string>com.jasongdove.ersatztv</string>
<key>CFBundleName</key>
<string>ErsatzTV</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.3.7-alpha</string>
<key>CFBundleVersion</key>
<string>0.3.7-alpha</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
</dict>
</plist>
+8 -12
View File
@@ -1,26 +1,22 @@
#! /bin/bash
SCRIPT_FOLDER=$(dirname ${BASH_SOURCE[0]})
REPO_ROOT="$SCRIPT_FOLDER/../.."
REPO_ROOT=$(realpath "$SCRIPT_FOLDER/../..")
APP_NAME="$REPO_ROOT/ErsatzTV.app"
PUBLISH_OUTPUT_DIRECTORY="$REPO_ROOT/publish/."
INFO_PLIST="$SCRIPT_FOLDER/Info.plist"
ICON_SOURCE="$REPO_ROOT/artwork/ErsatzTV.icns"
ICON_FILE="ErsatzTV.icns"
if [ -d "$APP_NAME" ]
then
rm -rf "$APP_NAME"
fi
mkdir "$APP_NAME"
cd "$REPO_ROOT/ErsatzTV-macOS" || exit
xcodebuild build
cd "$REPO_ROOT" || exit
cp -R "$REPO_ROOT/ErsatzTV-macOS/build/Release/ErsatzTV-macOS.app" "$APP_NAME"
mkdir "$APP_NAME/Contents"
mkdir "$APP_NAME/Contents/MacOS"
mkdir "$APP_NAME/Contents/Resources"
cp "$INFO_PLIST" "$APP_NAME/Contents/Info.plist"
cp "$ICON_SOURCE" "$APP_NAME/Contents/Resources/$ICON_FILE"
cp -a "$PUBLISH_OUTPUT_DIRECTORY" "$APP_NAME/Contents/MacOS"
cp "$SCRIPT_FOLDER/launcher.sh" "$APP_NAME/Contents/MacOS/"
chmod +x "$APP_NAME/Contents/MacOS/ErsatzTV"
chmod +x "$APP_NAME/Contents/MacOS/ErsatzTV-macOS"
-9
View File
@@ -1,9 +0,0 @@
#! /bin/sh
# Set the working directory
DIR=$(cd "$(dirname "$0")"; pwd)
# Run the application
echo "running from $DIR"
open -a Terminal $DIR/ErsatzTV