Compare commits
69 Commits
v0.6.8-bet
...
v0.7.1-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a43e5bbe9d | ||
|
|
b7bd4541b1 | ||
|
|
648f25e9cc | ||
|
|
ccbe85a46a | ||
|
|
d168d79fe0 | ||
|
|
d37dde2477 | ||
|
|
8e13b07c84 | ||
|
|
927e7724f0 | ||
|
|
6558c5bd69 | ||
|
|
5f7efbb69c | ||
|
|
b79795af50 | ||
|
|
9479806cb0 | ||
|
|
6e49ea78ec | ||
|
|
7b1edd9c54 | ||
|
|
aeaafd2964 | ||
|
|
622fa01602 | ||
|
|
e2b3c1ce8e | ||
|
|
6c5db650e7 | ||
|
|
731072425b | ||
|
|
0f817308a8 | ||
|
|
0fc1e15cac | ||
|
|
acf30384b7 | ||
|
|
d2040eaac9 | ||
|
|
93673fce03 | ||
|
|
d7a432068b | ||
|
|
cb9215980a | ||
|
|
a4fc1f1c6f | ||
|
|
cbbdb11938 | ||
|
|
a2274bca7b | ||
|
|
f84496b09d | ||
|
|
3abf310a3b | ||
|
|
f12e361c2e | ||
|
|
cd0f1e98cc | ||
|
|
325ef80951 | ||
|
|
9a30d7c7da | ||
|
|
25ea75b761 | ||
|
|
32edf77d35 | ||
|
|
47fbb2b1b7 | ||
|
|
e388f81e1f | ||
|
|
f0bea295c4 | ||
|
|
7439ded43d | ||
|
|
6a640d3708 | ||
|
|
776bce9087 | ||
|
|
3c499f9e97 | ||
|
|
114ff7a3e3 | ||
|
|
527cdf523c | ||
|
|
91eb8ab824 | ||
|
|
7a87fb1c2e | ||
|
|
d8cc6b4c22 | ||
|
|
c9bd94d9f8 | ||
|
|
93bf818882 | ||
|
|
723fb3848d | ||
|
|
6a213e2249 | ||
|
|
a6c5c3a317 | ||
|
|
9313d2c8eb | ||
|
|
485a874ab5 | ||
|
|
f2bc884632 | ||
|
|
39d6653f8e | ||
|
|
2ce0fcb264 | ||
|
|
8bf5e18ae5 | ||
|
|
88f4d8074a | ||
|
|
f5aa2fcac8 | ||
|
|
6f892bea6b | ||
|
|
cbf0c9c988 | ||
|
|
393c67213d | ||
|
|
f69de9f071 | ||
|
|
2e400c0d22 | ||
|
|
6035c10550 | ||
|
|
555b156154 |
33
.github/workflows/artifacts.yml
vendored
33
.github/workflows/artifacts.yml
vendored
@@ -47,9 +47,9 @@ jobs:
|
||||
submodules: true
|
||||
|
||||
- name: Setup .NET Core
|
||||
uses: actions/setup-dotnet@v2
|
||||
uses: actions/setup-dotnet@v3
|
||||
with:
|
||||
dotnet-version: 6.0.x
|
||||
dotnet-version: 7.0.x
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
@@ -81,7 +81,10 @@ jobs:
|
||||
|
||||
- name: Build
|
||||
shell: bash
|
||||
run: dotnet publish ErsatzTV/ErsatzTV.csproj --framework net6.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
run: |
|
||||
sed -i '' '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net7.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net7.0 --runtime "${{ matrix.target }}" -c Release -o publish -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
|
||||
|
||||
- name: Bundle
|
||||
shell: bash
|
||||
@@ -130,7 +133,8 @@ jobs:
|
||||
rm -r ErsatzTV.app
|
||||
|
||||
- name: Delete old release assets
|
||||
uses: mknejp/delete-release-assets@v1
|
||||
uses: asfernandes/delete-release-assets@update-libraries-and-node
|
||||
if: ${{ inputs.release_tag == 'develop' }}
|
||||
with:
|
||||
token: ${{ secrets.gh_token }}
|
||||
tag: ${{ inputs.release_tag }}
|
||||
@@ -173,15 +177,21 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup .NET Core
|
||||
uses: actions/setup-dotnet@v2
|
||||
uses: actions/setup-dotnet@v3
|
||||
with:
|
||||
dotnet-version: 6.0.x
|
||||
dotnet-version: 7.0.x
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '14'
|
||||
|
||||
- name: Setup Rust
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
if: ${{ matrix.kind == 'windows' }}
|
||||
|
||||
- name: Cache NPM dependencies
|
||||
uses: bahmutov/npm-install@v1.4.5
|
||||
with:
|
||||
@@ -209,11 +219,15 @@ jobs:
|
||||
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
|
||||
sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||
dotnet publish ErsatzTV.Scanner/ErsatzTV.Scanner.csproj --framework net7.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
|
||||
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net7.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
|
||||
cargo build --manifest-path=ErsatzTV-Windows/Cargo.toml --release --all-features
|
||||
ls -l ErsatzTV-Windows/target/release
|
||||
mv ErsatzTV-Windows/target/release/ersatztv_windows.exe "$release_name/ErsatzTV-Windows.exe"
|
||||
fi
|
||||
|
||||
# Download ffmpeg
|
||||
@@ -236,7 +250,8 @@ jobs:
|
||||
AC_PASSWORD: ${{ secrets.ac_password }}
|
||||
|
||||
- name: Delete old release assets
|
||||
uses: mknejp/delete-release-assets@v1
|
||||
uses: asfernandes/delete-release-assets@update-libraries-and-node
|
||||
if: ${{ inputs.release_tag == 'develop' }}
|
||||
with:
|
||||
token: ${{ secrets.gh_token }}
|
||||
tag: ${{ inputs.release_tag }}
|
||||
|
||||
78
.github/workflows/pr.yml
vendored
78
.github/workflows/pr.yml
vendored
@@ -2,20 +2,21 @@
|
||||
on:
|
||||
pull_request:
|
||||
jobs:
|
||||
build_and_test:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ windows-latest, ubuntu-latest, macos-latest ]
|
||||
build_and_test_windows:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup .NET Core
|
||||
uses: actions/setup-dotnet@v2
|
||||
uses: actions/setup-dotnet@v3
|
||||
with:
|
||||
dotnet-version: 6.0.x
|
||||
dotnet-version: 7.0.x
|
||||
|
||||
- name: Setup Rust
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
@@ -23,6 +24,67 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: dotnet restore
|
||||
|
||||
- name: Prep project file
|
||||
run: sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||
|
||||
- name: Build
|
||||
run: dotnet build --configuration Release --no-restore
|
||||
|
||||
- name: Test
|
||||
run: dotnet test --no-restore --verbosity normal
|
||||
|
||||
- name: Build Windows
|
||||
run: |
|
||||
cd ErsatzTV-Windows
|
||||
cargo build --release --all-features
|
||||
build_and_test_linux:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup .NET Core
|
||||
uses: actions/setup-dotnet@v3
|
||||
with:
|
||||
dotnet-version: 7.0.x
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
|
||||
- name: Install dependencies
|
||||
run: dotnet restore
|
||||
|
||||
- name: Prep project file
|
||||
run: sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||
|
||||
- name: Build
|
||||
run: dotnet build --configuration Release --no-restore
|
||||
|
||||
- name: Test
|
||||
run: dotnet test --no-restore --verbosity normal
|
||||
build_and_test_mac:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Get the sources
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
|
||||
- name: Setup .NET Core
|
||||
uses: actions/setup-dotnet@v3
|
||||
with:
|
||||
dotnet-version: 7.0.x
|
||||
|
||||
- name: Clean
|
||||
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
|
||||
|
||||
- name: Install dependencies
|
||||
run: dotnet restore
|
||||
|
||||
- name: Prep project file
|
||||
run: sed -i '' '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||
|
||||
- name: Build
|
||||
run: dotnet build --configuration Release --no-restore
|
||||
|
||||
|
||||
126
CHANGELOG.md
126
CHANGELOG.md
@@ -1,10 +1,129 @@
|
||||
# Changelog
|
||||
Changelog
|
||||
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.7.1-beta] - 2023-01-03
|
||||
### Added
|
||||
- Add new music video credit templates
|
||||
|
||||
### Fixed
|
||||
- Fix many transcoding failures caused by the colorspace filter
|
||||
- Fix song playback with VAAPI and NVENC
|
||||
- Fix edge case where some local movies would not automatically be restored from trash
|
||||
- Fix synchronizing Jellyfin and Emby collection items
|
||||
- Fix saving some external subtitle records to database
|
||||
|
||||
### Changed
|
||||
- Upgrade to dotnet 7
|
||||
- Upgrade all docker images to ubuntu jammy and ffmpeg 5.1.2
|
||||
- Limit library scan interval between 0 and 1,000,000
|
||||
- 0 means do not automatically scan libraries
|
||||
- 1 to 999,999 means scan if it has been that many hours since the last scan
|
||||
- Use new `ErsatzTV.Scanner` process for scanning all libraries
|
||||
- This should reduce the ongoing memory footprint
|
||||
|
||||
## [0.7.0-beta] - 2022-12-11
|
||||
### Fixed
|
||||
- Fix removing Jellyfin and Emby libraries that have been deleted from the source media server
|
||||
- Fix `Work-Ahead HLS Segmenter Limit` setting to properly limit number of channels that can work-ahead at once
|
||||
- Include base path value in generated channel playlist (M3U) and channel guide (XMLTV) links
|
||||
- Fix parsing song metadata from OGG audio files
|
||||
- Properly unlock/re-enable trakt list operations after an operation is canceled
|
||||
|
||||
### Added
|
||||
- Add (required) bit depth normalization option to ffmpeg profile
|
||||
- This can help if your card only supports e.g. h264 encoding, normalizing to 8 bits will allow the hardware encoder to be used
|
||||
- Extract font attachments after extracting text subtitles
|
||||
- This should improve SubStation Alpha subtitle rendering
|
||||
- Detect VAAPI capabilities and fallback to software decoding/encoding as needed
|
||||
- Add audio stream selector scripts for episodes and movies
|
||||
- This will let you customize which audio stream is selected for playback
|
||||
- Episodes are passed the following data:
|
||||
- `channelNumber`
|
||||
- `channelName`
|
||||
- `showTitle`
|
||||
- `showGuids`: array of string ids like `imdb_1234` or `tvdb_1234`
|
||||
- `seasonNumber`
|
||||
- `episodeNumber`
|
||||
- `episodeGuids`: array of string ids like `imdb_1234` or `tvdb_1234`
|
||||
- `preferredLanguageCodes`: array of string preferred language codes configured for the channel
|
||||
- `audioStreams`: array of audio stream data, each containing
|
||||
- `index`: the stream's index number, this is what the function needs to return
|
||||
- `channels`: the number of audio channels
|
||||
- `codec`: the audio codec
|
||||
- `isDefault`: bool indicating whether the stream is flagged as default
|
||||
- `isForced`: bool indicating whether the stream is flagged as forced
|
||||
- `language`: the stream's language
|
||||
- `title`: the stream's title
|
||||
- Movies are passed the following data:
|
||||
- `channelNumber`
|
||||
- `channelName`
|
||||
- `title`
|
||||
- `guids`: array of string ids like `imdb_1234` or `tvdb_1234`
|
||||
- `preferredLanguageCodes`: array of string preferred language codes configured for the channel
|
||||
- `audioStreams`: array of audio stream data, each containing
|
||||
- `index`: the stream's index number, this is what the function needs to return
|
||||
- `channels`: the number of audio channels
|
||||
- `codec`: the audio codec
|
||||
- `isDefault`: bool indicating whether the stream is flagged as default
|
||||
- `isForced`: bool indicating whether the stream is flagged as forced
|
||||
- `language`: the stream's language
|
||||
- `title`: the stream's title
|
||||
- Add new fields to search index
|
||||
- `video_codec`: the video codec
|
||||
- `video_bit_depth`: the number of bits in the video stream's pixel format, e.g. 8 or 10
|
||||
- `video_dynamic_range`: the video's dynamic range, either `sdr` or `hdr`
|
||||
|
||||
### Changed
|
||||
- Change `Multi-Episode Shuffle` scripting system to use Javascript instead of Lua
|
||||
|
||||
## [0.6.9-beta] - 2022-10-21
|
||||
### Fixed
|
||||
- Fix bug where tail or fallback filler would sometimes schedule much longer than expected
|
||||
- This only happened with fixed start schedule items following a schedule item with tail or fallback filler
|
||||
- Fix NFO reader bug that caused inaccurate warning messages about invalid XML and incomplete metadata
|
||||
- Fix reverse proxy SSL termination support by supporting `X-Forwarded-Proto` header
|
||||
- Fix automatic playout reset scheduling
|
||||
- Playouts would reset every 30 minutes between midnight and the configured time, instead of only at the configured time
|
||||
- XMLTV: properly group schedule items with `Custom Title` followed by item(s) with `Guide Mode` set to `Filler`
|
||||
|
||||
### Added
|
||||
- Add music video credits template system
|
||||
- Templates are selected in each channel's settings
|
||||
- Templates should be copied from `_default.ass.sbntxt` which is located in the config subfolder `templates/music-video-credits`
|
||||
- Copy the file, give it any name ending with `.ass.sbntext`, and only make edits to the copied file
|
||||
- The default template will be extracted and overwritten every time ErsatzTV is started
|
||||
- The template is an [Advanced SubStation Alpha](http://www.tcax.org/docs/ass-specs.htm) file using [scribian](https://github.com/scriban/scriban/tree/master/doc) template syntax
|
||||
- The following fields are available for use in the template:
|
||||
- `resolution`: the ffmpeg profile's resolution, which is used for margin calculations
|
||||
- `title`: the title of the music video
|
||||
- `track`: the music video's track number
|
||||
- `album`: the music video's album
|
||||
- `plot`: the music video's plot
|
||||
- `release_date`: the music video's release date
|
||||
- `artist`: the music videos artist (the parent folder)
|
||||
- `all_artists`: a list of additional artists from the music video's sidecar NFO metadata file
|
||||
- `duration`: the timespan duration of the music video, which can be used to calculate timing of additional subtitles
|
||||
- `stream_seek`: the timespan that ffmpeg will seek into the media item before beginning playback
|
||||
- Add `Multi-Episode Shuffle` playout order for `Television Show` schedule items
|
||||
- The purpose of this playout order is to improve randomization for shows that normally have intro, multiple episodes, and outro
|
||||
- This playout order requires splitting the parts into individual files (e.g. splitting `s01e01-03.mkv` into `s01e01.mkv`, `s01e02.mkv` and `s01e03.mkv`)
|
||||
- This playout order requires a lua script in the config subfolder `scripts/multi-episode-shuffle`
|
||||
- The lua script should be named for the television show's guid, e.g. `tvdb_12345.lua` or `imdb_tt123456789.lua`
|
||||
- The script defines the number of parts that each un-split file typically contains
|
||||
- The script also defines a function to map each episode to a part number (or no part number i.e. `nil` if an episode has not been split)
|
||||
- All groups of part numbers (i.e. all part 1s, all part 2s) will be shuffled
|
||||
- The playout order will then schedule a random part 1 followed by a random part 2, etc
|
||||
- Un-split (`nil`) episodes will be randomly placed between re-combined parts (e.g. part1, part2, part3, un-split, part1, part2, part3)
|
||||
- Add `ETV_BASE_URL` environment variable to support reverse proxies that use paths (e.g. `/ersatztv`)
|
||||
|
||||
### Changed
|
||||
- No longer place watermarks within content by default (e.g. within 4:3 content padded to a 16:9 resolution)
|
||||
- This can be re-enabled if desired using the `Place Within Source Content` checkbox in watermark settings
|
||||
|
||||
## [0.6.8-beta] - 2022-10-05
|
||||
### Fixed
|
||||
- Fix typo introduced in `0.6.7-beta` that stopped QSV HEVC encoder from working
|
||||
@@ -1337,7 +1456,10 @@ 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.6.8-beta...HEAD
|
||||
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.7.1-beta...HEAD
|
||||
[0.7.1-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.7.0-beta...v0.7.1-beta
|
||||
[0.7.0-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.6.9-beta...v0.7.0-beta
|
||||
[0.6.9-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.6.8-beta...v0.6.9-beta
|
||||
[0.6.8-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.6.7-beta...v0.6.8-beta
|
||||
[0.6.7-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.6.6-beta...v0.6.7-beta
|
||||
[0.6.6-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.6.5-beta...v0.6.6-beta
|
||||
|
||||
2
ErsatzTV-Windows/.gitignore
vendored
Normal file
2
ErsatzTV-Windows/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
target/
|
||||
|
||||
1028
ErsatzTV-Windows/Cargo.lock
generated
Normal file
1028
ErsatzTV-Windows/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
ErsatzTV-Windows/Cargo.toml
Normal file
19
ErsatzTV-Windows/Cargo.toml
Normal file
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "ersatztv_windows"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
tray-item = { git = "https://github.com/olback/tray-item-rs" }
|
||||
special-folder = { git = "https://github.com/masinc/special-folder-rs" }
|
||||
process_path = "0.1.4"
|
||||
|
||||
[dependencies.windows]
|
||||
version = "0.43.0"
|
||||
features = [
|
||||
"Win32_System_Console",
|
||||
"Win32_Foundation"
|
||||
]
|
||||
|
||||
[build-dependencies]
|
||||
windres = "*"
|
||||
@@ -1,33 +0,0 @@
|
||||
<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="CliWrap" Version="3.5.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ErsatzTV.Core\ErsatzTV.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Update="Program.cs">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Compile>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,14 +0,0 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
using System.Diagnostics;
|
||||
using CliWrap;
|
||||
|
||||
namespace ErsatzTV_Windows;
|
||||
|
||||
public class TrayApplicationContext : ApplicationContext
|
||||
{
|
||||
private readonly NotifyIcon _trayIcon;
|
||||
private readonly CancellationTokenSource _tokenSource;
|
||||
|
||||
public TrayApplicationContext()
|
||||
{
|
||||
_trayIcon = new NotifyIcon
|
||||
{
|
||||
Icon = new Icon("./Ersatztv.ico"),
|
||||
ContextMenuStrip = new ContextMenuStrip(),
|
||||
Visible = true
|
||||
};
|
||||
|
||||
_tokenSource = new CancellationTokenSource();
|
||||
|
||||
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))
|
||||
{
|
||||
|
||||
Cli.Wrap(exe)
|
||||
.WithWorkingDirectory(folder)
|
||||
.WithValidation(CommandResultValidation.None)
|
||||
.ExecuteAsync(_tokenSource.Token);
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
_tokenSource?.Cancel();
|
||||
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();
|
||||
}
|
||||
}
|
||||
5
ErsatzTV-Windows/build.rs
Normal file
5
ErsatzTV-Windows/build.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
use windres::Build;
|
||||
|
||||
fn main() {
|
||||
Build::new().compile("ersatztv_windows.rc").unwrap();
|
||||
}
|
||||
2
ErsatzTV-Windows/ersatztv_windows.rc
Normal file
2
ErsatzTV-Windows/ersatztv_windows.rc
Normal file
@@ -0,0 +1,2 @@
|
||||
id ICON "ersatztv.ico"
|
||||
ersatztv-icon ICON "ersatztv.ico"
|
||||
112
ErsatzTV-Windows/src/main.rs
Normal file
112
ErsatzTV-Windows/src/main.rs
Normal file
@@ -0,0 +1,112 @@
|
||||
#![windows_subsystem = "windows"]
|
||||
|
||||
use special_folder::SpecialFolder;
|
||||
use std::fs;
|
||||
use std::os::windows::process::CommandExt;
|
||||
use std::process::Child;
|
||||
use std::process::Command;
|
||||
use std::process::Stdio;
|
||||
use windows::Win32::System::Console;
|
||||
use {std::sync::mpsc, tray_item::TrayItem};
|
||||
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
|
||||
enum Message {
|
||||
Exit,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let mut tray = TrayItem::new("ErsatzTV", "ersatztv-icon").unwrap();
|
||||
|
||||
let (tx, rx) = mpsc::channel();
|
||||
|
||||
tray.add_menu_item("Launch Web UI", || {
|
||||
let _ = Command::new("cmd")
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.arg("/C")
|
||||
.arg("start")
|
||||
.arg("http://localhost:8409")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn();
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
tray.add_menu_item("Show Logs", || {
|
||||
let path = SpecialFolder::LocalApplicationData
|
||||
.get()
|
||||
.unwrap()
|
||||
.join("ersatztv")
|
||||
.join("logs");
|
||||
match path.to_str() {
|
||||
None => {}
|
||||
Some(folder) => {
|
||||
fs::create_dir_all(folder).unwrap();
|
||||
let _ = Command::new("cmd")
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.arg("/C")
|
||||
.arg("start")
|
||||
.arg(folder)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn();
|
||||
}
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
tray.inner_mut().add_separator().unwrap();
|
||||
|
||||
tray.add_menu_item("Exit", move || {
|
||||
tx.send(Message::Exit).unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let path = process_path::get_executable_path();
|
||||
let mut child: Option<Child> = None;
|
||||
match path {
|
||||
None => {}
|
||||
Some(path) => {
|
||||
let etv = path.parent().unwrap().join("ErsatzTV.exe");
|
||||
if etv.exists() {
|
||||
match etv.to_str() {
|
||||
None => {}
|
||||
Some(etv) => {
|
||||
child = Some(
|
||||
Command::new(etv)
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loop {
|
||||
match rx.recv() {
|
||||
Ok(Message::Exit) => {
|
||||
match child {
|
||||
None => {}
|
||||
Some(mut child) => {
|
||||
unsafe {
|
||||
if Console::AttachConsole(child.id()) == true
|
||||
{
|
||||
Console::GenerateConsoleCtrlEvent(Console::CTRL_C_EVENT, 0);
|
||||
}
|
||||
}
|
||||
child.wait().unwrap();
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,4 +18,5 @@ public record ChannelViewModel(
|
||||
int PlayoutCount,
|
||||
string PreferredSubtitleLanguageCode,
|
||||
ChannelSubtitleMode SubtitleMode,
|
||||
ChannelMusicVideoCreditsMode MusicVideoCreditsMode);
|
||||
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
|
||||
string MusicVideoCreditsTemplate);
|
||||
|
||||
@@ -18,4 +18,5 @@ public record CreateChannel
|
||||
int? FallbackFillerId,
|
||||
string PreferredSubtitleLanguageCode,
|
||||
ChannelSubtitleMode SubtitleMode,
|
||||
ChannelMusicVideoCreditsMode MusicVideoCreditsMode) : IRequest<Either<BaseError, CreateChannelResult>>;
|
||||
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
|
||||
string MusicVideoCreditsTemplate) : IRequest<Either<BaseError, CreateChannelResult>>;
|
||||
|
||||
@@ -74,7 +74,8 @@ public class CreateChannelHandler : IRequestHandler<CreateChannel, Either<BaseEr
|
||||
PreferredAudioTitle = request.PreferredAudioTitle,
|
||||
PreferredSubtitleLanguageCode = preferredSubtitleLanguageCode,
|
||||
SubtitleMode = request.SubtitleMode,
|
||||
MusicVideoCreditsMode = request.MusicVideoCreditsMode
|
||||
MusicVideoCreditsMode = request.MusicVideoCreditsMode,
|
||||
MusicVideoCreditsTemplate = request.MusicVideoCreditsTemplate
|
||||
};
|
||||
|
||||
foreach (int id in watermarkId)
|
||||
|
||||
@@ -19,4 +19,5 @@ public record UpdateChannel
|
||||
int? FallbackFillerId,
|
||||
string PreferredSubtitleLanguageCode,
|
||||
ChannelSubtitleMode SubtitleMode,
|
||||
ChannelMusicVideoCreditsMode MusicVideoCreditsMode) : IRequest<Either<BaseError, ChannelViewModel>>;
|
||||
ChannelMusicVideoCreditsMode MusicVideoCreditsMode,
|
||||
string MusicVideoCreditsTemplate) : IRequest<Either<BaseError, ChannelViewModel>>;
|
||||
|
||||
@@ -46,6 +46,7 @@ public class UpdateChannelHandler : IRequestHandler<UpdateChannel, Either<BaseEr
|
||||
c.PreferredSubtitleLanguageCode = update.PreferredSubtitleLanguageCode;
|
||||
c.SubtitleMode = update.SubtitleMode;
|
||||
c.MusicVideoCreditsMode = update.MusicVideoCreditsMode;
|
||||
c.MusicVideoCreditsTemplate = update.MusicVideoCreditsTemplate;
|
||||
c.Artwork ??= new List<Artwork>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(update.Logo))
|
||||
|
||||
@@ -22,7 +22,8 @@ internal static class Mapper
|
||||
channel.Playouts?.Count ?? 0,
|
||||
channel.PreferredSubtitleLanguageCode,
|
||||
channel.SubtitleMode,
|
||||
channel.MusicVideoCreditsMode);
|
||||
channel.MusicVideoCreditsMode,
|
||||
channel.MusicVideoCreditsTemplate);
|
||||
|
||||
internal static ChannelResponseModel ProjectToResponseModel(Channel channel) =>
|
||||
new(
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record GetChannelGuide(string Scheme, string Host) : IRequest<ChannelGuide>;
|
||||
public record GetChannelGuide(string Scheme, string Host, string BaseUrl) : IRequest<ChannelGuide>;
|
||||
|
||||
@@ -19,5 +19,11 @@ public class GetChannelGuideHandler : IRequestHandler<GetChannelGuide, ChannelGu
|
||||
|
||||
public Task<ChannelGuide> Handle(GetChannelGuide request, CancellationToken cancellationToken) =>
|
||||
_channelRepository.GetAllForGuide()
|
||||
.Map(channels => new ChannelGuide(_recyclableMemoryStreamManager, request.Scheme, request.Host, channels));
|
||||
.Map(
|
||||
channels => new ChannelGuide(
|
||||
_recyclableMemoryStreamManager,
|
||||
request.Scheme,
|
||||
request.Host,
|
||||
request.BaseUrl,
|
||||
channels));
|
||||
}
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public record GetChannelPlaylist(string Scheme, string Host, string Mode) : IRequest<ChannelPlaylist>;
|
||||
public record GetChannelPlaylist(string Scheme, string Host, string BaseUrl, string Mode) : IRequest<ChannelPlaylist>;
|
||||
|
||||
@@ -14,7 +14,7 @@ public class GetChannelPlaylistHandler : IRequestHandler<GetChannelPlaylist, Cha
|
||||
public Task<ChannelPlaylist> Handle(GetChannelPlaylist request, CancellationToken cancellationToken) =>
|
||||
_channelRepository.GetAll()
|
||||
.Map(channels => EnsureMode(channels, request.Mode))
|
||||
.Map(channels => new ChannelPlaylist(request.Scheme, request.Host, channels));
|
||||
.Map(channels => new ChannelPlaylist(request.Scheme, request.Host, request.BaseUrl, channels));
|
||||
|
||||
private static List<Channel> EnsureMode(IEnumerable<Channel> channels, string mode)
|
||||
{
|
||||
|
||||
@@ -24,8 +24,8 @@ public class UpdateLibraryRefreshIntervalHandler :
|
||||
|
||||
private static Task<Validation<BaseError, Unit>> Validate(UpdateLibraryRefreshInterval request) =>
|
||||
Optional(request.LibraryRefreshInterval)
|
||||
.Where(lri => lri > 0)
|
||||
.Where(lri => lri is >= 0 and < 1_000_000)
|
||||
.Map(_ => Unit.Default)
|
||||
.ToValidation<BaseError>("Tuner count must be greater than zero")
|
||||
.ToValidation<BaseError>("Library refresh interval must be zero or greated")
|
||||
.AsTask();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.FFmpeg.Runtime;
|
||||
|
||||
namespace ErsatzTV.Application.Emby;
|
||||
|
||||
public class CallEmbyLibraryScannerHandler : CallLibraryScannerHandler,
|
||||
IRequestHandler<ForceSynchronizeEmbyLibraryById, Either<BaseError, string>>,
|
||||
IRequestHandler<SynchronizeEmbyLibraryByIdIfNeeded, Either<BaseError, string>>
|
||||
{
|
||||
public CallEmbyLibraryScannerHandler(
|
||||
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
|
||||
IMediator mediator,
|
||||
IRuntimeInfo runtimeInfo)
|
||||
: base(channel, mediator, runtimeInfo)
|
||||
{
|
||||
}
|
||||
|
||||
Task<Either<BaseError, string>> IRequestHandler<ForceSynchronizeEmbyLibraryById, Either<BaseError, string>>.Handle(
|
||||
ForceSynchronizeEmbyLibraryById request,
|
||||
CancellationToken cancellationToken) => Handle(request, cancellationToken);
|
||||
|
||||
Task<Either<BaseError, string>> IRequestHandler<SynchronizeEmbyLibraryByIdIfNeeded, Either<BaseError, string>>.Handle(
|
||||
SynchronizeEmbyLibraryByIdIfNeeded request,
|
||||
CancellationToken cancellationToken) => Handle(request, cancellationToken);
|
||||
|
||||
private async Task<Either<BaseError, string>> Handle(
|
||||
ISynchronizeEmbyLibraryById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, string> validation = Validate();
|
||||
return await validation.Match(
|
||||
scanner => PerformScan(scanner, request, cancellationToken),
|
||||
error => Task.FromResult<Either<BaseError, string>>(error.Join()));
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, string>> PerformScan(
|
||||
string scanner,
|
||||
ISynchronizeEmbyLibraryById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var arguments = new List<string>
|
||||
{
|
||||
"scan-emby", request.EmbyLibraryId.ToString()
|
||||
};
|
||||
|
||||
if (request.ForceScan)
|
||||
{
|
||||
arguments.Add("--force");
|
||||
}
|
||||
|
||||
return await base.PerformScan(scanner, arguments, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Emby;
|
||||
|
||||
public record SynchronizeEmbyCollections(int EmbyMediaSourceId) : IRequest<Either<BaseError, Unit>>,
|
||||
IEmbyBackgroundServiceRequest;
|
||||
@@ -1,22 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<TargetFramework>net7.0</TargetFramework>
|
||||
<NoWarn>VSTHRD200</NoWarn>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Bugsnag" Version="3.1.0" />
|
||||
<PackageReference Include="CliWrap" Version="3.5.0" />
|
||||
<PackageReference Include="CliWrap" Version="3.6.0" />
|
||||
<PackageReference Include="Humanizer.Core" Version="2.14.1" />
|
||||
<PackageReference Include="MediatR" Version="11.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.3.44">
|
||||
<PackageReference Include="MediatR" Version="11.1.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="7.0.0" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.4.27">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.2" />
|
||||
<PackageReference Include="Serilog.Formatting.Compact.Reader" Version="2.0.0" />
|
||||
<PackageReference Include="Winista.MimeDetect" Version="1.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -40,5 +40,6 @@
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=streaming_005Cqueries/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=subtitles_005Ccommands/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=television_005Cqueries/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=templates_005Cqueries/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=watermarks_005Ccommands/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=watermarks_005Cqueries/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
|
||||
@@ -13,6 +13,7 @@ public record CreateFFmpegProfile(
|
||||
int? QsvExtraHardwareFrames,
|
||||
int ResolutionId,
|
||||
FFmpegProfileVideoFormat VideoFormat,
|
||||
FFmpegProfileBitDepth BitDepth,
|
||||
int VideoBitrate,
|
||||
int VideoBufferSize,
|
||||
FFmpegProfileAudioFormat AudioFormat,
|
||||
|
||||
@@ -47,6 +47,7 @@ public class CreateFFmpegProfileHandler :
|
||||
QsvExtraHardwareFrames = request.QsvExtraHardwareFrames,
|
||||
ResolutionId = resolutionId,
|
||||
VideoFormat = request.VideoFormat,
|
||||
BitDepth = request.BitDepth,
|
||||
VideoBitrate = request.VideoBitrate,
|
||||
VideoBufferSize = request.VideoBufferSize,
|
||||
AudioFormat = request.AudioFormat,
|
||||
|
||||
@@ -14,6 +14,7 @@ public record UpdateFFmpegProfile(
|
||||
int? QsvExtraHardwareFrames,
|
||||
int ResolutionId,
|
||||
FFmpegProfileVideoFormat VideoFormat,
|
||||
FFmpegProfileBitDepth BitDepth,
|
||||
int VideoBitrate,
|
||||
int VideoBufferSize,
|
||||
FFmpegProfileAudioFormat AudioFormat,
|
||||
|
||||
@@ -36,6 +36,7 @@ public class
|
||||
p.QsvExtraHardwareFrames = update.QsvExtraHardwareFrames;
|
||||
p.ResolutionId = update.ResolutionId;
|
||||
p.VideoFormat = update.VideoFormat;
|
||||
p.BitDepth = update.BitDepth;
|
||||
p.VideoBitrate = update.VideoBitrate;
|
||||
p.VideoBufferSize = update.VideoBufferSize;
|
||||
p.AudioFormat = update.AudioFormat;
|
||||
|
||||
@@ -14,6 +14,7 @@ public record FFmpegProfileViewModel(
|
||||
int? QsvExtraHardwareFrames,
|
||||
ResolutionViewModel Resolution,
|
||||
FFmpegProfileVideoFormat VideoFormat,
|
||||
FFmpegProfileBitDepth BitDepth,
|
||||
int VideoBitrate,
|
||||
int VideoBufferSize,
|
||||
FFmpegProfileAudioFormat AudioFormat,
|
||||
|
||||
@@ -17,6 +17,7 @@ internal static class Mapper
|
||||
profile.QsvExtraHardwareFrames,
|
||||
Project(profile.Resolution),
|
||||
profile.VideoFormat,
|
||||
profile.BitDepth,
|
||||
profile.VideoBitrate,
|
||||
profile.VideoBufferSize,
|
||||
profile.AudioFormat,
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace ErsatzTV.Application;
|
||||
|
||||
public interface ISearchIndexBackgroundServiceRequest
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.FFmpeg.Runtime;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin;
|
||||
|
||||
public class CallJellyfinLibraryScannerHandler : CallLibraryScannerHandler,
|
||||
IRequestHandler<ForceSynchronizeJellyfinLibraryById, Either<BaseError, string>>,
|
||||
IRequestHandler<SynchronizeJellyfinLibraryByIdIfNeeded, Either<BaseError, string>>
|
||||
{
|
||||
public CallJellyfinLibraryScannerHandler(
|
||||
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
|
||||
IMediator mediator,
|
||||
IRuntimeInfo runtimeInfo)
|
||||
: base(channel, mediator, runtimeInfo)
|
||||
{
|
||||
}
|
||||
|
||||
Task<Either<BaseError, string>> IRequestHandler<ForceSynchronizeJellyfinLibraryById, Either<BaseError, string>>.Handle(
|
||||
ForceSynchronizeJellyfinLibraryById request,
|
||||
CancellationToken cancellationToken) => Handle(request, cancellationToken);
|
||||
|
||||
Task<Either<BaseError, string>> IRequestHandler<SynchronizeJellyfinLibraryByIdIfNeeded, Either<BaseError, string>>.Handle(
|
||||
SynchronizeJellyfinLibraryByIdIfNeeded request,
|
||||
CancellationToken cancellationToken) => Handle(request, cancellationToken);
|
||||
|
||||
private async Task<Either<BaseError, string>> Handle(
|
||||
ISynchronizeJellyfinLibraryById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, string> validation = Validate();
|
||||
return await validation.Match(
|
||||
scanner => PerformScan(scanner, request, cancellationToken),
|
||||
error => Task.FromResult<Either<BaseError, string>>(error.Join()));
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, string>> PerformScan(
|
||||
string scanner,
|
||||
ISynchronizeJellyfinLibraryById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var arguments = new List<string>
|
||||
{
|
||||
"scan-jellyfin", request.JellyfinLibraryId.ToString()
|
||||
};
|
||||
|
||||
if (request.ForceScan)
|
||||
{
|
||||
arguments.Add("--force");
|
||||
}
|
||||
|
||||
return await base.PerformScan(scanner, arguments, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin;
|
||||
|
||||
public record SynchronizeJellyfinCollections(int JellyfinMediaSourceId) : IRequest<Either<BaseError, Unit>>,
|
||||
IJellyfinBackgroundServiceRequest;
|
||||
@@ -0,0 +1,142 @@
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading.Channels;
|
||||
using CliWrap;
|
||||
using ErsatzTV.Application.Search;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.MediaSources;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using ErsatzTV.FFmpeg.Runtime;
|
||||
using Newtonsoft.Json;
|
||||
using Serilog;
|
||||
using Serilog.Formatting.Compact.Reader;
|
||||
|
||||
namespace ErsatzTV.Application.Libraries;
|
||||
|
||||
public abstract class CallLibraryScannerHandler
|
||||
{
|
||||
private readonly ChannelWriter<ISearchIndexBackgroundServiceRequest> _channel;
|
||||
private readonly IMediator _mediator;
|
||||
private readonly IRuntimeInfo _runtimeInfo;
|
||||
private string _libraryName;
|
||||
|
||||
protected CallLibraryScannerHandler(
|
||||
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
|
||||
IMediator mediator,
|
||||
IRuntimeInfo runtimeInfo)
|
||||
{
|
||||
_channel = channel;
|
||||
_mediator = mediator;
|
||||
_runtimeInfo = runtimeInfo;
|
||||
}
|
||||
|
||||
protected async Task<Either<BaseError, string>> PerformScan(
|
||||
string scanner,
|
||||
List<string> arguments,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var forcefulCts = new CancellationTokenSource();
|
||||
|
||||
await using CancellationTokenRegistration link = cancellationToken.Register(
|
||||
() => forcefulCts.CancelAfter(TimeSpan.FromSeconds(10))
|
||||
);
|
||||
|
||||
CommandResult process = await Cli.Wrap(scanner)
|
||||
.WithArguments(arguments)
|
||||
.WithValidation(CommandResultValidation.None)
|
||||
.WithStandardErrorPipe(PipeTarget.ToDelegate(ProcessLogOutput))
|
||||
.WithStandardOutputPipe(PipeTarget.ToDelegate(ProcessProgressOutput))
|
||||
.ExecuteAsync(forcefulCts.Token, cancellationToken);
|
||||
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
return BaseError.New($"ErsatzTV.Scanner exited with code {process.ExitCode}");
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
|
||||
return _libraryName ?? string.Empty;
|
||||
}
|
||||
|
||||
private static void ProcessLogOutput(string s)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(s))
|
||||
{
|
||||
try
|
||||
{
|
||||
Log.Write(LogEventReader.ReadFromString(s));
|
||||
}
|
||||
catch
|
||||
{
|
||||
Console.WriteLine(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessProgressOutput(string s)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(s))
|
||||
{
|
||||
try
|
||||
{
|
||||
ScannerProgressUpdate progressUpdate = JsonConvert.DeserializeObject<ScannerProgressUpdate>(s);
|
||||
if (progressUpdate != null)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(progressUpdate.LibraryName))
|
||||
{
|
||||
_libraryName = progressUpdate.LibraryName;
|
||||
}
|
||||
|
||||
if (progressUpdate.PercentComplete is not null)
|
||||
{
|
||||
var progress = new LibraryScanProgress(
|
||||
progressUpdate.LibraryId,
|
||||
progressUpdate.PercentComplete.Value);
|
||||
|
||||
await _mediator.Publish(progress);
|
||||
}
|
||||
|
||||
if (progressUpdate.ItemsToReindex.Length > 0)
|
||||
{
|
||||
var reindex = new ReindexMediaItems(progressUpdate.ItemsToReindex);
|
||||
await _channel.WriteAsync(reindex);
|
||||
}
|
||||
|
||||
if (progressUpdate.ItemsToRemove.Length > 0)
|
||||
{
|
||||
var remove = new RemoveMediaItems(progressUpdate.ItemsToRemove);
|
||||
await _channel.WriteAsync(remove);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Logger.Warning(ex, "Unable to process scanner progress update");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected Validation<BaseError, string> Validate()
|
||||
{
|
||||
string executable = _runtimeInfo.IsOSPlatform(OSPlatform.Windows)
|
||||
? "ErsatzTV.Scanner.exe"
|
||||
: "ErsatzTV.Scanner";
|
||||
|
||||
string processFileName = Process.GetCurrentProcess().MainModule?.FileName ?? string.Empty;
|
||||
if (!string.IsNullOrWhiteSpace(processFileName))
|
||||
{
|
||||
string localFileName = Path.Combine(Path.GetDirectoryName(processFileName) ?? string.Empty, executable);
|
||||
if (File.Exists(localFileName))
|
||||
{
|
||||
return localFileName;
|
||||
}
|
||||
}
|
||||
|
||||
return BaseError.New("Unable to locate ErsatzTV.Scanner executable");
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
using Serilog.Events;
|
||||
|
||||
namespace ErsatzTV.Application.Logs;
|
||||
|
||||
public record LogEntryViewModel(
|
||||
int Id,
|
||||
DateTime Timestamp,
|
||||
LogEventLevel Level,
|
||||
string Exception,
|
||||
string Message);
|
||||
@@ -1,42 +0,0 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Serilog.Events;
|
||||
|
||||
namespace ErsatzTV.Application.Logs;
|
||||
|
||||
internal static class Mapper
|
||||
{
|
||||
internal static LogEntryViewModel ProjectToViewModel(LogEntry logEntry)
|
||||
{
|
||||
string message = logEntry.RenderedMessage;
|
||||
if (!string.IsNullOrWhiteSpace(logEntry.Properties))
|
||||
{
|
||||
foreach (KeyValuePair<string, JToken> property in JObject.Parse(logEntry.Properties))
|
||||
{
|
||||
var token = $"{{{property.Key}}}";
|
||||
if (message.Contains(token))
|
||||
{
|
||||
message = message.Replace(token, property.Value.ToString());
|
||||
}
|
||||
|
||||
var destructureToken = $"{{@{property.Key}}}";
|
||||
if (message.Contains(destructureToken))
|
||||
{
|
||||
message = message.Replace(destructureToken, property.Value.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!Enum.TryParse(logEntry.Level, out LogEventLevel level))
|
||||
{
|
||||
level = LogEventLevel.Debug;
|
||||
}
|
||||
|
||||
return new LogEntryViewModel(
|
||||
logEntry.Id,
|
||||
logEntry.Timestamp,
|
||||
level,
|
||||
logEntry.Exception,
|
||||
message);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
namespace ErsatzTV.Application.Logs;
|
||||
|
||||
public record PagedLogEntriesViewModel(int TotalCount, List<LogEntryViewModel> Page);
|
||||
@@ -1,10 +0,0 @@
|
||||
using System.Linq.Expressions;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.Logs;
|
||||
|
||||
public record GetRecentLogEntries(int PageNum, int PageSize) : IRequest<PagedLogEntriesViewModel>
|
||||
{
|
||||
public Expression<Func<LogEntry, object>> SortExpression { get; set; }
|
||||
public Option<bool> SortDescending { get; set; }
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.Logs.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Logs;
|
||||
|
||||
public class GetRecentLogEntriesHandler : IRequestHandler<GetRecentLogEntries, PagedLogEntriesViewModel>
|
||||
{
|
||||
private readonly IDbContextFactory<LogContext> _dbContextFactory;
|
||||
|
||||
public GetRecentLogEntriesHandler(IDbContextFactory<LogContext> dbContextFactory) =>
|
||||
_dbContextFactory = dbContextFactory;
|
||||
|
||||
public async Task<PagedLogEntriesViewModel> Handle(
|
||||
GetRecentLogEntries request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using LogContext logContext = _dbContextFactory.CreateDbContext();
|
||||
int count = await logContext.LogEntries.CountAsync(cancellationToken);
|
||||
|
||||
IOrderedQueryable<LogEntry> ordered = logContext.LogEntries
|
||||
.OrderByDescending(le => le.Id);
|
||||
|
||||
foreach (bool descending in request.SortDescending)
|
||||
{
|
||||
ordered = descending
|
||||
? logContext.LogEntries.OrderByDescending(request.SortExpression).ThenByDescending(le => le.Id)
|
||||
: logContext.LogEntries.OrderBy(request.SortExpression).ThenByDescending(le => le.Id);
|
||||
}
|
||||
|
||||
List<LogEntryViewModel> page = await ordered
|
||||
.Skip(request.PageNum * request.PageSize)
|
||||
.Take(request.PageSize)
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(list => list.Map(ProjectToViewModel).ToList());
|
||||
|
||||
return new PagedLogEntriesViewModel(count, page);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using ErsatzTV.Core;
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Search;
|
||||
@@ -8,13 +9,16 @@ namespace ErsatzTV.Application.Maintenance;
|
||||
|
||||
public class EmptyTrashHandler : IRequestHandler<EmptyTrash, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IClient _client;
|
||||
private readonly IMediaItemRepository _mediaItemRepository;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
|
||||
public EmptyTrashHandler(
|
||||
IClient client,
|
||||
IMediaItemRepository mediaItemRepository,
|
||||
ISearchIndex searchIndex)
|
||||
{
|
||||
_client = client;
|
||||
_mediaItemRepository = mediaItemRepository;
|
||||
_searchIndex = searchIndex;
|
||||
}
|
||||
@@ -39,7 +43,7 @@ public class EmptyTrashHandler : IRequestHandler<EmptyTrash, Either<BaseError, U
|
||||
|
||||
foreach (string type in types)
|
||||
{
|
||||
SearchResult result = await _searchIndex.Search($"type:{type} AND (state:FileNotFound)", 0, 0);
|
||||
SearchResult result = _searchIndex.Search(_client, $"type:{type} AND (state:FileNotFound)", 0, 0);
|
||||
ids.AddRange(result.Items.Map(i => i.Id));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.FFmpeg.Runtime;
|
||||
|
||||
namespace ErsatzTV.Application.MediaSources;
|
||||
|
||||
public class CallLocalLibraryScannerHandler : CallLibraryScannerHandler,
|
||||
IRequestHandler<ForceScanLocalLibrary, Either<BaseError, string>>,
|
||||
IRequestHandler<ScanLocalLibraryIfNeeded, Either<BaseError, string>>
|
||||
{
|
||||
public CallLocalLibraryScannerHandler(
|
||||
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
|
||||
IMediator mediator,
|
||||
IRuntimeInfo runtimeInfo)
|
||||
: base(channel, mediator, runtimeInfo)
|
||||
{
|
||||
}
|
||||
|
||||
Task<Either<BaseError, string>> IRequestHandler<ForceScanLocalLibrary, Either<BaseError, string>>.Handle(
|
||||
ForceScanLocalLibrary request,
|
||||
CancellationToken cancellationToken) => Handle(request, cancellationToken);
|
||||
|
||||
Task<Either<BaseError, string>> IRequestHandler<ScanLocalLibraryIfNeeded, Either<BaseError, string>>.Handle(
|
||||
ScanLocalLibraryIfNeeded request,
|
||||
CancellationToken cancellationToken) => Handle(request, cancellationToken);
|
||||
|
||||
private async Task<Either<BaseError, string>> Handle(IScanLocalLibrary request, CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, string> validation = Validate();
|
||||
return await validation.Match(
|
||||
scanner => PerformScan(scanner, request, cancellationToken),
|
||||
error => Task.FromResult<Either<BaseError, string>>(error.Join()));
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, string>> PerformScan(
|
||||
string scanner,
|
||||
IScanLocalLibrary request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var arguments = new List<string>
|
||||
{
|
||||
"scan-local", request.LibraryId.ToString()
|
||||
};
|
||||
|
||||
if (request.ForceScan)
|
||||
{
|
||||
arguments.Add("--force");
|
||||
}
|
||||
|
||||
return await base.PerformScan(scanner, arguments, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
using System.Diagnostics;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using Humanizer;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Application.MediaSources;
|
||||
|
||||
public class ScanLocalLibraryHandler : IRequestHandler<ForceScanLocalLibrary, Either<BaseError, string>>,
|
||||
IRequestHandler<ScanLocalLibraryIfNeeded, Either<BaseError, string>>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly IEntityLocker _entityLocker;
|
||||
private readonly ILibraryRepository _libraryRepository;
|
||||
private readonly ILogger<ScanLocalLibraryHandler> _logger;
|
||||
private readonly IMediator _mediator;
|
||||
private readonly IMovieFolderScanner _movieFolderScanner;
|
||||
private readonly IMusicVideoFolderScanner _musicVideoFolderScanner;
|
||||
private readonly IOtherVideoFolderScanner _otherVideoFolderScanner;
|
||||
private readonly ISongFolderScanner _songFolderScanner;
|
||||
private readonly ITelevisionFolderScanner _televisionFolderScanner;
|
||||
|
||||
public ScanLocalLibraryHandler(
|
||||
ILibraryRepository libraryRepository,
|
||||
IConfigElementRepository configElementRepository,
|
||||
IMovieFolderScanner movieFolderScanner,
|
||||
ITelevisionFolderScanner televisionFolderScanner,
|
||||
IMusicVideoFolderScanner musicVideoFolderScanner,
|
||||
IOtherVideoFolderScanner otherVideoFolderScanner,
|
||||
ISongFolderScanner songFolderScanner,
|
||||
IEntityLocker entityLocker,
|
||||
IMediator mediator,
|
||||
ILogger<ScanLocalLibraryHandler> logger)
|
||||
{
|
||||
_libraryRepository = libraryRepository;
|
||||
_configElementRepository = configElementRepository;
|
||||
_movieFolderScanner = movieFolderScanner;
|
||||
_televisionFolderScanner = televisionFolderScanner;
|
||||
_musicVideoFolderScanner = musicVideoFolderScanner;
|
||||
_otherVideoFolderScanner = otherVideoFolderScanner;
|
||||
_songFolderScanner = songFolderScanner;
|
||||
_entityLocker = entityLocker;
|
||||
_mediator = mediator;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
Task<Either<BaseError, string>> IRequestHandler<ForceScanLocalLibrary, Either<BaseError, string>>.Handle(
|
||||
ForceScanLocalLibrary request,
|
||||
CancellationToken cancellationToken) => Handle(request, cancellationToken);
|
||||
|
||||
Task<Either<BaseError, string>> IRequestHandler<ScanLocalLibraryIfNeeded, Either<BaseError, string>>.Handle(
|
||||
ScanLocalLibraryIfNeeded request,
|
||||
CancellationToken cancellationToken) => Handle(request, cancellationToken);
|
||||
|
||||
private Task<Either<BaseError, string>> Handle(IScanLocalLibrary request, CancellationToken cancellationToken) =>
|
||||
Validate(request)
|
||||
.MapT(parameters => PerformScan(parameters, cancellationToken).Map(_ => parameters.LocalLibrary.Name))
|
||||
.Bind(v => v.ToEitherAsync());
|
||||
|
||||
private async Task<Unit> PerformScan(RequestParameters parameters, CancellationToken cancellationToken)
|
||||
{
|
||||
(LocalLibrary localLibrary, string ffprobePath, string ffmpegPath, bool forceScan,
|
||||
int libraryRefreshInterval) = parameters;
|
||||
|
||||
try
|
||||
{
|
||||
var sw = new Stopwatch();
|
||||
sw.Start();
|
||||
|
||||
var scanned = false;
|
||||
|
||||
for (var i = 0; i < localLibrary.Paths.Count; i++)
|
||||
{
|
||||
LibraryPath libraryPath = localLibrary.Paths[i];
|
||||
|
||||
decimal progressMin = (decimal)i / localLibrary.Paths.Count;
|
||||
decimal progressMax = (decimal)(i + 1) / localLibrary.Paths.Count;
|
||||
|
||||
var lastScan = new DateTimeOffset(libraryPath.LastScan ?? SystemTime.MinValueUtc, TimeSpan.Zero);
|
||||
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(libraryRefreshInterval);
|
||||
if (forceScan || nextScan < DateTimeOffset.Now)
|
||||
{
|
||||
scanned = true;
|
||||
|
||||
Either<BaseError, Unit> result = localLibrary.MediaKind switch
|
||||
{
|
||||
LibraryMediaKind.Movies =>
|
||||
await _movieFolderScanner.ScanFolder(
|
||||
libraryPath,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
progressMin,
|
||||
progressMax,
|
||||
cancellationToken),
|
||||
LibraryMediaKind.Shows =>
|
||||
await _televisionFolderScanner.ScanFolder(
|
||||
libraryPath,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
progressMin,
|
||||
progressMax,
|
||||
cancellationToken),
|
||||
LibraryMediaKind.MusicVideos =>
|
||||
await _musicVideoFolderScanner.ScanFolder(
|
||||
libraryPath,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
progressMin,
|
||||
progressMax,
|
||||
cancellationToken),
|
||||
LibraryMediaKind.OtherVideos =>
|
||||
await _otherVideoFolderScanner.ScanFolder(
|
||||
libraryPath,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
progressMin,
|
||||
progressMax,
|
||||
cancellationToken),
|
||||
LibraryMediaKind.Songs =>
|
||||
await _songFolderScanner.ScanFolder(
|
||||
libraryPath,
|
||||
ffprobePath,
|
||||
ffmpegPath,
|
||||
progressMin,
|
||||
progressMax,
|
||||
cancellationToken),
|
||||
_ => Unit.Default
|
||||
};
|
||||
|
||||
if (result.IsRight)
|
||||
{
|
||||
libraryPath.LastScan = DateTime.UtcNow;
|
||||
await _libraryRepository.UpdateLastScan(libraryPath);
|
||||
}
|
||||
}
|
||||
|
||||
await _mediator.Publish(new LibraryScanProgress(libraryPath.LibraryId, progressMax), cancellationToken);
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
|
||||
if (scanned)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Scan of library {Name} completed in {Duration}",
|
||||
localLibrary.Name,
|
||||
sw.Elapsed.Humanize());
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Skipping unforced scan of local media library {Name}",
|
||||
localLibrary.Name);
|
||||
}
|
||||
|
||||
await _mediator.Publish(new LibraryScanProgress(localLibrary.Id, 0), cancellationToken);
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_entityLocker.UnlockLibrary(localLibrary.Id);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Validation<BaseError, RequestParameters>> Validate(IScanLocalLibrary request)
|
||||
{
|
||||
Validation<BaseError, LocalLibrary> libraryResult = await LocalLibraryMustExist(request);
|
||||
Validation<BaseError, string> ffprobePathResult = await ValidateFFprobePath();
|
||||
Validation<BaseError, string> ffmpegPathResult = await ValidateFFmpegPath();
|
||||
Validation<BaseError, int> refreshIntervalResult = await ValidateLibraryRefreshInterval();
|
||||
|
||||
try
|
||||
{
|
||||
return (libraryResult, ffprobePathResult, ffmpegPathResult, refreshIntervalResult)
|
||||
.Apply(
|
||||
(library, ffprobePath, ffmpegPath, libraryRefreshInterval) => new RequestParameters(
|
||||
library,
|
||||
ffprobePath,
|
||||
ffmpegPath,
|
||||
request.ForceScan,
|
||||
libraryRefreshInterval));
|
||||
}
|
||||
finally
|
||||
{
|
||||
// ensure we unlock the library if any validation is unsuccessful
|
||||
foreach (LocalLibrary library in libraryResult.SuccessToSeq())
|
||||
{
|
||||
if (ffprobePathResult.IsFail || ffmpegPathResult.IsFail || refreshIntervalResult.IsFail)
|
||||
{
|
||||
_entityLocker.UnlockLibrary(library.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Task<Validation<BaseError, LocalLibrary>> LocalLibraryMustExist(
|
||||
IScanLocalLibrary request) =>
|
||||
_libraryRepository.Get(request.LibraryId)
|
||||
.Map(maybeLibrary => maybeLibrary.Map(ms => ms as LocalLibrary))
|
||||
.Map(v => v.ToValidation<BaseError>($"Local library {request.LibraryId} does not exist."));
|
||||
|
||||
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 Task<Validation<BaseError, string>> ValidateFFmpegPath() =>
|
||||
_configElementRepository.GetValue<string>(ConfigElementKey.FFmpegPath)
|
||||
.FilterT(File.Exists)
|
||||
.Map(
|
||||
ffmpegPath =>
|
||||
ffmpegPath.ToValidation<BaseError>("FFmpeg path does not exist on the file system"));
|
||||
|
||||
private Task<Validation<BaseError, int>> ValidateLibraryRefreshInterval() =>
|
||||
_configElementRepository.GetValue<int>(ConfigElementKey.LibraryRefreshInterval)
|
||||
.FilterT(lri => lri > 0)
|
||||
.Map(lri => lri.ToValidation<BaseError>("Library refresh interval is invalid"));
|
||||
|
||||
private record RequestParameters(
|
||||
LocalLibrary LocalLibrary,
|
||||
string FFprobePath,
|
||||
string FFmpegPath,
|
||||
bool ForceScan,
|
||||
int LibraryRefreshInterval);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
namespace ErsatzTV.Application.MediaSources;
|
||||
|
||||
public record LocalMediaSourceViewModel(int Id) : MediaSourceViewModel(Id, "Local");
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.FFmpeg.Runtime;
|
||||
|
||||
namespace ErsatzTV.Application.Plex;
|
||||
|
||||
public class CallPlexLibraryScannerHandler : CallLibraryScannerHandler,
|
||||
IRequestHandler<ForceSynchronizePlexLibraryById, Either<BaseError, string>>,
|
||||
IRequestHandler<SynchronizePlexLibraryByIdIfNeeded, Either<BaseError, string>>
|
||||
{
|
||||
public CallPlexLibraryScannerHandler(
|
||||
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
|
||||
IMediator mediator,
|
||||
IRuntimeInfo runtimeInfo)
|
||||
: base(channel, mediator, runtimeInfo)
|
||||
{
|
||||
}
|
||||
|
||||
Task<Either<BaseError, string>> IRequestHandler<ForceSynchronizePlexLibraryById, Either<BaseError, string>>.Handle(
|
||||
ForceSynchronizePlexLibraryById request,
|
||||
CancellationToken cancellationToken) => Handle(request, cancellationToken);
|
||||
|
||||
Task<Either<BaseError, string>> IRequestHandler<SynchronizePlexLibraryByIdIfNeeded, Either<BaseError, string>>.Handle(
|
||||
SynchronizePlexLibraryByIdIfNeeded request,
|
||||
CancellationToken cancellationToken) => Handle(request, cancellationToken);
|
||||
|
||||
private async Task<Either<BaseError, string>> Handle(
|
||||
ISynchronizePlexLibraryById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, string> validation = Validate();
|
||||
return await validation.Match(
|
||||
scanner => PerformScan(scanner, request, cancellationToken),
|
||||
error => Task.FromResult<Either<BaseError, string>>(error.Join()));
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, string>> PerformScan(
|
||||
string scanner,
|
||||
ISynchronizePlexLibraryById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var arguments = new List<string>
|
||||
{
|
||||
"scan-plex", request.PlexLibraryId.ToString()
|
||||
};
|
||||
|
||||
if (request.ForceScan)
|
||||
{
|
||||
arguments.Add("--force");
|
||||
}
|
||||
|
||||
if (request.DeepScan)
|
||||
{
|
||||
arguments.Add("--deep");
|
||||
}
|
||||
|
||||
return await base.PerformScan(scanner, arguments, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,7 @@ public abstract class ProgramScheduleItemCommandBase
|
||||
{
|
||||
case PlaybackOrder.Chronological:
|
||||
case PlaybackOrder.Random:
|
||||
case PlaybackOrder.MultiEpisodeShuffle:
|
||||
return BaseError.New($"Invalid playback order for multi collection: '{item.PlaybackOrder}'");
|
||||
case PlaybackOrder.Shuffle:
|
||||
case PlaybackOrder.ShuffleInOrder:
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace ErsatzTV.Application.Search;
|
||||
|
||||
public record ReindexMediaItems(IReadOnlyCollection<int> MediaItemIds) : IRequest<Unit>,
|
||||
ISearchIndexBackgroundServiceRequest;
|
||||
@@ -0,0 +1,29 @@
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories.Caching;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
|
||||
namespace ErsatzTV.Application.Search;
|
||||
|
||||
public class ReindexMediaItemsHandler : IRequestHandler<ReindexMediaItems, Unit>
|
||||
{
|
||||
private readonly ICachingSearchRepository _cachingSearchRepository;
|
||||
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
|
||||
public ReindexMediaItemsHandler(
|
||||
ICachingSearchRepository cachingSearchRepository,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
ISearchIndex searchIndex)
|
||||
{
|
||||
_cachingSearchRepository = cachingSearchRepository;
|
||||
_fallbackMetadataProvider = fallbackMetadataProvider;
|
||||
_searchIndex = searchIndex;
|
||||
}
|
||||
|
||||
public async Task<Unit> Handle(ReindexMediaItems request, CancellationToken cancellationToken)
|
||||
{
|
||||
await _searchIndex.RebuildItems(_cachingSearchRepository, _fallbackMetadataProvider, request.MediaItemIds);
|
||||
_searchIndex.Commit();
|
||||
return Unit.Default;
|
||||
}
|
||||
}
|
||||
4
ErsatzTV.Application/Search/Commands/RemoveMediaItems.cs
Normal file
4
ErsatzTV.Application/Search/Commands/RemoveMediaItems.cs
Normal file
@@ -0,0 +1,4 @@
|
||||
namespace ErsatzTV.Application.Search;
|
||||
|
||||
public record RemoveMediaItems(IReadOnlyCollection<int> MediaItemIds) : IRequest<Unit>,
|
||||
ISearchIndexBackgroundServiceRequest;
|
||||
@@ -0,0 +1,17 @@
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
|
||||
namespace ErsatzTV.Application.Search;
|
||||
|
||||
public class RemoveMediaItemsHandler : IRequestHandler<RemoveMediaItems, Unit>
|
||||
{
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
|
||||
public RemoveMediaItemsHandler(ISearchIndex searchIndex) => _searchIndex = searchIndex;
|
||||
|
||||
public async Task<Unit> Handle(RemoveMediaItems request, CancellationToken cancellationToken)
|
||||
{
|
||||
await _searchIndex.RemoveItems(request.MediaItemIds);
|
||||
_searchIndex.Commit();
|
||||
return Unit.Default;
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,33 @@
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Search;
|
||||
|
||||
namespace ErsatzTV.Application.Search;
|
||||
|
||||
public class
|
||||
QuerySearchIndexAllItemsHandler : IRequestHandler<QuerySearchIndexAllItems, SearchResultAllItemsViewModel>
|
||||
public class QuerySearchIndexAllItemsHandler : IRequestHandler<QuerySearchIndexAllItems, SearchResultAllItemsViewModel>
|
||||
{
|
||||
private readonly IClient _client;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
|
||||
public QuerySearchIndexAllItemsHandler(ISearchIndex searchIndex) => _searchIndex = searchIndex;
|
||||
public QuerySearchIndexAllItemsHandler(IClient client, ISearchIndex searchIndex)
|
||||
{
|
||||
_client = client;
|
||||
_searchIndex = searchIndex;
|
||||
}
|
||||
|
||||
public async Task<SearchResultAllItemsViewModel> Handle(
|
||||
public Task<SearchResultAllItemsViewModel> Handle(
|
||||
QuerySearchIndexAllItems request,
|
||||
CancellationToken cancellationToken) =>
|
||||
new(
|
||||
await GetIds(SearchIndex.MovieType, request.Query),
|
||||
await GetIds(SearchIndex.ShowType, request.Query),
|
||||
await GetIds(SearchIndex.SeasonType, request.Query),
|
||||
await GetIds(SearchIndex.EpisodeType, request.Query),
|
||||
await GetIds(SearchIndex.ArtistType, request.Query),
|
||||
await GetIds(SearchIndex.MusicVideoType, request.Query),
|
||||
await GetIds(SearchIndex.OtherVideoType, request.Query),
|
||||
await GetIds(SearchIndex.SongType, request.Query));
|
||||
new SearchResultAllItemsViewModel(
|
||||
GetIds(SearchIndex.MovieType, request.Query),
|
||||
GetIds(SearchIndex.ShowType, request.Query),
|
||||
GetIds(SearchIndex.SeasonType, request.Query),
|
||||
GetIds(SearchIndex.EpisodeType, request.Query),
|
||||
GetIds(SearchIndex.ArtistType, request.Query),
|
||||
GetIds(SearchIndex.MusicVideoType, request.Query),
|
||||
GetIds(SearchIndex.OtherVideoType, request.Query),
|
||||
GetIds(SearchIndex.SongType, request.Query)).AsTask();
|
||||
|
||||
private Task<List<int>> GetIds(string type, string query) =>
|
||||
_searchIndex.Search($"type:{type} AND ({query})", 0, 0)
|
||||
.Map(result => result.Items.Map(i => i.Id).ToList());
|
||||
private List<int> GetIds(string type, string query) =>
|
||||
_searchIndex.Search(_client, $"type:{type} AND ({query})", 0, 0).Items.Map(i => i.Id).ToList();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ErsatzTV.Application.MediaCards;
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Application.MediaCards;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Search;
|
||||
@@ -6,15 +7,15 @@ using static ErsatzTV.Application.MediaCards.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Search;
|
||||
|
||||
public class
|
||||
QuerySearchIndexArtistsHandler : IRequestHandler<QuerySearchIndexArtists, ArtistCardResultsViewModel
|
||||
>
|
||||
public class QuerySearchIndexArtistsHandler : IRequestHandler<QuerySearchIndexArtists, ArtistCardResultsViewModel>
|
||||
{
|
||||
private readonly IArtistRepository _artistRepository;
|
||||
private readonly IClient _client;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
|
||||
public QuerySearchIndexArtistsHandler(ISearchIndex searchIndex, IArtistRepository artistRepository)
|
||||
public QuerySearchIndexArtistsHandler(IClient client, ISearchIndex searchIndex, IArtistRepository artistRepository)
|
||||
{
|
||||
_client = client;
|
||||
_searchIndex = searchIndex;
|
||||
_artistRepository = artistRepository;
|
||||
}
|
||||
@@ -23,7 +24,8 @@ public class
|
||||
QuerySearchIndexArtists request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
SearchResult searchResult = await _searchIndex.Search(
|
||||
SearchResult searchResult = _searchIndex.Search(
|
||||
_client,
|
||||
request.Query,
|
||||
(request.PageNumber - 1) * request.PageSize,
|
||||
request.PageSize);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ErsatzTV.Application.MediaCards;
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Application.MediaCards;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.Interfaces.Emby;
|
||||
@@ -24,10 +25,12 @@ public class
|
||||
private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService;
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly IPlexPathReplacementService _plexPathReplacementService;
|
||||
private readonly IClient _client;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ITelevisionRepository _televisionRepository;
|
||||
|
||||
public QuerySearchIndexEpisodesHandler(
|
||||
IClient client,
|
||||
ISearchIndex searchIndex,
|
||||
ITelevisionRepository televisionRepository,
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
@@ -37,6 +40,7 @@ public class
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
IDbContextFactory<TvContext> dbContextFactory)
|
||||
{
|
||||
_client = client;
|
||||
_searchIndex = searchIndex;
|
||||
_televisionRepository = televisionRepository;
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
@@ -51,7 +55,8 @@ public class
|
||||
QuerySearchIndexEpisodes request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
SearchResult searchResult = await _searchIndex.Search(
|
||||
SearchResult searchResult = _searchIndex.Search(
|
||||
_client,
|
||||
request.Query,
|
||||
(request.PageNumber - 1) * request.PageSize,
|
||||
request.PageSize);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ErsatzTV.Application.MediaCards;
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Application.MediaCards;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
@@ -11,13 +12,16 @@ public class QuerySearchIndexMoviesHandler : IRequestHandler<QuerySearchIndexMov
|
||||
{
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly IMovieRepository _movieRepository;
|
||||
private readonly IClient _client;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
|
||||
public QuerySearchIndexMoviesHandler(
|
||||
IClient client,
|
||||
ISearchIndex searchIndex,
|
||||
IMovieRepository movieRepository,
|
||||
IMediaSourceRepository mediaSourceRepository)
|
||||
{
|
||||
_client = client;
|
||||
_searchIndex = searchIndex;
|
||||
_movieRepository = movieRepository;
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
@@ -27,7 +31,8 @@ public class QuerySearchIndexMoviesHandler : IRequestHandler<QuerySearchIndexMov
|
||||
QuerySearchIndexMovies request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
SearchResult searchResult = await _searchIndex.Search(
|
||||
SearchResult searchResult = _searchIndex.Search(
|
||||
_client,
|
||||
request.Query,
|
||||
(request.PageNumber - 1) * request.PageSize,
|
||||
request.PageSize);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ErsatzTV.Application.MediaCards;
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Application.MediaCards;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.Interfaces.Emby;
|
||||
@@ -18,15 +19,18 @@ public class
|
||||
private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService;
|
||||
private readonly IMusicVideoRepository _musicVideoRepository;
|
||||
private readonly IPlexPathReplacementService _plexPathReplacementService;
|
||||
private readonly IClient _client;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
|
||||
public QuerySearchIndexMusicVideosHandler(
|
||||
IClient client,
|
||||
ISearchIndex searchIndex,
|
||||
IMusicVideoRepository musicVideoRepository,
|
||||
IPlexPathReplacementService plexPathReplacementService,
|
||||
IJellyfinPathReplacementService jellyfinPathReplacementService,
|
||||
IEmbyPathReplacementService embyPathReplacementService)
|
||||
{
|
||||
_client = client;
|
||||
_searchIndex = searchIndex;
|
||||
_musicVideoRepository = musicVideoRepository;
|
||||
_plexPathReplacementService = plexPathReplacementService;
|
||||
@@ -38,7 +42,8 @@ public class
|
||||
QuerySearchIndexMusicVideos request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
SearchResult searchResult = await _searchIndex.Search(
|
||||
SearchResult searchResult = _searchIndex.Search(
|
||||
_client,
|
||||
request.Query,
|
||||
(request.PageNumber - 1) * request.PageSize,
|
||||
request.PageSize);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ErsatzTV.Application.MediaCards;
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Application.MediaCards;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Search;
|
||||
@@ -11,10 +12,15 @@ public class
|
||||
OtherVideoCardResultsViewModel>
|
||||
{
|
||||
private readonly IOtherVideoRepository _otherVideoRepository;
|
||||
private readonly IClient _client;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
|
||||
public QuerySearchIndexOtherVideosHandler(ISearchIndex searchIndex, IOtherVideoRepository otherVideoRepository)
|
||||
public QuerySearchIndexOtherVideosHandler(
|
||||
IClient client,
|
||||
ISearchIndex searchIndex,
|
||||
IOtherVideoRepository otherVideoRepository)
|
||||
{
|
||||
_client = client;
|
||||
_searchIndex = searchIndex;
|
||||
_otherVideoRepository = otherVideoRepository;
|
||||
}
|
||||
@@ -23,7 +29,8 @@ public class
|
||||
QuerySearchIndexOtherVideos request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
SearchResult searchResult = await _searchIndex.Search(
|
||||
SearchResult searchResult = _searchIndex.Search(
|
||||
_client,
|
||||
request.Query,
|
||||
(request.PageNumber - 1) * request.PageSize,
|
||||
request.PageSize);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ErsatzTV.Application.MediaCards;
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Application.MediaCards;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
@@ -11,14 +12,17 @@ public class
|
||||
QuerySearchIndexSeasonsHandler : IRequestHandler<QuerySearchIndexSeasons, TelevisionSeasonCardResultsViewModel>
|
||||
{
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly IClient _client;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ITelevisionRepository _televisionRepository;
|
||||
|
||||
public QuerySearchIndexSeasonsHandler(
|
||||
IClient client,
|
||||
ISearchIndex searchIndex,
|
||||
ITelevisionRepository televisionRepository,
|
||||
IMediaSourceRepository mediaSourceRepository)
|
||||
{
|
||||
_client = client;
|
||||
_searchIndex = searchIndex;
|
||||
_televisionRepository = televisionRepository;
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
@@ -28,7 +32,8 @@ public class
|
||||
QuerySearchIndexSeasons request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
SearchResult searchResult = await _searchIndex.Search(
|
||||
SearchResult searchResult = _searchIndex.Search(
|
||||
_client,
|
||||
request.Query,
|
||||
(request.PageNumber - 1) * request.PageSize,
|
||||
request.PageSize);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ErsatzTV.Application.MediaCards;
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Application.MediaCards;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
@@ -11,14 +12,17 @@ public class
|
||||
QuerySearchIndexShowsHandler : IRequestHandler<QuerySearchIndexShows, TelevisionShowCardResultsViewModel>
|
||||
{
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly IClient _client;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ITelevisionRepository _televisionRepository;
|
||||
|
||||
public QuerySearchIndexShowsHandler(
|
||||
IClient client,
|
||||
ISearchIndex searchIndex,
|
||||
ITelevisionRepository televisionRepository,
|
||||
IMediaSourceRepository mediaSourceRepository)
|
||||
{
|
||||
_client = client;
|
||||
_searchIndex = searchIndex;
|
||||
_televisionRepository = televisionRepository;
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
@@ -28,7 +32,8 @@ public class
|
||||
QuerySearchIndexShows request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
SearchResult searchResult = await _searchIndex.Search(
|
||||
SearchResult searchResult = _searchIndex.Search(
|
||||
_client,
|
||||
request.Query,
|
||||
(request.PageNumber - 1) * request.PageSize,
|
||||
request.PageSize);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ErsatzTV.Application.MediaCards;
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Application.MediaCards;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Search;
|
||||
@@ -6,15 +7,15 @@ using static ErsatzTV.Application.MediaCards.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Search;
|
||||
|
||||
public class
|
||||
QuerySearchIndexSongsHandler : IRequestHandler<QuerySearchIndexSongs,
|
||||
SongCardResultsViewModel>
|
||||
public class QuerySearchIndexSongsHandler : IRequestHandler<QuerySearchIndexSongs, SongCardResultsViewModel>
|
||||
{
|
||||
private readonly IClient _client;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ISongRepository _songRepository;
|
||||
|
||||
public QuerySearchIndexSongsHandler(ISearchIndex searchIndex, ISongRepository songRepository)
|
||||
public QuerySearchIndexSongsHandler(IClient client, ISearchIndex searchIndex, ISongRepository songRepository)
|
||||
{
|
||||
_client = client;
|
||||
_searchIndex = searchIndex;
|
||||
_songRepository = songRepository;
|
||||
}
|
||||
@@ -23,7 +24,8 @@ public class
|
||||
QuerySearchIndexSongs request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
SearchResult searchResult = await _searchIndex.Search(
|
||||
SearchResult searchResult = _searchIndex.Search(
|
||||
_client,
|
||||
request.Query,
|
||||
(request.PageNumber - 1) * request.PageSize,
|
||||
request.PageSize);
|
||||
|
||||
@@ -351,7 +351,10 @@ public class HlsSessionWorker : IHlsSessionWorker
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Decrement(ref _workAheadCount);
|
||||
if (!realtime)
|
||||
{
|
||||
Interlocked.Decrement(ref _workAheadCount);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
@@ -4,6 +4,7 @@ using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Emby;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
@@ -93,6 +94,9 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
.ThenInclude(mi => (mi as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mvm => mvm.Subtitles)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(mi => (mi as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mvm => mvm.Artists)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(mi => (mi as MusicVideo).MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.Include(i => i.MediaItem)
|
||||
@@ -161,18 +165,16 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
.GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
|
||||
.Map(result => result.IfNone(false));
|
||||
|
||||
List<Subtitle> subtitles = await GetSubtitles(playoutItemWithPath, channel);
|
||||
|
||||
Command process = await _ffmpegProcessService.ForPlayoutItem(
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
saveReports,
|
||||
channel,
|
||||
videoVersion,
|
||||
audioVersion,
|
||||
new MediaItemAudioVersion(playoutItemWithPath.PlayoutItem.MediaItem, audioVersion),
|
||||
videoPath,
|
||||
audioPath,
|
||||
subtitles,
|
||||
settings => GetSubtitles(playoutItemWithPath, channel, settings),
|
||||
playoutItemWithPath.PlayoutItem.PreferredAudioLanguageCode ?? channel.PreferredAudioLanguageCode,
|
||||
playoutItemWithPath.PlayoutItem.PreferredAudioTitle ?? channel.PreferredAudioTitle,
|
||||
playoutItemWithPath.PlayoutItem.PreferredSubtitleLanguageCode ?? channel.PreferredSubtitleLanguageCode,
|
||||
@@ -191,7 +193,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
playoutItemWithPath.PlayoutItem.OutPoint,
|
||||
request.PtsOffset,
|
||||
request.TargetFramerate,
|
||||
playoutItemWithPath.PlayoutItem.DisableWatermarks);
|
||||
playoutItemWithPath.PlayoutItem.DisableWatermarks,
|
||||
_ => { });
|
||||
|
||||
var result = new PlayoutItemProcessModel(
|
||||
process,
|
||||
@@ -269,7 +272,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
|
||||
private async Task<List<Subtitle>> GetSubtitles(
|
||||
PlayoutItemWithPath playoutItemWithPath,
|
||||
Channel channel)
|
||||
Channel channel,
|
||||
FFmpegPlaybackSettings settings)
|
||||
{
|
||||
List<Subtitle> allSubtitles = playoutItemWithPath.PlayoutItem.MediaItem switch
|
||||
{
|
||||
@@ -279,7 +283,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
Movie movie => await Optional(movie.MovieMetadata).Flatten().HeadOrNone()
|
||||
.Map(mm => mm.Subtitles ?? new List<Subtitle>())
|
||||
.IfNoneAsync(new List<Subtitle>()),
|
||||
MusicVideo musicVideo => await GetMusicVideoSubtitles(musicVideo, channel),
|
||||
MusicVideo musicVideo => await GetMusicVideoSubtitles(musicVideo, channel, settings),
|
||||
OtherVideo otherVideo => await Optional(otherVideo.OtherVideoMetadata).Flatten().HeadOrNone()
|
||||
.Map(mm => mm.Subtitles ?? new List<Subtitle>())
|
||||
.IfNoneAsync(new List<Subtitle>()),
|
||||
@@ -320,22 +324,44 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
|
||||
return allSubtitles;
|
||||
}
|
||||
|
||||
private async Task<List<Subtitle>> GetMusicVideoSubtitles(MusicVideo musicVideo, Channel channel)
|
||||
private async Task<List<Subtitle>> GetMusicVideoSubtitles(
|
||||
MusicVideo musicVideo,
|
||||
Channel channel,
|
||||
FFmpegPlaybackSettings settings)
|
||||
{
|
||||
var subtitles = new List<Subtitle>();
|
||||
|
||||
bool musicVideoCredits = channel.MusicVideoCreditsMode == ChannelMusicVideoCreditsMode.GenerateSubtitles;
|
||||
if (musicVideoCredits)
|
||||
switch (channel.MusicVideoCreditsMode)
|
||||
{
|
||||
subtitles.AddRange(
|
||||
await _musicVideoCreditsGenerator.GenerateCreditsSubtitle(musicVideo, channel.FFmpegProfile));
|
||||
}
|
||||
else
|
||||
{
|
||||
subtitles.AddRange(
|
||||
await Optional(musicVideo.MusicVideoMetadata).Flatten().HeadOrNone()
|
||||
.Map(mm => mm.Subtitles)
|
||||
.IfNoneAsync(new List<Subtitle>()));
|
||||
case ChannelMusicVideoCreditsMode.GenerateSubtitles:
|
||||
var fileWithExtension = $"{channel.MusicVideoCreditsTemplate}.sbntxt";
|
||||
if (!string.IsNullOrWhiteSpace(fileWithExtension))
|
||||
{
|
||||
subtitles.AddRange(
|
||||
await _musicVideoCreditsGenerator.GenerateCreditsSubtitleFromTemplate(
|
||||
musicVideo,
|
||||
channel.FFmpegProfile,
|
||||
settings,
|
||||
Path.Combine(FileSystemLayout.MusicVideoCreditsTemplatesFolder, fileWithExtension)));
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Music video credits template {Template} does not exist; falling back to built-in template",
|
||||
fileWithExtension);
|
||||
|
||||
subtitles.AddRange(
|
||||
await _musicVideoCreditsGenerator.GenerateCreditsSubtitle(musicVideo, channel.FFmpegProfile));
|
||||
}
|
||||
|
||||
break;
|
||||
case ChannelMusicVideoCreditsMode.None:
|
||||
default:
|
||||
subtitles.AddRange(
|
||||
await Optional(musicVideo.MusicVideoMetadata).Flatten().HeadOrNone()
|
||||
.Map(mm => mm.Subtitles)
|
||||
.IfNoneAsync(new List<Subtitle>()));
|
||||
break;
|
||||
}
|
||||
|
||||
return subtitles;
|
||||
|
||||
@@ -30,7 +30,7 @@ public class GetWrappedProcessByChannelNumberHandler : FFmpegProcessHandler<GetW
|
||||
.GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
|
||||
.Map(result => result.IfNone(false));
|
||||
|
||||
Command process = _ffmpegProcessService.WrapSegmenter(
|
||||
Command process = await _ffmpegProcessService.WrapSegmenter(
|
||||
ffmpegPath,
|
||||
saveReports,
|
||||
channel,
|
||||
|
||||
@@ -109,31 +109,31 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
|
||||
.Filter(pi => pi.Start <= until)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// TODO: support other media kinds (movies, other videos, etc)
|
||||
|
||||
var mediaItemIds = playoutItems.Map(pi => pi.MediaItemId).ToList();
|
||||
|
||||
// filter for subtitles that need extraction
|
||||
List<int> unextractedMediaItemIds =
|
||||
await GetUnextractedMediaItemIds(dbContext, mediaItemIds, cancellationToken);
|
||||
// filter for items with text subtitles or font attachments
|
||||
List<int> mediaItemIdsWithTextSubtitles =
|
||||
await GetMediaItemIdsWithTextSubtitles(dbContext, mediaItemIds, cancellationToken);
|
||||
|
||||
if (unextractedMediaItemIds.Any())
|
||||
if (mediaItemIdsWithTextSubtitles.Any())
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Found media items {MediaItemIds} with text subtitles to extract for playouts {PlayoutIds}",
|
||||
unextractedMediaItemIds,
|
||||
"Checking media items {MediaItemIds} for text subtitles or fonts to extract for playouts {PlayoutIds}",
|
||||
mediaItemIdsWithTextSubtitles,
|
||||
playoutIdsToCheck);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("Found no text subtitles to extract for playouts {PlayoutIds}", playoutIdsToCheck);
|
||||
_logger.LogDebug(
|
||||
"Found no text subtitles or fonts to extract for playouts {PlayoutIds}",
|
||||
playoutIdsToCheck);
|
||||
}
|
||||
|
||||
// sort by start time
|
||||
var toUpdate = playoutItems
|
||||
.Filter(pi => pi.Finish >= DateTime.UtcNow)
|
||||
.DistinctBy(pi => pi.MediaItemId)
|
||||
.Filter(pi => unextractedMediaItemIds.Contains(pi.MediaItemId))
|
||||
.Filter(pi => mediaItemIdsWithTextSubtitles.Contains(pi.MediaItemId))
|
||||
.OrderBy(pi => pi.StartOffset)
|
||||
.Map(pi => pi.MediaItemId)
|
||||
.ToList();
|
||||
@@ -145,14 +145,13 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
PlayoutItem pi = playoutItems.Find(pi => pi.MediaItemId == mediaItemId);
|
||||
_logger.LogDebug("Extracting subtitles for item with start time {StartTime}", pi?.StartOffset);
|
||||
|
||||
// extract subtitles and fonts for each item and update db
|
||||
await ExtractSubtitles(dbContext, mediaItemId, ffmpegPath, cancellationToken);
|
||||
// await ExtractFonts(dbContext, episodeId, ffmpegPath, cancellationToken);
|
||||
await ExtractFonts(dbContext, mediaItemId, ffmpegPath, cancellationToken);
|
||||
}
|
||||
|
||||
_logger.LogDebug("Done checking playouts {PlayoutIds} for text subtitles to extract", playoutIdsToCheck);
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
|
||||
@@ -161,7 +160,7 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<int>> GetUnextractedMediaItemIds(
|
||||
private async Task<List<int>> GetMediaItemIdsWithTextSubtitles(
|
||||
TvContext dbContext,
|
||||
List<int> mediaItemIds,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -174,7 +173,7 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
|
||||
.Filter(em => mediaItemIds.Contains(em.EpisodeId))
|
||||
.Filter(
|
||||
em => em.Subtitles.Any(
|
||||
s => s.SubtitleKind == SubtitleKind.Embedded && s.IsExtracted == false &&
|
||||
s => s.SubtitleKind == SubtitleKind.Embedded &&
|
||||
s.Codec != "hdmv_pgs_subtitle" && s.Codec != "dvd_subtitle"))
|
||||
.Map(em => em.EpisodeId)
|
||||
.ToListAsync(cancellationToken);
|
||||
@@ -184,7 +183,7 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
|
||||
.Filter(mm => mediaItemIds.Contains(mm.MovieId))
|
||||
.Filter(
|
||||
mm => mm.Subtitles.Any(
|
||||
s => s.SubtitleKind == SubtitleKind.Embedded && s.IsExtracted == false &&
|
||||
s => s.SubtitleKind == SubtitleKind.Embedded &&
|
||||
s.Codec != "hdmv_pgs_subtitle" && s.Codec != "dvd_subtitle"))
|
||||
.Map(mm => mm.MovieId)
|
||||
.ToListAsync(cancellationToken);
|
||||
@@ -194,7 +193,7 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
|
||||
.Filter(mm => mediaItemIds.Contains(mm.MusicVideoId))
|
||||
.Filter(
|
||||
mm => mm.Subtitles.Any(
|
||||
s => s.SubtitleKind == SubtitleKind.Embedded && s.IsExtracted == false &&
|
||||
s => s.SubtitleKind == SubtitleKind.Embedded &&
|
||||
s.Codec != "hdmv_pgs_subtitle" && s.Codec != "dvd_subtitle"))
|
||||
.Map(mm => mm.MusicVideoId)
|
||||
.ToListAsync(cancellationToken);
|
||||
@@ -204,7 +203,7 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
|
||||
.Filter(ovm => mediaItemIds.Contains(ovm.OtherVideoId))
|
||||
.Filter(
|
||||
ovm => ovm.Subtitles.Any(
|
||||
s => s.SubtitleKind == SubtitleKind.Embedded && s.IsExtracted == false &&
|
||||
s => s.SubtitleKind == SubtitleKind.Embedded &&
|
||||
s.Codec != "hdmv_pgs_subtitle" && s.Codec != "dvd_subtitle"))
|
||||
.Map(ovm => ovm.OtherVideoId)
|
||||
.ToListAsync(cancellationToken);
|
||||
@@ -218,40 +217,13 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<Unit> ExtractSubtitles(
|
||||
private async Task ExtractSubtitles(
|
||||
TvContext dbContext,
|
||||
int mediaItemId,
|
||||
string ffmpegPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<MediaItem> maybeMediaItem = await dbContext.MediaItems
|
||||
.Include(mi => (mi as Episode).MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.Include(mi => (mi as Episode).MediaVersions)
|
||||
.ThenInclude(mv => mv.Streams)
|
||||
.Include(mi => (mi as Episode).EpisodeMetadata)
|
||||
.ThenInclude(em => em.Subtitles)
|
||||
.Include(mi => (mi as Movie).MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.Include(mi => (mi as Movie).MediaVersions)
|
||||
.ThenInclude(mv => mv.Streams)
|
||||
.Include(mi => (mi as Movie).MovieMetadata)
|
||||
.ThenInclude(em => em.Subtitles)
|
||||
.Include(mi => (mi as MusicVideo).MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.Include(mi => (mi as MusicVideo).MediaVersions)
|
||||
.ThenInclude(mv => mv.Streams)
|
||||
.Include(mi => (mi as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(em => em.Subtitles)
|
||||
.Include(mi => (mi as OtherVideo).MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.Include(mi => (mi as OtherVideo).MediaVersions)
|
||||
.ThenInclude(mv => mv.Streams)
|
||||
.Include(mi => (mi as OtherVideo).OtherVideoMetadata)
|
||||
.ThenInclude(em => em.Subtitles)
|
||||
.SelectOneAsync(e => e.Id, e => e.Id == mediaItemId);
|
||||
|
||||
foreach (MediaItem mediaItem in maybeMediaItem)
|
||||
foreach (MediaItem mediaItem in await GetMediaItem(dbContext, mediaItemId))
|
||||
{
|
||||
foreach (List<Subtitle> allSubtitles in GetSubtitles(mediaItem))
|
||||
{
|
||||
@@ -273,6 +245,11 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
|
||||
}
|
||||
}
|
||||
|
||||
if (subtitlesToExtract.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string mediaItemPath = await GetMediaItemPath(mediaItem);
|
||||
|
||||
ArgumentsBuilder args = new ArgumentsBuilder()
|
||||
@@ -316,10 +293,36 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private static async Task<Option<MediaItem>> GetMediaItem(TvContext dbContext, int mediaItemId) =>
|
||||
await dbContext.MediaItems
|
||||
.Include(mi => (mi as Episode).MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.Include(mi => (mi as Episode).MediaVersions)
|
||||
.ThenInclude(mv => mv.Streams)
|
||||
.Include(mi => (mi as Episode).EpisodeMetadata)
|
||||
.ThenInclude(em => em.Subtitles)
|
||||
.Include(mi => (mi as Movie).MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.Include(mi => (mi as Movie).MediaVersions)
|
||||
.ThenInclude(mv => mv.Streams)
|
||||
.Include(mi => (mi as Movie).MovieMetadata)
|
||||
.ThenInclude(em => em.Subtitles)
|
||||
.Include(mi => (mi as MusicVideo).MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.Include(mi => (mi as MusicVideo).MediaVersions)
|
||||
.ThenInclude(mv => mv.Streams)
|
||||
.Include(mi => (mi as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(em => em.Subtitles)
|
||||
.Include(mi => (mi as OtherVideo).MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.Include(mi => (mi as OtherVideo).MediaVersions)
|
||||
.ThenInclude(mv => mv.Streams)
|
||||
.Include(mi => (mi as OtherVideo).OtherVideoMetadata)
|
||||
.ThenInclude(em => em.Subtitles)
|
||||
.SelectOneAsync(e => e.Id, e => e.Id == mediaItemId);
|
||||
|
||||
private static Option<List<Subtitle>> GetSubtitles(MediaItem mediaItem) =>
|
||||
mediaItem switch
|
||||
{
|
||||
@@ -330,44 +333,64 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
|
||||
_ => None
|
||||
};
|
||||
|
||||
private async Task<Unit> ExtractFonts(
|
||||
private async Task ExtractFonts(
|
||||
TvContext dbContext,
|
||||
int mediaItemId,
|
||||
string ffmpegPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<Episode> maybeEpisode = await dbContext.Episodes
|
||||
.Include(e => e.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.Include(e => e.MediaVersions)
|
||||
.ThenInclude(mv => mv.Streams)
|
||||
.Include(e => e.EpisodeMetadata)
|
||||
.ThenInclude(em => em.Subtitles)
|
||||
.SelectOneAsync(e => e.Id, e => e.Id == mediaItemId);
|
||||
|
||||
foreach (Episode episode in maybeEpisode)
|
||||
foreach (MediaItem mediaItem in await GetMediaItem(dbContext, mediaItemId))
|
||||
{
|
||||
string mediaItemPath = episode.GetHeadVersion().MediaFiles.Head().Path;
|
||||
MediaVersion headVersion = mediaItem.GetHeadVersion();
|
||||
var attachments = headVersion.Streams
|
||||
.Filter(s => s.MediaStreamKind == MediaStreamKind.Attachment)
|
||||
.OrderBy(s => s.Index)
|
||||
.ToList();
|
||||
|
||||
var arguments = $"-nostdin -hide_banner -dump_attachment:t \"\" -i \"{mediaItemPath}\" -y";
|
||||
for (var attachmentIndex = 0; attachmentIndex < attachments.Count; attachmentIndex++)
|
||||
{
|
||||
MediaStream fontStream = attachments[attachmentIndex];
|
||||
|
||||
BufferedCommandResult result = await Cli.Wrap(ffmpegPath)
|
||||
.WithWorkingDirectory(FileSystemLayout.FontsCacheFolder)
|
||||
.WithArguments(arguments)
|
||||
.WithValidation(CommandResultValidation.None)
|
||||
.ExecuteBufferedAsync(cancellationToken);
|
||||
if (!(fontStream.MimeType ?? string.Empty).Contains("font") &&
|
||||
!(fontStream.MimeType ?? string.Empty).Contains("opentype"))
|
||||
{
|
||||
// not a font
|
||||
continue;
|
||||
}
|
||||
|
||||
// if (result.ExitCode == 0)
|
||||
// {
|
||||
// _logger.LogDebug("Successfully extracted attached fonts");
|
||||
// }
|
||||
// else
|
||||
// {
|
||||
// _logger.LogError("Failed to extract attached fonts. {Error}", result.StandardError);
|
||||
// }
|
||||
string fullOutputPath = Path.Combine(FileSystemLayout.FontsCacheFolder, fontStream.FileName);
|
||||
if (_localFileSystem.FileExists(fullOutputPath))
|
||||
{
|
||||
// already extracted
|
||||
continue;
|
||||
}
|
||||
|
||||
string mediaItemPath = await GetMediaItemPath(mediaItem);
|
||||
|
||||
var arguments =
|
||||
$"-nostdin -hide_banner -dump_attachment:t:{attachmentIndex} \"\" -i \"{mediaItemPath}\" -y";
|
||||
|
||||
BufferedCommandResult result = await Cli.Wrap(ffmpegPath)
|
||||
.WithWorkingDirectory(FileSystemLayout.FontsCacheFolder)
|
||||
.WithArguments(arguments)
|
||||
.WithValidation(CommandResultValidation.None)
|
||||
.ExecuteBufferedAsync(cancellationToken);
|
||||
|
||||
// ffmpeg seems to return exit code 1 in all cases when dumping an attachment
|
||||
// so ignore it and check success a different way
|
||||
if (_localFileSystem.FileExists(fullOutputPath))
|
||||
{
|
||||
_logger.LogDebug("Successfully extracted font {Font}", fontStream.FileName);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError(
|
||||
"Failed to extract attached font {Font}. {Error}",
|
||||
fontStream.FileName,
|
||||
result.StandardError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, string>> FFmpegPathMustExist(TvContext dbContext) =>
|
||||
@@ -442,6 +465,4 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
|
||||
}
|
||||
|
||||
private record SubtitleToExtract(Subtitle Subtitle, string OutputPath);
|
||||
|
||||
private record FontToExtract(MediaStream Stream, string OutputPath);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Templates;
|
||||
|
||||
public record GetMusicVideoCreditTemplates : IRequest<List<string>>;
|
||||
@@ -0,0 +1,20 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
|
||||
namespace ErsatzTV.Application.Templates;
|
||||
|
||||
public class GetMusicVideoCreditTemplatesHandler : IRequestHandler<GetMusicVideoCreditTemplates, List<string>>
|
||||
{
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
|
||||
public GetMusicVideoCreditTemplatesHandler(ILocalFileSystem localFileSystem)
|
||||
{
|
||||
_localFileSystem = localFileSystem;
|
||||
}
|
||||
|
||||
public Task<List<string>> Handle(GetMusicVideoCreditTemplates request, CancellationToken cancellationToken) =>
|
||||
_localFileSystem.ListFiles(FileSystemLayout.MusicVideoCreditsTemplatesFolder)
|
||||
.Map(Path.GetFileNameWithoutExtension)
|
||||
.ToList()
|
||||
.AsTask();
|
||||
}
|
||||
@@ -16,6 +16,7 @@ public record CreateWatermark(
|
||||
int VerticalMargin,
|
||||
int FrequencyMinutes,
|
||||
int DurationSeconds,
|
||||
int Opacity) : IRequest<Either<BaseError, CreateWatermarkResult>>;
|
||||
int Opacity,
|
||||
bool PlaceWithinSourceContent) : IRequest<Either<BaseError, CreateWatermarkResult>>;
|
||||
|
||||
public record CreateWatermarkResult(int WatermarkId) : EntityIdResult(WatermarkId);
|
||||
|
||||
@@ -46,7 +46,8 @@ public class CreateWatermarkHandler : IRequestHandler<CreateWatermark, Either<Ba
|
||||
VerticalMarginPercent = request.VerticalMargin,
|
||||
FrequencyMinutes = request.FrequencyMinutes,
|
||||
DurationSeconds = request.DurationSeconds,
|
||||
Opacity = request.Opacity
|
||||
Opacity = request.Opacity,
|
||||
PlaceWithinSourceContent = request.PlaceWithinSourceContent
|
||||
});
|
||||
|
||||
private static Validation<BaseError, string> ValidateName(CreateWatermark request) =>
|
||||
|
||||
@@ -17,6 +17,7 @@ public record UpdateWatermark(
|
||||
int VerticalMargin,
|
||||
int FrequencyMinutes,
|
||||
int DurationSeconds,
|
||||
int Opacity) : IRequest<Either<BaseError, UpdateWatermarkResult>>;
|
||||
int Opacity,
|
||||
bool PlaceWithinSourceContent) : IRequest<Either<BaseError, UpdateWatermarkResult>>;
|
||||
|
||||
public record UpdateWatermarkResult(int WatermarkId) : EntityIdResult(WatermarkId);
|
||||
|
||||
@@ -19,7 +19,7 @@ public class UpdateWatermarkHandler : IRequestHandler<UpdateWatermark, Either<Ba
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, ChannelWatermark> validation = await Validate(dbContext, request);
|
||||
return await LanguageExtensions.Apply(validation, p => ApplyUpdateRequest(dbContext, p, request));
|
||||
return await validation.Apply(p => ApplyUpdateRequest(dbContext, p, request));
|
||||
}
|
||||
|
||||
private static async Task<UpdateWatermarkResult> ApplyUpdateRequest(
|
||||
@@ -39,6 +39,7 @@ public class UpdateWatermarkHandler : IRequestHandler<UpdateWatermark, Either<Ba
|
||||
p.FrequencyMinutes = update.FrequencyMinutes;
|
||||
p.DurationSeconds = update.DurationSeconds;
|
||||
p.Opacity = update.Opacity;
|
||||
p.PlaceWithinSourceContent = update.PlaceWithinSourceContent;
|
||||
await dbContext.SaveChangesAsync();
|
||||
return new UpdateWatermarkResult(p.Id);
|
||||
}
|
||||
|
||||
@@ -18,5 +18,6 @@ internal static class Mapper
|
||||
watermark.VerticalMarginPercent,
|
||||
watermark.FrequencyMinutes,
|
||||
watermark.DurationSeconds,
|
||||
watermark.Opacity);
|
||||
watermark.Opacity,
|
||||
watermark.PlaceWithinSourceContent);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ public class GetAllWatermarksHandler : IRequestHandler<GetAllWatermarks, List<Wa
|
||||
GetAllWatermarks request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
return await dbContext.ChannelWatermarks
|
||||
.ToListAsync(cancellationToken)
|
||||
.Map(list => list.Map(ProjectToViewModel).ToList());
|
||||
|
||||
@@ -16,5 +16,6 @@ public record WatermarkViewModel(
|
||||
int VerticalMargin,
|
||||
int FrequencyMinutes,
|
||||
int DurationSeconds,
|
||||
int Opacity
|
||||
int Opacity,
|
||||
bool PlaceWithinSourceContent
|
||||
);
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<TargetFramework>net7.0</TargetFramework>
|
||||
<NoWarn>VSTHRD200</NoWarn>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Bugsnag" Version="3.1.0" />
|
||||
<PackageReference Include="CliWrap" Version="3.5.0" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.7.0" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.2.9" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="6.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="6.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.3.2" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.3.44">
|
||||
<PackageReference Include="CliWrap" Version="3.6.0" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.8.0" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.4.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="7.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="7.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" Version="7.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="7.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="7.0.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.4.27">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Moq" Version="4.18.2" />
|
||||
<PackageReference Include="Moq" Version="4.18.4" />
|
||||
<PackageReference Include="NUnit" Version="3.13.3" />
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="4.2.1" />
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="4.3.1" />
|
||||
<PackageReference Include="Serilog" Version="2.12.0" />
|
||||
<PackageReference Include="Serilog.Extensions.Logging" Version="3.1.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Debug" Version="2.0.0" />
|
||||
@@ -38,21 +38,12 @@
|
||||
<Content Include="Resources\ErsatzTV.png">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="Resources\Nfo\ArtistInvalidCharacters1.nfo">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="Resources\Nfo\ArtistInvalidCharacters2.nfo">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="Resources\test.sup">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="Resources\test.srt">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="Resources\Nfo\EpisodeInvalidCharacters.nfo">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,777 +0,0 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.FFmpeg.State;
|
||||
using FluentAssertions;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.FFmpeg;
|
||||
|
||||
[TestFixture]
|
||||
public class FFmpegComplexFilterBuilderTests
|
||||
{
|
||||
[TestFixture]
|
||||
public class Build
|
||||
{
|
||||
[Test]
|
||||
public void Should_Return_None_With_No_Filters()
|
||||
{
|
||||
var builder = new FFmpegComplexFilterBuilder();
|
||||
|
||||
Option<FFmpegComplexFilter> result = builder.Build(false, 0, 0, 0, 1, false);
|
||||
|
||||
result.IsNone.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Return_Audio_Filter_With_AudioDuration()
|
||||
{
|
||||
var duration = TimeSpan.FromMilliseconds(1000.1);
|
||||
FFmpegComplexFilterBuilder builder = new FFmpegComplexFilterBuilder()
|
||||
.WithAlignedAudio(duration);
|
||||
|
||||
Option<FFmpegComplexFilter> result = builder.Build(false, 0, 0, 0, 1, false);
|
||||
|
||||
result.IsSome.Should().BeTrue();
|
||||
result.IfSome(
|
||||
filter =>
|
||||
{
|
||||
filter.ComplexFilter.Should().Be("[0:1]apad=whole_dur=1000.1ms[a]");
|
||||
filter.AudioLabel.Should().Be("[a]");
|
||||
filter.VideoLabel.Should().Be("0:0");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
// this needs to be a culture where '.' is a group separator
|
||||
[SetCulture("it-IT")]
|
||||
public void Should_Return_Audio_Filter_With_AudioDuration_Decimal()
|
||||
{
|
||||
var duration = TimeSpan.FromMilliseconds(1000.1);
|
||||
FFmpegComplexFilterBuilder builder = new FFmpegComplexFilterBuilder()
|
||||
.WithAlignedAudio(duration);
|
||||
|
||||
Option<FFmpegComplexFilter> result = builder.Build(false, 0, 0, 0, 1, false);
|
||||
|
||||
result.IsSome.Should().BeTrue();
|
||||
result.IfSome(
|
||||
filter =>
|
||||
{
|
||||
filter.ComplexFilter.Should().Be("[0:1]apad=whole_dur=1000.1ms[a]");
|
||||
filter.AudioLabel.Should().Be("[a]");
|
||||
filter.VideoLabel.Should().Be("0:0");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Return_Audio_And_Video_Filter()
|
||||
{
|
||||
var duration = TimeSpan.FromMinutes(54);
|
||||
FFmpegComplexFilterBuilder builder = new FFmpegComplexFilterBuilder()
|
||||
.WithAlignedAudio(duration)
|
||||
.WithDeinterlace(true);
|
||||
|
||||
Option<FFmpegComplexFilter> result = builder.Build(false, 0, 0, 0, 1, false);
|
||||
|
||||
result.IsSome.Should().BeTrue();
|
||||
result.IfSome(
|
||||
filter =>
|
||||
{
|
||||
filter.ComplexFilter.Should().Be(
|
||||
$"[0:1]apad=whole_dur={duration.TotalMilliseconds}ms[a];[0:0]yadif=1[v]");
|
||||
filter.AudioLabel.Should().Be("[a]");
|
||||
filter.VideoLabel.Should().Be("[v]");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
[TestCase(true, false, false, "[0:0]yadif=1[v]", "[v]")]
|
||||
[TestCase(true, true, false, "[0:0]yadif=1,scale=1920:1000:flags=fast_bilinear,setsar=1[v]", "[v]")]
|
||||
[TestCase(true, false, true, "[0:0]yadif=1,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2[v]", "[v]")]
|
||||
[TestCase(
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
"[0:0]yadif=1,scale=1920:1000:flags=fast_bilinear,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2[v]",
|
||||
"[v]")]
|
||||
[TestCase(false, true, false, "[0:0]scale=1920:1000:flags=fast_bilinear,setsar=1[v]", "[v]")]
|
||||
[TestCase(false, false, true, "[0:0]setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2[v]", "[v]")]
|
||||
[TestCase(
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
"[0:0]scale=1920:1000:flags=fast_bilinear,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2[v]",
|
||||
"[v]")]
|
||||
public void Should_Return_Software_Video_Filter(
|
||||
bool deinterlace,
|
||||
bool scale,
|
||||
bool pad,
|
||||
string expectedVideoFilter,
|
||||
string expectedVideoLabel)
|
||||
{
|
||||
FFmpegComplexFilterBuilder builder = new FFmpegComplexFilterBuilder()
|
||||
.WithDeinterlace(deinterlace);
|
||||
|
||||
if (scale)
|
||||
{
|
||||
builder = builder.WithScaling(new Resolution { Width = 1920, Height = 1000 });
|
||||
}
|
||||
|
||||
if (pad)
|
||||
{
|
||||
builder = builder.WithBlackBars(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("0:1");
|
||||
filter.VideoLabel.Should().Be(expectedVideoLabel);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
[TestCase(
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
WatermarkLocation.BottomLeft,
|
||||
false,
|
||||
100,
|
||||
"[0:0][1:v]overlay=x=134:y=H-h-54[v]",
|
||||
"0:1",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
WatermarkLocation.BottomRight,
|
||||
false,
|
||||
100,
|
||||
"[0:0][1:v]overlay=x=W-w-134:y=H-h-54[v]",
|
||||
"0:1",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
WatermarkLocation.TopLeft,
|
||||
false,
|
||||
100,
|
||||
"[0:0][1:v]overlay=x=134:y=54[v]",
|
||||
"0:1",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
WatermarkLocation.TopRight,
|
||||
false,
|
||||
100,
|
||||
"[0:0][1:v]overlay=x=W-w-134:y=54[v]",
|
||||
"0:1",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
WatermarkLocation.TopLeft,
|
||||
false,
|
||||
100,
|
||||
"[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(
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
WatermarkLocation.TopLeft,
|
||||
true,
|
||||
100,
|
||||
"[1:v]scale=384:-1[wmp];[0:0][wmp]overlay=x=134:y=54[v]",
|
||||
"0:1",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
WatermarkLocation.TopLeft,
|
||||
false,
|
||||
90,
|
||||
"[1:v]format=yuva420p|yuva444p|yuva422p|rgba|abgr|bgra|gbrap|ya8,colorchannelmixer=aa=0.90[wmp];[0:0][wmp]overlay=x=134:y=54[v]",
|
||||
"0:1",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
WatermarkLocation.TopLeft,
|
||||
false,
|
||||
100,
|
||||
"[0:0]yadif=1[vt];[vt][1:v]overlay=x=134:y=54[v]",
|
||||
"0:1",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
WatermarkLocation.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,
|
||||
WatermarkLocation.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,
|
||||
WatermarkLocation.TopLeft,
|
||||
false,
|
||||
100,
|
||||
"[0:1]apad=whole_dur=3300000ms[a];[0:0][1:v]overlay=x=134:y=54[v]",
|
||||
"[a]",
|
||||
"[v]")]
|
||||
public void Should_Return_Watermark(
|
||||
bool alignAudio,
|
||||
bool deinterlace,
|
||||
bool intermittent,
|
||||
WatermarkLocation location,
|
||||
bool scaled,
|
||||
int opacity,
|
||||
string expectedVideoFilter,
|
||||
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 ? WatermarkSize.Scaled : WatermarkSize.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(watermark),
|
||||
maybeFadePoints,
|
||||
new Resolution { Width = 1920, Height = 1080 },
|
||||
None)
|
||||
.WithDeinterlace(deinterlace)
|
||||
.WithAlignedAudio(alignAudio ? Some(TimeSpan.FromMinutes(55)) : None);
|
||||
|
||||
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(
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
WatermarkLocation.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,
|
||||
WatermarkLocation.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,
|
||||
WatermarkLocation.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,
|
||||
WatermarkLocation.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,
|
||||
WatermarkLocation.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,
|
||||
WatermarkLocation.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,
|
||||
WatermarkLocation.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,
|
||||
WatermarkLocation.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,
|
||||
// WatermarkLocation.TopLeft,
|
||||
// false,
|
||||
// 100,
|
||||
// "[0:0]yadif=1[vt];[vt][1:v]overlay=x=134:y=54[v]",
|
||||
// "0:1",
|
||||
// "[v]")]
|
||||
// [TestCase(
|
||||
// false,
|
||||
// true,
|
||||
// false,
|
||||
// WatermarkLocation.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,
|
||||
// WatermarkLocation.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,
|
||||
WatermarkLocation.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,
|
||||
WatermarkLocation.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,
|
||||
WatermarkLocation 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 ? WatermarkSize.Scaled : WatermarkSize.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]")]
|
||||
[TestCase(
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
"[0:0]deinterlace_qsv,scale_qsv=w=1920:h=1000,setsar=1[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
"[0:0]deinterlace_qsv,setsar=1,hwdownload,format=nv12,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload=extra_hw_frames=64[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
"[0:0]deinterlace_qsv,scale_qsv=w=1920:h=1000,setsar=1,hwdownload,format=nv12,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload=extra_hw_frames=64[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
"[0:0]scale_qsv=w=1920:h=1000,setsar=1[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
"[0:0]setsar=1,hwdownload,format=nv12,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload=extra_hw_frames=64[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
"[0:0]scale_qsv=w=1920:h=1000,setsar=1,hwdownload,format=nv12,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload=extra_hw_frames=64[v]",
|
||||
"[v]")]
|
||||
public void Should_Return_QSV_Video_Filter(
|
||||
bool deinterlace,
|
||||
bool scale,
|
||||
bool pad,
|
||||
string expectedVideoFilter,
|
||||
string expectedVideoLabel)
|
||||
{
|
||||
FFmpegComplexFilterBuilder builder = new FFmpegComplexFilterBuilder()
|
||||
.WithHardwareAcceleration(HardwareAccelerationKind.Qsv)
|
||||
.WithDeinterlace(deinterlace);
|
||||
|
||||
if (scale)
|
||||
{
|
||||
builder = builder.WithScaling(new Resolution { Width = 1920, Height = 1000 });
|
||||
}
|
||||
|
||||
if (pad)
|
||||
{
|
||||
builder = builder.WithBlackBars(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("0:1");
|
||||
filter.VideoLabel.Should().Be(expectedVideoLabel);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
[TestCase(true, false, false, "[0:0]yadif_cuda[v]", "[v]")]
|
||||
[TestCase(
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
"[0:0]yadif_cuda,scale_cuda=1920:1000,setsar=1[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
"[0:0]yadif_cuda,setsar=1,hwdownload,format=nv12,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
"[0:0]yadif_cuda,scale_cuda=1920:1000,setsar=1,hwdownload,format=nv12,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
"[0:0]scale_cuda=1920:1000,setsar=1[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
"[0:0]setsar=1,hwdownload,format=nv12,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
"[0:0]scale_cuda=1920:1000,setsar=1,hwdownload,format=nv12,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[v]")]
|
||||
public void Should_Return_NVENC_Video_Filter(
|
||||
bool deinterlace,
|
||||
bool scale,
|
||||
bool pad,
|
||||
string expectedVideoFilter,
|
||||
string expectedVideoLabel)
|
||||
{
|
||||
FFmpegComplexFilterBuilder builder = new FFmpegComplexFilterBuilder()
|
||||
.WithHardwareAcceleration(HardwareAccelerationKind.Nvenc)
|
||||
.WithDeinterlace(deinterlace)
|
||||
.WithInputPixelFormat("h264");
|
||||
|
||||
if (scale)
|
||||
{
|
||||
builder = builder.WithScaling(new Resolution { Width = 1920, Height = 1000 });
|
||||
}
|
||||
|
||||
if (pad)
|
||||
{
|
||||
builder = builder.WithBlackBars(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("0:1");
|
||||
filter.VideoLabel.Should().Be(expectedVideoLabel);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
[TestCase("h264", true, false, false, "[0:0]deinterlace_vaapi[v]", "[v]")]
|
||||
[TestCase(
|
||||
"h264",
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
"[0:0]deinterlace_vaapi,scale_vaapi=format=nv12:w=1920:h=1000,setsar=1[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
"h264",
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
"[0:0]deinterlace_vaapi,setsar=1,hwdownload,format=nv12|vaapi,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
"h264",
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
"[0:0]deinterlace_vaapi,scale_vaapi=format=nv12:w=1920:h=1000,setsar=1,hwdownload,format=nv12|vaapi,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
"h264",
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
"[0:0]scale_vaapi=format=nv12:w=1920:h=1000,setsar=1[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
"h264",
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
"[0:0]setsar=1,hwdownload,format=nv12|vaapi,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
"h264",
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
"[0:0]scale_vaapi=format=nv12:w=1920:h=1000,setsar=1,hwdownload,format=nv12|vaapi,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase("mpeg4", true, false, false, "[0:0]hwupload,deinterlace_vaapi[v]", "[v]")]
|
||||
[TestCase(
|
||||
"mpeg4",
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
"[0:0]hwupload,deinterlace_vaapi,scale_vaapi=format=nv12:w=1920:h=1000,setsar=1[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
"mpeg4",
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
"[0:0]hwupload,deinterlace_vaapi,setsar=1,hwdownload,format=nv12|vaapi,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
"mpeg4",
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
"[0:0]hwupload,deinterlace_vaapi,scale_vaapi=format=nv12:w=1920:h=1000,setsar=1,hwdownload,format=nv12|vaapi,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
"mpeg4",
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
"[0:0]hwupload,scale_vaapi=format=nv12:w=1920:h=1000,setsar=1[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
"mpeg4",
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
"[0:0]setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[v]")]
|
||||
[TestCase(
|
||||
"mpeg4",
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
"[0:0]hwupload,scale_vaapi=format=nv12:w=1920:h=1000,setsar=1,hwdownload,format=nv12|vaapi,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
|
||||
"[v]")]
|
||||
public void Should_Return_VAAPI_Video_Filter(
|
||||
string codec,
|
||||
bool deinterlace,
|
||||
bool scale,
|
||||
bool pad,
|
||||
string expectedVideoFilter,
|
||||
string expectedVideoLabel)
|
||||
{
|
||||
FFmpegComplexFilterBuilder builder = new FFmpegComplexFilterBuilder()
|
||||
.WithHardwareAcceleration(HardwareAccelerationKind.Vaapi)
|
||||
.WithInputCodec(codec)
|
||||
.WithDeinterlace(deinterlace);
|
||||
|
||||
if (scale)
|
||||
{
|
||||
builder = builder.WithScaling(new Resolution { Width = 1920, Height = 1000 });
|
||||
}
|
||||
|
||||
if (pad)
|
||||
{
|
||||
builder = builder.WithBlackBars(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("0:1");
|
||||
filter.VideoLabel.Should().Be(expectedVideoLabel);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,603 +0,0 @@
|
||||
using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Bugsnag;
|
||||
using CliWrap;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using ErsatzTV.FFmpeg;
|
||||
using ErsatzTV.FFmpeg.Capabilities;
|
||||
using ErsatzTV.FFmpeg.State;
|
||||
using ErsatzTV.Infrastructure.Runtime;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using Serilog;
|
||||
using MediaStream = ErsatzTV.Core.Domain.MediaStream;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.FFmpeg;
|
||||
|
||||
[TestFixture]
|
||||
[Explicit]
|
||||
public class TranscodingTests
|
||||
{
|
||||
private static readonly ILoggerFactory LoggerFactory;
|
||||
|
||||
static TranscodingTests()
|
||||
{
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.MinimumLevel.Debug()
|
||||
.WriteTo.Console()
|
||||
.CreateLogger();
|
||||
|
||||
LoggerFactory = new LoggerFactory().AddSerilog(Log.Logger);
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Explicit]
|
||||
public void DeleteTestVideos()
|
||||
{
|
||||
foreach (string file in Directory.GetFiles(TestContext.CurrentContext.TestDirectory, "*.mkv"))
|
||||
{
|
||||
File.Delete(file);
|
||||
}
|
||||
|
||||
Assert.Pass();
|
||||
}
|
||||
|
||||
public record InputFormat(string Encoder, string PixelFormat);
|
||||
|
||||
public enum Padding
|
||||
{
|
||||
NoPadding,
|
||||
WithPadding
|
||||
}
|
||||
|
||||
public enum Watermark
|
||||
{
|
||||
None,
|
||||
PermanentOpaqueScaled,
|
||||
PermanentOpaqueActualSize,
|
||||
PermanentTransparentScaled,
|
||||
PermanentTransparentActualSize,
|
||||
IntermittentOpaque,
|
||||
IntermittentTransparent
|
||||
|
||||
// TODO: animated vs static
|
||||
}
|
||||
|
||||
public enum Subtitle
|
||||
{
|
||||
None,
|
||||
Picture,
|
||||
Text
|
||||
}
|
||||
|
||||
private class TestData
|
||||
{
|
||||
public static Watermark[] Watermarks =
|
||||
{
|
||||
Watermark.None,
|
||||
Watermark.PermanentOpaqueScaled,
|
||||
Watermark.PermanentOpaqueActualSize,
|
||||
Watermark.PermanentTransparentScaled,
|
||||
Watermark.PermanentTransparentActualSize
|
||||
};
|
||||
|
||||
public static Subtitle[] Subtitles =
|
||||
{
|
||||
Subtitle.None,
|
||||
Subtitle.Picture,
|
||||
Subtitle.Text
|
||||
};
|
||||
|
||||
public static Padding[] Paddings =
|
||||
{
|
||||
Padding.NoPadding,
|
||||
Padding.WithPadding
|
||||
};
|
||||
|
||||
public static VideoScanKind[] VideoScanKinds =
|
||||
{
|
||||
VideoScanKind.Progressive,
|
||||
VideoScanKind.Interlaced
|
||||
};
|
||||
|
||||
public static InputFormat[] InputFormats =
|
||||
{
|
||||
new("libx264", "yuv420p"),
|
||||
new("libx264", "yuvj420p"),
|
||||
new("libx264", "yuv420p10le"),
|
||||
// new("libx264", "yuv444p10le"),
|
||||
|
||||
new("mpeg1video", "yuv420p"),
|
||||
|
||||
new("mpeg2video", "yuv420p"),
|
||||
|
||||
new("libx265", "yuv420p"),
|
||||
new("libx265", "yuv420p10le"),
|
||||
|
||||
new("mpeg4", "yuv420p"),
|
||||
|
||||
new("libvpx-vp9", "yuv420p"),
|
||||
|
||||
// new("libaom-av1", "yuv420p")
|
||||
// av1 yuv420p10le 51
|
||||
|
||||
new("msmpeg4v2", "yuv420p"),
|
||||
new("msmpeg4v3", "yuv420p")
|
||||
|
||||
// wmv3 yuv420p 1
|
||||
};
|
||||
|
||||
public static Resolution[] Resolutions =
|
||||
{
|
||||
new() { Width = 1920, Height = 1080 },
|
||||
new() { Width = 1280, Height = 720 }
|
||||
};
|
||||
|
||||
public static HardwareAccelerationKind[] NoAcceleration =
|
||||
{
|
||||
HardwareAccelerationKind.None
|
||||
};
|
||||
|
||||
public static FFmpegProfileVideoFormat[] VideoFormats =
|
||||
{
|
||||
FFmpegProfileVideoFormat.H264,
|
||||
FFmpegProfileVideoFormat.Hevc
|
||||
};
|
||||
|
||||
public static HardwareAccelerationKind[] NvidiaAcceleration =
|
||||
{
|
||||
HardwareAccelerationKind.Nvenc
|
||||
};
|
||||
|
||||
public static HardwareAccelerationKind[] VaapiAcceleration =
|
||||
{
|
||||
HardwareAccelerationKind.Vaapi
|
||||
};
|
||||
|
||||
public static HardwareAccelerationKind[] VideoToolboxAcceleration =
|
||||
{
|
||||
HardwareAccelerationKind.VideoToolbox
|
||||
};
|
||||
|
||||
public static HardwareAccelerationKind[] AmfAcceleration =
|
||||
{
|
||||
HardwareAccelerationKind.Amf
|
||||
};
|
||||
|
||||
public static HardwareAccelerationKind[] QsvAcceleration =
|
||||
{
|
||||
HardwareAccelerationKind.Qsv
|
||||
};
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Combinatorial]
|
||||
public async Task Transcode(
|
||||
[ValueSource(typeof(TestData), nameof(TestData.InputFormats))]
|
||||
InputFormat inputFormat,
|
||||
[ValueSource(typeof(TestData), nameof(TestData.Resolutions))]
|
||||
Resolution profileResolution,
|
||||
[ValueSource(typeof(TestData), nameof(TestData.Paddings))]
|
||||
Padding padding,
|
||||
[ValueSource(typeof(TestData), nameof(TestData.VideoScanKinds))]
|
||||
VideoScanKind videoScanKind,
|
||||
[ValueSource(typeof(TestData), nameof(TestData.Watermarks))]
|
||||
Watermark watermark,
|
||||
[ValueSource(typeof(TestData), nameof(TestData.Subtitles))]
|
||||
Subtitle subtitle,
|
||||
[ValueSource(typeof(TestData), nameof(TestData.VideoFormats))]
|
||||
FFmpegProfileVideoFormat profileVideoFormat,
|
||||
// [ValueSource(typeof(TestData), nameof(TestData.NoAcceleration))] HardwareAccelerationKind profileAcceleration)
|
||||
[ValueSource(typeof(TestData), nameof(TestData.NvidiaAcceleration))] HardwareAccelerationKind profileAcceleration)
|
||||
// [ValueSource(typeof(TestData), nameof(TestData.VaapiAcceleration))] HardwareAccelerationKind profileAcceleration)
|
||||
// [ValueSource(typeof(TestData), nameof(TestData.QsvAcceleration))] HardwareAccelerationKind profileAcceleration)
|
||||
// [ValueSource(typeof(TestData), nameof(TestData.VideoToolboxAcceleration))] HardwareAccelerationKind profileAcceleration)
|
||||
// [ValueSource(typeof(TestData), nameof(TestData.AmfAcceleration))] HardwareAccelerationKind profileAcceleration)
|
||||
{
|
||||
if (inputFormat.Encoder is "mpeg1video" or "msmpeg4v2" or "msmpeg4v3")
|
||||
{
|
||||
if (videoScanKind == VideoScanKind.Interlaced)
|
||||
{
|
||||
Assert.Inconclusive($"{inputFormat.Encoder} does not support interlaced content");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
string name = GetStringSha256Hash(
|
||||
$"{inputFormat.Encoder}_{inputFormat.PixelFormat}_{videoScanKind}_{padding}_{watermark}_{subtitle}_{profileResolution}_{profileVideoFormat}_{profileAcceleration}");
|
||||
|
||||
string file = Path.Combine(TestContext.CurrentContext.TestDirectory, $"{name}.mkv");
|
||||
if (!File.Exists(file))
|
||||
{
|
||||
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;
|
||||
|
||||
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 {inputFormat.Encoder} -shortest -pix_fmt {inputFormat.PixelFormat} -strict -2 {flags} {file}";
|
||||
var p1 = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = ExecutableName("ffmpeg"),
|
||||
Arguments = args
|
||||
}
|
||||
};
|
||||
|
||||
p1.Start();
|
||||
await p1.WaitForExitAsync();
|
||||
// ReSharper disable once MethodHasAsyncOverload
|
||||
p1.WaitForExit();
|
||||
p1.ExitCode.Should().Be(0);
|
||||
|
||||
switch (subtitle)
|
||||
{
|
||||
case Subtitle.Text or Subtitle.Picture:
|
||||
string sourceFile = Path.GetTempFileName() + ".mkv";
|
||||
File.Move(file, sourceFile, true);
|
||||
|
||||
string tempFileName = Path.GetTempFileName() + ".mkv";
|
||||
string subPath = Path.Combine(
|
||||
TestContext.CurrentContext.TestDirectory,
|
||||
"Resources",
|
||||
subtitle == Subtitle.Picture ? "test.sup" : "test.srt");
|
||||
var p2 = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = ExecutableName("mkvmerge"),
|
||||
Arguments = $"-o {tempFileName} {sourceFile} {subPath}"
|
||||
}
|
||||
};
|
||||
|
||||
p2.Start();
|
||||
await p2.WaitForExitAsync();
|
||||
// ReSharper disable once MethodHasAsyncOverload
|
||||
p2.WaitForExit();
|
||||
if (p2.ExitCode != 0)
|
||||
{
|
||||
if (File.Exists(sourceFile))
|
||||
{
|
||||
File.Delete(sourceFile);
|
||||
}
|
||||
|
||||
if (File.Exists(file))
|
||||
{
|
||||
File.Delete(file);
|
||||
}
|
||||
}
|
||||
|
||||
p2.ExitCode.Should().Be(0);
|
||||
|
||||
File.Move(tempFileName, file, true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var imageCache = new Mock<IImageCache>();
|
||||
|
||||
// always return the static watermark resource
|
||||
imageCache.Setup(
|
||||
ic => ic.GetPathForImage(
|
||||
It.IsAny<string>(),
|
||||
It.Is<ArtworkKind>(x => x == ArtworkKind.Watermark),
|
||||
It.IsAny<Option<int>>()))
|
||||
.Returns(Path.Combine(TestContext.CurrentContext.TestDirectory, "Resources", "ErsatzTV.png"));
|
||||
|
||||
var oldService = new FFmpegProcessService(
|
||||
new FFmpegPlaybackSettingsCalculator(),
|
||||
new FakeStreamSelector(),
|
||||
imageCache.Object,
|
||||
new Mock<ITempFilePool>().Object,
|
||||
new Mock<IClient>().Object,
|
||||
new MemoryCache(new MemoryCacheOptions()),
|
||||
LoggerFactory.CreateLogger<FFmpegProcessService>());
|
||||
|
||||
var service = new FFmpegLibraryProcessService(
|
||||
oldService,
|
||||
new FFmpegPlaybackSettingsCalculator(),
|
||||
new FakeStreamSelector(),
|
||||
new Mock<ITempFilePool>().Object,
|
||||
new FakeNvidiaCapabilitiesFactory(),
|
||||
// new HardwareCapabilitiesFactory(
|
||||
// new MemoryCache(new MemoryCacheOptions()),
|
||||
// LoggerFactory.CreateLogger<HardwareCapabilitiesFactory>()),
|
||||
new RuntimeInfo(),
|
||||
LoggerFactory.CreateLogger<FFmpegLibraryProcessService>());
|
||||
|
||||
var v = new MediaVersion
|
||||
{
|
||||
MediaFiles = new List<MediaFile>
|
||||
{
|
||||
new() { Path = file }
|
||||
},
|
||||
Streams = new List<MediaStream>()
|
||||
};
|
||||
|
||||
var metadataRepository = new Mock<IMetadataRepository>();
|
||||
metadataRepository
|
||||
.Setup(r => r.UpdateLocalStatistics(It.IsAny<MediaItem>(), It.IsAny<MediaVersion>(), It.IsAny<bool>()))
|
||||
.Callback<MediaItem, MediaVersion, bool>(
|
||||
(_, version, _) =>
|
||||
{
|
||||
version.MediaFiles = v.MediaFiles;
|
||||
v = version;
|
||||
});
|
||||
|
||||
var localStatisticsProvider = new LocalStatisticsProvider(
|
||||
metadataRepository.Object,
|
||||
new LocalFileSystem(new Mock<IClient>().Object, LoggerFactory.CreateLogger<LocalFileSystem>()),
|
||||
new Mock<IClient>().Object,
|
||||
LoggerFactory.CreateLogger<LocalStatisticsProvider>());
|
||||
|
||||
await localStatisticsProvider.RefreshStatistics(
|
||||
ExecutableName("ffmpeg"),
|
||||
ExecutableName("ffprobe"),
|
||||
new Movie
|
||||
{
|
||||
MediaVersions = new List<MediaVersion>
|
||||
{
|
||||
new()
|
||||
{
|
||||
MediaFiles = new List<MediaFile>
|
||||
{
|
||||
new() { Path = file }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
var subtitleStreams = v.Streams
|
||||
.Filter(s => s.MediaStreamKind == MediaStreamKind.Subtitle)
|
||||
.ToList();
|
||||
|
||||
var subtitles = new List<Domain.Subtitle>();
|
||||
|
||||
foreach (MediaStream stream in subtitleStreams)
|
||||
{
|
||||
var s = new Domain.Subtitle
|
||||
{
|
||||
Codec = stream.Codec,
|
||||
Default = stream.Default,
|
||||
Forced = stream.Forced,
|
||||
Language = stream.Language,
|
||||
StreamIndex = stream.Index,
|
||||
SubtitleKind = SubtitleKind.Embedded,
|
||||
DateAdded = DateTime.UtcNow,
|
||||
DateUpdated = DateTime.UtcNow,
|
||||
Path = "test.srt",
|
||||
IsExtracted = true
|
||||
};
|
||||
|
||||
subtitles.Add(s);
|
||||
}
|
||||
|
||||
DateTimeOffset now = DateTimeOffset.Now;
|
||||
|
||||
Option<ChannelWatermark> channelWatermark = Option<ChannelWatermark>.None;
|
||||
switch (watermark)
|
||||
{
|
||||
case Watermark.None:
|
||||
break;
|
||||
case Watermark.IntermittentOpaque:
|
||||
channelWatermark = new ChannelWatermark
|
||||
{
|
||||
ImageSource = ChannelWatermarkImageSource.Custom,
|
||||
Mode = ChannelWatermarkMode.Intermittent,
|
||||
// TODO: how do we make sure this actually appears
|
||||
FrequencyMinutes = 1,
|
||||
DurationSeconds = 2,
|
||||
Opacity = 100
|
||||
};
|
||||
break;
|
||||
case Watermark.IntermittentTransparent:
|
||||
channelWatermark = new ChannelWatermark
|
||||
{
|
||||
ImageSource = ChannelWatermarkImageSource.Custom,
|
||||
Mode = ChannelWatermarkMode.Intermittent,
|
||||
// TODO: how do we make sure this actually appears
|
||||
FrequencyMinutes = 1,
|
||||
DurationSeconds = 2,
|
||||
Opacity = 80
|
||||
};
|
||||
break;
|
||||
case Watermark.PermanentOpaqueScaled:
|
||||
channelWatermark = new ChannelWatermark
|
||||
{
|
||||
ImageSource = ChannelWatermarkImageSource.Custom,
|
||||
Mode = ChannelWatermarkMode.Permanent,
|
||||
Opacity = 100,
|
||||
Size = WatermarkSize.Scaled
|
||||
};
|
||||
break;
|
||||
case Watermark.PermanentOpaqueActualSize:
|
||||
channelWatermark = new ChannelWatermark
|
||||
{
|
||||
ImageSource = ChannelWatermarkImageSource.Custom,
|
||||
Mode = ChannelWatermarkMode.Permanent,
|
||||
Opacity = 100,
|
||||
Size = WatermarkSize.ActualSize
|
||||
};
|
||||
break;
|
||||
case Watermark.PermanentTransparentScaled:
|
||||
channelWatermark = new ChannelWatermark
|
||||
{
|
||||
ImageSource = ChannelWatermarkImageSource.Custom,
|
||||
Mode = ChannelWatermarkMode.Permanent,
|
||||
Opacity = 80,
|
||||
Size = WatermarkSize.Scaled
|
||||
};
|
||||
break;
|
||||
case Watermark.PermanentTransparentActualSize:
|
||||
channelWatermark = new ChannelWatermark
|
||||
{
|
||||
ImageSource = ChannelWatermarkImageSource.Custom,
|
||||
Mode = ChannelWatermarkMode.Permanent,
|
||||
Opacity = 80,
|
||||
Size = WatermarkSize.ActualSize
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
ChannelSubtitleMode subtitleMode = subtitle switch
|
||||
{
|
||||
Subtitle.Picture or Subtitle.Text => ChannelSubtitleMode.Any,
|
||||
_ => ChannelSubtitleMode.None
|
||||
};
|
||||
|
||||
string srtFile = Path.Combine(FileSystemLayout.SubtitleCacheFolder, "test.srt");
|
||||
if (subtitle == Subtitle.Text && !File.Exists(srtFile))
|
||||
{
|
||||
string sourceFile = Path.Combine(TestContext.CurrentContext.TestDirectory, "Resources", "test.srt");
|
||||
Directory.CreateDirectory(FileSystemLayout.SubtitleCacheFolder);
|
||||
File.Copy(sourceFile, srtFile, true);
|
||||
}
|
||||
|
||||
Command process = await service.ForPlayoutItem(
|
||||
ExecutableName("ffmpeg"),
|
||||
ExecutableName("ffprobe"),
|
||||
false,
|
||||
new Channel(Guid.NewGuid())
|
||||
{
|
||||
Number = "1",
|
||||
FFmpegProfile = FFmpegProfile.New("test", profileResolution) with
|
||||
{
|
||||
HardwareAcceleration = profileAcceleration,
|
||||
VideoFormat = profileVideoFormat,
|
||||
AudioFormat = FFmpegProfileAudioFormat.Aac,
|
||||
DeinterlaceVideo = true
|
||||
},
|
||||
StreamingMode = StreamingMode.TransportStream,
|
||||
SubtitleMode = subtitleMode
|
||||
},
|
||||
v,
|
||||
v,
|
||||
file,
|
||||
file,
|
||||
subtitles,
|
||||
string.Empty,
|
||||
string.Empty,
|
||||
string.Empty,
|
||||
subtitleMode,
|
||||
now,
|
||||
now + TimeSpan.FromSeconds(5),
|
||||
now,
|
||||
Option<ChannelWatermark>.None,
|
||||
channelWatermark,
|
||||
VaapiDriver.Default,
|
||||
"/dev/dri/renderD128",
|
||||
Option<int>.None,
|
||||
false,
|
||||
FillerKind.None,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.FromSeconds(5),
|
||||
0,
|
||||
None,
|
||||
false);
|
||||
|
||||
// Console.WriteLine($"ffmpeg arguments {string.Join(" ", process.StartInfo.ArgumentList)}");
|
||||
|
||||
string[] unsupportedMessages =
|
||||
{
|
||||
"No support for codec",
|
||||
"No usable",
|
||||
"Provided device doesn't support",
|
||||
"Current pixel format is unsupported"
|
||||
};
|
||||
|
||||
var sb = new StringBuilder();
|
||||
CommandResult result;
|
||||
var timeoutSignal = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
||||
try
|
||||
{
|
||||
result = await process
|
||||
.WithStandardOutputPipe(PipeTarget.ToStream(Stream.Null))
|
||||
.WithStandardErrorPipe(PipeTarget.ToStringBuilder(sb))
|
||||
.ExecuteAsync(timeoutSignal.Token);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Assert.Fail($"Transcode failure (timeout): ffmpeg {process.Arguments}");
|
||||
return;
|
||||
}
|
||||
|
||||
var error = sb.ToString();
|
||||
bool isUnsupported = unsupportedMessages.Any(error.Contains);
|
||||
|
||||
if (profileAcceleration != HardwareAccelerationKind.None && isUnsupported)
|
||||
{
|
||||
result.ExitCode.Should().Be(1, $"Error message with successful exit code? {process.Arguments}");
|
||||
Assert.Warn($"Unsupported on this hardware: ffmpeg {process.Arguments}");
|
||||
}
|
||||
else if (error.Contains("Impossible to convert between"))
|
||||
{
|
||||
Assert.Fail($"Transcode failure: ffmpeg {process.Arguments}");
|
||||
}
|
||||
else
|
||||
{
|
||||
result.ExitCode.Should().Be(0, error + Environment.NewLine + process.Arguments);
|
||||
if (result.ExitCode == 0)
|
||||
{
|
||||
Console.WriteLine(process.Arguments);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetStringSha256Hash(string text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
using var sha = SHA256.Create();
|
||||
byte[] textData = Encoding.UTF8.GetBytes(text);
|
||||
byte[] hash = sha.ComputeHash(textData);
|
||||
return BitConverter.ToString(hash).Replace("-", string.Empty);
|
||||
}
|
||||
|
||||
private class FakeStreamSelector : IFFmpegStreamSelector
|
||||
{
|
||||
public Task<MediaStream> SelectVideoStream(MediaVersion version) =>
|
||||
version.Streams.First(s => s.MediaStreamKind == MediaStreamKind.Video).AsTask();
|
||||
|
||||
public Task<Option<MediaStream>> SelectAudioStream(
|
||||
MediaVersion version,
|
||||
StreamingMode streamingMode,
|
||||
string channelNumber,
|
||||
string preferredAudioLanguage,
|
||||
string preferredAudioTitle) =>
|
||||
Optional(version.Streams.First(s => s.MediaStreamKind == MediaStreamKind.Audio)).AsTask();
|
||||
|
||||
public Task<Option<Domain.Subtitle>> SelectSubtitleStream(
|
||||
List<Domain.Subtitle> subtitles,
|
||||
Channel channel,
|
||||
string preferredSubtitleLanguage,
|
||||
ChannelSubtitleMode subtitleMode) =>
|
||||
subtitles.HeadOrNone().AsTask();
|
||||
}
|
||||
|
||||
private class FakeNvidiaCapabilitiesFactory : IHardwareCapabilitiesFactory
|
||||
{
|
||||
public Task<IHardwareCapabilities> GetHardwareCapabilities(
|
||||
string ffmpegPath,
|
||||
HardwareAccelerationMode hardwareAccelerationMode) =>
|
||||
Task.FromResult<IHardwareCapabilities>(new NvidiaHardwareCapabilities(61, string.Empty));
|
||||
}
|
||||
|
||||
private static string ExecutableName(string baseName) =>
|
||||
OperatingSystem.IsWindows() ? $"{baseName}.exe" : baseName;
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
using Destructurama;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Scheduling;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Core.Tests.Fakes;
|
||||
using FluentAssertions;
|
||||
@@ -547,11 +549,15 @@ public class PlayoutBuilderTests
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
var televisionRepo = new FakeTelevisionRepository();
|
||||
var artistRepo = new Mock<IArtistRepository>();
|
||||
var factory = new Mock<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
|
||||
var localFileSystem = new Mock<ILocalFileSystem>();
|
||||
var builder = new PlayoutBuilder(
|
||||
configRepo.Object,
|
||||
fakeRepository,
|
||||
televisionRepo,
|
||||
artistRepo.Object,
|
||||
factory.Object,
|
||||
localFileSystem.Object,
|
||||
_logger);
|
||||
|
||||
DateTimeOffset start = HoursAfterMidnight(0);
|
||||
@@ -639,11 +645,15 @@ public class PlayoutBuilderTests
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
var televisionRepo = new FakeTelevisionRepository();
|
||||
var artistRepo = new Mock<IArtistRepository>();
|
||||
var factory = new Mock<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
|
||||
var localFileSystem = new Mock<ILocalFileSystem>();
|
||||
var builder = new PlayoutBuilder(
|
||||
configRepo.Object,
|
||||
fakeRepository,
|
||||
televisionRepo,
|
||||
artistRepo.Object,
|
||||
factory.Object,
|
||||
localFileSystem.Object,
|
||||
_logger);
|
||||
|
||||
DateTimeOffset start = HoursAfterMidnight(0);
|
||||
@@ -779,11 +789,15 @@ public class PlayoutBuilderTests
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
var televisionRepo = new FakeTelevisionRepository();
|
||||
var artistRepo = new Mock<IArtistRepository>();
|
||||
var factory = new Mock<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
|
||||
var localFileSystem = new Mock<ILocalFileSystem>();
|
||||
var builder = new PlayoutBuilder(
|
||||
configRepo.Object,
|
||||
fakeRepository,
|
||||
televisionRepo,
|
||||
artistRepo.Object,
|
||||
factory.Object,
|
||||
localFileSystem.Object,
|
||||
_logger);
|
||||
|
||||
DateTimeOffset start = HoursAfterMidnight(0);
|
||||
@@ -877,11 +891,15 @@ public class PlayoutBuilderTests
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
var televisionRepo = new FakeTelevisionRepository();
|
||||
var artistRepo = new Mock<IArtistRepository>();
|
||||
var factory = new Mock<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
|
||||
var localFileSystem = new Mock<ILocalFileSystem>();
|
||||
var builder = new PlayoutBuilder(
|
||||
configRepo.Object,
|
||||
fakeRepository,
|
||||
televisionRepo,
|
||||
artistRepo.Object,
|
||||
factory.Object,
|
||||
localFileSystem.Object,
|
||||
_logger);
|
||||
|
||||
DateTimeOffset start = HoursAfterMidnight(0);
|
||||
@@ -984,11 +1002,15 @@ public class PlayoutBuilderTests
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
var televisionRepo = new FakeTelevisionRepository();
|
||||
var artistRepo = new Mock<IArtistRepository>();
|
||||
var factory = new Mock<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
|
||||
var localFileSystem = new Mock<ILocalFileSystem>();
|
||||
var builder = new PlayoutBuilder(
|
||||
configRepo.Object,
|
||||
fakeRepository,
|
||||
televisionRepo,
|
||||
artistRepo.Object,
|
||||
factory.Object,
|
||||
localFileSystem.Object,
|
||||
_logger);
|
||||
|
||||
DateTimeOffset start = HoursAfterMidnight(0);
|
||||
@@ -1084,11 +1106,15 @@ public class PlayoutBuilderTests
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
var televisionRepo = new FakeTelevisionRepository();
|
||||
var artistRepo = new Mock<IArtistRepository>();
|
||||
var factory = new Mock<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
|
||||
var localFileSystem = new Mock<ILocalFileSystem>();
|
||||
var builder = new PlayoutBuilder(
|
||||
configRepo.Object,
|
||||
fakeRepository,
|
||||
televisionRepo,
|
||||
artistRepo.Object,
|
||||
factory.Object,
|
||||
localFileSystem.Object,
|
||||
_logger);
|
||||
|
||||
DateTimeOffset start = HoursAfterMidnight(0);
|
||||
@@ -1188,11 +1214,15 @@ public class PlayoutBuilderTests
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
var televisionRepo = new FakeTelevisionRepository();
|
||||
var artistRepo = new Mock<IArtistRepository>();
|
||||
var factory = new Mock<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
|
||||
var localFileSystem = new Mock<ILocalFileSystem>();
|
||||
var builder = new PlayoutBuilder(
|
||||
configRepo.Object,
|
||||
fakeRepository,
|
||||
televisionRepo,
|
||||
artistRepo.Object,
|
||||
factory.Object,
|
||||
localFileSystem.Object,
|
||||
_logger);
|
||||
|
||||
DateTimeOffset start = HoursAfterMidnight(0);
|
||||
@@ -1297,11 +1327,15 @@ public class PlayoutBuilderTests
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
var televisionRepo = new FakeTelevisionRepository();
|
||||
var artistRepo = new Mock<IArtistRepository>();
|
||||
var factory = new Mock<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
|
||||
var localFileSystem = new Mock<ILocalFileSystem>();
|
||||
var builder = new PlayoutBuilder(
|
||||
configRepo.Object,
|
||||
fakeRepository,
|
||||
televisionRepo,
|
||||
artistRepo.Object,
|
||||
factory.Object,
|
||||
localFileSystem.Object,
|
||||
_logger);
|
||||
|
||||
DateTimeOffset start = HoursAfterMidnight(0);
|
||||
@@ -1395,11 +1429,15 @@ public class PlayoutBuilderTests
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
var televisionRepo = new FakeTelevisionRepository();
|
||||
var artistRepo = new Mock<IArtistRepository>();
|
||||
var factory = new Mock<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
|
||||
var localFileSystem = new Mock<ILocalFileSystem>();
|
||||
var builder = new PlayoutBuilder(
|
||||
configRepo.Object,
|
||||
fakeRepository,
|
||||
televisionRepo,
|
||||
artistRepo.Object,
|
||||
factory.Object,
|
||||
localFileSystem.Object,
|
||||
_logger);
|
||||
|
||||
DateTimeOffset start = HoursAfterMidnight(0);
|
||||
@@ -1504,11 +1542,15 @@ public class PlayoutBuilderTests
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
var televisionRepo = new FakeTelevisionRepository();
|
||||
var artistRepo = new Mock<IArtistRepository>();
|
||||
var factory = new Mock<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
|
||||
var localFileSystem = new Mock<ILocalFileSystem>();
|
||||
var builder = new PlayoutBuilder(
|
||||
configRepo.Object,
|
||||
fakeRepository,
|
||||
televisionRepo,
|
||||
artistRepo.Object,
|
||||
factory.Object,
|
||||
localFileSystem.Object,
|
||||
_logger);
|
||||
|
||||
DateTimeOffset start = HoursAfterMidnight(0);
|
||||
@@ -1624,11 +1666,15 @@ public class PlayoutBuilderTests
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
var televisionRepo = new FakeTelevisionRepository();
|
||||
var artistRepo = new Mock<IArtistRepository>();
|
||||
var factory = new Mock<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
|
||||
var localFileSystem = new Mock<ILocalFileSystem>();
|
||||
var builder = new PlayoutBuilder(
|
||||
configRepo.Object,
|
||||
fakeRepository,
|
||||
televisionRepo,
|
||||
artistRepo.Object,
|
||||
factory.Object,
|
||||
localFileSystem.Object,
|
||||
_logger);
|
||||
|
||||
DateTimeOffset start = HoursAfterMidnight(0);
|
||||
@@ -1736,11 +1782,15 @@ public class PlayoutBuilderTests
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
var televisionRepo = new FakeTelevisionRepository();
|
||||
var artistRepo = new Mock<IArtistRepository>();
|
||||
var factory = new Mock<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
|
||||
var localFileSystem = new Mock<ILocalFileSystem>();
|
||||
var builder = new PlayoutBuilder(
|
||||
configRepo.Object,
|
||||
fakeRepository,
|
||||
televisionRepo,
|
||||
artistRepo.Object,
|
||||
factory.Object,
|
||||
localFileSystem.Object,
|
||||
_logger);
|
||||
|
||||
DateTimeOffset start = HoursAfterMidnight(0);
|
||||
@@ -1808,11 +1858,15 @@ public class PlayoutBuilderTests
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
var televisionRepo = new FakeTelevisionRepository();
|
||||
var artistRepo = new Mock<IArtistRepository>();
|
||||
var factory = new Mock<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
|
||||
var localFileSystem = new Mock<ILocalFileSystem>();
|
||||
var builder = new PlayoutBuilder(
|
||||
configRepo.Object,
|
||||
fakeRepository,
|
||||
televisionRepo,
|
||||
artistRepo.Object,
|
||||
factory.Object,
|
||||
localFileSystem.Object,
|
||||
_logger);
|
||||
|
||||
DateTimeOffset start = HoursAfterMidnight(0);
|
||||
@@ -2017,11 +2071,15 @@ public class PlayoutBuilderTests
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
var televisionRepo = new FakeTelevisionRepository();
|
||||
var artistRepo = new Mock<IArtistRepository>();
|
||||
var factory = new Mock<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
|
||||
var localFileSystem = new Mock<ILocalFileSystem>();
|
||||
var builder = new PlayoutBuilder(
|
||||
configRepo.Object,
|
||||
fakeRepository,
|
||||
televisionRepo,
|
||||
artistRepo.Object,
|
||||
factory.Object,
|
||||
localFileSystem.Object,
|
||||
_logger);
|
||||
|
||||
DateTimeOffset start = HoursAfterMidnight(24);
|
||||
@@ -2385,11 +2443,15 @@ public class PlayoutBuilderTests
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
var televisionRepo = new FakeTelevisionRepository();
|
||||
var artistRepo = new Mock<IArtistRepository>();
|
||||
var factory = new Mock<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
|
||||
var localFileSystem = new Mock<ILocalFileSystem>();
|
||||
var builder = new PlayoutBuilder(
|
||||
configRepo.Object,
|
||||
fakeRepository,
|
||||
televisionRepo,
|
||||
artistRepo.Object,
|
||||
factory.Object,
|
||||
localFileSystem.Object,
|
||||
_logger);
|
||||
|
||||
DateTimeOffset start = HoursAfterMidnight(0);
|
||||
@@ -2492,11 +2554,15 @@ public class PlayoutBuilderTests
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
var televisionRepo = new FakeTelevisionRepository();
|
||||
var artistRepo = new Mock<IArtistRepository>();
|
||||
var factory = new Mock<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
|
||||
var localFileSystem = new Mock<ILocalFileSystem>();
|
||||
var builder = new PlayoutBuilder(
|
||||
configRepo.Object,
|
||||
fakeRepository,
|
||||
televisionRepo,
|
||||
artistRepo.Object,
|
||||
factory.Object,
|
||||
localFileSystem.Object,
|
||||
_logger);
|
||||
|
||||
DateTimeOffset start = HoursAfterMidnight(0);
|
||||
@@ -2599,11 +2665,15 @@ public class PlayoutBuilderTests
|
||||
var configRepo = new Mock<IConfigElementRepository>();
|
||||
var televisionRepo = new FakeTelevisionRepository();
|
||||
var artistRepo = new Mock<IArtistRepository>();
|
||||
var factory = new Mock<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
|
||||
var localFileSystem = new Mock<ILocalFileSystem>();
|
||||
var builder = new PlayoutBuilder(
|
||||
configRepo.Object,
|
||||
fakeRepository,
|
||||
televisionRepo,
|
||||
artistRepo.Object,
|
||||
factory.Object,
|
||||
localFileSystem.Object,
|
||||
_logger);
|
||||
|
||||
DateTimeOffset start = HoursAfterMidnight(0);
|
||||
@@ -2679,11 +2749,15 @@ public class PlayoutBuilderTests
|
||||
var collectionRepo = new FakeMediaCollectionRepository(Map((mediaCollection.Id, mediaItems)));
|
||||
var televisionRepo = new FakeTelevisionRepository();
|
||||
var artistRepo = new Mock<IArtistRepository>();
|
||||
var factory = new Mock<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
|
||||
var localFileSystem = new Mock<ILocalFileSystem>();
|
||||
var builder = new PlayoutBuilder(
|
||||
configRepo.Object,
|
||||
collectionRepo,
|
||||
televisionRepo,
|
||||
artistRepo.Object,
|
||||
factory.Object,
|
||||
localFileSystem.Object,
|
||||
_logger);
|
||||
|
||||
var items = new List<ProgramScheduleItem> { Flood(mediaCollection, playbackOrder) };
|
||||
|
||||
@@ -823,6 +823,216 @@ public class PlayoutModeSchedulerFloodTests : SchedulerTestBase
|
||||
playoutItems[6].GuideGroup.Should().Be(3);
|
||||
playoutItems[6].FillerKind.Should().Be(FillerKind.Fallback);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Not_Schedule_Fallback_Filler_Incomplete_Flood()
|
||||
{
|
||||
Collection collectionOne = TwoItemCollection(1, 2, TimeSpan.FromMinutes(20));
|
||||
Collection collectionTwo = TwoItemCollection(3, 4, TimeSpan.FromMinutes(1));
|
||||
|
||||
var scheduleItem = new ProgramScheduleItemFlood
|
||||
{
|
||||
Id = 1,
|
||||
Index = 1,
|
||||
Collection = collectionOne,
|
||||
CollectionId = collectionOne.Id,
|
||||
StartTime = null,
|
||||
PlaybackOrder = PlaybackOrder.Chronological,
|
||||
TailFiller = null,
|
||||
FallbackFiller = new FillerPreset
|
||||
{
|
||||
FillerKind = FillerKind.Fallback,
|
||||
Collection = collectionTwo,
|
||||
CollectionId = collectionTwo.Id
|
||||
}
|
||||
};
|
||||
|
||||
var enumerator1 = new ChronologicalMediaCollectionEnumerator(
|
||||
collectionOne.MediaItems,
|
||||
new CollectionEnumeratorState());
|
||||
|
||||
var enumerator2 = new ChronologicalMediaCollectionEnumerator(
|
||||
collectionTwo.MediaItems,
|
||||
new CollectionEnumeratorState());
|
||||
|
||||
var sortedScheduleItems = new List<ProgramScheduleItem>
|
||||
{
|
||||
scheduleItem,
|
||||
NextScheduleItem
|
||||
};
|
||||
|
||||
var scheduleItemsEnumerator = new OrderedScheduleItemsEnumerator(
|
||||
sortedScheduleItems,
|
||||
new CollectionEnumeratorState());
|
||||
|
||||
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
|
||||
|
||||
var scheduler = new PlayoutModeSchedulerFlood(new Mock<ILogger>().Object);
|
||||
|
||||
// hard stop at 2, an hour before the "next schedule item" at 3
|
||||
DateTimeOffset hardStop = StartState(scheduleItemsEnumerator).CurrentTime.AddHours(2);
|
||||
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
|
||||
startState,
|
||||
CollectionEnumerators(
|
||||
scheduleItem,
|
||||
enumerator1,
|
||||
scheduleItem.FallbackFiller,
|
||||
enumerator2),
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
hardStop);
|
||||
|
||||
playoutBuilderState.CurrentTime.Should().Be(startState.CurrentTime.AddHours(2));
|
||||
playoutItems.Last().FinishOffset.Should().Be(playoutBuilderState.CurrentTime);
|
||||
|
||||
playoutBuilderState.NextGuideGroup.Should().Be(7);
|
||||
playoutBuilderState.DurationFinish.IsNone.Should().BeTrue();
|
||||
playoutBuilderState.InFlood.Should().BeTrue();
|
||||
playoutBuilderState.MultipleRemaining.IsNone.Should().BeTrue();
|
||||
playoutBuilderState.InDurationFiller.Should().BeFalse();
|
||||
playoutBuilderState.ScheduleItemsEnumerator.State.Index.Should().Be(0);
|
||||
|
||||
enumerator1.State.Index.Should().Be(0);
|
||||
enumerator2.State.Index.Should().Be(0);
|
||||
|
||||
playoutItems.Count.Should().Be(6);
|
||||
|
||||
playoutItems[0].MediaItemId.Should().Be(1);
|
||||
playoutItems[0].StartOffset.Should().Be(startState.CurrentTime);
|
||||
playoutItems[0].GuideGroup.Should().Be(1);
|
||||
playoutItems[0].FillerKind.Should().Be(FillerKind.None);
|
||||
|
||||
playoutItems[1].MediaItemId.Should().Be(2);
|
||||
playoutItems[1].StartOffset.Should().Be(startState.CurrentTime.AddMinutes(20));
|
||||
playoutItems[1].GuideGroup.Should().Be(2);
|
||||
playoutItems[1].FillerKind.Should().Be(FillerKind.None);
|
||||
|
||||
playoutItems[2].MediaItemId.Should().Be(1);
|
||||
playoutItems[2].StartOffset.Should().Be(startState.CurrentTime.AddMinutes(40));
|
||||
playoutItems[2].GuideGroup.Should().Be(3);
|
||||
playoutItems[2].FillerKind.Should().Be(FillerKind.None);
|
||||
|
||||
playoutItems[3].MediaItemId.Should().Be(2);
|
||||
playoutItems[3].StartOffset.Should().Be(startState.CurrentTime.AddMinutes(60));
|
||||
playoutItems[3].GuideGroup.Should().Be(4);
|
||||
playoutItems[3].FillerKind.Should().Be(FillerKind.None);
|
||||
|
||||
playoutItems[4].MediaItemId.Should().Be(1);
|
||||
playoutItems[4].StartOffset.Should().Be(startState.CurrentTime.AddMinutes(80));
|
||||
playoutItems[4].GuideGroup.Should().Be(5);
|
||||
playoutItems[4].FillerKind.Should().Be(FillerKind.None);
|
||||
|
||||
playoutItems[5].MediaItemId.Should().Be(2);
|
||||
playoutItems[5].StartOffset.Should().Be(startState.CurrentTime.AddMinutes(100));
|
||||
playoutItems[5].GuideGroup.Should().Be(6);
|
||||
playoutItems[5].FillerKind.Should().Be(FillerKind.None);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Not_Schedule_Tail_Filler_Incomplete_Flood()
|
||||
{
|
||||
Collection collectionOne = TwoItemCollection(1, 2, TimeSpan.FromMinutes(20));
|
||||
Collection collectionTwo = TwoItemCollection(3, 4, TimeSpan.FromMinutes(1));
|
||||
|
||||
var scheduleItem = new ProgramScheduleItemFlood
|
||||
{
|
||||
Id = 1,
|
||||
Index = 1,
|
||||
Collection = collectionOne,
|
||||
CollectionId = collectionOne.Id,
|
||||
StartTime = null,
|
||||
PlaybackOrder = PlaybackOrder.Chronological,
|
||||
TailFiller = new FillerPreset
|
||||
{
|
||||
FillerKind = FillerKind.Tail,
|
||||
Collection = collectionTwo,
|
||||
CollectionId = collectionTwo.Id
|
||||
},
|
||||
FallbackFiller = null
|
||||
};
|
||||
|
||||
var enumerator1 = new ChronologicalMediaCollectionEnumerator(
|
||||
collectionOne.MediaItems,
|
||||
new CollectionEnumeratorState());
|
||||
|
||||
var enumerator2 = new ChronologicalMediaCollectionEnumerator(
|
||||
collectionTwo.MediaItems,
|
||||
new CollectionEnumeratorState());
|
||||
|
||||
var sortedScheduleItems = new List<ProgramScheduleItem>
|
||||
{
|
||||
scheduleItem,
|
||||
NextScheduleItem
|
||||
};
|
||||
|
||||
var scheduleItemsEnumerator = new OrderedScheduleItemsEnumerator(
|
||||
sortedScheduleItems,
|
||||
new CollectionEnumeratorState());
|
||||
|
||||
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
|
||||
|
||||
var scheduler = new PlayoutModeSchedulerFlood(new Mock<ILogger>().Object);
|
||||
|
||||
// hard stop at 2, an hour before the "next schedule item" at 3
|
||||
DateTimeOffset hardStop = StartState(scheduleItemsEnumerator).CurrentTime.AddHours(2);
|
||||
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
|
||||
startState,
|
||||
CollectionEnumerators(
|
||||
scheduleItem,
|
||||
enumerator1,
|
||||
scheduleItem.TailFiller,
|
||||
enumerator2),
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
hardStop);
|
||||
|
||||
playoutBuilderState.CurrentTime.Should().Be(startState.CurrentTime.AddHours(2));
|
||||
playoutItems.Last().FinishOffset.Should().Be(playoutBuilderState.CurrentTime);
|
||||
|
||||
playoutBuilderState.NextGuideGroup.Should().Be(7);
|
||||
playoutBuilderState.DurationFinish.IsNone.Should().BeTrue();
|
||||
playoutBuilderState.InFlood.Should().BeTrue();
|
||||
playoutBuilderState.MultipleRemaining.IsNone.Should().BeTrue();
|
||||
playoutBuilderState.InDurationFiller.Should().BeFalse();
|
||||
playoutBuilderState.ScheduleItemsEnumerator.State.Index.Should().Be(0);
|
||||
|
||||
enumerator1.State.Index.Should().Be(0);
|
||||
enumerator2.State.Index.Should().Be(0);
|
||||
|
||||
playoutItems.Count.Should().Be(6);
|
||||
|
||||
playoutItems[0].MediaItemId.Should().Be(1);
|
||||
playoutItems[0].StartOffset.Should().Be(startState.CurrentTime);
|
||||
playoutItems[0].GuideGroup.Should().Be(1);
|
||||
playoutItems[0].FillerKind.Should().Be(FillerKind.None);
|
||||
|
||||
playoutItems[1].MediaItemId.Should().Be(2);
|
||||
playoutItems[1].StartOffset.Should().Be(startState.CurrentTime.AddMinutes(20));
|
||||
playoutItems[1].GuideGroup.Should().Be(2);
|
||||
playoutItems[1].FillerKind.Should().Be(FillerKind.None);
|
||||
|
||||
playoutItems[2].MediaItemId.Should().Be(1);
|
||||
playoutItems[2].StartOffset.Should().Be(startState.CurrentTime.AddMinutes(40));
|
||||
playoutItems[2].GuideGroup.Should().Be(3);
|
||||
playoutItems[2].FillerKind.Should().Be(FillerKind.None);
|
||||
|
||||
playoutItems[3].MediaItemId.Should().Be(2);
|
||||
playoutItems[3].StartOffset.Should().Be(startState.CurrentTime.AddMinutes(60));
|
||||
playoutItems[3].GuideGroup.Should().Be(4);
|
||||
playoutItems[3].FillerKind.Should().Be(FillerKind.None);
|
||||
|
||||
playoutItems[4].MediaItemId.Should().Be(1);
|
||||
playoutItems[4].StartOffset.Should().Be(startState.CurrentTime.AddMinutes(80));
|
||||
playoutItems[4].GuideGroup.Should().Be(5);
|
||||
playoutItems[4].FillerKind.Should().Be(FillerKind.None);
|
||||
|
||||
playoutItems[5].MediaItemId.Should().Be(2);
|
||||
playoutItems[5].StartOffset.Should().Be(startState.CurrentTime.AddMinutes(100));
|
||||
playoutItems[5].GuideGroup.Should().Be(6);
|
||||
playoutItems[5].FillerKind.Should().Be(FillerKind.None);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Not_Have_Gap_With_Unused_Tail_And_Unused_Fallback()
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
using Bugsnag;
|
||||
using Dapper;
|
||||
using Destructurama;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Scheduling;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
@@ -150,9 +153,11 @@ public class ScheduleIntegrationTests
|
||||
|
||||
var builder = new PlayoutBuilder(
|
||||
new ConfigElementRepository(factory),
|
||||
new MediaCollectionRepository(new Mock<ISearchIndex>().Object, factory),
|
||||
new MediaCollectionRepository(new Mock<IClient>().Object, new Mock<ISearchIndex>().Object, factory),
|
||||
new TelevisionRepository(factory),
|
||||
new ArtistRepository(factory),
|
||||
new Mock<IMultiEpisodeShuffleCollectionEnumeratorFactory>().Object,
|
||||
new Mock<ILocalFileSystem>().Object,
|
||||
provider.GetRequiredService<ILogger<PlayoutBuilder>>());
|
||||
|
||||
for (var i = 0; i <= (24 * 4); i++)
|
||||
|
||||
@@ -27,4 +27,5 @@ public class Channel
|
||||
public string PreferredSubtitleLanguageCode { get; set; }
|
||||
public ChannelSubtitleMode SubtitleMode { get; set; }
|
||||
public ChannelMusicVideoCreditsMode MusicVideoCreditsMode { get; set; }
|
||||
public string MusicVideoCreditsTemplate { get; set; }
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ public class ChannelWatermark
|
||||
public int FrequencyMinutes { get; set; }
|
||||
public int DurationSeconds { get; set; }
|
||||
public int Opacity { get; set; }
|
||||
public bool PlaceWithinSourceContent { get; set; }
|
||||
}
|
||||
|
||||
public enum ChannelWatermarkMode
|
||||
|
||||
@@ -14,6 +14,7 @@ public record FFmpegProfile
|
||||
public int ResolutionId { get; set; }
|
||||
public Resolution Resolution { get; set; }
|
||||
public FFmpegProfileVideoFormat VideoFormat { get; set; }
|
||||
public FFmpegProfileBitDepth BitDepth { get; set; }
|
||||
public int VideoBitrate { get; set; }
|
||||
public int VideoBufferSize { get; set; }
|
||||
public FFmpegProfileAudioFormat AudioFormat { get; set; }
|
||||
|
||||
7
ErsatzTV.Core/Domain/FFmpegProfileBitDepth.cs
Normal file
7
ErsatzTV.Core/Domain/FFmpegProfileBitDepth.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
|
||||
public enum FFmpegProfileBitDepth
|
||||
{
|
||||
EightBit = 0,
|
||||
TenBit = 1
|
||||
}
|
||||
@@ -7,5 +7,7 @@ public enum FillerKind
|
||||
MidRoll = 2,
|
||||
PostRoll = 3,
|
||||
Tail = 4,
|
||||
Fallback = 5
|
||||
Fallback = 5,
|
||||
|
||||
GuideMode = 99
|
||||
}
|
||||
|
||||
@@ -14,6 +14,10 @@ public class MediaStream
|
||||
public bool Forced { get; set; }
|
||||
public bool AttachedPic { get; set; }
|
||||
public string PixelFormat { get; set; }
|
||||
public string ColorRange { get; set; }
|
||||
public string ColorSpace { get; set; }
|
||||
public string ColorTransfer { get; set; }
|
||||
public string ColorPrimaries { get; set; }
|
||||
public int BitsPerRawSample { get; set; }
|
||||
public string FileName { get; set; }
|
||||
public string MimeType { get; set; }
|
||||
|
||||
@@ -5,5 +5,6 @@ public enum PlaybackOrder
|
||||
Chronological = 1,
|
||||
Random = 2,
|
||||
Shuffle = 3,
|
||||
ShuffleInOrder = 4
|
||||
ShuffleInOrder = 4,
|
||||
MultiEpisodeShuffle = 5
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<TargetFramework>net7.0</TargetFramework>
|
||||
<NoWarn>VSTHRD200</NoWarn>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
@@ -9,20 +9,20 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Bugsnag" Version="3.1.0" />
|
||||
<PackageReference Include="Destructurama.Attributed" Version="3.0.0" />
|
||||
<PackageReference Include="Flurl" Version="3.0.6" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.2.9" />
|
||||
<PackageReference Include="LanguageExt.Transformers" Version="4.2.9" />
|
||||
<PackageReference Include="MediatR" Version="11.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="6.0.2" />
|
||||
<PackageReference Include="Flurl" Version="3.0.7" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.4.0" />
|
||||
<PackageReference Include="LanguageExt.Transformers" Version="4.4.0" />
|
||||
<PackageReference Include="MediatR" Version="11.1.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="7.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="7.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="7.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="7.0.0" />
|
||||
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="2.2.1" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.3.44">
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.4.27">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.2" />
|
||||
<PackageReference Include="Serilog" Version="2.12.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="4.1.0" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Globalization;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
@@ -9,29 +8,16 @@ namespace ErsatzTV.Core.FFmpeg;
|
||||
|
||||
public class FFmpegComplexFilterBuilder
|
||||
{
|
||||
private Option<TimeSpan> _audioDuration = None;
|
||||
private bool _boxBlur;
|
||||
private bool _deinterlace;
|
||||
private Option<HardwareAccelerationKind> _hardwareAccelerationKind = None;
|
||||
private string _inputCodec;
|
||||
private Option<List<FadePoint>> _maybeFadePoints = None;
|
||||
private bool _normalizeLoudness;
|
||||
private Option<IDisplaySize> _padToSize = None;
|
||||
private string _pixelFormat;
|
||||
private IDisplaySize _resolution;
|
||||
private Option<IDisplaySize> _scaleToSize = None;
|
||||
private Option<string> _subtitle;
|
||||
private string _videoDecoder;
|
||||
private FFmpegProfileVideoFormat _videoFormat;
|
||||
private Option<ChannelWatermark> _watermark;
|
||||
private Option<int> _watermarkIndex;
|
||||
|
||||
public FFmpegComplexFilterBuilder WithHardwareAcceleration(HardwareAccelerationKind hardwareAccelerationKind)
|
||||
{
|
||||
_hardwareAccelerationKind = Some(hardwareAccelerationKind);
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegComplexFilterBuilder WithScaling(IDisplaySize scaleToSize)
|
||||
{
|
||||
_scaleToSize = Some(scaleToSize);
|
||||
@@ -44,40 +30,6 @@ public class FFmpegComplexFilterBuilder
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegComplexFilterBuilder WithDeinterlace(bool deinterlace)
|
||||
{
|
||||
_deinterlace = deinterlace;
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegComplexFilterBuilder WithAlignedAudio(Option<TimeSpan> audioDuration)
|
||||
{
|
||||
_audioDuration = audioDuration;
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegComplexFilterBuilder WithNormalizeLoudness(bool normalizeLoudness)
|
||||
{
|
||||
_normalizeLoudness = normalizeLoudness;
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegComplexFilterBuilder WithInputCodec(Option<string> maybeCodec)
|
||||
{
|
||||
foreach (string codec in maybeCodec)
|
||||
{
|
||||
_inputCodec = codec;
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegComplexFilterBuilder WithDecoder(string decoder)
|
||||
{
|
||||
_videoDecoder = decoder;
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegComplexFilterBuilder WithInputPixelFormat(Option<string> maybePixelFormat)
|
||||
{
|
||||
foreach (string pixelFormat in maybePixelFormat)
|
||||
@@ -131,12 +83,6 @@ public class FFmpegComplexFilterBuilder
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegComplexFilterBuilder WithVideoFormat(FFmpegProfileVideoFormat videoFormat)
|
||||
{
|
||||
_videoFormat = videoFormat;
|
||||
return this;
|
||||
}
|
||||
|
||||
public Option<FFmpegComplexFilter> Build(
|
||||
bool videoOnly,
|
||||
int videoInput,
|
||||
@@ -153,121 +99,13 @@ public class FFmpegComplexFilterBuilder
|
||||
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" &&
|
||||
(_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 &&
|
||||
(string.IsNullOrWhiteSpace(_videoDecoder) ||
|
||||
_videoDecoder.Contains("cuvid")),
|
||||
HardwareAccelerationKind.Qsv => !isSong,
|
||||
HardwareAccelerationKind.VideoToolbox => false,
|
||||
HardwareAccelerationKind.Amf => 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>();
|
||||
var watermarkPreprocess = new List<string>();
|
||||
string watermarkOverlay = string.Empty;
|
||||
|
||||
if (_normalizeLoudness)
|
||||
{
|
||||
audioFilterQueue.Add("loudnorm=I=-16:TP=-1.5:LRA=11");
|
||||
}
|
||||
|
||||
_audioDuration.IfSome(
|
||||
audioDuration =>
|
||||
{
|
||||
var durationString = audioDuration.TotalMilliseconds.ToString(NumberFormatInfo.InvariantInfo);
|
||||
audioFilterQueue.Add($"apad=whole_dur={durationString}ms");
|
||||
});
|
||||
|
||||
bool usesHardwareFilters = acceleration != HardwareAccelerationKind.None &&
|
||||
acceleration != HardwareAccelerationKind.VideoToolbox &&
|
||||
acceleration != HardwareAccelerationKind.Amf &&
|
||||
!isHardwareDecode &&
|
||||
(_deinterlace || _scaleToSize.IsSome);
|
||||
|
||||
if (isSong)
|
||||
{
|
||||
switch (acceleration)
|
||||
{
|
||||
case HardwareAccelerationKind.Qsv:
|
||||
videoFilterQueue.Add("format=nv12");
|
||||
break;
|
||||
case HardwareAccelerationKind.Vaapi:
|
||||
videoFilterQueue.Add("format=nv12|vaapi");
|
||||
break;
|
||||
default:
|
||||
videoFilterQueue.Add("format=yuv420p");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
switch (usesHardwareFilters || isSong, acceleration)
|
||||
{
|
||||
case (true, HardwareAccelerationKind.Nvenc):
|
||||
videoFilterQueue.Add("hwupload_cuda");
|
||||
break;
|
||||
case (true, HardwareAccelerationKind.Qsv):
|
||||
videoFilterQueue.Add("hwupload=extra_hw_frames=64");
|
||||
break;
|
||||
case (true, HardwareAccelerationKind.Vaapi):
|
||||
videoFilterQueue.Add("hwupload");
|
||||
break;
|
||||
case (true, _) when usesHardwareFilters:
|
||||
videoFilterQueue.Add("hwupload");
|
||||
break;
|
||||
}
|
||||
|
||||
if (_deinterlace)
|
||||
{
|
||||
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"
|
||||
};
|
||||
|
||||
foreach (string filter in maybeFilter)
|
||||
{
|
||||
videoFilterQueue.Add(filter);
|
||||
}
|
||||
}
|
||||
|
||||
string[] h264hevc = { "h264", "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");
|
||||
}
|
||||
|
||||
if (acceleration == HardwareAccelerationKind.Vaapi && _pixelFormat == "yuv444p" &&
|
||||
h264hevc.Contains(_inputCodec))
|
||||
{
|
||||
videoFilterQueue.Add("format=nv12|vaapi,hwupload");
|
||||
videoFilterQueue.Add("format=yuv420p");
|
||||
}
|
||||
|
||||
bool scaleOrPad = _scaleToSize.IsSome || _padToSize.IsSome;
|
||||
@@ -277,31 +115,6 @@ public class FFmpegComplexFilterBuilder
|
||||
var softwareFilterQueue = new List<string>();
|
||||
if (usesSoftwareFilters)
|
||||
{
|
||||
if (acceleration != HardwareAccelerationKind.None && (isHardwareDecode || usesHardwareFilters))
|
||||
{
|
||||
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"
|
||||
};
|
||||
|
||||
foreach (string format in maybeFormat)
|
||||
{
|
||||
softwareFilterQueue.Add("hwdownload");
|
||||
softwareFilterQueue.Add(format);
|
||||
}
|
||||
|
||||
if (nvencDeinterlace)
|
||||
{
|
||||
softwareFilterQueue.Add("hwdownload");
|
||||
}
|
||||
}
|
||||
|
||||
if (_boxBlur)
|
||||
{
|
||||
softwareFilterQueue.Add("boxblur=40");
|
||||
@@ -314,16 +127,9 @@ public class FFmpegComplexFilterBuilder
|
||||
|
||||
foreach (ChannelWatermark watermark in _watermark)
|
||||
{
|
||||
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
|
||||
};
|
||||
Option<string> maybeFormats = watermark.Opacity != 100 || hasFadePoints
|
||||
? "yuva420p|yuva444p|yuva422p|rgba|abgr|bgra|gbrap|ya8"
|
||||
: None;
|
||||
|
||||
foreach (string formats in maybeFormats)
|
||||
{
|
||||
@@ -362,69 +168,29 @@ public class FFmpegComplexFilterBuilder
|
||||
watermarkPreprocess.AddRange(fadePoints.Map(fp => fp.ToFilter()));
|
||||
}
|
||||
|
||||
if (acceleration == HardwareAccelerationKind.Nvenc)
|
||||
{
|
||||
watermarkPreprocess.Add("hwupload_cuda");
|
||||
}
|
||||
watermarkOverlay = $"overlay={position}";
|
||||
|
||||
watermarkOverlay = acceleration switch
|
||||
if (hasFadePoints)
|
||||
{
|
||||
HardwareAccelerationKind.Nvenc => $"overlay_cuda={position}",
|
||||
_ => $"overlay={position}"
|
||||
};
|
||||
|
||||
if (hasFadePoints && acceleration != HardwareAccelerationKind.Nvenc)
|
||||
{
|
||||
watermarkOverlay += "," + acceleration switch
|
||||
watermarkOverlay += "," + isSong switch
|
||||
{
|
||||
HardwareAccelerationKind.Vaapi => "format=nv12|vaapi",
|
||||
_ when isSong => "format=yuv420p",
|
||||
_ => "format=nv12"
|
||||
true => "format=yuv420p",
|
||||
false => "format=nv12"
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
string outputPixelFormat = null;
|
||||
if (!usesSoftwareFilters && string.IsNullOrWhiteSpace(watermarkOverlay))
|
||||
{
|
||||
switch (acceleration, _videoFormat, _pixelFormat)
|
||||
{
|
||||
case (HardwareAccelerationKind.Nvenc, FFmpegProfileVideoFormat.H264, "yuv420p10le"):
|
||||
outputPixelFormat = "yuv420p";
|
||||
break;
|
||||
case (HardwareAccelerationKind.Nvenc, FFmpegProfileVideoFormat.H264, "yuv444p10le"):
|
||||
outputPixelFormat = "yuv444p";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
string outputFormat = (acceleration, _videoFormat, _pixelFormat) switch
|
||||
{
|
||||
(HardwareAccelerationKind.Nvenc, FFmpegProfileVideoFormat.Hevc, "yuv420p10le") => "p010le",
|
||||
(HardwareAccelerationKind.Nvenc, FFmpegProfileVideoFormat.H264, "yuv420p10le") => "p010le",
|
||||
_ => null
|
||||
};
|
||||
|
||||
_scaleToSize.IfSome(
|
||||
size =>
|
||||
{
|
||||
string filter = acceleration switch
|
||||
string filter = videoOnly 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 =>
|
||||
true =>
|
||||
$"scale={size.Width}:{size.Height}:force_original_aspect_ratio=increase,crop={size.Width}:{size.Height}",
|
||||
_ => $"scale={size.Width}:{size.Height}:flags=fast_bilinear"
|
||||
false => $"scale={size.Width}:{size.Height}:flags=fast_bilinear"
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter))
|
||||
@@ -435,14 +201,6 @@ public class FFmpegComplexFilterBuilder
|
||||
|
||||
if (scaleOrPad && _boxBlur == false)
|
||||
{
|
||||
if (acceleration == HardwareAccelerationKind.Nvenc)
|
||||
{
|
||||
if (!isHardwareDecode && !string.IsNullOrWhiteSpace(outputPixelFormat))
|
||||
{
|
||||
videoFilterQueue.Add($"hwdownload,format={outputPixelFormat}");
|
||||
}
|
||||
}
|
||||
|
||||
videoFilterQueue.Add("setsar=1");
|
||||
}
|
||||
|
||||
@@ -450,63 +208,13 @@ public class FFmpegComplexFilterBuilder
|
||||
|
||||
_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)
|
||||
{
|
||||
complexFilter.Append($"[{audioLabel}]");
|
||||
complexFilter.Append(string.Join(",", audioFilterQueue));
|
||||
audioLabel = "[a]";
|
||||
complexFilter.Append(audioLabel);
|
||||
}
|
||||
|
||||
// vaapi downsample 10bit hevc to 8bit h264
|
||||
if (acceleration == HardwareAccelerationKind.Vaapi && !videoFilterQueue.Any() &&
|
||||
_pixelFormat == "yuv420p10le" && _videoFormat == FFmpegProfileVideoFormat.H264)
|
||||
{
|
||||
videoFilterQueue.Add("scale_vaapi=format=nv12");
|
||||
}
|
||||
|
||||
if (videoFilterQueue.Any() || !string.IsNullOrWhiteSpace(watermarkOverlay))
|
||||
{
|
||||
if (hasAudioFilters)
|
||||
{
|
||||
complexFilter.Append(';');
|
||||
}
|
||||
|
||||
if (videoFilterQueue.Any())
|
||||
{
|
||||
complexFilter.Append($"[{videoLabel}]");
|
||||
@@ -538,25 +246,6 @@ public class FFmpegComplexFilterBuilder
|
||||
videoFilterQueue.Any()
|
||||
? $"[vt]{watermarkLabel}{watermarkOverlay}"
|
||||
: $"[{videoLabel}]{watermarkLabel}{watermarkOverlay}");
|
||||
|
||||
if (usesSoftwareFilters && acceleration != HardwareAccelerationKind.None)
|
||||
{
|
||||
switch (isSong, acceleration)
|
||||
{
|
||||
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;
|
||||
default:
|
||||
complexFilter.Append(",hwupload");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
videoLabel = "[v]";
|
||||
|
||||
@@ -3,11 +3,10 @@ using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.FFmpeg;
|
||||
using ErsatzTV.FFmpeg.Capabilities;
|
||||
using ErsatzTV.FFmpeg.Environment;
|
||||
using ErsatzTV.FFmpeg.Format;
|
||||
using ErsatzTV.FFmpeg.OutputFormat;
|
||||
using ErsatzTV.FFmpeg.Runtime;
|
||||
using ErsatzTV.FFmpeg.Pipeline;
|
||||
using ErsatzTV.FFmpeg.State;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MediaStream = ErsatzTV.Core.Domain.MediaStream;
|
||||
@@ -18,27 +17,24 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
{
|
||||
private readonly FFmpegProcessService _ffmpegProcessService;
|
||||
private readonly IFFmpegStreamSelector _ffmpegStreamSelector;
|
||||
private readonly IHardwareCapabilitiesFactory _hardwareCapabilitiesFactory;
|
||||
private readonly IRuntimeInfo _runtimeInfo;
|
||||
private readonly ILogger<FFmpegLibraryProcessService> _logger;
|
||||
private readonly FFmpegPlaybackSettingsCalculator _playbackSettingsCalculator;
|
||||
private readonly ITempFilePool _tempFilePool;
|
||||
private readonly IPipelineBuilderFactory _pipelineBuilderFactory;
|
||||
|
||||
public FFmpegLibraryProcessService(
|
||||
FFmpegProcessService ffmpegProcessService,
|
||||
FFmpegPlaybackSettingsCalculator playbackSettingsCalculator,
|
||||
IFFmpegStreamSelector ffmpegStreamSelector,
|
||||
ITempFilePool tempFilePool,
|
||||
IHardwareCapabilitiesFactory hardwareCapabilitiesFactory,
|
||||
IRuntimeInfo runtimeInfo,
|
||||
IPipelineBuilderFactory pipelineBuilderFactory,
|
||||
ILogger<FFmpegLibraryProcessService> logger)
|
||||
{
|
||||
_ffmpegProcessService = ffmpegProcessService;
|
||||
_playbackSettingsCalculator = playbackSettingsCalculator;
|
||||
_ffmpegStreamSelector = ffmpegStreamSelector;
|
||||
_tempFilePool = tempFilePool;
|
||||
_hardwareCapabilitiesFactory = hardwareCapabilitiesFactory;
|
||||
_runtimeInfo = runtimeInfo;
|
||||
_pipelineBuilderFactory = pipelineBuilderFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -48,10 +44,10 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
bool saveReports,
|
||||
Channel channel,
|
||||
MediaVersion videoVersion,
|
||||
MediaVersion audioVersion,
|
||||
MediaItemAudioVersion audioVersion,
|
||||
string videoPath,
|
||||
string audioPath,
|
||||
List<Subtitle> subtitles,
|
||||
Func<FFmpegPlaybackSettings, Task<List<Subtitle>>> getSubtitles,
|
||||
string preferredAudioLanguage,
|
||||
string preferredAudioTitle,
|
||||
string preferredSubtitleLanguage,
|
||||
@@ -70,22 +66,17 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
TimeSpan outPoint,
|
||||
long ptsOffset,
|
||||
Option<int> targetFramerate,
|
||||
bool disableWatermarks)
|
||||
bool disableWatermarks,
|
||||
Action<FFmpegPipeline> pipelineAction)
|
||||
{
|
||||
MediaStream videoStream = await _ffmpegStreamSelector.SelectVideoStream(videoVersion);
|
||||
Option<MediaStream> maybeAudioStream =
|
||||
await _ffmpegStreamSelector.SelectAudioStream(
|
||||
audioVersion,
|
||||
channel.StreamingMode,
|
||||
channel.Number,
|
||||
channel,
|
||||
preferredAudioLanguage,
|
||||
preferredAudioTitle);
|
||||
Option<Subtitle> maybeSubtitle =
|
||||
await _ffmpegStreamSelector.SelectSubtitleStream(
|
||||
subtitles,
|
||||
channel,
|
||||
preferredSubtitleLanguage,
|
||||
subtitleMode);
|
||||
|
||||
FFmpegPlaybackSettings playbackSettings = _playbackSettingsCalculator.CalculateSettings(
|
||||
channel.StreamingMode,
|
||||
@@ -100,6 +91,13 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
hlsRealtime,
|
||||
targetFramerate);
|
||||
|
||||
Option<Subtitle> maybeSubtitle =
|
||||
await _ffmpegStreamSelector.SelectSubtitleStream(
|
||||
await getSubtitles(playbackSettings),
|
||||
channel,
|
||||
preferredSubtitleLanguage,
|
||||
subtitleMode);
|
||||
|
||||
Option<WatermarkOptions> watermarkOptions = disableWatermarks
|
||||
? None
|
||||
: await _ffmpegProcessService.GetWatermarkOptions(
|
||||
@@ -146,11 +144,17 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
videoStream.Index,
|
||||
videoStream.Codec,
|
||||
AvailablePixelFormats.ForPixelFormat(videoStream.PixelFormat, _logger),
|
||||
new ColorParams(
|
||||
videoStream.ColorRange,
|
||||
videoStream.ColorSpace,
|
||||
videoStream.ColorTransfer,
|
||||
videoStream.ColorPrimaries),
|
||||
new FrameSize(videoVersion.Width, videoVersion.Height),
|
||||
videoVersion.SampleAspectRatio,
|
||||
videoVersion.DisplayAspectRatio,
|
||||
videoVersion.RFrameRate,
|
||||
videoPath != audioPath); // still image when paths are different
|
||||
videoPath != audioPath, // still image when paths are different
|
||||
videoVersion.VideoScanKind == VideoScanKind.Progressive ? ScanKind.Progressive : ScanKind.Interlaced);
|
||||
|
||||
var videoInputFile = new VideoInputFile(videoPath, new List<VideoStream> { ffmpegVideoStream });
|
||||
|
||||
@@ -207,14 +211,15 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
: Option<string>.None;
|
||||
|
||||
// normalize songs to yuv420p
|
||||
Option<IPixelFormat> desiredPixelFormat =
|
||||
videoPath == audioPath ? ffmpegVideoStream.PixelFormat : new PixelFormatYuv420P();
|
||||
IPixelFormat desiredPixelFormat =
|
||||
videoPath == audioPath ? playbackSettings.PixelFormat : new PixelFormatYuv420P();
|
||||
|
||||
var desiredState = new FrameState(
|
||||
playbackSettings.RealtimeOutput,
|
||||
false, // TODO: fallback filler needs to loop
|
||||
videoFormat,
|
||||
desiredPixelFormat,
|
||||
Optional(videoStream.Profile),
|
||||
Optional(desiredPixelFormat),
|
||||
ffmpegVideoStream.SquarePixelFrameSize(
|
||||
new FrameSize(channel.FFmpegProfile.Resolution.Width, channel.FFmpegProfile.Resolution.Height)),
|
||||
new FrameSize(channel.FFmpegProfile.Resolution.Width, channel.FFmpegProfile.Resolution.Height),
|
||||
@@ -246,19 +251,22 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
|
||||
_logger.LogDebug("FFmpeg desired state {FrameState}", desiredState);
|
||||
|
||||
var pipelineBuilder = new PipelineBuilder(
|
||||
_runtimeInfo,
|
||||
await _hardwareCapabilitiesFactory.GetHardwareCapabilities(ffmpegPath, hwAccel),
|
||||
IPipelineBuilder pipelineBuilder = await _pipelineBuilderFactory.GetBuilder(
|
||||
hwAccel,
|
||||
videoInputFile,
|
||||
audioInputFile,
|
||||
watermarkInputFile,
|
||||
subtitleInputFile,
|
||||
VaapiDriverName(hwAccel, vaapiDriver),
|
||||
VaapiDeviceName(hwAccel, vaapiDevice),
|
||||
FileSystemLayout.FFmpegReportsFolder,
|
||||
FileSystemLayout.FontsCacheFolder,
|
||||
_logger);
|
||||
ffmpegPath);
|
||||
|
||||
FFmpegPipeline pipeline = pipelineBuilder.Build(ffmpegState, desiredState);
|
||||
|
||||
pipelineAction?.Invoke(pipeline);
|
||||
|
||||
return GetCommand(ffmpegPath, videoInputFile, audioInputFile, watermarkInputFile, None, pipeline);
|
||||
}
|
||||
|
||||
@@ -312,6 +320,7 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
playbackSettings.RealtimeOutput,
|
||||
false,
|
||||
GetVideoFormat(playbackSettings),
|
||||
VideoProfile.Main,
|
||||
new PixelFormatYuv420P(),
|
||||
new FrameSize(desiredResolution.Width, desiredResolution.Height),
|
||||
new FrameSize(desiredResolution.Width, desiredResolution.Height),
|
||||
@@ -342,11 +351,13 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
0,
|
||||
VideoFormat.GeneratedImage,
|
||||
new PixelFormatUnknown(), // leave this unknown so we convert to desired yuv420p
|
||||
ColorParams.Default,
|
||||
new FrameSize(videoVersion.Width, videoVersion.Height),
|
||||
videoVersion.SampleAspectRatio,
|
||||
videoVersion.DisplayAspectRatio,
|
||||
None,
|
||||
true);
|
||||
true,
|
||||
ScanKind.Progressive);
|
||||
|
||||
var videoInputFile = new VideoInputFile(videoPath, new List<VideoStream> { ffmpegVideoStream });
|
||||
|
||||
@@ -382,17 +393,18 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
|
||||
_logger.LogDebug("FFmpeg desired error state {FrameState}", desiredState);
|
||||
|
||||
var pipelineBuilder = new PipelineBuilder(
|
||||
_runtimeInfo,
|
||||
await _hardwareCapabilitiesFactory.GetHardwareCapabilities(ffmpegPath, hwAccel),
|
||||
IPipelineBuilder pipelineBuilder = await _pipelineBuilderFactory.GetBuilder(
|
||||
hwAccel,
|
||||
videoInputFile,
|
||||
audioInputFile,
|
||||
None,
|
||||
subtitleInputFile,
|
||||
VaapiDriverName(hwAccel, vaapiDriver),
|
||||
VaapiDeviceName(hwAccel, vaapiDevice),
|
||||
FileSystemLayout.FFmpegReportsFolder,
|
||||
FileSystemLayout.FontsCacheFolder,
|
||||
_logger);
|
||||
|
||||
ffmpegPath);
|
||||
|
||||
FFmpegPipeline pipeline = pipelineBuilder.Build(ffmpegState, desiredState);
|
||||
|
||||
return GetCommand(ffmpegPath, videoInputFile, audioInputFile, None, None, pipeline);
|
||||
@@ -411,17 +423,18 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
$"http://localhost:{Settings.ListenPort}/ffmpeg/concat/{channel.Number}",
|
||||
resolution);
|
||||
|
||||
var pipelineBuilder = new PipelineBuilder(
|
||||
_runtimeInfo,
|
||||
await _hardwareCapabilitiesFactory.GetHardwareCapabilities(ffmpegPath, HardwareAccelerationMode.None),
|
||||
IPipelineBuilder pipelineBuilder = await _pipelineBuilderFactory.GetBuilder(
|
||||
HardwareAccelerationMode.None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
FileSystemLayout.FFmpegReportsFolder,
|
||||
FileSystemLayout.FontsCacheFolder,
|
||||
_logger);
|
||||
|
||||
ffmpegPath);
|
||||
|
||||
FFmpegPipeline pipeline = pipelineBuilder.Concat(
|
||||
concatInputFile,
|
||||
FFmpegState.Concat(saveReports, channel.Name));
|
||||
@@ -429,37 +442,74 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
return GetCommand(ffmpegPath, None, None, None, concatInputFile, pipeline);
|
||||
}
|
||||
|
||||
public Command WrapSegmenter(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host) =>
|
||||
_ffmpegProcessService.WrapSegmenter(ffmpegPath, saveReports, channel, scheme, host);
|
||||
|
||||
public async Task<Command> ResizeImage(string ffmpegPath, string inputFile, string outputFile, int height)
|
||||
public async Task<Command> WrapSegmenter(
|
||||
string ffmpegPath,
|
||||
bool saveReports,
|
||||
Channel channel,
|
||||
string scheme,
|
||||
string host)
|
||||
{
|
||||
var videoInputFile = new VideoInputFile(
|
||||
inputFile,
|
||||
new List<VideoStream> { new(0, string.Empty, None, FrameSize.Unknown, string.Empty, string.Empty, None, true) });
|
||||
var resolution = new FrameSize(channel.FFmpegProfile.Resolution.Width, channel.FFmpegProfile.Resolution.Height);
|
||||
|
||||
var pipelineBuilder = new PipelineBuilder(
|
||||
_runtimeInfo,
|
||||
await _hardwareCapabilitiesFactory.GetHardwareCapabilities(ffmpegPath, HardwareAccelerationMode.None),
|
||||
videoInputFile,
|
||||
var concatInputFile = new ConcatInputFile(
|
||||
$"http://localhost:{Settings.ListenPort}/iptv/channel/{channel.Number}.m3u8?mode=segmenter",
|
||||
resolution);
|
||||
|
||||
IPipelineBuilder pipelineBuilder = await _pipelineBuilderFactory.GetBuilder(
|
||||
HardwareAccelerationMode.None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
FileSystemLayout.FFmpegReportsFolder,
|
||||
FileSystemLayout.FontsCacheFolder,
|
||||
_logger);
|
||||
ffmpegPath);
|
||||
|
||||
FFmpegPipeline pipeline = pipelineBuilder.WrapSegmenter(
|
||||
concatInputFile,
|
||||
FFmpegState.Concat(saveReports, channel.Name));
|
||||
|
||||
return GetCommand(ffmpegPath, None, None, None, concatInputFile, pipeline);
|
||||
}
|
||||
|
||||
public async Task<Command> ResizeImage(string ffmpegPath, string inputFile, string outputFile, int height)
|
||||
{
|
||||
var videoInputFile = new VideoInputFile(
|
||||
inputFile,
|
||||
new List<VideoStream>
|
||||
{
|
||||
new(
|
||||
0,
|
||||
string.Empty,
|
||||
None,
|
||||
ColorParams.Default,
|
||||
FrameSize.Unknown,
|
||||
string.Empty,
|
||||
string.Empty,
|
||||
None,
|
||||
true,
|
||||
ScanKind.Progressive)
|
||||
});
|
||||
|
||||
IPipelineBuilder pipelineBuilder = await _pipelineBuilderFactory.GetBuilder(
|
||||
HardwareAccelerationMode.None,
|
||||
videoInputFile,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
FileSystemLayout.FFmpegReportsFolder,
|
||||
FileSystemLayout.FontsCacheFolder,
|
||||
ffmpegPath);
|
||||
|
||||
FFmpegPipeline pipeline = pipelineBuilder.Resize(outputFile, new FrameSize(-1, height));
|
||||
|
||||
return GetCommand(ffmpegPath, videoInputFile, None, None, None, pipeline, false);
|
||||
}
|
||||
|
||||
public Command ConvertToPng(string ffmpegPath, string inputFile, string outputFile) =>
|
||||
_ffmpegProcessService.ConvertToPng(ffmpegPath, inputFile, outputFile);
|
||||
|
||||
public Command ExtractAttachedPicAsPng(string ffmpegPath, string inputFile, int streamIndex, string outputFile) =>
|
||||
_ffmpegProcessService.ExtractAttachedPicAsPng(ffmpegPath, inputFile, streamIndex, outputFile);
|
||||
|
||||
public Task<Either<BaseError, string>> GenerateSongImage(
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
@@ -516,11 +566,13 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
options.ImageStreamIndex.IfNone(0),
|
||||
"unknown",
|
||||
new PixelFormatUnknown(),
|
||||
ColorParams.Default,
|
||||
new FrameSize(1, 1),
|
||||
string.Empty,
|
||||
string.Empty,
|
||||
Option<string>.None,
|
||||
!options.IsAnimated)
|
||||
!options.IsAnimated,
|
||||
ScanKind.Progressive)
|
||||
},
|
||||
new WatermarkState(
|
||||
maybeFadePoints.Map(
|
||||
@@ -545,7 +597,8 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
watermark.WidthPercent,
|
||||
watermark.HorizontalMarginPercent,
|
||||
watermark.VerticalMarginPercent,
|
||||
watermark.Opacity));
|
||||
watermark.Opacity,
|
||||
watermark.PlaceWithinSourceContent));
|
||||
|
||||
return watermarkInputFile;
|
||||
}
|
||||
@@ -566,10 +619,10 @@ public class FFmpegLibraryProcessService : IFFmpegProcessService
|
||||
bool log = true)
|
||||
{
|
||||
IEnumerable<string> loggedSteps = pipeline.PipelineSteps.Map(ps => ps.GetType().Name);
|
||||
IEnumerable<string> loggedVideoFilters =
|
||||
videoInputFile.Map(f => f.FilterSteps.Map(vf => vf.GetType().Name)).Flatten();
|
||||
IEnumerable<string> loggedAudioFilters =
|
||||
audioInputFile.Map(f => f.FilterSteps.Map(af => af.GetType().Name)).Flatten();
|
||||
IEnumerable<string> loggedVideoFilters =
|
||||
videoInputFile.Map(f => f.FilterSteps.Map(vf => vf.GetType().Name)).Flatten();
|
||||
|
||||
if (log)
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.FFmpeg.Format;
|
||||
|
||||
namespace ErsatzTV.Core.FFmpeg;
|
||||
|
||||
@@ -14,6 +15,7 @@ public class FFmpegPlaybackSettings
|
||||
public Option<IDisplaySize> ScaledSize { get; set; }
|
||||
public bool PadToDesiredResolution { get; set; }
|
||||
public FFmpegProfileVideoFormat VideoFormat { get; set; }
|
||||
public IPixelFormat PixelFormat { get; set; }
|
||||
public Option<int> VideoBitrate { get; set; }
|
||||
public Option<int> VideoBufferSize { get; set; }
|
||||
public Option<int> AudioBitrate { get; set; }
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.FFmpeg.Format;
|
||||
|
||||
namespace ErsatzTV.Core.FFmpeg;
|
||||
|
||||
@@ -144,6 +145,13 @@ public class FFmpegPlaybackSettingsCalculator
|
||||
};
|
||||
}
|
||||
|
||||
result.PixelFormat = ffmpegProfile.BitDepth switch
|
||||
{
|
||||
FFmpegProfileBitDepth.TenBit => new PixelFormatYuv420P10Le(),
|
||||
_ => new PixelFormatYuv420P()
|
||||
// _ => new PixelFormatYuv420P10Le()
|
||||
};
|
||||
|
||||
result.AudioFormat = ffmpegProfile.AudioFormat;
|
||||
result.AudioBitrate = ffmpegProfile.AudioBitrate;
|
||||
result.AudioBufferSize = ffmpegProfile.AudioBufferSize;
|
||||
|
||||
@@ -19,12 +19,9 @@
|
||||
// 3. This notice may not be removed or altered from any source distribution.
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Core.FFmpeg;
|
||||
|
||||
@@ -32,36 +29,11 @@ internal class FFmpegProcessBuilder
|
||||
{
|
||||
private readonly List<string> _arguments = new();
|
||||
private readonly string _ffmpegPath;
|
||||
private readonly ILogger _logger;
|
||||
private readonly bool _saveReports;
|
||||
private FFmpegComplexFilterBuilder _complexFilterBuilder = new();
|
||||
private HardwareAccelerationKind _hwAccel;
|
||||
private bool _isConcat;
|
||||
private bool _noAutoScale;
|
||||
private Option<int> _outputFramerate;
|
||||
private string _outputPixelFormat;
|
||||
private string _vaapiDevice;
|
||||
private VaapiDriver _vaapiDriver;
|
||||
|
||||
public FFmpegProcessBuilder(string ffmpegPath, bool saveReports, ILogger logger)
|
||||
public FFmpegProcessBuilder(string ffmpegPath)
|
||||
{
|
||||
_ffmpegPath = ffmpegPath;
|
||||
_saveReports = saveReports;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithVaapiDriver(VaapiDriver vaapiDriver, string vaapiDevice)
|
||||
{
|
||||
if (vaapiDriver != VaapiDriver.Default)
|
||||
{
|
||||
_vaapiDriver = vaapiDriver;
|
||||
}
|
||||
|
||||
_vaapiDevice = string.IsNullOrWhiteSpace(vaapiDevice)
|
||||
? "/dev/dri/renderD128"
|
||||
: vaapiDevice;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithThreads(int threads)
|
||||
@@ -71,162 +43,6 @@ internal class FFmpegProcessBuilder
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithHardwareAcceleration(
|
||||
HardwareAccelerationKind hwAccel,
|
||||
Option<string> pixelFormat,
|
||||
FFmpegProfileVideoFormat videoFormat)
|
||||
{
|
||||
_hwAccel = hwAccel;
|
||||
|
||||
switch (hwAccel)
|
||||
{
|
||||
case HardwareAccelerationKind.Qsv:
|
||||
_arguments.Add("-hwaccel");
|
||||
_arguments.Add("qsv");
|
||||
_arguments.Add("-init_hw_device");
|
||||
_arguments.Add("qsv=qsv:MFX_IMPL_hw_any");
|
||||
break;
|
||||
case HardwareAccelerationKind.Nvenc:
|
||||
string outputFormat = (videoFormat, pixelFormat.IfNone("")) switch
|
||||
{
|
||||
(FFmpegProfileVideoFormat.Hevc, "yuv420p10le") => "p010le",
|
||||
(FFmpegProfileVideoFormat.H264, "yuv420p10le") => "p010le",
|
||||
// ("hevc_nvenc", "yuv444p10le") => "p016le",
|
||||
_ => "cuda"
|
||||
};
|
||||
|
||||
_arguments.Add("-hwaccel");
|
||||
_arguments.Add("cuda");
|
||||
_arguments.Add("-hwaccel_output_format");
|
||||
_arguments.Add(outputFormat);
|
||||
break;
|
||||
case HardwareAccelerationKind.Vaapi:
|
||||
_arguments.Add("-hwaccel");
|
||||
_arguments.Add("vaapi");
|
||||
_arguments.Add("-vaapi_device");
|
||||
_arguments.Add(_vaapiDevice);
|
||||
_arguments.Add("-hwaccel_output_format");
|
||||
_arguments.Add("vaapi");
|
||||
break;
|
||||
case HardwareAccelerationKind.VideoToolbox:
|
||||
_arguments.Add("-hwaccel");
|
||||
_arguments.Add("videotoolbox");
|
||||
break;
|
||||
}
|
||||
|
||||
_complexFilterBuilder = _complexFilterBuilder.WithHardwareAcceleration(hwAccel);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithRealtimeOutput(bool realtimeOutput)
|
||||
{
|
||||
if (realtimeOutput)
|
||||
{
|
||||
if (!_arguments.Contains("-re"))
|
||||
{
|
||||
_arguments.Add("-re");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_arguments.RemoveAll(s => s == "-re");
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithSeek(Option<TimeSpan> maybeStart)
|
||||
{
|
||||
maybeStart.IfSome(
|
||||
start =>
|
||||
{
|
||||
_arguments.Add("-ss");
|
||||
_arguments.Add($"{start:c}");
|
||||
});
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithInfiniteLoop(bool loop = true)
|
||||
{
|
||||
if (loop)
|
||||
{
|
||||
_arguments.Add("-stream_loop");
|
||||
_arguments.Add("-1");
|
||||
|
||||
if (_hwAccel is HardwareAccelerationKind.Qsv or HardwareAccelerationKind.Vaapi)
|
||||
{
|
||||
_noAutoScale = true;
|
||||
}
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithLoopedImage(string input)
|
||||
{
|
||||
_arguments.Add("-loop");
|
||||
_arguments.Add("1");
|
||||
return WithInput(input);
|
||||
}
|
||||
|
||||
|
||||
public FFmpegProcessBuilder WithPipe()
|
||||
{
|
||||
_arguments.Add("pipe:1");
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithPixfmt(string pixfmt)
|
||||
{
|
||||
_arguments.Add("-pix_fmt");
|
||||
_arguments.Add(pixfmt);
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithLibavfilter()
|
||||
{
|
||||
_arguments.Add("-f");
|
||||
_arguments.Add("lavfi");
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithInput(string input)
|
||||
{
|
||||
_arguments.Add("-i");
|
||||
_arguments.Add(input);
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithMap(string map)
|
||||
{
|
||||
_arguments.Add("-map");
|
||||
_arguments.Add(map);
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithCopyCodec()
|
||||
{
|
||||
_arguments.Add("-c");
|
||||
_arguments.Add("copy");
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithFrameRate(Option<int> frameRate)
|
||||
{
|
||||
foreach (int fr in frameRate)
|
||||
{
|
||||
_arguments.Add("-r");
|
||||
_arguments.Add($"{fr}");
|
||||
|
||||
_arguments.Add("-vsync");
|
||||
_arguments.Add("cfr");
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithWatermark(
|
||||
Option<WatermarkOptions> watermarkOptions,
|
||||
Option<List<FadePoint>> maybeFadePoints,
|
||||
@@ -279,74 +95,12 @@ internal class FFmpegProcessBuilder
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithInputCodec(
|
||||
Option<TimeSpan> maybeStart,
|
||||
bool loop,
|
||||
string videoPath,
|
||||
string audioPath,
|
||||
string decoder,
|
||||
Option<string> codec,
|
||||
Option<string> pixelFormat,
|
||||
bool deinterlace)
|
||||
{
|
||||
if (audioPath == videoPath)
|
||||
{
|
||||
WithSeek(maybeStart);
|
||||
WithInfiniteLoop(loop);
|
||||
}
|
||||
else
|
||||
{
|
||||
_noAutoScale = true;
|
||||
_outputFramerate = 30;
|
||||
|
||||
_arguments.Add("-loop");
|
||||
_arguments.Add("1");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(decoder))
|
||||
{
|
||||
_arguments.Add("-c:v");
|
||||
_arguments.Add(decoder);
|
||||
|
||||
if (decoder == "mpeg2_cuvid" && deinterlace)
|
||||
{
|
||||
_arguments.Add("-deint");
|
||||
_arguments.Add("2");
|
||||
}
|
||||
|
||||
_complexFilterBuilder = _complexFilterBuilder
|
||||
.WithDecoder(decoder);
|
||||
}
|
||||
|
||||
_complexFilterBuilder = _complexFilterBuilder
|
||||
.WithInputCodec(codec)
|
||||
.WithInputPixelFormat(pixelFormat);
|
||||
|
||||
_arguments.Add("-i");
|
||||
_arguments.Add(videoPath);
|
||||
|
||||
if (audioPath != videoPath)
|
||||
{
|
||||
WithSeek(maybeStart);
|
||||
|
||||
_arguments.Add("-i");
|
||||
_arguments.Add(audioPath);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithSongInput(
|
||||
string videoPath,
|
||||
Option<string> codec,
|
||||
Option<string> pixelFormat,
|
||||
bool boxBlur)
|
||||
{
|
||||
_noAutoScale = true;
|
||||
_outputFramerate = 30;
|
||||
|
||||
_complexFilterBuilder = _complexFilterBuilder
|
||||
.WithInputCodec(codec)
|
||||
.WithInputPixelFormat(pixelFormat)
|
||||
.WithBoxBlur(boxBlur);
|
||||
|
||||
@@ -356,52 +110,6 @@ internal class FFmpegProcessBuilder
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithConcat(string concatPlaylist)
|
||||
{
|
||||
_isConcat = true;
|
||||
|
||||
var arguments = new List<string>
|
||||
{
|
||||
"-f", "concat",
|
||||
"-safe", "0",
|
||||
"-protocol_whitelist", "file,http,tcp,https,tcp,tls",
|
||||
"-probesize", "32",
|
||||
"-i", concatPlaylist,
|
||||
"-c", "copy",
|
||||
"-muxdelay", "0",
|
||||
"-muxpreload", "0"
|
||||
// "-avoid_negative_ts", "make_zero"
|
||||
};
|
||||
_arguments.AddRange(arguments);
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithMetadata(Channel channel, Option<MediaStream> maybeAudioStream)
|
||||
{
|
||||
if (channel.StreamingMode == StreamingMode.TransportStream)
|
||||
{
|
||||
_arguments.AddRange(new[] { "-map_metadata", "-1" });
|
||||
}
|
||||
|
||||
foreach (MediaStream audioStream in maybeAudioStream)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(audioStream.Language))
|
||||
{
|
||||
_arguments.AddRange(new[] { "-metadata:s:a:0", $"language={audioStream.Language}" });
|
||||
}
|
||||
}
|
||||
|
||||
var arguments = new List<string>
|
||||
{
|
||||
"-metadata", "service_provider=\"ErsatzTV\"",
|
||||
"-metadata", $"service_name=\"{channel.Name}\""
|
||||
};
|
||||
|
||||
_arguments.AddRange(arguments);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithFormatFlags(IEnumerable<string> formatFlags)
|
||||
{
|
||||
_arguments.Add("-fflags");
|
||||
@@ -409,138 +117,6 @@ internal class FFmpegProcessBuilder
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithDuration(TimeSpan duration)
|
||||
{
|
||||
_arguments.Add("-t");
|
||||
_arguments.Add($"{duration:c}");
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithFormat(string format)
|
||||
{
|
||||
_arguments.Add("-f");
|
||||
_arguments.Add($"{format}");
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithInitialDiscontinuity()
|
||||
{
|
||||
_arguments.Add("-mpegts_flags");
|
||||
_arguments.Add("+initial_discontinuity");
|
||||
return this;
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
foreach (int timescale in maybeTimeScale)
|
||||
{
|
||||
_arguments.Add("-output_ts_offset");
|
||||
_arguments.Add($"{(ptsOffset / (double)timescale).ToString(NumberFormatInfo.InvariantInfo)}");
|
||||
}
|
||||
|
||||
_arguments.AddRange(
|
||||
new[]
|
||||
{
|
||||
"-g", $"{frameRate * SEGMENT_SECONDS}",
|
||||
"-keyint_min", $"{frameRate * SEGMENT_SECONDS}",
|
||||
"-force_key_frames", $"expr:gte(t,n_forced*{SEGMENT_SECONDS})",
|
||||
"-f", "hls",
|
||||
"-hls_time", $"{SEGMENT_SECONDS}",
|
||||
"-hls_list_size", "0",
|
||||
"-segment_list_flags", "+live",
|
||||
"-hls_segment_filename",
|
||||
Path.Combine(FileSystemLayout.TranscodeFolder, channelNumber, "live%06d.ts"),
|
||||
"-hls_flags", "program_date_time+append_list+discont_start+omit_endlist+independent_segments",
|
||||
"-mpegts_flags", "+initial_discontinuity",
|
||||
Path.Combine(FileSystemLayout.TranscodeFolder, channelNumber, "live.m3u8")
|
||||
});
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithPlaybackArgs(
|
||||
FFmpegPlaybackSettings playbackSettings,
|
||||
string videoCodec,
|
||||
string audioCodec)
|
||||
{
|
||||
var arguments = new List<string>
|
||||
{
|
||||
"-c:v", videoCodec,
|
||||
"-flags", "cgop",
|
||||
// disable scene change detection except with mpeg2video
|
||||
"-sc_threshold", playbackSettings.VideoFormat == FFmpegProfileVideoFormat.Mpeg2Video ? "1000000000" : "0"
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_outputPixelFormat))
|
||||
{
|
||||
arguments.AddRange(new[] { "-pix_fmt", _outputPixelFormat });
|
||||
}
|
||||
|
||||
string[] videoBitrateArgs = playbackSettings.VideoBitrate.Match(
|
||||
bitrate =>
|
||||
new[]
|
||||
{
|
||||
"-b:v", $"{bitrate}k",
|
||||
"-maxrate:v", $"{bitrate}k"
|
||||
},
|
||||
Array.Empty<string>());
|
||||
arguments.AddRange(videoBitrateArgs);
|
||||
|
||||
playbackSettings.VideoBufferSize
|
||||
.IfSome(bufferSize => arguments.AddRange(new[] { "-bufsize:v", $"{bufferSize}k" }));
|
||||
|
||||
string[] audioBitrateArgs = playbackSettings.AudioBitrate.Match(
|
||||
bitrate =>
|
||||
new[]
|
||||
{
|
||||
"-b:a", $"{bitrate}k",
|
||||
"-maxrate:a", $"{bitrate}k"
|
||||
},
|
||||
Array.Empty<string>());
|
||||
arguments.AddRange(audioBitrateArgs);
|
||||
|
||||
playbackSettings.AudioBufferSize
|
||||
.IfSome(bufferSize => arguments.AddRange(new[] { "-bufsize:a", $"{bufferSize}k" }));
|
||||
|
||||
playbackSettings.AudioChannels
|
||||
.IfSome(channels => arguments.AddRange(new[] { "-ac", $"{channels}" }));
|
||||
|
||||
playbackSettings.AudioSampleRate
|
||||
.IfSome(sampleRate => arguments.AddRange(new[] { "-ar", $"{sampleRate}k" }));
|
||||
|
||||
arguments.AddRange(
|
||||
new[]
|
||||
{
|
||||
"-c:a", audioCodec,
|
||||
"-movflags", "+faststart",
|
||||
"-muxdelay", "0",
|
||||
"-muxpreload", "0"
|
||||
});
|
||||
|
||||
_arguments.AddRange(arguments);
|
||||
|
||||
if (_noAutoScale)
|
||||
{
|
||||
_arguments.Add("-noautoscale");
|
||||
}
|
||||
|
||||
foreach (int framerate in _outputFramerate)
|
||||
{
|
||||
_arguments.Add("-r");
|
||||
_arguments.Add(framerate.ToString());
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithScaling(IDisplaySize displaySize)
|
||||
{
|
||||
_complexFilterBuilder = _complexFilterBuilder.WithScaling(displaySize);
|
||||
@@ -553,35 +129,6 @@ internal class FFmpegProcessBuilder
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithAlignedAudio(Option<TimeSpan> audioDuration)
|
||||
{
|
||||
_complexFilterBuilder = _complexFilterBuilder.WithAlignedAudio(audioDuration);
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithNormalizeLoudness(bool normalizeLoudness)
|
||||
{
|
||||
_complexFilterBuilder = _complexFilterBuilder.WithNormalizeLoudness(normalizeLoudness);
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithVideoTrackTimeScale(Option<int> videoTrackTimeScale)
|
||||
{
|
||||
videoTrackTimeScale.IfSome(
|
||||
timeScale =>
|
||||
{
|
||||
_arguments.Add("-video_track_timescale");
|
||||
_arguments.Add($"{timeScale}");
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithDeinterlace(bool deinterlace)
|
||||
{
|
||||
_complexFilterBuilder = _complexFilterBuilder.WithDeinterlace(deinterlace);
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegProcessBuilder WithOutputFormat(string format, string output)
|
||||
{
|
||||
_arguments.Add("-f");
|
||||
@@ -597,11 +144,8 @@ internal class FFmpegProcessBuilder
|
||||
MediaStream videoStream,
|
||||
Option<MediaStream> maybeAudioStream,
|
||||
string videoPath,
|
||||
Option<string> audioPath,
|
||||
FFmpegProfileVideoFormat videoFormat)
|
||||
Option<string> audioPath)
|
||||
{
|
||||
_complexFilterBuilder = _complexFilterBuilder.WithVideoFormat(videoFormat);
|
||||
|
||||
int videoStreamIndex = videoStream.Index;
|
||||
Option<int> maybeIndex = maybeAudioStream.Map(ms => ms.Index);
|
||||
|
||||
@@ -615,10 +159,6 @@ internal class FFmpegProcessBuilder
|
||||
else if (audioPath.IfNone("NotARealPath") != videoPath)
|
||||
{
|
||||
audioIndex = 1;
|
||||
if (_hwAccel == HardwareAccelerationKind.None)
|
||||
{
|
||||
_outputPixelFormat = "yuv420p";
|
||||
}
|
||||
}
|
||||
|
||||
string videoLabel = $"{videoIndex}:{videoStreamIndex}";
|
||||
@@ -639,11 +179,6 @@ internal class FFmpegProcessBuilder
|
||||
_arguments.Add(filter.ComplexFilter);
|
||||
videoLabel = filter.VideoLabel;
|
||||
audioLabel = filter.AudioLabel;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.PixelFormat))
|
||||
{
|
||||
_outputPixelFormat = filter.PixelFormat;
|
||||
}
|
||||
});
|
||||
|
||||
foreach (string _ in audioPath)
|
||||
@@ -676,44 +211,6 @@ internal class FFmpegProcessBuilder
|
||||
StandardOutputEncoding = Encoding.UTF8
|
||||
};
|
||||
|
||||
if (_hwAccel == HardwareAccelerationKind.Vaapi)
|
||||
{
|
||||
switch (_vaapiDriver)
|
||||
{
|
||||
case VaapiDriver.i965:
|
||||
startInfo.EnvironmentVariables["LIBVA_DRIVER_NAME"] = "i965";
|
||||
break;
|
||||
case VaapiDriver.iHD:
|
||||
startInfo.EnvironmentVariables["LIBVA_DRIVER_NAME"] = "iHD";
|
||||
break;
|
||||
case VaapiDriver.RadeonSI:
|
||||
startInfo.EnvironmentVariables["LIBVA_DRIVER_NAME"] = "radeonsi";
|
||||
break;
|
||||
case VaapiDriver.Nouveau:
|
||||
startInfo.EnvironmentVariables["LIBVA_DRIVER_NAME"] = "nouveau";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (_saveReports)
|
||||
{
|
||||
string fileName = _isConcat
|
||||
? Path.Combine(FileSystemLayout.FFmpegReportsFolder, "ffmpeg-%t-concat.log")
|
||||
: Path.Combine(FileSystemLayout.FFmpegReportsFolder, "ffmpeg-%t-transcode.log");
|
||||
|
||||
// rework filename in a format that works on windows
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
// \ is escape, so use / for directory separators
|
||||
fileName = fileName.Replace(@"\", @"/");
|
||||
|
||||
// colon after drive letter needs to be escaped
|
||||
fileName = fileName.Replace(@":/", @"\:/");
|
||||
}
|
||||
|
||||
startInfo.EnvironmentVariables["FFREPORT"] = $"file={fileName}:level=32";
|
||||
}
|
||||
|
||||
startInfo.ArgumentList.Add("-nostdin");
|
||||
foreach (string argument in _arguments)
|
||||
{
|
||||
@@ -725,30 +222,4 @@ internal class FFmpegProcessBuilder
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,63 +40,6 @@ public class FFmpegProcessService
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Command WrapSegmenter(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host)
|
||||
{
|
||||
FFmpegPlaybackSettings playbackSettings = _playbackSettingsCalculator.ConcatSettings;
|
||||
|
||||
Process process = new FFmpegProcessBuilder(ffmpegPath, saveReports, _logger)
|
||||
.WithThreads(1)
|
||||
.WithQuiet()
|
||||
.WithFormatFlags(playbackSettings.FormatFlags)
|
||||
.WithRealtimeOutput(true)
|
||||
.WithInput($"http://localhost:{Settings.ListenPort}/iptv/channel/{channel.Number}.m3u8?mode=segmenter")
|
||||
.WithMap("0")
|
||||
.WithCopyCodec()
|
||||
.WithMetadata(channel, None)
|
||||
.WithFormat("mpegts")
|
||||
.WithPipe()
|
||||
.Build();
|
||||
|
||||
return Cli.Wrap(process.StartInfo.FileName)
|
||||
.WithArguments(process.StartInfo.ArgumentList)
|
||||
.WithValidation(CommandResultValidation.None)
|
||||
.WithEnvironmentVariables(process.StartInfo.Environment.ToDictionary(kvp => kvp.Key, kvp => kvp.Value))
|
||||
.WithStandardErrorPipe(PipeTarget.ToStream(Stream.Null));
|
||||
}
|
||||
|
||||
public Command ConvertToPng(string ffmpegPath, string inputFile, string outputFile)
|
||||
{
|
||||
Process process = new FFmpegProcessBuilder(ffmpegPath, false, _logger)
|
||||
.WithThreads(1)
|
||||
.WithQuiet()
|
||||
.WithInput(inputFile)
|
||||
.WithOutputFormat("apng", outputFile)
|
||||
.Build();
|
||||
|
||||
return Cli.Wrap(process.StartInfo.FileName)
|
||||
.WithArguments(process.StartInfo.ArgumentList)
|
||||
.WithValidation(CommandResultValidation.None)
|
||||
.WithEnvironmentVariables(process.StartInfo.Environment.ToDictionary(kvp => kvp.Key, kvp => kvp.Value))
|
||||
.WithStandardErrorPipe(PipeTarget.ToStream(Stream.Null));
|
||||
}
|
||||
|
||||
public Command ExtractAttachedPicAsPng(string ffmpegPath, string inputFile, int streamIndex, string outputFile)
|
||||
{
|
||||
Process process = new FFmpegProcessBuilder(ffmpegPath, false, _logger)
|
||||
.WithThreads(1)
|
||||
.WithQuiet()
|
||||
.WithInput(inputFile)
|
||||
.WithMap($"0:{streamIndex}")
|
||||
.WithOutputFormat("apng", outputFile)
|
||||
.Build();
|
||||
|
||||
return Cli.Wrap(process.StartInfo.FileName)
|
||||
.WithArguments(process.StartInfo.ArgumentList)
|
||||
.WithValidation(CommandResultValidation.None)
|
||||
.WithEnvironmentVariables(process.StartInfo.Environment.ToDictionary(kvp => kvp.Key, kvp => kvp.Value))
|
||||
.WithStandardErrorPipe(PipeTarget.ToStream(Stream.Null));
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, string>> GenerateSongImage(
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
@@ -163,11 +106,11 @@ public class FFmpegProcessService
|
||||
false,
|
||||
Option<int>.None);
|
||||
|
||||
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath, false, _logger)
|
||||
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath)
|
||||
.WithThreads(1)
|
||||
.WithQuiet()
|
||||
.WithFormatFlags(playbackSettings.FormatFlags)
|
||||
.WithSongInput(videoPath, videoStream.Codec, videoStream.PixelFormat, boxBlur)
|
||||
.WithSongInput(videoPath, videoStream.PixelFormat, boxBlur)
|
||||
.WithWatermark(watermarkOptions, None, channel.FFmpegProfile.Resolution)
|
||||
.WithSubtitleFile(subtitleFile);
|
||||
|
||||
@@ -186,8 +129,7 @@ public class FFmpegProcessService
|
||||
videoStream,
|
||||
None,
|
||||
videoPath,
|
||||
None,
|
||||
playbackSettings.VideoFormat)
|
||||
None)
|
||||
.WithOutputFormat("apng", outputFile)
|
||||
.Build();
|
||||
|
||||
|
||||
@@ -1,33 +1,47 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Scripting;
|
||||
using ErsatzTV.Core.Scripting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Core.FFmpeg;
|
||||
|
||||
public class FFmpegStreamSelector : IFFmpegStreamSelector
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly ILogger<FFmpegStreamSelector> _logger;
|
||||
private readonly IScriptEngine _scriptEngine;
|
||||
private readonly IStreamSelectorRepository _streamSelectorRepository;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILogger<FFmpegStreamSelector> _logger;
|
||||
|
||||
public FFmpegStreamSelector(
|
||||
IScriptEngine scriptEngine,
|
||||
IStreamSelectorRepository streamSelectorRepository,
|
||||
ISearchRepository searchRepository,
|
||||
ILogger<FFmpegStreamSelector> logger,
|
||||
IConfigElementRepository configElementRepository)
|
||||
IConfigElementRepository configElementRepository,
|
||||
ILocalFileSystem localFileSystem,
|
||||
ILogger<FFmpegStreamSelector> logger)
|
||||
{
|
||||
_scriptEngine = scriptEngine;
|
||||
_streamSelectorRepository = streamSelectorRepository;
|
||||
_searchRepository = searchRepository;
|
||||
_logger = logger;
|
||||
_configElementRepository = configElementRepository;
|
||||
_localFileSystem = localFileSystem;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task<MediaStream> SelectVideoStream(MediaVersion version) =>
|
||||
version.Streams.First(s => s.MediaStreamKind == MediaStreamKind.Video).AsTask();
|
||||
|
||||
public async Task<Option<MediaStream>> SelectAudioStream(
|
||||
MediaVersion version,
|
||||
MediaItemAudioVersion version,
|
||||
StreamingMode streamingMode,
|
||||
string channelNumber,
|
||||
Channel channel,
|
||||
string preferredAudioLanguage,
|
||||
string preferredAudioTitle)
|
||||
{
|
||||
@@ -36,16 +50,14 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Channel {Number} is HLS Direct with no preferred audio language or title; using all audio streams",
|
||||
channelNumber);
|
||||
channel.Number);
|
||||
return None;
|
||||
}
|
||||
|
||||
var audioStreams = version.Streams.Filter(s => s.MediaStreamKind == MediaStreamKind.Audio).ToList();
|
||||
|
||||
string language = (preferredAudioLanguage ?? string.Empty).ToLowerInvariant();
|
||||
if (string.IsNullOrWhiteSpace(language))
|
||||
{
|
||||
_logger.LogDebug("Channel {Number} has no preferred audio language code", channelNumber);
|
||||
_logger.LogDebug("Channel {Number} has no preferred audio language code", channel.Number);
|
||||
Option<string> maybeDefaultLanguage = await _configElementRepository.GetValue<string>(
|
||||
ConfigElementKey.FFmpegPreferredLanguageCode);
|
||||
maybeDefaultLanguage.Match(
|
||||
@@ -57,33 +69,53 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
|
||||
});
|
||||
}
|
||||
|
||||
List<string> allCodes = await _searchRepository.GetAllLanguageCodes(new List<string> { language });
|
||||
if (allCodes.Count > 1)
|
||||
List<string> allLanguageCodes = await _searchRepository.GetAllLanguageCodes(new List<string> { language });
|
||||
if (allLanguageCodes.Count > 1)
|
||||
{
|
||||
_logger.LogDebug("Preferred audio language has multiple codes {Codes}", allCodes);
|
||||
_logger.LogDebug("Preferred audio language has multiple codes {Codes}", allLanguageCodes);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
switch (version.MediaItem)
|
||||
{
|
||||
case Episode:
|
||||
var sw = Stopwatch.StartNew();
|
||||
Option<MediaStream> result = await SelectEpisodeAudioStream(
|
||||
channel,
|
||||
allLanguageCodes,
|
||||
version.MediaItem.Id,
|
||||
version.MediaVersion);
|
||||
sw.Stop();
|
||||
_logger.LogDebug("SelectAudioStream duration: {Duration}", sw.Elapsed);
|
||||
if (result.IsSome)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
break;
|
||||
case Movie:
|
||||
var sw2 = Stopwatch.StartNew();
|
||||
Option<MediaStream> result2 = await SelectMovieAudioStream(
|
||||
channel,
|
||||
allLanguageCodes,
|
||||
version.MediaItem.Id,
|
||||
version.MediaVersion);
|
||||
sw2.Stop();
|
||||
_logger.LogDebug("SelectAudioStream duration: {Duration}", sw2.Elapsed);
|
||||
if (result2.IsSome)
|
||||
{
|
||||
return result2;
|
||||
}
|
||||
break;
|
||||
// let default fall through
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to execute audio stream selector script; falling back to built-in logic");
|
||||
}
|
||||
|
||||
var correctLanguage = audioStreams.Filter(
|
||||
s => allCodes.Any(
|
||||
c => string.Equals(
|
||||
s.Language,
|
||||
c,
|
||||
StringComparison.InvariantCultureIgnoreCase))).ToList();
|
||||
if (correctLanguage.Any())
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Found {Count} audio streams with preferred audio language code(s) {Code}",
|
||||
correctLanguage.Count,
|
||||
allCodes);
|
||||
|
||||
return PrioritizeAudioTitle(correctLanguage, preferredAudioTitle ?? string.Empty);
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Unable to find audio stream with preferred audio language code(s) {Code}",
|
||||
allCodes);
|
||||
|
||||
return PrioritizeAudioTitle(audioStreams, preferredAudioTitle ?? string.Empty);
|
||||
return DefaultSelectAudioStream(version.MediaVersion, allLanguageCodes, preferredAudioTitle);
|
||||
}
|
||||
|
||||
public async Task<Option<Subtitle>> SelectSubtitleStream(
|
||||
@@ -92,7 +124,7 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
|
||||
string preferredSubtitleLanguage,
|
||||
ChannelSubtitleMode subtitleMode)
|
||||
{
|
||||
if (channel.MusicVideoCreditsMode == ChannelMusicVideoCreditsMode.GenerateSubtitles &&
|
||||
if (channel.MusicVideoCreditsMode is ChannelMusicVideoCreditsMode.GenerateSubtitles &&
|
||||
subtitles.FirstOrDefault(s => s.SubtitleKind == SubtitleKind.Generated) is { } generatedSubtitle)
|
||||
{
|
||||
_logger.LogDebug("Selecting generated subtitle for channel {Number}", channel.Number);
|
||||
@@ -165,9 +197,39 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
|
||||
return None;
|
||||
}
|
||||
|
||||
private Option<MediaStream> DefaultSelectAudioStream(
|
||||
MediaVersion version,
|
||||
List<string> preferredLanguageCodes,
|
||||
string preferredAudioTitle)
|
||||
{
|
||||
var audioStreams = version.Streams.Filter(s => s.MediaStreamKind == MediaStreamKind.Audio).ToList();
|
||||
|
||||
var correctLanguage = audioStreams.Filter(
|
||||
s => preferredLanguageCodes.Any(
|
||||
c => string.Equals(
|
||||
s.Language,
|
||||
c,
|
||||
StringComparison.InvariantCultureIgnoreCase))).ToList();
|
||||
|
||||
if (correctLanguage.Any())
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Found {Count} audio streams with preferred audio language code(s) {Code}",
|
||||
correctLanguage.Count,
|
||||
preferredLanguageCodes);
|
||||
|
||||
return PrioritizeAudioTitle(correctLanguage, preferredAudioTitle ?? string.Empty);
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Unable to find audio stream with preferred audio language code(s) {Code}",
|
||||
preferredLanguageCodes);
|
||||
|
||||
return PrioritizeAudioTitle(audioStreams, preferredAudioTitle ?? string.Empty);
|
||||
}
|
||||
|
||||
private Option<MediaStream> PrioritizeAudioTitle(IReadOnlyCollection<MediaStream> streams, string title)
|
||||
{
|
||||
// return correctLanguage.OrderByDescending(s => s.Channels).Head();
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
{
|
||||
_logger.LogDebug("No audio title has been specified; selecting stream with most channels");
|
||||
@@ -194,4 +256,125 @@ public class FFmpegStreamSelector : IFFmpegStreamSelector
|
||||
|
||||
return streams.OrderByDescending(s => s.Channels).Head();
|
||||
}
|
||||
|
||||
private async Task<Option<MediaStream>> SelectEpisodeAudioStream(
|
||||
Channel channel,
|
||||
List<string> preferredLanguageCodes,
|
||||
int episodeId,
|
||||
MediaVersion version)
|
||||
{
|
||||
string jsScriptPath = Path.ChangeExtension(
|
||||
Path.Combine(FileSystemLayout.AudioStreamSelectorScriptsFolder, "episode"),
|
||||
"js");
|
||||
|
||||
_logger.LogDebug("Checking for JS Script at {Path}", jsScriptPath);
|
||||
if (!_localFileSystem.FileExists(jsScriptPath))
|
||||
{
|
||||
_logger.LogWarning("Unable to locate episode audio stream selector script; falling back to built-in logic");
|
||||
return Option<MediaStream>.None;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Found JS Script at {Path}", jsScriptPath);
|
||||
|
||||
await _scriptEngine.LoadAsync(jsScriptPath);
|
||||
|
||||
EpisodeAudioStreamSelectorData data = await _streamSelectorRepository.GetEpisodeData(episodeId);
|
||||
|
||||
AudioStream[] audioStreams = GetAudioStreamsForScript(version);
|
||||
|
||||
object result = _scriptEngine.Invoke(
|
||||
"selectEpisodeAudioStreamIndex",
|
||||
channel.Number,
|
||||
channel.Name,
|
||||
data.ShowTitle,
|
||||
data.ShowGuids,
|
||||
data.SeasonNumber,
|
||||
data.EpisodeNumber,
|
||||
data.EpisodeGuids,
|
||||
preferredLanguageCodes.ToArray(),
|
||||
audioStreams);
|
||||
|
||||
return ProcessScriptResult(version, result);
|
||||
}
|
||||
|
||||
private async Task<Option<MediaStream>> SelectMovieAudioStream(
|
||||
Channel channel,
|
||||
List<string> preferredLanguageCodes,
|
||||
int movieId,
|
||||
MediaVersion version)
|
||||
{
|
||||
string jsScriptPath = Path.ChangeExtension(
|
||||
Path.Combine(FileSystemLayout.AudioStreamSelectorScriptsFolder, "movie"),
|
||||
"js");
|
||||
|
||||
_logger.LogDebug("Checking for JS Script at {Path}", jsScriptPath);
|
||||
if (!_localFileSystem.FileExists(jsScriptPath))
|
||||
{
|
||||
_logger.LogWarning("Unable to locate movie audio stream selector script; falling back to built-in logic");
|
||||
return Option<MediaStream>.None;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Found JS Script at {Path}", jsScriptPath);
|
||||
|
||||
await _scriptEngine.LoadAsync(jsScriptPath);
|
||||
|
||||
MovieAudioStreamSelectorData data = await _streamSelectorRepository.GetMovieData(movieId);
|
||||
|
||||
AudioStream[] audioStreams = GetAudioStreamsForScript(version);
|
||||
|
||||
object result = _scriptEngine.Invoke(
|
||||
"selectMovieAudioStreamIndex",
|
||||
channel.Number,
|
||||
channel.Name,
|
||||
data.Title,
|
||||
data.Guids,
|
||||
preferredLanguageCodes.ToArray(),
|
||||
audioStreams);
|
||||
|
||||
return ProcessScriptResult(version, result);
|
||||
}
|
||||
|
||||
private Option<MediaStream> ProcessScriptResult(MediaVersion version, object result)
|
||||
{
|
||||
if (result is double d)
|
||||
{
|
||||
var streamIndex = (int)d;
|
||||
Option<MediaStream> maybeStream = version.Streams.Find(s => s.Index == streamIndex);
|
||||
foreach (MediaStream stream in maybeStream)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"JS Script returned audio stream index {Index} with language {Language} and {Channels} audio channel(s)",
|
||||
streamIndex,
|
||||
stream.Language,
|
||||
stream.Channels);
|
||||
return stream;
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"JS Script returned audio stream index {Index} which does not exist",
|
||||
streamIndex);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"JS Script did not return an audio stream index; falling back to built-in logic");
|
||||
}
|
||||
|
||||
return Option<MediaStream>.None;
|
||||
}
|
||||
|
||||
private static AudioStream[] GetAudioStreamsForScript(MediaVersion version) => version.Streams
|
||||
.Filter(s => s.MediaStreamKind == MediaStreamKind.Audio)
|
||||
.Map(a => new AudioStream(a.Index, a.Channels, a.Codec, a.Default, a.Forced, a.Language, a.Title))
|
||||
.ToArray();
|
||||
|
||||
[SuppressMessage("ReSharper", "InconsistentNaming")]
|
||||
private record AudioStream(
|
||||
int index,
|
||||
int channels,
|
||||
string codec,
|
||||
bool isDefault,
|
||||
bool isForced,
|
||||
string language,
|
||||
string title);
|
||||
}
|
||||
|
||||
5
ErsatzTV.Core/FFmpeg/MediaItemAudioVersion.cs
Normal file
5
ErsatzTV.Core/FFmpeg/MediaItemAudioVersion.cs
Normal file
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Core.FFmpeg;
|
||||
|
||||
public record MediaItemAudioVersion(MediaItem MediaItem, MediaVersion MediaVersion);
|
||||
@@ -19,7 +19,6 @@ public static class FileSystemLayout
|
||||
|
||||
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");
|
||||
@@ -45,4 +44,17 @@ public static class FileSystemLayout
|
||||
|
||||
public static readonly string SubtitleCacheFolder = Path.Combine(StreamsCacheFolder, "subtitles");
|
||||
public static readonly string FontsCacheFolder = Path.Combine(StreamsCacheFolder, "fonts");
|
||||
|
||||
public static readonly string TemplatesFolder = Path.Combine(AppDataFolder, "templates");
|
||||
|
||||
public static readonly string MusicVideoCreditsTemplatesFolder =
|
||||
Path.Combine(TemplatesFolder, "music-video-credits");
|
||||
|
||||
public static readonly string ScriptsFolder = Path.Combine(AppDataFolder, "scripts");
|
||||
|
||||
public static readonly string MultiEpisodeShuffleTemplatesFolder =
|
||||
Path.Combine(ScriptsFolder, "multi-episode-shuffle");
|
||||
|
||||
public static readonly string AudioStreamSelectorScriptsFolder =
|
||||
Path.Combine(ScriptsFolder, "audio-stream-selector");
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.FFmpeg;
|
||||
using ErsatzTV.FFmpeg.State;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
@@ -14,10 +15,10 @@ public interface IFFmpegProcessService
|
||||
bool saveReports,
|
||||
Channel channel,
|
||||
MediaVersion videoVersion,
|
||||
MediaVersion audioVersion,
|
||||
MediaItemAudioVersion audioVersion,
|
||||
string videoPath,
|
||||
string audioPath,
|
||||
List<Subtitle> subtitles,
|
||||
Func<FFmpegPlaybackSettings, Task<List<Subtitle>>> getSubtitles,
|
||||
string preferredAudioLanguage,
|
||||
string preferredAudioTitle,
|
||||
string preferredSubtitleLanguage,
|
||||
@@ -36,7 +37,8 @@ public interface IFFmpegProcessService
|
||||
TimeSpan outPoint,
|
||||
long ptsOffset,
|
||||
Option<int> targetFramerate,
|
||||
bool disableWatermarks);
|
||||
bool disableWatermarks,
|
||||
Action<FFmpegPipeline> pipelineAction);
|
||||
|
||||
Task<Command> ForError(
|
||||
string ffmpegPath,
|
||||
@@ -51,14 +53,10 @@ public interface IFFmpegProcessService
|
||||
|
||||
Task<Command> ConcatChannel(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host);
|
||||
|
||||
Command WrapSegmenter(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host);
|
||||
Task<Command> WrapSegmenter(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host);
|
||||
|
||||
Task<Command> ResizeImage(string ffmpegPath, string inputFile, string outputFile, int height);
|
||||
|
||||
Command ConvertToPng(string ffmpegPath, string inputFile, string outputFile);
|
||||
|
||||
Command ExtractAttachedPicAsPng(string ffmpegPath, string inputFile, int streamIndex, string outputFile);
|
||||
|
||||
Task<Either<BaseError, string>> GenerateSongImage(
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
|
||||
@@ -7,9 +8,9 @@ public interface IFFmpegStreamSelector
|
||||
Task<MediaStream> SelectVideoStream(MediaVersion version);
|
||||
|
||||
Task<Option<MediaStream>> SelectAudioStream(
|
||||
MediaVersion version,
|
||||
MediaItemAudioVersion version,
|
||||
StreamingMode streamingMode,
|
||||
string channelNumber,
|
||||
Channel channel,
|
||||
string preferredAudioLanguage,
|
||||
string preferredAudioTitle);
|
||||
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
|
||||
public interface IMusicVideoCreditsGenerator
|
||||
{
|
||||
Task<Option<Subtitle>> GenerateCreditsSubtitle(MusicVideo musicVideo, FFmpegProfile ffmpegProfile);
|
||||
|
||||
Task<Option<Subtitle>> GenerateCreditsSubtitleFromTemplate(
|
||||
MusicVideo musicVideo,
|
||||
FFmpegProfile ffmpegProfile,
|
||||
FFmpegPlaybackSettings settings,
|
||||
string templateFileName);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user