Compare commits

..
119 Commits
Author SHA1 Message Date
Jason Dove a386fe9ba1 update changelog for release v0.7.5-beta [no ci] 2023-03-05 10:12:21 -06:00
Jason DoveandGitHub 4d84fc242b plex scanner improvement (#1193)
* fix crash with some plex multi-episode files

* comments cleanup
2023-03-03 06:08:42 -06:00
Jason DoveandGitHub 40e79a3a14 fix plex scanner crash (#1192) 2023-03-02 22:49:21 -06:00
Jason Dove c653bb32a7 plex scanner logging 2023-03-02 20:57:38 -06:00
Jason DoveandGitHub b032e70d7e support more local season folder names (#1191) 2023-03-02 20:13:34 -06:00
Jason DoveandGitHub 074816be50 simplify qsv accel syntax on linux (#1189) 2023-03-02 06:00:36 -06:00
Jason DoveandGitHub 3fafd5192f fix hevc_nvenc encoder on sm < 75 (#1187) 2023-03-01 20:17:58 -06:00
Jason DoveandGitHub 1d63197b56 fix yuv444p10le (#1186) 2023-03-01 19:56:14 -06:00
Jason DoveandGitHub b2c57e7407 upgrade to ffmpeg 6 (#1185) 2023-03-01 19:21:53 -06:00
Jason DoveandGitHub 581aa51792 fix trash display for certain episodes (#1184)
* fix trash display for certain episodes

* fix multi-episode fallback metadata
2023-02-28 09:37:30 -06:00
Jason DoveandGitHub 4d57ece30d check ffmpeg for available decoders, filters, encoders (#1183)
* check ffmpeg for available decoders, filters, encoders

* revert csproj change
2023-02-27 19:28:42 -06:00
Jason DoveandGitHub eddbf07b11 vaapi: decode vp9 and av1 (#1181) 2023-02-27 05:57:13 -06:00
Jason Dove 450ea063b4 update vaapi docker bundled ffmpeg version 2023-02-26 21:39:22 -06:00
Jason DoveandGitHub f320d84874 fix ffmpeg version health check for vaapi docker (#1179) 2023-02-25 08:18:49 -06:00
Jason DoveandGitHub c832c8e860 prioritize default audio tracks (#1178) 2023-02-24 15:45:30 -06:00
Jason DoveandGitHub e5ef8eaf72 fix some cases where vaapi hwdownload would fail; use libvpl in docker (#1177)
* fix some cases where vaapi hwdownload would fail

* update changelog
2023-02-24 09:10:42 -06:00
Jason DoveandGitHub 6db71f525d remove duplicate filter from search index (#1172) 2023-02-21 22:15:57 -06:00
Jason Dove 3ab66ef12a update intel media driver in vaapi docker image 2023-02-21 19:02:54 -06:00
Jason DoveandGitHub 018f759fa4 improve vaapi capability detection (#1171) 2023-02-21 09:53:23 -06:00
Jason DoveandGitHub 1afff11063 software decoder fixes (#1169)
* fix software decoder pipeline bugs

* tweak nvidia scaling logic

* update changelog

* update dependencies
2023-02-20 19:45:25 -06:00
Jason DoveandGitHub 7e3436e68f direct stream content from emby as needed (#1168) 2023-02-19 20:29:09 -06:00
Jason DoveandGitHub b751f1054b direct stream content from jellyfin if needed (#1167)
* redirect to jellyfin stream as needed

* get jellyfin playback info

* sync chapters from jellyfin

* update changelog

* cleanup
2023-02-19 10:37:48 -06:00
Jason DoveandGitHub 900e9e75f3 sync chapter markers from plex (#1166) 2023-02-18 14:51:51 -06:00
Jason DoveandGitHub 62c28d9f51 direct stream content from plex if needed (#1165)
* start to stream directly from plex

* update metadata and statistics with one plex api call

* stream movies from plex

* scanning bug fix; update changelog
2023-02-18 10:40:05 -06:00
Jason DoveandGitHub 132ca99f94 fix default dockerfile (#1156) 2023-02-13 05:38:11 -06:00
Jason Dove c309ab430e update changelog for release v0.7.4-beta [no ci] 2023-02-12 18:21:11 -06:00
Jason DoveandGitHub 13e21bbcce sync episode tags and genres (#1155)
* sync episode tags and genres

* update dependencies

* property update local episode genres and tags

* fix test
2023-02-12 09:53:20 -06:00
Jason DoveandGitHub 0eb36f0ce1 prioritize default audio streams (#1154) 2023-02-10 09:31:55 -06:00
Jason DoveandGitHub 6429f0f064 fix filler padding (#1153)
* fix filler padding

* update dependencies
2023-02-07 19:55:30 -06:00
Jason DoveandGitHub 7412ac6fc9 fix mid and post roll filler ordering (#1152) 2023-02-07 12:25:22 -06:00
Jason DoveandGitHub e58e3c786d fix last scan check (#1150) 2023-02-06 05:38:08 -06:00
Jason DoveandGitHub 93fc1e4eb4 fix fallback filler looping (#1146) 2023-02-04 08:49:52 -06:00
Jason DoveandGitHub cacde26796 merge other video folder tags with nfo tags (#1144) 2023-02-01 05:58:26 -06:00
Jason DoveandGitHub 0a3db92c60 fix schedule copy (#1142) 2023-01-30 10:23:18 -06:00
Jason DoveandGitHub 8bb0cd5ab5 add copy schedule feature (#1141) 2023-01-30 06:38:34 -06:00
Jason DoveandGitHub e497dc4e36 fix nvidia vp9 color normalization (#1140) 2023-01-29 16:02:29 -06:00
Jason DoveandGitHub 2689a67eb8 qsv and vaapi fixes (#1139)
* lots of qsv fixes

* update changelog

* fix qsv mpeg2

* vaapi fixes

* update changelog

* upgrade mudblazor

* fix bug with undefined input colorspace
2023-01-29 10:00:52 -06:00
Jason Dove 3d821043bb update changelog for v0.7.3-beta [no ci] 2023-01-25 12:01:38 -06:00
Jason DoveandGitHub e69c58e615 conditionally disable v2 apis (#1135)
* conditionally disable v2 apis

* update changelog
2023-01-25 11:37:43 -06:00
Jason DoveandGitHub a21b6f9f4e add oidc logout url to support auth0 (#1134) 2023-01-25 09:30:36 -06:00
Jason DoveandGitHub 99b8038852 add oidc support (#1133) 2023-01-25 08:37:59 -06:00
Jason DoveandGitHub ef8ca9f8c6 build mac artifacts on macos 11 (#1132) 2023-01-24 15:04:55 -06:00
Jason DoveandGitHub d9186df157 minor logging and doc updates (#1130) 2023-01-23 05:28:17 -06:00
Jason DoveandGitHub aca6bfb0bb fix multiple gcs after extracting subtitles (#1129) 2023-01-22 13:10:13 -06:00
Jason DoveandGitHub 587fc3a98f release memory after extracting embedded subtitles (#1128) 2023-01-22 12:34:42 -06:00
Jason DoveandGitHub ab1c67e60e memory improvements (#1127)
* regularly release memory

* don't aggressively GC while legacy streaming

* update changelog
2023-01-22 09:16:24 -06:00
Jason DoveandGitHub e271f43066 more scan check fixes (#1126) 2023-01-21 08:22:56 -06:00
Jason DoveandGitHub 6bf8feb26e fix local library scan check with new install (#1125) 2023-01-21 08:10:42 -06:00
Jason DoveandGitHub ffd66f6a21 fix removing media server libraries (#1124) 2023-01-20 09:31:18 -06:00
Jason DoveandGitHub 3b135df4c1 scan with below-normal priority when unforced (#1123) 2023-01-20 06:05:39 -06:00
Jason DoveandGitHub 4369d04940 scanner improvements (#1122)
* optimize periodic scanning

* set scanner process priority

* update dependencies
2023-01-20 05:37:39 -06:00
Jason Dove faaa78fed7 update changelog [no ci] 2023-01-18 15:40:00 -06:00
Jason DoveandGitHub 6bea1660ea disable mac compression; this is needed until dotnet 7.0.3 (#1120) 2023-01-18 15:13:07 -06:00
Jason DoveandGitHub 8d46676c25 try to fix mac scanning (#1119) 2023-01-18 14:43:26 -06:00
Jason DoveandGitHub 4c75e638a2 fix bug with smart collection progress (#1118) 2023-01-18 14:09:54 -06:00
Jason DoveandGitHub dd73a3803a fix schedule editor crash (#1115)
* fix schedule editor crash due to bad music video artist data

* update dependencies
2023-01-15 06:35:51 -06:00
Jason Dove f6c345d7cf fix build 2023-01-10 15:13:42 -06:00
Jason DoveandGitHub 585b56a668 bug fixes (#1107)
* don't search an empty search index

* fix bug with flood filler prediction check

* extract subtitles on primary worker thread
2023-01-10 14:45:04 -06:00
Jason Dove f18f3b4f35 try to fix develop artifacts 2023-01-09 08:46:55 -06:00
Jason DoveandGitHub eb7871a048 fix alternate schedule playout update check (#1106)
* fix alternate schedule playout update check

* Revert "use mknejp/delete-release-assets again"

This reverts commit 07ac833067.
2023-01-09 05:36:15 -06:00
Jason DoveandGitHub 000fc78fd3 add alternate schedule system (#1105)
* start to add program schedule alternates

* edit days of the week

* editor improvements

* save changes

* build playouts using alternate schedules

* reset playout as needed

* add priority message
2023-01-08 23:22:17 -06:00
Jason DoveandGitHub ba676ef956 add jellyfin admin error logging (#1102) 2023-01-07 09:55:02 -06:00
Jason DoveandGitHub 36ea88e2d6 fix error display (#1099)
* fix error display by ignoring hw accel setting

* update changelog

* revert background change
2023-01-05 20:02:48 -06:00
Jason Dove 5237e6fa50 update changelog for release v0.7.2-beta [no ci] 2023-01-05 11:51:57 -06:00
Jason DoveandGitHub 99bde1819c use mknejp/delete-release-assets again (#1098) 2023-01-05 10:26:42 -06:00
Jason Dove f5d7ec2890 update workflow [no ci] 2023-01-05 10:06:32 -06:00
Jason DoveandGitHub 13c65435d3 update dependencies (#1097) 2023-01-05 09:27:12 -06:00
Jason DoveandGitHub 315420f1a5 fix log viewer on windows (#1095)
* fix log viewer on windows

* catch cancellation on trakt page

* update changelog
2023-01-04 22:26:35 -06:00
Jason DoveandGitHub ab7051f075 reimplement log viewer (#1094) 2023-01-04 10:09:11 -06:00
Jason Dove a43e5bbe9d update changelog for release v0.7.1-beta [no ci] 2023-01-03 09:40:59 -06:00
Jason DoveandGitHub b7bd4541b1 hide windows on windows (#1091)
* hide windows on windows

* update dependencies
2023-01-03 09:14:50 -06:00
Jason Dove 648f25e9cc fix terminating server process 2023-01-01 14:20:52 -06:00
Jason Dove ccbe85a46a also hide the main server window 2023-01-01 14:12:41 -06:00
Jason DoveandGitHub d168d79fe0 hide windows wrapper console (#1088) 2023-01-01 13:42:39 -06:00
Jason Dove d37dde2477 try to fix windows build 2023-01-01 13:19:12 -06:00
Jason DoveandGitHub 8e13b07c84 convert windows project from dotnet to rust (#1087)
* convert windows project from dotnet to rust

* update pr jobs

* pr job fixes

* don't bother building mac app in prs for now

* build windows wrapper with rust
2023-01-01 13:01:58 -06:00
Jason DoveandGitHub 927e7724f0 fix search bug (#1086)
* fix removing media items from search index

* update changelog
2023-01-01 08:49:15 -06:00
Jason DoveandGitHub 6558c5bd69 fix subtitle update bug (#1085)
* fix saving some subtitles to database

* fix ffprobe regression
2022-12-31 19:57:14 -06:00
Jason DoveandGitHub 5f7efbb69c properly fall back to software pipeline (#1084) 2022-12-31 14:06:00 -06:00
Jason DoveandGitHub b79795af50 add debug logging to local subtitle provider (#1083) 2022-12-31 11:36:08 -06:00
Jason DoveandGitHub 9479806cb0 scanner refactoring and other cleanup (#1082)
* move subtitles provider into scanner

* move more stuff into scanner

* move nfo into scanner

* add scan subcommand

* fix a bunch of nfo build warnings

* more subcommands

* fix warnings

* cleanup logging

* remove unused code

* cleanup old ffmpeg stuff

* rename complex filter

* refactor wrapped segmenter
2022-12-31 10:57:20 -06:00
Jason DoveandGitHub 6e49ea78ec music video template contrib (#1081)
* add another music video template

* add more music video credit templates
2022-12-30 13:26:07 -06:00
Jason DoveandGitHub 7b1edd9c54 add new scanner process (#1080)
* start moving local scans to separate process

* send progress updates to main process

* move scanners and tests

* simplify dependencies; sync search index

* commit search index more often when scanning

* support forced scan and cancellation

* use scanner process for plex libraries

* update changelog

* update dockerfiles

* fix search index for local folder scanning

* rework plex scanners

* rework scanner handlers

* emby works again

* sync jellyfin

* cleanup

* update build

* update changelog

* remove scanner dependency in pr and artifacts workflows

* fix mac sed syntax

* fix pr build
2022-12-30 12:53:05 -06:00
Jason DoveandGitHub aeaafd2964 add scanner subtitle logging (#1079) 2022-12-29 06:00:00 -06:00
Jason DoveandGitHub 622fa01602 update dependencies and fix some types (#1077) 2022-12-28 14:21:25 -06:00
Jason DoveandGitHub e2b3c1ce8e properly unflag local movies that are now present on disk (#1076) 2022-12-28 13:41:01 -06:00
Jason DoveandGitHub 6c5db650e7 nvidia pixel format and song fixes (#1075)
* fix nvidia pixel format edge case

* fix nvidia song playback
2022-12-24 20:39:22 -06:00
Jason DoveandGitHub 731072425b fix nvidia pipeline that only requires setparams (#1074) 2022-12-24 13:19:27 -06:00
Jason DoveandGitHub 0f817308a8 limit library scan interval (#1073)
* limit library scan interval

* fix condition
2022-12-24 12:58:38 -06:00
Jason DoveandGitHub 0fc1e15cac colorspace fixes; song playback fixes (#1072)
* fix colorspace bug, vaapi song playback

* more colorspace fixes, nvidia fixes

* nvidia colorspace fixes

* fix some qsv output color metadata

* update changelog

* update changelog
2022-12-23 15:11:05 -06:00
Jason Dove acf30384b7 update changelog [no ci] 2022-12-20 20:14:31 -06:00
Jason DoveandGitHub d2040eaac9 pipeline fixes when colorspace filter is used (#1068)
* fix colorspace filter with missing input transfer or input primaries

* properly download before applying colorspace filter

* fix extra hwupload/hwdownload with nvidia pipeline

* colorspace tests

* update dependencies
2022-12-20 20:12:27 -06:00
Jason DoveandGitHub 93673fce03 add more logging to vaapi capabilities detection (#1059) 2022-12-15 19:32:48 -06:00
Jason Dove d7a432068b fix arm docker builds 2022-12-15 14:26:00 -06:00
Jason Dove cb9215980a fix dockerfiles, focal to jammy 2022-12-15 14:14:07 -06:00
Jason DoveandGitHub a4fc1f1c6f upgrade to dotnet 7, ffmpeg 5.1.2 (#1058)
* wip

* update dockerfiles

* more net6 to net7

* update dependencies

* update builds
2022-12-15 14:08:21 -06:00
Jason Dove cbbdb11938 update changelog for release v0.7.0-beta [no ci] 2022-12-11 06:53:05 -06:00
Jason DoveandGitHub a2274bca7b detect vaapi capabilities (#1051)
* remove unused pipeline

* spike vaapi hardware capabilities

* more vaapi capabilities

* use proper vaapi driver

* update readme

* update dependencies
2022-12-10 14:10:19 -06:00
Jason DoveandGitHub f84496b09d extract attached fonts (#1050) 2022-12-09 22:22:15 -06:00
Jason DoveandGitHub 3abf310a3b add amf pipeline (#1048) 2022-12-09 15:23:00 -06:00
Jason DoveandGitHub f12e361c2e fix videotoolbox color normalization (#1047) 2022-12-08 13:11:46 -06:00
Jason DoveandGitHub cd0f1e98cc fix qsv color normalization (#1046) 2022-12-08 08:17:59 -06:00
Jason DoveandGitHub 325ef80951 normalize bit depth via new pipeline (#1045)
* fix subtitles test and nvidia subtitles

* new ffmpeg pipelines; software and nvidia

* partial qsv

* fix qsv

* fix software pipeline

* add vaapi pipeline

* fix qsv 10-bit h264 output

* nvidia fixes

* properly disable 10-bit h264 hardware encoders

* more nvidia fixes

* add video toolbox pipeline
2022-12-07 21:25:55 -06:00
Jason DoveandGitHub 9a30d7c7da error report bug fixes (#1042)
* fix some potential null reference exceptions

* searching isn't actually async

* add search query breadcrumb
2022-12-03 05:47:04 -06:00
Jason DoveandGitHub 25ea75b761 more color fixes (#1040) 2022-11-25 21:25:04 -06:00
Jason DoveandGitHub 32edf77d35 fix bt709 check (#1039) 2022-11-25 10:09:58 -06:00
Jason DoveandGitHub 47fbb2b1b7 properly unlock trakt (#1035) 2022-11-23 18:34:55 -06:00
Jason DoveandGitHub e388f81e1f re-enable bugsnag auto notification (#1034) 2022-11-22 20:09:25 -06:00
Jason DoveandGitHub f0bea295c4 add video stats to search index (#1033) 2022-11-22 09:35:28 -06:00
Jason DoveandGitHub 7439ded43d normalize bit depth (#1031)
* normalize bit depth and color for nvenc

* fix hls direct

* update changelog

* add bit depth option to ffmpeg profile
2022-11-21 20:20:07 -06:00
Jason DoveandGitHub 6a640d3708 fix ogg song metadata (#1030) 2022-11-20 12:44:08 -06:00
Jason DoveandGitHub 776bce9087 use base path in channel playlist and channel guide (#1028) 2022-11-20 08:28:14 -06:00
Jason Dove 3c499f9e97 proper fix 2022-11-19 10:41:16 -06:00
Jason DoveandGitHub 114ff7a3e3 fix develop build cleanup (#1027) 2022-11-19 10:16:12 -06:00
Jason DoveandGitHub 527cdf523c fix work-ahead limit setting (#1023) 2022-11-16 21:17:23 -06:00
Jason Dove 91eb8ab824 try to fix develop release 2022-11-04 11:26:05 -05:00
Jason DoveandGitHub 7a87fb1c2e fix removing emby and jellyfin libraries (#1011)
* fix removing jellyfin and emby libraries

* remove unneeded change
2022-11-04 06:25:51 -05:00
Jason DoveandGitHub d8cc6b4c22 fix audio stream selection (#1010) 2022-11-02 14:55:43 -05:00
Jason DoveandGitHub c9bd94d9f8 use javascript instead of lua for external scripts; add audio stream selector scripts (#1005)
* use js instead of lua

* update dependencies

* add audio stream selector script for episodes

* add audio stream selector script for movies

* update changelog
2022-10-28 17:05:07 -05:00
523 changed files with 47884 additions and 7548 deletions
+28 -15
View File
@@ -33,10 +33,10 @@ jobs:
strategy:
matrix:
include:
- os: macos-latest
- os: macos-11
kind: macOS
target: osx-x64
- os: macos-latest
- os: macos-11
kind: macOS
target: osx-arm64
steps:
@@ -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
@@ -57,7 +57,7 @@ jobs:
node-version: '14'
- name: Cache NPM dependencies
uses: bahmutov/npm-install@v1.4.5
uses: bahmutov/npm-install@v1.8.28
with:
working-directory: ErsatzTV/client-app
@@ -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=false -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=false -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
- name: Bundle
shell: bash
@@ -131,7 +134,7 @@ jobs:
- name: Delete old release assets
uses: mknejp/delete-release-assets@v1
if: ${{ inputs.release_version == 'develop' }}
if: ${{ inputs.release_tag == 'develop' }}
with:
token: ${{ secrets.gh_token }}
tag: ${{ inputs.release_tag }}
@@ -174,17 +177,23 @@ 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
uses: bahmutov/npm-install@v1.8.28
with:
working-directory: ErsatzTV/client-app
@@ -194,12 +203,12 @@ jobs:
- name: Install dependencies
run: dotnet restore -r "${{ matrix.target }}"
- uses: suisei-cn/actions-download-file@v1
- uses: suisei-cn/actions-download-file@v1.3.0
if: ${{ matrix.kind == 'windows' }}
id: downloadffmpeg
name: Download ffmpeg
with:
url: "https://github.com/GyanD/codexffmpeg/releases/download/5.1/ffmpeg-5.1-full_build.7z"
url: "https://github.com/GyanD/codexffmpeg/releases/download/6.0/ffmpeg-6.0-full_build.7z"
target: ffmpeg/
- name: Build
@@ -210,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
@@ -238,7 +251,7 @@ jobs:
- name: Delete old release assets
uses: mknejp/delete-release-assets@v1
if: ${{ inputs.release_version == 'develop' }}
if: ${{ inputs.release_tag == 'develop' }}
with:
token: ${{ secrets.gh_token }}
tag: ${{ inputs.release_tag }}
+70 -8
View File
@@ -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-11
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
+177 -1
View File
@@ -5,6 +5,176 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
## [0.7.5-beta] - 2023-03-05
### Added
- Use AV1 hardware-accelerated decoder with VAAPI, QSV, NVIDIA when available
- Use VP9 hardware-accelerated decoder with VAAPI when available
### Fixed
- Align default docker image (no acceleration) with new images from [ErsatzTV-ffmpeg](https://github.com/jasongdove/ErsatzTV-ffmpeg)
- Fix some transcoding pipelines that use software decoders
- Improve VAAPI encoder capability detection on newer hardware
- Fix trash page to properly display episodes with missing metadata or titles
- Fix playback of content with yuv444p10le pixel format
- Fix case where some multi-episode files from Plex would crash the scanner
### Changed
- Upgrade all docker images and windows builds to ffmpeg 6.0
- Plex, Jellyfin and Emby libraries now retrieve all metadata and statistics from the media server
- File systems will no longer be periodically scanned for libraries using these media sources
- Plex, Jellyfin and Emby libraries now direct stream content when files are not found on ErsatzTV's file system
- Content will still be normalized according to the Channel and FFmpeg Profile settings
- Streaming from disk is preferred, so every playback attempt will first check the local file system
- Use libvpl instead of libmfx to provide intel acceleration in vaapi docker images
- Search queries no longer remove duplicate results as this was causing incorrect behavior
- Prioritize audio streams that are flagged as "default" over number of audio channels
- For example, a video with a stereo commentary track and a mono "default" track will now prefer the "default" track
- Support many more season folder names with local television libraries
## [0.7.4-beta] - 2023-02-12
### Added
- Add button to copy/clone schedule from schedules table
- Synchronize episode tags and genres from Jellyfin, Emby and Local show libraries
- Add `Deep Scan` button to Jellyfin and Emby libraries
- This is now required to update some metadata for existing libraries, when targeted updates are not possible
- For example, if you already have tags and genres on your episodes in Jellyfin or Emby, you will need to deep scan each library to update that metadata on existing items in ErsatzTV
### Fixed
- Fix many QSV pipeline bugs
- Fix MPEG2 video format with QSV and VAAPI acceleration
- Fix playback of content with undefined colorspace
- Fix NVIDIA color normalization with VP9 sources
- Fix fallback filler looping
- Fix bug where some libraries would never scan
- Fix filler ordering so post-roll is properly scheduled after padded mid-roll
- Fix pre/post-roll filler padding when used with mid-roll
- This caused overlapping schedule items, fallback filler that was too long, etc.
### Changed
- Merge generated `Other Video` folder tags with tags from sidecar NFO
- Prioritize audio streams that are flagged as "default" when multiple candidate streams are available
- For example, a video with a stereo commentary track and a stereo "default" track will now prefer the "default" track
## [0.7.3-beta] - 2023-01-25
### Added
- Attempt to release memory periodically
- Add OpenID Connect (OIDC) support (e.g. Keycloak, Authelia, Auth0)
- This only protects the management UI; all streaming endpoints will continue to allow anonymous access
- This can be configured with the following env vars (note the double underscore separator `__`)
- `OIDC__AUTHORITY`
- `OIDC__CLIENTID`
- `OIDC__CLIENTSECRET`
- `OIDC__LOGOUTURI` (optional, needed for Auth0, use `https://{auth0-domain}/v2/logout?client_id={auth0-client-id}` with proper values for domain and client-id)
- Add *experimental* alternate schedule system
- This allows a single playout to dynamically select a schedule based on date criteria, for example:
- Weekday vs weekend schedules
- Summer vs fall schedules
- Shark week schedules
- Alternate schedules can be managed by clicking the calendar icon in the playout list
- Playouts contain a prioritized (top to bottom) list of alternate schedules
- Whenever a playout is built for a given day, ErsatzTV will check for a matching schedule from top to bottom
- A given day must match all alternate schedule parameters; wildcards (`*any*`) will always match
- Day of week
- Day of month
- Month
- The lowest priority (bottom) item will always match all parameters, and can be considered a "default" or "fallback" schedule
### Fixed
- Fix schedule editor crashing due to bad music video artist data
- Fix bug where playouts would not maintain smart collection progress on schedules that use multiple smart collections
- Fix library scanning on osx-arm64
- Fix ability to remove some media server libraries from ErsatzTV
### Changed
- Always use software pipeline for error display
- This ensures errors will display even when hardware acceleration is misconfigured
- Call scanner process only when scanning is required based on library refresh interval
- Use lower process priority for scanner process with unforced (automatic) library scans
- Disable V2 UI and APIs by default
- V2 UI can be re-enabled by setting the env var `ETV_UI_V2` to any value
## [0.7.2-beta] - 2023-01-05
### Fixed
- Fix VAAPI encoding in docker by switching to non-free driver
### Changed
- Rewrite log page to read directly from log files instead of sqlite
## [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
@@ -1381,7 +1551,13 @@ 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.9-beta...HEAD
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.7.5-beta...HEAD
[0.7.5-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.7.4-beta...v0.7.5-beta
[0.7.4-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.7.3-beta...v0.7.4-beta
[0.7.3-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.7.2-beta...v0.7.3-beta
[0.7.2-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.7.1-beta...v0.7.2-beta
[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
+2
View File
@@ -0,0 +1,2 @@
target/
+1028
View File
File diff suppressed because it is too large Load Diff
+19
View 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 = "*"
-33
View File
@@ -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>
-14
View File
@@ -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
View File
@@ -0,0 +1,5 @@
use windres::Build;
fn main() {
Build::new().compile("ersatztv_windows.rc").unwrap();
}
+2
View File
@@ -0,0 +1,2 @@
id ICON "ersatztv.ico"
ersatztv-icon ICON "ersatztv.ico"
+112
View 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;
}
_ => {}
}
}
}
@@ -1,22 +1,44 @@
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.MediaItems.Mapper;
namespace ErsatzTV.Application.Artists;
public class GetAllArtistsHandler : IRequestHandler<GetAllArtists, List<NamedMediaItemViewModel>>
{
private readonly IArtistRepository _artistRepository;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public GetAllArtistsHandler(IArtistRepository artistRepository) => _artistRepository = artistRepository;
public GetAllArtistsHandler(IDbContextFactory<TvContext> dbContextFactory)
{
_dbContextFactory = dbContextFactory;
}
public Task<List<NamedMediaItemViewModel>> Handle(
public async Task<List<NamedMediaItemViewModel>> Handle(
GetAllArtists request,
CancellationToken cancellationToken) =>
_artistRepository.GetAllArtists()
.Map(
list => list.Filter(
a => !string.IsNullOrWhiteSpace(
a.ArtistMetadata.HeadOrNone().Match(am => am.Title, () => string.Empty))))
.Map(list => list.Map(ProjectToViewModel).ToList());
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
List<Artist> allArtists = await dbContext.Artists
.AsNoTracking()
.Include(a => a.ArtistMetadata)
.ToListAsync(cancellationToken: cancellationToken);
return allArtists.Bind(a => ProjectArtist(a)).ToList();
}
private static Option<NamedMediaItemViewModel> ProjectArtist(Artist a)
{
foreach (ArtistMetadata metadata in a.ArtistMetadata.HeadOrNone())
{
if (!string.IsNullOrWhiteSpace(metadata.Title))
{
return ProjectToViewModel(a);
}
}
return None;
}
}
@@ -15,13 +15,13 @@ namespace ErsatzTV.Application.Channels;
public class UpdateChannelHandler : IRequestHandler<UpdateChannel, Either<BaseError, ChannelViewModel>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ChannelWriter<ISubtitleWorkerRequest> _ffmpegWorkerChannel;
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
public UpdateChannelHandler(
ChannelWriter<ISubtitleWorkerRequest> ffmpegWorkerChannel,
ChannelWriter<IBackgroundServiceRequest> workerChannel,
IDbContextFactory<TvContext> dbContextFactory)
{
_ffmpegWorkerChannel = ffmpegWorkerChannel;
_workerChannel = workerChannel;
_dbContextFactory = dbContextFactory;
}
@@ -85,7 +85,7 @@ public class UpdateChannelHandler : IRequestHandler<UpdateChannel, Either<BaseEr
foreach (Playout playout in maybePlayout)
{
await _ffmpegWorkerChannel.WriteAsync(new ExtractEmbeddedSubtitles(playout.Id));
await _workerChannel.WriteAsync(new ExtractEmbeddedSubtitles(playout.Id));
}
}
@@ -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));
}
@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Channels;
public record GetChannelNameByPlayoutId(int PlayoutId) : IRequest<Option<string>>;
@@ -0,0 +1,24 @@
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Channels;
public class GetChannelNameByPlayoutIdHandler : IRequestHandler<GetChannelNameByPlayoutId, Option<string>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public GetChannelNameByPlayoutIdHandler(IDbContextFactory<TvContext> dbContextFactory)
{
_dbContextFactory = dbContextFactory;
}
public async Task<Option<string>> Handle(GetChannelNameByPlayoutId request, CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
return await dbContext.Playouts
.Include(p => p.Channel)
.SelectOneAsync(p => p.Id, p => p.Id == request.PlayoutId)
.MapT(p => p.Channel.Name);
}
}
@@ -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)
{
@@ -2,4 +2,4 @@
namespace ErsatzTV.Application.Configuration;
public record SaveConfigElementByKey(ConfigElementKey Key, string Value) : IRequest<Unit>;
public record SaveConfigElementByKey(ConfigElementKey Key, string Value) : IRequest;
@@ -2,16 +2,15 @@
namespace ErsatzTV.Application.Configuration;
public class SaveConfigElementByKeyHandler : IRequestHandler<SaveConfigElementByKey, Unit>
public class SaveConfigElementByKeyHandler : IRequestHandler<SaveConfigElementByKey>
{
private readonly IConfigElementRepository _configElementRepository;
public SaveConfigElementByKeyHandler(IConfigElementRepository configElementRepository) =>
_configElementRepository = configElementRepository;
public async Task<Unit> Handle(SaveConfigElementByKey request, CancellationToken cancellationToken)
public async Task Handle(SaveConfigElementByKey request, CancellationToken cancellationToken)
{
await _configElementRepository.Upsert(request.Key, request.Value);
return Unit.Default;
}
}
@@ -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,98 @@
using System.Threading.Channels;
using ErsatzTV.Application.Libraries;
using ErsatzTV.Core;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.FFmpeg.Runtime;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Emby;
public class CallEmbyLibraryScannerHandler : CallLibraryScannerHandler<ISynchronizeEmbyLibraryById>,
IRequestHandler<ForceSynchronizeEmbyLibraryById, Either<BaseError, string>>,
IRequestHandler<SynchronizeEmbyLibraryByIdIfNeeded, Either<BaseError, string>>
{
public CallEmbyLibraryScannerHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository,
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
IMediator mediator,
IRuntimeInfo runtimeInfo)
: base(dbContextFactory, configElementRepository, 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 = await Validate(request);
return await validation.Match(
scanner => PerformScan(scanner, request, cancellationToken),
error =>
{
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
{
return Task.FromResult<Either<BaseError, string>>(scanIsNotRequired);
}
return 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");
}
if (request.DeepScan)
{
arguments.Add("--deep");
}
return await base.PerformScan(scanner, arguments, cancellationToken);
}
protected override async Task<DateTimeOffset> GetLastScan(
TvContext dbContext,
ISynchronizeEmbyLibraryById request)
{
return await dbContext.EmbyLibraries
.SelectOneAsync(l => l.Id, l => l.Id == request.EmbyLibraryId)
.Match(l => l.LastScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
}
protected override bool ScanIsRequired(
DateTimeOffset lastScan,
int libraryRefreshInterval,
ISynchronizeEmbyLibraryById request)
{
if (lastScan == SystemTime.MaxValueUtc)
{
return false;
}
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(libraryRefreshInterval);
return request.ForceScan || (libraryRefreshInterval > 0 && nextScan < DateTimeOffset.Now);
}
}
@@ -1,6 +0,0 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Emby;
public record SynchronizeEmbyCollections(int EmbyMediaSourceId) : IRequest<Either<BaseError, Unit>>,
IEmbyBackgroundServiceRequest;
@@ -2,19 +2,20 @@
namespace ErsatzTV.Application.Emby;
public interface ISynchronizeEmbyLibraryById : IRequest<Either<BaseError, string>>,
IEmbyBackgroundServiceRequest
public interface ISynchronizeEmbyLibraryById : IRequest<Either<BaseError, string>>, IEmbyBackgroundServiceRequest
{
int EmbyLibraryId { get; }
bool ForceScan { get; }
bool DeepScan { get; }
}
public record SynchronizeEmbyLibraryByIdIfNeeded(int EmbyLibraryId) : ISynchronizeEmbyLibraryById
{
public bool ForceScan => false;
public bool DeepScan => false;
}
public record ForceSynchronizeEmbyLibraryById(int EmbyLibraryId) : ISynchronizeEmbyLibraryById
public record ForceSynchronizeEmbyLibraryById(int EmbyLibraryId, bool DeepScan) : ISynchronizeEmbyLibraryById
{
public bool ForceScan => true;
}
@@ -1,3 +1,3 @@
namespace ErsatzTV.Application.Emby;
public record EmbyConnectionParametersViewModel(string Address);
public record EmbyConnectionParametersViewModel(string Address, string ApiKey);
@@ -1,5 +1,7 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Interfaces.Emby;
using ErsatzTV.Core.Interfaces.Repositories;
using Microsoft.Extensions.Caching.Memory;
@@ -9,14 +11,17 @@ public class GetEmbyConnectionParametersHandler : IRequestHandler<GetEmbyConnect
Either<BaseError, EmbyConnectionParametersViewModel>>
{
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IEmbySecretStore _embySecretStore;
private readonly IMemoryCache _memoryCache;
public GetEmbyConnectionParametersHandler(
IMemoryCache memoryCache,
IMediaSourceRepository mediaSourceRepository)
IMediaSourceRepository mediaSourceRepository,
IEmbySecretStore embySecretStore)
{
_memoryCache = memoryCache;
_mediaSourceRepository = mediaSourceRepository;
_embySecretStore = embySecretStore;
}
public async Task<Either<BaseError, EmbyConnectionParametersViewModel>> Handle(
@@ -30,7 +35,7 @@ public class GetEmbyConnectionParametersHandler : IRequestHandler<GetEmbyConnect
Either<BaseError, EmbyConnectionParametersViewModel> maybeParameters =
await Validate()
.MapT(cp => new EmbyConnectionParametersViewModel(cp.ActiveConnection.Address))
.MapT(cp => new EmbyConnectionParametersViewModel(cp.ActiveConnection.Address, cp.ApiKey))
.Map(v => v.ToEither<EmbyConnectionParametersViewModel>());
return maybeParameters.Match(
@@ -44,7 +49,8 @@ public class GetEmbyConnectionParametersHandler : IRequestHandler<GetEmbyConnect
private Task<Validation<BaseError, ConnectionParameters>> Validate() =>
EmbyMediaSourceMustExist()
.BindT(MediaSourceMustHaveActiveConnection);
.BindT(MediaSourceMustHaveActiveConnection)
.BindT(MediaSourceMustHaveApiKey);
private Task<Validation<BaseError, EmbyMediaSource>> EmbyMediaSourceMustExist() =>
_mediaSourceRepository.GetAllEmby().Map(list => list.HeadOrNone())
@@ -59,8 +65,21 @@ public class GetEmbyConnectionParametersHandler : IRequestHandler<GetEmbyConnect
return maybeConnection.Map(connection => new ConnectionParameters(embyMediaSource, connection))
.ToValidation<BaseError>("Emby media source requires an active connection");
}
private async Task<Validation<BaseError, ConnectionParameters>> MediaSourceMustHaveApiKey(
ConnectionParameters connectionParameters)
{
EmbySecrets secrets = await _embySecretStore.ReadSecrets();
return Optional(secrets.Address == connectionParameters.ActiveConnection.Address)
.Where(match => match)
.Map(_ => connectionParameters with { ApiKey = secrets.ApiKey })
.ToValidation<BaseError>("Emby media source requires an api key");
}
private record ConnectionParameters(
EmbyMediaSource EmbyMediaSource,
EmbyConnection ActiveConnection);
EmbyConnection ActiveConnection)
{
public string ApiKey { get; set; }
}
}
@@ -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="12.0.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="7.0.0" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.5.22">
<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>
@@ -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,12 @@ public class
p.QsvExtraHardwareFrames = update.QsvExtraHardwareFrames;
p.ResolutionId = update.ResolutionId;
p.VideoFormat = update.VideoFormat;
// mpeg2video only supports 8-bit content
p.BitDepth = update.VideoFormat == FFmpegProfileVideoFormat.Mpeg2Video
? FFmpegProfileBitDepth.EightBit
: update.BitDepth;
p.VideoBitrate = update.VideoBitrate;
p.VideoBufferSize = update.VideoBufferSize;
p.AudioFormat = update.AudioFormat;
@@ -52,7 +52,7 @@ public class UpdateFFmpegSettingsHandler : IRequestHandler<UpdateFFmpegSettings,
UseShellExecute = false
};
var test = new Process
using var test = new Process
{
StartInfo = startInfo
};
@@ -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
{
}
@@ -1,5 +0,0 @@
namespace ErsatzTV.Application;
public interface ISubtitleWorkerRequest
{
}
@@ -0,0 +1,98 @@
using System.Threading.Channels;
using ErsatzTV.Application.Libraries;
using ErsatzTV.Core;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.FFmpeg.Runtime;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Jellyfin;
public class CallJellyfinLibraryScannerHandler : CallLibraryScannerHandler<ISynchronizeJellyfinLibraryById>,
IRequestHandler<ForceSynchronizeJellyfinLibraryById, Either<BaseError, string>>,
IRequestHandler<SynchronizeJellyfinLibraryByIdIfNeeded, Either<BaseError, string>>
{
public CallJellyfinLibraryScannerHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository,
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
IMediator mediator,
IRuntimeInfo runtimeInfo)
: base(dbContextFactory, configElementRepository, 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 = await Validate(request);
return await validation.Match(
scanner => PerformScan(scanner, request, cancellationToken),
error =>
{
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
{
return Task.FromResult<Either<BaseError, string>>(scanIsNotRequired);
}
return 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");
}
if (request.DeepScan)
{
arguments.Add("--deep");
}
return await base.PerformScan(scanner, arguments, cancellationToken);
}
protected override async Task<DateTimeOffset> GetLastScan(
TvContext dbContext,
ISynchronizeJellyfinLibraryById request)
{
return await dbContext.JellyfinLibraries
.SelectOneAsync(l => l.Id, l => l.Id == request.JellyfinLibraryId)
.Match(l => l.LastScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
}
protected override bool ScanIsRequired(
DateTimeOffset lastScan,
int libraryRefreshInterval,
ISynchronizeJellyfinLibraryById request)
{
if (lastScan == SystemTime.MaxValueUtc)
{
return false;
}
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(libraryRefreshInterval);
return request.ForceScan || (libraryRefreshInterval > 0 && nextScan < DateTimeOffset.Now);
}
}
@@ -1,6 +0,0 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Jellyfin;
public record SynchronizeJellyfinCollections(int JellyfinMediaSourceId) : IRequest<Either<BaseError, Unit>>,
IJellyfinBackgroundServiceRequest;
@@ -7,14 +7,17 @@ public interface ISynchronizeJellyfinLibraryById : IRequest<Either<BaseError, st
{
int JellyfinLibraryId { get; }
bool ForceScan { get; }
bool DeepScan { get; }
}
public record SynchronizeJellyfinLibraryByIdIfNeeded(int JellyfinLibraryId) : ISynchronizeJellyfinLibraryById
{
public bool ForceScan => false;
public bool DeepScan => false;
}
public record ForceSynchronizeJellyfinLibraryById(int JellyfinLibraryId) : ISynchronizeJellyfinLibraryById
public record ForceSynchronizeJellyfinLibraryById
(int JellyfinLibraryId, bool DeepScan) : ISynchronizeJellyfinLibraryById
{
public bool ForceScan => true;
}
@@ -0,0 +1,180 @@
using System.Runtime.InteropServices;
using System.Threading.Channels;
using CliWrap;
using ErsatzTV.Application.Search;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.MediaSources;
using ErsatzTV.Core.Metadata;
using ErsatzTV.FFmpeg.Runtime;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json;
using Serilog;
using Serilog.Events;
using Serilog.Formatting.Compact.Reader;
namespace ErsatzTV.Application.Libraries;
public abstract class CallLibraryScannerHandler<TRequest>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IConfigElementRepository _configElementRepository;
private readonly ChannelWriter<ISearchIndexBackgroundServiceRequest> _channel;
private readonly IMediator _mediator;
private readonly IRuntimeInfo _runtimeInfo;
private string _libraryName;
protected CallLibraryScannerHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository,
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
IMediator mediator,
IRuntimeInfo runtimeInfo)
{
_dbContextFactory = dbContextFactory;
_configElementRepository = configElementRepository;
_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
{
// make a new log event to force using local time
// because the compact json writer used by the scanner
// writes in UTC
LogEvent logEvent = LogEventReader.ReadFromString(s);
Log.Write(
new LogEvent(
logEvent.Timestamp.ToLocalTime(),
logEvent.Level,
logEvent.Exception,
logEvent.MessageTemplate,
logEvent.Properties.Map(pair => new LogEventProperty(pair.Key, pair.Value))));
}
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 abstract Task<DateTimeOffset> GetLastScan(TvContext dbContext, TRequest request);
protected abstract bool ScanIsRequired(DateTimeOffset lastScan, int libraryRefreshInterval, TRequest request);
protected async Task<Validation<BaseError, string>> Validate(TRequest request)
{
int libraryRefreshInterval = await _configElementRepository
.GetValue<int>(ConfigElementKey.LibraryRefreshInterval)
.IfNoneAsync(0);
libraryRefreshInterval = Math.Clamp(libraryRefreshInterval, 0, 999_999);
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
DateTimeOffset lastScan = await GetLastScan(dbContext, request);
if (!ScanIsRequired(lastScan, libraryRefreshInterval, request))
{
return new ScanIsNotRequired();
}
string executable = _runtimeInfo.IsOSPlatform(OSPlatform.Windows)
? "ErsatzTV.Scanner.exe"
: "ErsatzTV.Scanner";
string processFileName = Environment.ProcessPath ?? 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");
}
}
@@ -3,8 +3,6 @@
namespace ErsatzTV.Application.Logs;
public record LogEntryViewModel(
int Id,
DateTime Timestamp,
DateTimeOffset Timestamp,
LogEventLevel Level,
string Exception,
string Message);
+18 -29
View File
@@ -1,42 +1,31 @@
using ErsatzTV.Core.Domain;
using Newtonsoft.Json.Linq;
using System.Text.RegularExpressions;
using Serilog.Events;
namespace ErsatzTV.Application.Logs;
internal static class Mapper
internal partial class Mapper
{
internal static LogEntryViewModel ProjectToViewModel(LogEntry logEntry)
[GeneratedRegex(@"(.*)\[(DBG|INF|WRN|ERR|FTL)\](.*)")]
private static partial Regex LogEntryRegex();
internal static Option<LogEntryViewModel> ProjectToViewModel(string line)
{
string message = logEntry.RenderedMessage;
if (!string.IsNullOrWhiteSpace(logEntry.Properties))
Match match = LogEntryRegex().Match(line);
if (!match.Success)
{
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());
}
}
return None;
}
if (!Enum.TryParse(logEntry.Level, out LogEventLevel level))
var timestamp = DateTimeOffset.Parse(match.Groups[1].Value);
LogEventLevel level = match.Groups[2].Value switch
{
level = LogEventLevel.Debug;
}
"FTL" => LogEventLevel.Fatal,
"ERR" => LogEventLevel.Error,
"WRN" => LogEventLevel.Warning,
"INF" => LogEventLevel.Information,
_ => LogEventLevel.Debug
};
return new LogEntryViewModel(
logEntry.Id,
logEntry.Timestamp,
level,
logEntry.Exception,
message);
return new LogEntryViewModel(timestamp, level, match.Groups[3].Value);
}
}
@@ -1,10 +1,9 @@
using System.Linq.Expressions;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Logs;
public record GetRecentLogEntries(int PageNum, int PageSize) : IRequest<PagedLogEntriesViewModel>
public record GetRecentLogEntries(int PageNum, int PageSize, string Filter) : IRequest<PagedLogEntriesViewModel>
{
public Expression<Func<LogEntry, object>> SortExpression { get; set; }
public Option<bool> SortDescending { get; set; }
public Expression<Func<LogEntryViewModel, object>> SortExpression { get; init; }
public Option<bool> SortDescending { get; init; }
}
@@ -1,40 +1,66 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Metadata;
using static ErsatzTV.Application.Logs.Mapper;
namespace ErsatzTV.Application.Logs;
public class GetRecentLogEntriesHandler : IRequestHandler<GetRecentLogEntries, PagedLogEntriesViewModel>
{
private readonly IDbContextFactory<LogContext> _dbContextFactory;
private readonly ILocalFileSystem _localFileSystem;
public GetRecentLogEntriesHandler(IDbContextFactory<LogContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public GetRecentLogEntriesHandler(ILocalFileSystem localFileSystem)
{
_localFileSystem = localFileSystem;
}
public async Task<PagedLogEntriesViewModel> Handle(
public Task<PagedLogEntriesViewModel> Handle(
GetRecentLogEntries request,
CancellationToken cancellationToken)
{
await using LogContext logContext = _dbContextFactory.CreateDbContext();
int count = await logContext.LogEntries.CountAsync(cancellationToken);
// get most recent file
string logFileName = _localFileSystem.ListFiles(FileSystemLayout.LogsFolder)
.OrderDescending()
.FirstOrDefault();
IOrderedQueryable<LogEntry> ordered = logContext.LogEntries
.OrderByDescending(le => le.Id);
foreach (bool descending in request.SortDescending)
if (logFileName is not null)
{
ordered = descending
? logContext.LogEntries.OrderByDescending(request.SortExpression).ThenByDescending(le => le.Id)
: logContext.LogEntries.OrderBy(request.SortExpression).ThenByDescending(le => le.Id);
IQueryable<LogEntryViewModel> entries = ReadFrom(logFileName)
.Bind(line => ProjectToViewModel(line))
.AsQueryable();
if (!string.IsNullOrWhiteSpace(request.Filter))
{
entries = entries.Filter(
le => le.Level.ToString().Contains(request.Filter, StringComparison.OrdinalIgnoreCase) ||
le.Message.Contains(request.Filter, StringComparison.OrdinalIgnoreCase));
}
int count = entries.Count();
IOrderedQueryable<LogEntryViewModel> ordered = request.SortDescending.Match(
descending => descending
? entries.OrderByDescending(request.SortExpression).ThenByDescending(le => le.Timestamp)
: entries.OrderBy(request.SortExpression).ThenByDescending(le => le.Timestamp),
() => entries.OrderByDescending(le => le.Timestamp));
var page = ordered
.Skip(request.PageNum * request.PageSize)
.Take(request.PageSize)
.ToList();
return new PagedLogEntriesViewModel(count, page).AsTask();
}
List<LogEntryViewModel> page = await ordered
.Skip(request.PageNum * request.PageSize)
.Take(request.PageSize)
.ToListAsync(cancellationToken)
.Map(list => list.Map(ProjectToViewModel).ToList());
return new PagedLogEntriesViewModel(0, new List<LogEntryViewModel>()).AsTask();
}
return new PagedLogEntriesViewModel(count, page);
private static IEnumerable<string> ReadFrom(string file)
{
using FileStream fs = File.Open(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
using var reader = new StreamReader(fs);
while (reader.ReadLine() is { } line)
{
yield return line;
}
}
}
@@ -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,6 @@
namespace ErsatzTV.Application.Maintenance;
public record ReleaseMemory(bool ForceAggressive) : IRequest, IBackgroundServiceRequest
{
public DateTimeOffset RequestTime = DateTimeOffset.Now;
}
@@ -0,0 +1,50 @@
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.FFmpeg;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Maintenance;
public class ReleaseMemoryHandler : IRequestHandler<ReleaseMemory>
{
private static long _lastRelease;
private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
private readonly ILogger<ReleaseMemoryHandler> _logger;
public ReleaseMemoryHandler(
IFFmpegSegmenterService ffmpegSegmenterService,
ILogger<ReleaseMemoryHandler> logger)
{
_ffmpegSegmenterService = ffmpegSegmenterService;
_logger = logger;
}
public Task Handle(ReleaseMemory request, CancellationToken cancellationToken)
{
if (!request.ForceAggressive && _lastRelease > request.RequestTime.Ticks)
{
// we've already released since the request was created, so don't bother
return Task.CompletedTask;
}
bool hasActiveWorkers = _ffmpegSegmenterService.SessionWorkers.Any() || FFmpegProcess.ProcessCount > 0;
if (request.ForceAggressive || !hasActiveWorkers)
{
_logger.LogDebug("Starting aggressive garbage collection");
GC.Collect(2, GCCollectionMode.Aggressive, blocking: true, compacting: true);
}
else
{
_logger.LogDebug("Starting garbage collection");
GC.Collect(2, GCCollectionMode.Forced, blocking: false);
}
GC.WaitForPendingFinalizers();
GC.Collect();
_logger.LogDebug("Completed garbage collection");
Interlocked.Exchange(ref _lastRelease, DateTimeOffset.Now.Ticks);
return Task.CompletedTask;
}
}
@@ -0,0 +1,94 @@
using System.Threading.Channels;
using ErsatzTV.Application.Libraries;
using ErsatzTV.Core;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.FFmpeg.Runtime;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.MediaSources;
public class CallLocalLibraryScannerHandler : CallLibraryScannerHandler<IScanLocalLibrary>,
IRequestHandler<ForceScanLocalLibrary, Either<BaseError, string>>,
IRequestHandler<ScanLocalLibraryIfNeeded, Either<BaseError, string>>
{
public CallLocalLibraryScannerHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository,
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
IMediator mediator,
IRuntimeInfo runtimeInfo)
: base(dbContextFactory, configElementRepository, 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 = await Validate(request);
return await validation.Match(
scanner => PerformScan(scanner, request, cancellationToken),
error =>
{
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
{
return Task.FromResult<Either<BaseError, string>>(scanIsNotRequired);
}
return 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);
}
protected override async Task<DateTimeOffset> GetLastScan(TvContext dbContext, IScanLocalLibrary request)
{
var libraryPaths = await dbContext.LibraryPaths
.Filter(lp => lp.LibraryId == request.LibraryId)
.ToListAsync();
var minDateTime = libraryPaths.Any()
? libraryPaths.Min(lp => lp.LastScan ?? SystemTime.MinValueUtc)
: SystemTime.MaxValueUtc;
return new DateTimeOffset(minDateTime, TimeSpan.Zero);
}
protected override bool ScanIsRequired(
DateTimeOffset lastScan,
int libraryRefreshInterval,
IScanLocalLibrary request)
{
if (lastScan == SystemTime.MaxValueUtc)
{
return false;
}
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(libraryRefreshInterval);
return request.ForceScan || (libraryRefreshInterval > 0 && nextScan < DateTimeOffset.Now);
}
}
@@ -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");
@@ -17,7 +17,7 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
private readonly IClient _client;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
private readonly ChannelWriter<ISubtitleWorkerRequest> _ffmpegWorkerChannel;
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
private readonly IPlayoutBuilder _playoutBuilder;
public BuildPlayoutHandler(
@@ -25,13 +25,13 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
IDbContextFactory<TvContext> dbContextFactory,
IPlayoutBuilder playoutBuilder,
IFFmpegSegmenterService ffmpegSegmenterService,
ChannelWriter<ISubtitleWorkerRequest> ffmpegWorkerChannel)
ChannelWriter<IBackgroundServiceRequest> workerChannel)
{
_client = client;
_dbContextFactory = dbContextFactory;
_playoutBuilder = playoutBuilder;
_ffmpegSegmenterService = ffmpegSegmenterService;
_ffmpegWorkerChannel = ffmpegWorkerChannel;
_workerChannel = workerChannel;
}
public async Task<Either<BaseError, Unit>> Handle(BuildPlayout request, CancellationToken cancellationToken)
@@ -56,7 +56,7 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
_ffmpegSegmenterService.PlayoutUpdated(playout.Channel.Number);
}
await _ffmpegWorkerChannel.WriteAsync(new ExtractEmbeddedSubtitles(playout.Id));
await _workerChannel.WriteAsync(new ExtractEmbeddedSubtitles(playout.Id));
}
catch (Exception ex)
{
@@ -75,8 +75,41 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
dbContext.Playouts
.Include(p => p.Channel)
.Include(p => p.Items)
.Include(p => p.ProgramScheduleAlternates)
.ThenInclude(a => a.ProgramSchedule)
.ThenInclude(ps => ps.Items)
.ThenInclude(psi => psi.Collection)
.Include(p => p.ProgramScheduleAlternates)
.ThenInclude(a => a.ProgramSchedule)
.ThenInclude(ps => ps.Items)
.ThenInclude(psi => psi.MediaItem)
.Include(p => p.ProgramScheduleAlternates)
.ThenInclude(a => a.ProgramSchedule)
.ThenInclude(ps => ps.Items)
.ThenInclude(psi => psi.PreRollFiller)
.Include(p => p.ProgramScheduleAlternates)
.ThenInclude(a => a.ProgramSchedule)
.ThenInclude(ps => ps.Items)
.ThenInclude(psi => psi.MidRollFiller)
.Include(p => p.ProgramScheduleAlternates)
.ThenInclude(a => a.ProgramSchedule)
.ThenInclude(ps => ps.Items)
.ThenInclude(psi => psi.PostRollFiller)
.Include(p => p.ProgramScheduleAlternates)
.ThenInclude(a => a.ProgramSchedule)
.ThenInclude(ps => ps.Items)
.ThenInclude(psi => psi.TailFiller)
.Include(p => p.ProgramScheduleAlternates)
.ThenInclude(a => a.ProgramSchedule)
.ThenInclude(ps => ps.Items)
.ThenInclude(psi => psi.FallbackFiller)
.Include(p => p.ProgramScheduleAnchors)
.ThenInclude(psa => psa.EnumeratorState)
.Include(p => p.ProgramScheduleAnchors)
.ThenInclude(a => a.MediaItem)
.Include(p => p.ProgramSchedule)
.ThenInclude(ps => ps.Items)
.ThenInclude(psi => psi.Collection)
@@ -98,6 +131,7 @@ public class BuildPlayoutHandler : IRequestHandler<BuildPlayout, Either<BaseErro
.Include(p => p.ProgramSchedule)
.ThenInclude(ps => ps.Items)
.ThenInclude(psi => psi.FallbackFiller)
.SelectOneAsync(p => p.Id, p => p.Id == buildPlayout.PlayoutId)
.Map(o => o.ToValidation<BaseError>("Playout does not exist."));
}
@@ -0,0 +1,9 @@
namespace ErsatzTV.Application.Playouts;
public record ReplacePlayoutAlternateSchedule(
int Id,
int Index,
int ProgramScheduleId,
List<DayOfWeek> DaysOfWeek,
List<int> DaysOfMonth,
List<int> MonthsOfYear);
@@ -0,0 +1,6 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Playouts;
public record ReplacePlayoutAlternateScheduleItems
(int PlayoutId, List<ReplacePlayoutAlternateSchedule> Items) : IRequest<Either<BaseError, Unit>>;
@@ -0,0 +1,158 @@
using System.Threading.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Playouts;
public class ReplacePlayoutAlternateScheduleItemsHandler :
IRequestHandler<ReplacePlayoutAlternateScheduleItems, Either<BaseError, Unit>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly ILogger<ReplacePlayoutAlternateScheduleItemsHandler> _logger;
public ReplacePlayoutAlternateScheduleItemsHandler(
IDbContextFactory<TvContext> dbContextFactory,
ChannelWriter<IBackgroundServiceRequest> channel,
ILogger<ReplacePlayoutAlternateScheduleItemsHandler> logger)
{
_dbContextFactory = dbContextFactory;
_channel = channel;
_logger = logger;
}
public async Task<Either<BaseError, Unit>> Handle(
ReplacePlayoutAlternateScheduleItems request,
CancellationToken cancellationToken)
{
// TODO: validate that items is not empty
try
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<Playout> maybePlayout = await dbContext.Playouts
.Include(p => p.ProgramSchedule)
.Include(p => p.ProgramScheduleAlternates)
.ThenInclude(p => p.ProgramSchedule)
.SelectOneAsync(p => p.Id, p => p.Id == request.PlayoutId);
foreach (Playout playout in maybePlayout)
{
var existingScheduleMap = new Dictionary<DateTimeOffset, ProgramSchedule>();
var daysToCheck = new List<DateTimeOffset>();
Option<PlayoutItem> maybeLastPlayoutItem = await dbContext.PlayoutItems
.Filter(pi => pi.PlayoutId == request.PlayoutId)
.OrderByDescending(pi => pi.Start)
.FirstOrDefaultAsync(cancellationToken)
.Map(Optional);
foreach (PlayoutItem lastPlayoutItem in maybeLastPlayoutItem)
{
DateTimeOffset start = DateTimeOffset.Now;
daysToCheck = Enumerable.Range(0, (lastPlayoutItem.StartOffset - start).Days + 1)
.Select(d => start.AddDays(d))
.ToList();
foreach (DateTimeOffset dayToCheck in daysToCheck)
{
ProgramSchedule schedule = PlayoutScheduleSelector.GetProgramScheduleFor(
playout.ProgramSchedule,
playout.ProgramScheduleAlternates,
dayToCheck);
existingScheduleMap.Add(dayToCheck, schedule);
}
}
// exclude highest index
int maxIndex = request.Items.Map(x => x.Index).Max();
ReplacePlayoutAlternateSchedule highest = request.Items.First(x => x.Index == maxIndex);
ProgramScheduleAlternate[] existing = playout.ProgramScheduleAlternates.ToArray();
var incoming = request.Items.Except(new[] { highest }).ToList();
var toAdd = incoming.Filter(x => existing.All(e => e.Id != x.Id)).ToList();
var toRemove = existing.Filter(e => incoming.All(m => m.Id != e.Id)).ToList();
var toUpdate = incoming.Except(toAdd).ToList();
playout.ProgramScheduleAlternates.RemoveAll(toRemove.Contains);
foreach (ReplacePlayoutAlternateSchedule add in toAdd)
{
playout.ProgramScheduleAlternates.Add(
new ProgramScheduleAlternate
{
PlayoutId = playout.Id,
Index = add.Index,
ProgramScheduleId = add.ProgramScheduleId,
DaysOfWeek = add.DaysOfWeek,
DaysOfMonth = add.DaysOfMonth,
MonthsOfYear = add.MonthsOfYear
});
}
foreach (ReplacePlayoutAlternateSchedule update in toUpdate)
{
foreach (ProgramScheduleAlternate ex in existing.Filter(x => x.Id == update.Id))
{
ex.Index = update.Index;
ex.ProgramScheduleId = update.ProgramScheduleId;
ex.DaysOfWeek = update.DaysOfWeek;
ex.DaysOfMonth = update.DaysOfMonth;
ex.MonthsOfYear = update.MonthsOfYear;
}
}
// save highest index directly to playout
if (playout.ProgramScheduleId != highest.ProgramScheduleId)
{
playout.ProgramScheduleId = highest.ProgramScheduleId;
}
await dbContext.SaveChangesAsync(cancellationToken);
foreach (PlayoutItem _ in maybeLastPlayoutItem)
{
foreach (DateTimeOffset dayToCheck in daysToCheck)
{
ProgramSchedule schedule = PlayoutScheduleSelector.GetProgramScheduleFor(
playout.ProgramSchedule,
playout.ProgramScheduleAlternates,
dayToCheck);
if (existingScheduleMap.TryGetValue(dayToCheck, out ProgramSchedule existingValue) &&
existingValue.Id != schedule.Id)
{
_logger.LogInformation(
"Alternate schedule change detected for day {Day}, schedule {One} => {Two}; will refresh playout",
dayToCheck,
existingValue.Name,
schedule.Name);
await _channel.WriteAsync(
new BuildPlayout(request.PlayoutId, PlayoutBuildMode.Refresh),
cancellationToken);
break;
}
}
}
}
return Unit.Default;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error saving alternate schedule items");
return BaseError.New(ex.Message);
}
}
}
+10
View File
@@ -10,6 +10,16 @@ internal static class Mapper
playoutItem.StartOffset,
GetDisplayDuration(playoutItem.FinishOffset - playoutItem.StartOffset));
internal static PlayoutAlternateScheduleViewModel ProjectToViewModel(
ProgramScheduleAlternate programScheduleAlternate) =>
new(
programScheduleAlternate.Id,
programScheduleAlternate.Index,
programScheduleAlternate.ProgramScheduleId,
programScheduleAlternate.DaysOfWeek,
programScheduleAlternate.DaysOfMonth,
programScheduleAlternate.MonthsOfYear);
private static string GetDisplayTitle(PlayoutItem playoutItem)
{
switch (playoutItem.MediaItem)
@@ -0,0 +1,9 @@
namespace ErsatzTV.Application.Playouts;
public record PlayoutAlternateScheduleViewModel(
int Id,
int Index,
int ProgramScheduleId,
ICollection<DayOfWeek> DaysOfWeek,
ICollection<int> DaysOfMonth,
ICollection<int> MonthsOfYear);
@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Playouts;
public record GetPlayoutAlternateSchedules(int PlayoutId) : IRequest<List<PlayoutAlternateScheduleViewModel>>;
@@ -0,0 +1,53 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.Playouts.Mapper;
namespace ErsatzTV.Application.Playouts;
public class GetPlayoutAlternateSchedulesHandler :
IRequestHandler<GetPlayoutAlternateSchedules, List<PlayoutAlternateScheduleViewModel>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public GetPlayoutAlternateSchedulesHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<List<PlayoutAlternateScheduleViewModel>> Handle(
GetPlayoutAlternateSchedules request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
List<PlayoutAlternateScheduleViewModel> result = await dbContext.ProgramScheduleAlternates
.Filter(psa => psa.PlayoutId == request.PlayoutId)
.Include(psa => psa.ProgramSchedule)
.Map(psa => ProjectToViewModel(psa))
.ToListAsync(cancellationToken);
Option<ProgramSchedule> maybeDefaultSchedule = await dbContext.Playouts
.Include(p => p.ProgramSchedule)
.SelectOneAsync(p => p.Id, p => p.Id == request.PlayoutId)
.MapT(p => p.ProgramSchedule);
foreach (ProgramSchedule defaultSchedule in maybeDefaultSchedule)
{
var psa = new ProgramScheduleAlternate
{
Id = -1,
PlayoutId = request.PlayoutId,
ProgramScheduleId = defaultSchedule.Id,
ProgramSchedule = defaultSchedule,
Index = result.Map(i => i.Index).DefaultIfEmpty().Max() + 1,
DaysOfMonth = ProgramScheduleAlternate.AllDaysOfMonth(),
DaysOfWeek = ProgramScheduleAlternate.AllDaysOfWeek(),
MonthsOfYear = ProgramScheduleAlternate.AllMonthsOfYear()
};
result.Add(ProjectToViewModel(psa));
}
return result;
}
}
@@ -0,0 +1,98 @@
using System.Threading.Channels;
using ErsatzTV.Application.Libraries;
using ErsatzTV.Core;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.FFmpeg.Runtime;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Plex;
public class CallPlexLibraryScannerHandler : CallLibraryScannerHandler<ISynchronizePlexLibraryById>,
IRequestHandler<ForceSynchronizePlexLibraryById, Either<BaseError, string>>,
IRequestHandler<SynchronizePlexLibraryByIdIfNeeded, Either<BaseError, string>>
{
public CallPlexLibraryScannerHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository,
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
IMediator mediator,
IRuntimeInfo runtimeInfo)
: base(dbContextFactory, configElementRepository, 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 = await Validate(request);
return await validation.Match(
scanner => PerformScan(scanner, request, cancellationToken),
error =>
{
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
{
return Task.FromResult<Either<BaseError, string>>(scanIsNotRequired);
}
return 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);
}
protected override async Task<DateTimeOffset> GetLastScan(
TvContext dbContext,
ISynchronizePlexLibraryById request)
{
return await dbContext.PlexLibraries
.SelectOneAsync(l => l.Id, l => l.Id == request.PlexLibraryId)
.Match(l => l.LastScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
}
protected override bool ScanIsRequired(
DateTimeOffset lastScan,
int libraryRefreshInterval,
ISynchronizePlexLibraryById request)
{
if (lastScan == SystemTime.MaxValueUtc)
{
return false;
}
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(libraryRefreshInterval);
return request.ForceScan || (libraryRefreshInterval > 0 && nextScan < DateTimeOffset.Now);
}
}
@@ -0,0 +1,6 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.ProgramSchedules;
public record CopyProgramSchedule
(int ProgramScheduleId, string Name) : IRequest<Either<BaseError, ProgramScheduleViewModel>>;
@@ -0,0 +1,103 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
using static ErsatzTV.Application.ProgramSchedules.Mapper;
namespace ErsatzTV.Application.ProgramSchedules;
public class
CopyProgramScheduleHandler : IRequestHandler<CopyProgramSchedule, Either<BaseError, ProgramScheduleViewModel>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public CopyProgramScheduleHandler(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<Either<BaseError, ProgramScheduleViewModel>> Handle(
CopyProgramSchedule request,
CancellationToken cancellationToken)
{
try
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, ProgramSchedule> validation = await Validate(dbContext, request);
return await validation.Apply(p => PerformCopy(dbContext, p, request, cancellationToken));
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
}
}
private async Task<ProgramScheduleViewModel> PerformCopy(
TvContext dbContext,
ProgramSchedule schedule,
CopyProgramSchedule request,
CancellationToken cancellationToken)
{
DetachEntity(dbContext, schedule);
schedule.Name = request.Name;
// no playouts, no alternates
schedule.Playouts = new List<Playout>();
schedule.ProgramScheduleAlternates = new List<ProgramScheduleAlternate>();
foreach (ProgramScheduleItem item in schedule.Items)
{
DetachEntity(dbContext, item);
item.ProgramScheduleId = 0;
item.ProgramSchedule = schedule;
}
await dbContext.ProgramSchedules.AddAsync(schedule, cancellationToken);
await dbContext.ProgramScheduleItems.AddRangeAsync(schedule.Items, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
return ProjectToViewModel(schedule);
}
private static async Task<Validation<BaseError, ProgramSchedule>> Validate(
TvContext dbContext,
CopyProgramSchedule request) =>
(await ScheduleMustExist(dbContext, request), await ValidateName(dbContext, request))
.Apply((programSchedule, _) => programSchedule);
private static Task<Validation<BaseError, ProgramSchedule>> ScheduleMustExist(
TvContext dbContext,
CopyProgramSchedule request) =>
dbContext.ProgramSchedules
.AsNoTracking()
.Include(ps => ps.Items)
.SelectOneAsync(p => p.Id, p => p.Id == request.ProgramScheduleId)
.Map(o => o.ToValidation<BaseError>("Schedule does not exist."));
private static async Task<Validation<BaseError, string>> ValidateName(
TvContext dbContext,
CopyProgramSchedule request)
{
List<string> allNames = await dbContext.ProgramSchedules
.Map(ps => ps.Name)
.ToListAsync();
Validation<BaseError, string> result1 = request.NotEmpty(c => c.Name)
.Bind(_ => request.NotLongerThan(50)(c => c.Name));
var result2 = Optional(request.Name)
.Where(name => !allNames.Contains(name))
.ToValidation<BaseError>("Schedule name must be unique");
return (result1, result2).Apply((_, _) => request.Name);
}
private static void DetachEntity<T>(DbContext db, T entity) where T : class
{
db.Entry(entity).State = EntityState.Detached;
if (entity.GetType().GetProperty("Id") is not null)
{
entity.GetType().GetProperty("Id")!.SetValue(entity, 0);
}
}
}
@@ -17,10 +17,10 @@ public class CreateProgramScheduleHandler :
CreateProgramSchedule request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, ProgramSchedule> validation = await Validate(dbContext, request);
return await LanguageExtensions.Apply(validation, ps => PersistProgramSchedule(dbContext, ps));
return await validation.Apply(ps => PersistProgramSchedule(dbContext, ps));
}
private static async Task<CreateProgramScheduleResult> PersistProgramSchedule(
@@ -1,3 +1,3 @@
namespace ErsatzTV.Application.Search;
public record RebuildSearchIndex : IRequest<Unit>, IBackgroundServiceRequest;
public record RebuildSearchIndex : IRequest, IBackgroundServiceRequest;
@@ -10,7 +10,7 @@ using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Search;
public class RebuildSearchIndexHandler : IRequestHandler<RebuildSearchIndex, Unit>
public class RebuildSearchIndexHandler : IRequestHandler<RebuildSearchIndex>
{
private readonly IConfigElementRepository _configElementRepository;
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
@@ -35,7 +35,7 @@ public class RebuildSearchIndexHandler : IRequestHandler<RebuildSearchIndex, Uni
_fallbackMetadataProvider = fallbackMetadataProvider;
}
public async Task<Unit> Handle(RebuildSearchIndex request, CancellationToken cancellationToken)
public async Task Handle(RebuildSearchIndex request, CancellationToken cancellationToken)
{
_logger.LogInformation("Initializing search index");
@@ -63,7 +63,5 @@ public class RebuildSearchIndexHandler : IRequestHandler<RebuildSearchIndex, Uni
{
_logger.LogInformation("Search index is already version {Version}", _searchIndex.Version);
}
return Unit.Default;
}
}
@@ -0,0 +1,4 @@
namespace ErsatzTV.Application.Search;
public record ReindexMediaItems(IReadOnlyCollection<int> MediaItemIds) : IRequest,
ISearchIndexBackgroundServiceRequest;
@@ -0,0 +1,28 @@
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>
{
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 Handle(ReindexMediaItems request, CancellationToken cancellationToken)
{
await _searchIndex.RebuildItems(_cachingSearchRepository, _fallbackMetadataProvider, request.MediaItemIds);
_searchIndex.Commit();
}
}
@@ -0,0 +1,4 @@
namespace ErsatzTV.Application.Search;
public record RemoveMediaItems(IReadOnlyCollection<int> MediaItemIds) : IRequest,
ISearchIndexBackgroundServiceRequest;
@@ -0,0 +1,16 @@
using ErsatzTV.Core.Interfaces.Search;
namespace ErsatzTV.Application.Search;
public class RemoveMediaItemsHandler : IRequestHandler<RemoveMediaItems>
{
private readonly ISearchIndex _searchIndex;
public RemoveMediaItemsHandler(ISearchIndex searchIndex) => _searchIndex = searchIndex;
public async Task Handle(RemoveMediaItems request, CancellationToken cancellationToken)
{
await _searchIndex.RemoveItems(request.MediaItemIds);
_searchIndex.Commit();
}
}
@@ -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);
@@ -1,4 +1,6 @@
using System.Diagnostics;
using System.Threading.Channels;
using ErsatzTV.Application.Maintenance;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
@@ -14,6 +16,7 @@ namespace ErsatzTV.Application.Streaming;
public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Either<BaseError, Unit>>
{
private readonly IConfigElementRepository _configElementRepository;
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
private readonly IFFmpegSegmenterService _ffmpegSegmenterService;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<StartFFmpegSessionHandler> _logger;
@@ -24,13 +27,15 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
ILogger<StartFFmpegSessionHandler> logger,
IServiceScopeFactory serviceScopeFactory,
IFFmpegSegmenterService ffmpegSegmenterService,
IConfigElementRepository configElementRepository)
IConfigElementRepository configElementRepository,
ChannelWriter<IBackgroundServiceRequest> workerChannel)
{
_localFileSystem = localFileSystem;
_logger = logger;
_serviceScopeFactory = serviceScopeFactory;
_ffmpegSegmenterService = ffmpegSegmenterService;
_configElementRepository = configElementRepository;
_workerChannel = workerChannel;
}
public Task<Either<BaseError, Unit>> Handle(StartFFmpegSession request, CancellationToken cancellationToken) =>
@@ -54,9 +59,14 @@ public class StartFFmpegSessionHandler : IRequestHandler<StartFFmpegSession, Eit
// fire and forget worker
_ = worker.Run(request.ChannelNumber, idleTimeout, cancellationToken)
.ContinueWith(
_ => _ffmpegSegmenterService.SessionWorkers.TryRemove(
request.ChannelNumber,
out IHlsSessionWorker _),
_ =>
{
_ffmpegSegmenterService.SessionWorkers.TryRemove(
request.ChannelNumber,
out IHlsSessionWorker _);
_workerChannel.TryWrite(new ReleaseMemory(false));
},
TaskScheduler.Default);
string playlistFileName = Path.Combine(
@@ -54,6 +54,8 @@ public class HlsSessionWorker : IHlsSessionWorker
{
lock (_sync)
{
_logger.LogDebug("Keep alive - session worker for channel {ChannelNumber}", _channelNumber);
_lastAccess = DateTimeOffset.Now;
_timer?.Stop();
@@ -351,7 +353,10 @@ public class HlsSessionWorker : IHlsSessionWorker
}
finally
{
Interlocked.Decrement(ref _workAheadCount);
if (!realtime)
{
Interlocked.Decrement(ref _workAheadCount);
}
}
return false;
@@ -1,4 +1,5 @@
using CliWrap;
using Dapper;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
@@ -126,7 +127,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
.Include(i => i.Watermark)
.ForChannelAndTime(channel.Id, now)
.Map(o => o.ToEither<BaseError>(new UnableToLocatePlayoutItem()))
.BindT(ValidatePlayoutItemPath);
.BindT(item => ValidatePlayoutItemPath(dbContext, item));
if (maybePlayoutItem.LeftAsEnumerable().Any(e => e is UnableToLocatePlayoutItem))
{
@@ -171,7 +172,7 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
saveReports,
channel,
videoVersion,
audioVersion,
new MediaItemAudioVersion(playoutItemWithPath.PlayoutItem.MediaItem, audioVersion),
videoPath,
audioPath,
settings => GetSubtitles(playoutItemWithPath, channel, settings),
@@ -193,7 +194,8 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
playoutItemWithPath.PlayoutItem.OutPoint,
request.PtsOffset,
request.TargetFramerate,
playoutItemWithPath.PlayoutItem.DisableWatermarks);
playoutItemWithPath.PlayoutItem.DisableWatermarks,
_ => { });
var result = new PlayoutItemProcessModel(
process,
@@ -439,29 +441,85 @@ public class GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<
DisableWatermarks = !fallbackPreset.AllowWatermarks
};
return await ValidatePlayoutItemPath(playoutItem);
return await ValidatePlayoutItemPath(dbContext, playoutItem);
}
return new UnableToLocatePlayoutItem();
}
private async Task<Either<BaseError, PlayoutItemWithPath>> ValidatePlayoutItemPath(PlayoutItem playoutItem)
private async Task<Either<BaseError, PlayoutItemWithPath>> ValidatePlayoutItemPath(
TvContext dbContext,
PlayoutItem playoutItem)
{
string path = await GetPlayoutItemPath(playoutItem);
// check filesystem first
if (_localFileSystem.FileExists(path))
{
return new PlayoutItemWithPath(playoutItem, path);
}
// attempt to remotely stream plex
MediaFile file = playoutItem.MediaItem.GetHeadVersion().MediaFiles.Head();
switch (file)
{
case PlexMediaFile pmf:
Option<int> maybeId = await dbContext.Connection.QuerySingleOrDefaultAsync<int>(
@"SELECT PMS.Id FROM PlexMediaSource PMS
INNER JOIN Library L on PMS.Id = L.MediaSourceId
INNER JOIN LibraryPath LP on L.Id = LP.LibraryId
WHERE LP.Id = @LibraryPathId",
new { playoutItem.MediaItem.LibraryPathId })
.Map(Optional);
foreach (int plexMediaSourceId in maybeId)
{
return new PlayoutItemWithPath(
playoutItem,
$"http://localhost:{Settings.ListenPort}/media/plex/{plexMediaSourceId}/{pmf.Key}");
}
break;
}
// attempt to remotely stream jellyfin
Option<string> jellyfinItemId = playoutItem.MediaItem switch
{
JellyfinEpisode e => e.ItemId,
JellyfinMovie m => m.ItemId,
_ => None
};
foreach (string itemId in jellyfinItemId)
{
return new PlayoutItemWithPath(
playoutItem,
$"http://localhost:{Settings.ListenPort}/media/jellyfin/{itemId}");
}
// attempt to remotely stream emby
Option<string> embyItemId = playoutItem.MediaItem switch
{
EmbyEpisode e => e.ItemId,
EmbyMovie m => m.ItemId,
_ => None
};
foreach (string itemId in embyItemId)
{
return new PlayoutItemWithPath(
playoutItem,
$"http://localhost:{Settings.ListenPort}/media/emby/{itemId}");
}
return new PlayoutItemDoesNotExistOnDisk(path);
}
private async Task<string> GetPlayoutItemPath(PlayoutItem playoutItem)
{
MediaVersion version = playoutItem.MediaItem.GetHeadVersion();
MediaFile file = version.MediaFiles.Head();
string path = file.Path;
return playoutItem.MediaItem switch
{
@@ -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,
@@ -3,4 +3,4 @@
namespace ErsatzTV.Application.Subtitles;
public record ExtractEmbeddedSubtitles(Option<int> PlayoutId) : IRequest<Either<BaseError, Unit>>,
ISubtitleWorkerRequest;
IBackgroundServiceRequest;
@@ -1,8 +1,10 @@
using System.Security.Cryptography;
using System.Text;
using System.Threading.Channels;
using CliWrap;
using CliWrap.Buffered;
using CliWrap.Builders;
using ErsatzTV.Application.Maintenance;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
@@ -21,6 +23,7 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IEmbyPathReplacementService _embyPathReplacementService;
private readonly ChannelWriter<IBackgroundServiceRequest> _workerChannel;
private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<ExtractEmbeddedSubtitlesHandler> _logger;
@@ -32,6 +35,7 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
IPlexPathReplacementService plexPathReplacementService,
IJellyfinPathReplacementService jellyfinPathReplacementService,
IEmbyPathReplacementService embyPathReplacementService,
ChannelWriter<IBackgroundServiceRequest> workerChannel,
ILogger<ExtractEmbeddedSubtitlesHandler> logger)
{
_dbContextFactory = dbContextFactory;
@@ -39,6 +43,7 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
_plexPathReplacementService = plexPathReplacementService;
_jellyfinPathReplacementService = jellyfinPathReplacementService;
_embyPathReplacementService = embyPathReplacementService;
_workerChannel = workerChannel;
_logger = logger;
}
@@ -49,7 +54,12 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, string> validation = await FFmpegPathMustExist(dbContext);
return await validation.Match(
ffmpegPath => ExtractAll(dbContext, request, ffmpegPath, cancellationToken),
async ffmpegPath =>
{
Either<BaseError, Unit> result = await ExtractAll(dbContext, request, ffmpegPath, cancellationToken);
await _workerChannel.WriteAsync(new ReleaseMemory(false), cancellationToken);
return result;
},
error => Task.FromResult<Either<BaseError, Unit>>(error.Join()));
}
@@ -68,6 +78,7 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
// only check the requested playout if subtitles are enabled
Option<Playout> requestedPlayout = await dbContext.Playouts
.AsNoTracking()
.Filter(
p => p.Channel.SubtitleMode != ChannelSubtitleMode.None ||
p.ProgramSchedule.Items.Any(psi => psi.SubtitleMode != ChannelSubtitleMode.None))
@@ -79,6 +90,7 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
if (request.PlayoutId.IsNone)
{
playoutIdsToCheck = dbContext.Playouts
.AsNoTracking()
.Filter(
p => p.Channel.SubtitleMode != ChannelSubtitleMode.None ||
p.ProgramSchedule.Items.Any(psi => psi.SubtitleMode != ChannelSubtitleMode.None))
@@ -104,36 +116,37 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
// find all playout items in the next hour
List<PlayoutItem> playoutItems = await dbContext.PlayoutItems
.AsNoTracking()
.Filter(pi => playoutIdsToCheck.Contains(pi.PlayoutId))
.Filter(pi => pi.Finish >= DateTime.UtcNow)
.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 +158,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 +173,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)
@@ -171,40 +183,44 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
try
{
List<int> episodeIds = await dbContext.EpisodeMetadata
.AsNoTracking()
.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);
result.AddRange(episodeIds);
List<int> movieIds = await dbContext.MovieMetadata
.AsNoTracking()
.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);
result.AddRange(movieIds);
List<int> musicVideoIds = await dbContext.MusicVideoMetadata
.AsNoTracking()
.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);
result.AddRange(musicVideoIds);
List<int> otherVideoIds = await dbContext.OtherVideoMetadata
.AsNoTracking()
.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 +234,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 +262,11 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
}
}
if (subtitlesToExtract.Count == 0)
{
continue;
}
string mediaItemPath = await GetMediaItemPath(mediaItem);
ArgumentsBuilder args = new ArgumentsBuilder()
@@ -316,10 +310,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 +350,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 +482,4 @@ public class ExtractEmbeddedSubtitlesHandler : IRequestHandler<ExtractEmbeddedSu
}
private record SubtitleToExtract(Subtitle Subtitle, string OutputPath);
private record FontToExtract(MediaStream Stream, string OutputPath);
}
+13 -34
View File
@@ -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.1" />
<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.10.0" />
<PackageReference Include="LanguageExt.Core" Version="4.4.2" />
<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.5.22">
<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" />
@@ -34,25 +34,4 @@
<ProjectReference Include="..\ErsatzTV.Infrastructure\ErsatzTV.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<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.AsTask(),
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;
}
@@ -15,7 +15,7 @@ public class FakeMediaCollectionRepository : IMediaCollectionRepository
public Task<List<MediaItem>> GetItems(int id) => _data[id].ToList().AsTask();
public Task<List<MediaItem>> GetMultiCollectionItems(int id) => throw new NotSupportedException();
public Task<List<MediaItem>> GetSmartCollectionItems(int id) => throw new NotSupportedException();
public Task<List<MediaItem>> GetSmartCollectionItems(int id) => _data[id].ToList().AsTask();
public Task<List<CollectionWithItems>> GetMultiCollectionCollections(int id) =>
throw new NotSupportedException();
@@ -59,6 +59,8 @@ public class FakeTelevisionRepository : ITelevisionRepository
public Task<List<int>> DeleteEmptyShows(LibraryPath libraryPath) => throw new NotSupportedException();
public Task<bool> AddGenre(ShowMetadata metadata, Genre genre) => throw new NotSupportedException();
public Task<bool> AddGenre(EpisodeMetadata metadata, Genre genre) => throw new NotSupportedException();
public Task<bool> AddTag(Domain.Metadata metadata, Tag tag) => throw new NotSupportedException();
public Task<bool> AddStudio(ShowMetadata metadata, Studio studio) => throw new NotSupportedException();
@@ -70,6 +70,30 @@ public class FallbackMetadataProviderTests
// metadata.Season.Should().Be(season);
metadata.Head().EpisodeNumber.Should().Be(episode);
}
[TestCase("Awesome Show - S01_BLAH.mkv", 0)]
[TestCase("Awesome Show - NO_EPISODE_NUMBER_HERE.mkv", 0)]
public void GetFallbackMetadata_ShouldHandleNonEpisodes(string path, int episode)
{
List<EpisodeMetadata> metadata = _fallbackMetadataProvider.GetFallbackMetadata(
new Episode
{
LibraryPath = new LibraryPath(),
MediaVersions = new List<MediaVersion>
{
new()
{
MediaFiles = new List<MediaFile>
{
new() { Path = path }
}
}
}
});
metadata.Count.Should().Be(1);
metadata.Head().EpisodeNumber.Should().Be(episode);
}
[Test]
[TestCase("Awesome Show - s01e02-s01e03.mkv", 1, 2, 3)]
@@ -126,4 +150,26 @@ public class FallbackMetadataProviderTests
metadata.Should().NotBeNull();
metadata.Title.Should().Be(expectedTitle);
}
[Test]
[TestCase(@"/Whatever/American Dad! S01", 1)]
[TestCase(@"/Whatever/Season 2", 2)]
[TestCase(@"/Whatever/Season 02", 2)]
[TestCase(@"/Whatever/Seinfeld/S02", 2)]
[TestCase(@"/Whatever/Seinfeld/2", 2)]
[TestCase(@"/Whatever/Season 2009", 2009)]
[TestCase(@"/Whatever/Season1", 1)]
[TestCase(@"/Bojack Horseman/Bojack.Horseman.S04.1080p.WEB.x264-ABBA", 4)]
[TestCase(@"/Whatever/Season 7 (2016)", 7)]
[TestCase(@"/Whatever/Season (8)", null)]
[TestCase(@"/Whatever/s06e05", null)]
[TestCase(@"/Whatever/The.Legend.of.Condor.Heroes.2017.V2.web-dl.1080p.h264.aac-hdctv", null)]
[TestCase(@"/Whatever/extras", null)]
[TestCase(@"/Whatever/specials", 0)]
[TestCase(@"Stargate SG1 S08", 8)]
public void GetSeasonNumberForFolder_ShouldHandleVariousFormats(string folder, int? season)
{
Option<int> actual = _fallbackMetadataProvider.GetSeasonNumberForFolder(folder);
actual.Should<Option<int>>().Be(Optional(season));
}
}
@@ -543,7 +543,8 @@ public class PlayoutBuilderTests
},
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>(),
Items = new List<PlayoutItem>()
Items = new List<PlayoutItem>(),
ProgramScheduleAlternates = new List<ProgramScheduleAlternate>()
};
var configRepo = new Mock<IConfigElementRepository>();
@@ -639,7 +640,8 @@ public class PlayoutBuilderTests
},
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>(),
Items = new List<PlayoutItem>()
Items = new List<PlayoutItem>(),
ProgramScheduleAlternates = new List<ProgramScheduleAlternate>()
};
var configRepo = new Mock<IConfigElementRepository>();
@@ -783,7 +785,8 @@ public class PlayoutBuilderTests
},
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>(),
Items = new List<PlayoutItem>()
Items = new List<PlayoutItem>(),
ProgramScheduleAlternates = new List<ProgramScheduleAlternate>()
};
var configRepo = new Mock<IConfigElementRepository>();
@@ -885,7 +888,8 @@ public class PlayoutBuilderTests
},
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>(),
Items = new List<PlayoutItem>()
Items = new List<PlayoutItem>(),
ProgramScheduleAlternates = new List<ProgramScheduleAlternate>()
};
var configRepo = new Mock<IConfigElementRepository>();
@@ -996,7 +1000,8 @@ public class PlayoutBuilderTests
InFlood = true
},
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>(),
Items = new List<PlayoutItem>()
Items = new List<PlayoutItem>(),
ProgramScheduleAlternates = new List<ProgramScheduleAlternate>()
};
var configRepo = new Mock<IConfigElementRepository>();
@@ -1100,7 +1105,8 @@ public class PlayoutBuilderTests
},
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>(),
Items = new List<PlayoutItem>()
Items = new List<PlayoutItem>(),
ProgramScheduleAlternates = new List<ProgramScheduleAlternate>()
};
var configRepo = new Mock<IConfigElementRepository>();
@@ -1208,7 +1214,8 @@ public class PlayoutBuilderTests
},
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>(),
Items = new List<PlayoutItem>()
Items = new List<PlayoutItem>(),
ProgramScheduleAlternates = new List<ProgramScheduleAlternate>()
};
var configRepo = new Mock<IConfigElementRepository>();
@@ -1321,7 +1328,8 @@ public class PlayoutBuilderTests
MultipleRemaining = 2
},
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>(),
Items = new List<PlayoutItem>()
Items = new List<PlayoutItem>(),
ProgramScheduleAlternates = new List<ProgramScheduleAlternate>()
};
var configRepo = new Mock<IConfigElementRepository>();
@@ -1423,7 +1431,8 @@ public class PlayoutBuilderTests
},
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>(),
Items = new List<PlayoutItem>()
Items = new List<PlayoutItem>(),
ProgramScheduleAlternates = new List<ProgramScheduleAlternate>()
};
var configRepo = new Mock<IConfigElementRepository>();
@@ -1536,7 +1545,8 @@ public class PlayoutBuilderTests
DurationFinish = HoursAfterMidnight(3).UtcDateTime
},
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>(),
Items = new List<PlayoutItem>()
Items = new List<PlayoutItem>(),
ProgramScheduleAlternates = new List<ProgramScheduleAlternate>()
};
var configRepo = new Mock<IConfigElementRepository>();
@@ -1660,7 +1670,8 @@ public class PlayoutBuilderTests
},
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>(),
Items = new List<PlayoutItem>()
Items = new List<PlayoutItem>(),
ProgramScheduleAlternates = new List<ProgramScheduleAlternate>()
};
var configRepo = new Mock<IConfigElementRepository>();
@@ -1776,7 +1787,8 @@ public class PlayoutBuilderTests
},
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>(),
Items = new List<PlayoutItem>()
Items = new List<PlayoutItem>(),
ProgramScheduleAlternates = new List<ProgramScheduleAlternate>()
};
var configRepo = new Mock<IConfigElementRepository>();
@@ -1852,7 +1864,8 @@ public class PlayoutBuilderTests
},
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>(),
Items = new List<PlayoutItem>()
Items = new List<PlayoutItem>(),
ProgramScheduleAlternates = new List<ProgramScheduleAlternate>()
};
var configRepo = new Mock<IConfigElementRepository>();
@@ -2049,7 +2062,8 @@ public class PlayoutBuilderTests
},
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>(),
Items = new List<PlayoutItem>()
Items = new List<PlayoutItem>(),
ProgramScheduleAlternates = new List<ProgramScheduleAlternate>()
};
playout.ProgramScheduleAnchors.Add(
@@ -2366,6 +2380,91 @@ public class PlayoutBuilderTests
// the continue anchor should have the same seed as the most recent (last) checkpoint from the first run
firstSeedValue.Should().Be(secondSeedValue);
}
[Test]
public async Task ShuffleFlood_MultipleSmartCollections_Should_MaintainRandomSeed()
{
var mediaItems = new List<MediaItem>
{
TestMovie(1, TimeSpan.FromHours(1), DateTime.Today),
TestMovie(2, TimeSpan.FromHours(1), DateTime.Today.AddHours(1)),
TestMovie(3, TimeSpan.FromHours(1), DateTime.Today.AddHours(3))
};
(PlayoutBuilder builder, Playout playout) =
TestDataFloodForSmartCollectionItems(mediaItems, PlaybackOrder.Shuffle);
DateTimeOffset start = HoursAfterMidnight(0);
DateTimeOffset finish = start + TimeSpan.FromHours(6);
Playout result = await builder.Build(playout, PlayoutBuildMode.Reset, start, finish);
result.Items.Count.Should().Be(6);
result.ProgramScheduleAnchors.Count.Should().Be(2);
PlayoutProgramScheduleAnchor primaryAnchor = result.ProgramScheduleAnchors.First(a => a.SmartCollectionId == 1);
primaryAnchor.EnumeratorState.Seed.Should().BeGreaterThan(0);
primaryAnchor.EnumeratorState.Index.Should().Be(0);
int firstSeedValue = primaryAnchor.EnumeratorState.Seed;
DateTimeOffset start2 = HoursAfterMidnight(0);
DateTimeOffset finish2 = start2 + TimeSpan.FromHours(6);
Playout result2 = await builder.Build(result, PlayoutBuildMode.Continue, start2, finish2);
primaryAnchor = result2.ProgramScheduleAnchors.First(a => a.SmartCollectionId == 1);
int secondSeedValue = primaryAnchor.EnumeratorState.Seed;
firstSeedValue.Should().Be(secondSeedValue);
primaryAnchor.EnumeratorState.Index.Should().Be(0);
}
[Test]
public async Task ShuffleFlood_MultipleSmartCollections_Should_MaintainRandomSeed_MultipleDays()
{
var mediaItems = new List<MediaItem>();
for (int i = 1; i <= 100; i++)
{
mediaItems.Add(TestMovie(i, TimeSpan.FromMinutes(55), DateTime.Today.AddHours(i)));
}
(PlayoutBuilder builder, Playout playout) =
TestDataFloodForSmartCollectionItems(mediaItems, PlaybackOrder.Shuffle);
DateTimeOffset start = HoursAfterMidnight(0).AddSeconds(5);
DateTimeOffset finish = start + TimeSpan.FromDays(2);
Playout result = await builder.Build(playout, PlayoutBuildMode.Reset, start, finish);
result.Items.Count.Should().Be(53);
result.ProgramScheduleAnchors.Count.Should().Be(4);
result.ProgramScheduleAnchors.All(x => x.AnchorDate is not null).Should().BeTrue();
PlayoutProgramScheduleAnchor lastCheckpoint = result.ProgramScheduleAnchors
.Filter(psa => psa.SmartCollectionId == 1)
.OrderByDescending(a => a.AnchorDate ?? DateTime.MinValue)
.First();
lastCheckpoint.EnumeratorState.Seed.Should().BeGreaterThan(0);
lastCheckpoint.EnumeratorState.Index.Should().Be(53);
int firstSeedValue = lastCheckpoint.EnumeratorState.Seed;
for (var i = 1; i < 20; i++)
{
DateTimeOffset start2 = start.AddHours(i);
DateTimeOffset finish2 = start2 + TimeSpan.FromDays(2);
Playout result2 = await builder.Build(result, PlayoutBuildMode.Continue, start2, finish2);
PlayoutProgramScheduleAnchor continueAnchor =
result2.ProgramScheduleAnchors
.Filter(psa => psa.SmartCollectionId == 1)
.First(x => x.AnchorDate is null);
int secondSeedValue = continueAnchor.EnumeratorState.Seed;
// the continue anchor should have the same seed as the most recent (last) checkpoint from the first run
firstSeedValue.Should().Be(secondSeedValue);
}
}
[Test]
public async Task FloodContent_Should_FloodWithFixedStartTime_FromAnchor()
@@ -2437,7 +2536,8 @@ public class PlayoutBuilderTests
InFlood = true
},
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>(),
Items = new List<PlayoutItem>()
Items = new List<PlayoutItem>(),
ProgramScheduleAlternates = new List<ProgramScheduleAlternate>()
};
var configRepo = new Mock<IConfigElementRepository>();
@@ -2548,7 +2648,8 @@ public class PlayoutBuilderTests
MultipleRemaining = 2
},
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>(),
Items = new List<PlayoutItem>()
Items = new List<PlayoutItem>(),
ProgramScheduleAlternates = new List<ProgramScheduleAlternate>()
};
var configRepo = new Mock<IConfigElementRepository>();
@@ -2659,7 +2760,8 @@ public class PlayoutBuilderTests
DurationFinish = HoursAfterMidnight(3).UtcDateTime
},
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>(),
Items = new List<PlayoutItem>()
Items = new List<PlayoutItem>(),
ProgramScheduleAlternates = new List<ProgramScheduleAlternate>()
};
var configRepo = new Mock<IConfigElementRepository>();
@@ -2710,10 +2812,34 @@ public class PlayoutBuilderTests
{
Id = 1,
Index = 1,
CollectionType = ProgramScheduleItemCollectionType.Collection,
Collection = mediaCollection,
CollectionId = mediaCollection.Id,
StartTime = null,
PlaybackOrder = playbackOrder
PlaybackOrder = playbackOrder,
};
private static ProgramScheduleItem Flood(
SmartCollection smartCollection,
SmartCollection fillerCollection,
PlaybackOrder playbackOrder) =>
new ProgramScheduleItemFlood
{
Id = 1,
Index = 1,
CollectionType = ProgramScheduleItemCollectionType.SmartCollection,
SmartCollection = smartCollection,
SmartCollectionId = smartCollection.Id,
StartTime = null,
PlaybackOrder = playbackOrder,
FallbackFiller = new FillerPreset
{
Id = 1,
CollectionType = ProgramScheduleItemCollectionType.SmartCollection,
SmartCollection = fillerCollection,
SmartCollectionId = fillerCollection.Id,
FillerKind = FillerKind.Fallback
}
};
private static Movie TestMovie(int id, TimeSpan duration, DateTime aired) =>
@@ -2768,7 +2894,61 @@ public class PlayoutBuilderTests
ProgramSchedule = new ProgramSchedule { Items = items },
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
Items = new List<PlayoutItem>(),
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>()
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>(),
ProgramScheduleAlternates = new List<ProgramScheduleAlternate>()
};
return new TestData(builder, playout);
}
private TestData TestDataFloodForSmartCollectionItems(
List<MediaItem> mediaItems,
PlaybackOrder playbackOrder,
Mock<IConfigElementRepository> configMock = null)
{
var mediaCollection = new SmartCollection
{
Id = 1,
Query = "asdf"
};
var fillerCollection = new SmartCollection
{
Id = 2,
Query = "ghjk"
};
Mock<IConfigElementRepository> configRepo = configMock ?? new Mock<IConfigElementRepository>();
var collectionRepo = new FakeMediaCollectionRepository(
Map(
(mediaCollection.Id, mediaItems),
(fillerCollection.Id, mediaItems.Take(1).ToList())
)
);
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, fillerCollection, playbackOrder) };
var playout = new Playout
{
Id = 1,
ProgramSchedule = new ProgramSchedule { Items = items },
Channel = new Channel(Guid.Empty) { Id = 1, Name = "Test Channel" },
Items = new List<PlayoutItem>(),
ProgramScheduleAnchors = new List<PlayoutProgramScheduleAnchor>(),
ProgramScheduleAlternates = new List<ProgramScheduleAlternate>()
};
return new TestData(builder, playout);
@@ -356,6 +356,214 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
playoutItems[2].MediaItemId.Should().Be(1);
playoutItems[2].StartOffset.Should().Be(startState.CurrentTime + TimeSpan.FromMinutes(11));
}
[Test]
public void Should_Schedule_Post_Roll_After_Padded_Mid_Roll()
{
// content 45 min, mid roll pad to 60, post roll 5 min
// content + post = 50 min, mid roll will add two 5 min items
// content + mid + post = 60 min
Collection collectionOne = TwoItemCollection(1, 2, TimeSpan.FromMinutes(45));
Collection collectionTwo = TwoItemCollection(3, 4, TimeSpan.FromMinutes(5));
Collection collectionThree = TwoItemCollection(5, 6, TimeSpan.FromMinutes(5));
var scheduleItem = new ProgramScheduleItemOne
{
Id = 1,
Index = 1,
Collection = collectionOne,
CollectionId = collectionOne.Id,
StartTime = null,
PlaybackOrder = PlaybackOrder.Chronological,
TailFiller = null,
FallbackFiller = null,
MidRollFiller = new FillerPreset
{
FillerKind = FillerKind.MidRoll,
FillerMode = FillerMode.Pad,
PadToNearestMinute = 60,
CollectionId = 2,
Collection = collectionTwo
},
PostRollFiller = new FillerPreset
{
FillerKind = FillerKind.PostRoll,
FillerMode = FillerMode.Count,
Count = 1,
CollectionId = 3,
Collection = collectionThree
}
};
var scheduleItemsEnumerator = new OrderedScheduleItemsEnumerator(
new List<ProgramScheduleItem> { scheduleItem },
new CollectionEnumeratorState());
var enumerator = new ChronologicalMediaCollectionEnumerator(
collectionOne.MediaItems,
new CollectionEnumeratorState());
var midRollFillerEnumerator = new ChronologicalMediaCollectionEnumerator(
collectionTwo.MediaItems,
new CollectionEnumeratorState());
var postRollFillerEnumerator = new ChronologicalMediaCollectionEnumerator(
collectionThree.MediaItems,
new CollectionEnumeratorState());
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
Dictionary<CollectionKey, IMediaCollectionEnumerator> enumerators = CollectionEnumerators(
scheduleItem,
enumerator);
enumerators.Add(CollectionKey.ForFillerPreset(scheduleItem.MidRollFiller), midRollFillerEnumerator);
enumerators.Add(CollectionKey.ForFillerPreset(scheduleItem.PostRollFiller), postRollFillerEnumerator);
List<PlayoutItem> playoutItems = Scheduler()
.AddFiller(
startState,
enumerators,
scheduleItem,
new PlayoutItem
{
MediaItemId = 1,
Start = startState.CurrentTime.UtcDateTime,
Finish = startState.CurrentTime.AddHours(1).UtcDateTime
},
new List<MediaChapter>
{
new() { StartTime = TimeSpan.Zero, EndTime = TimeSpan.FromMinutes(6) },
new() { StartTime = TimeSpan.FromMinutes(6), EndTime = TimeSpan.FromMinutes(45) }
});
playoutItems.Count.Should().Be(5);
// content chapter 1
playoutItems[0].MediaItemId.Should().Be(1);
playoutItems[0].StartOffset.Should().Be(startState.CurrentTime);
// mid-roll 1
playoutItems[1].MediaItemId.Should().Be(3);
playoutItems[1].StartOffset.Should().Be(startState.CurrentTime + TimeSpan.FromMinutes(6));
// mid-roll 2
playoutItems[2].MediaItemId.Should().Be(4);
playoutItems[2].StartOffset.Should().Be(startState.CurrentTime + TimeSpan.FromMinutes(11));
// content chapter 2
playoutItems[3].MediaItemId.Should().Be(1);
playoutItems[3].StartOffset.Should().Be(startState.CurrentTime + TimeSpan.FromMinutes(16));
// post-roll
playoutItems[4].MediaItemId.Should().Be(5);
playoutItems[4].StartOffset.Should().Be(startState.CurrentTime + TimeSpan.FromMinutes(55));
}
[Test]
public void Should_Schedule_Padded_Post_Roll_After_Mid_Roll_Count()
{
// content 45 min, mid roll 5 min, post roll pad to 60
// content + mid = 50 min, post roll will add two 5 min items
// content + mid + post = 60 min
Collection collectionOne = TwoItemCollection(1, 2, TimeSpan.FromMinutes(45));
Collection collectionTwo = TwoItemCollection(3, 4, TimeSpan.FromMinutes(5));
Collection collectionThree = TwoItemCollection(5, 6, TimeSpan.FromMinutes(5));
var scheduleItem = new ProgramScheduleItemOne
{
Id = 1,
Index = 1,
Collection = collectionOne,
CollectionId = collectionOne.Id,
StartTime = null,
PlaybackOrder = PlaybackOrder.Chronological,
TailFiller = null,
FallbackFiller = null,
MidRollFiller = new FillerPreset
{
FillerKind = FillerKind.MidRoll,
FillerMode = FillerMode.Count,
Count = 1,
CollectionId = 2,
Collection = collectionTwo
},
PostRollFiller = new FillerPreset
{
FillerKind = FillerKind.PostRoll,
FillerMode = FillerMode.Pad,
PadToNearestMinute = 60,
CollectionId = 3,
Collection = collectionThree
}
};
var scheduleItemsEnumerator = new OrderedScheduleItemsEnumerator(
new List<ProgramScheduleItem> { scheduleItem },
new CollectionEnumeratorState());
var enumerator = new ChronologicalMediaCollectionEnumerator(
collectionOne.MediaItems,
new CollectionEnumeratorState());
var midRollFillerEnumerator = new ChronologicalMediaCollectionEnumerator(
collectionTwo.MediaItems,
new CollectionEnumeratorState());
var postRollFillerEnumerator = new ChronologicalMediaCollectionEnumerator(
collectionThree.MediaItems,
new CollectionEnumeratorState());
PlayoutBuilderState startState = StartState(scheduleItemsEnumerator);
Dictionary<CollectionKey, IMediaCollectionEnumerator> enumerators = CollectionEnumerators(
scheduleItem,
enumerator);
enumerators.Add(CollectionKey.ForFillerPreset(scheduleItem.MidRollFiller), midRollFillerEnumerator);
enumerators.Add(CollectionKey.ForFillerPreset(scheduleItem.PostRollFiller), postRollFillerEnumerator);
List<PlayoutItem> playoutItems = Scheduler()
.AddFiller(
startState,
enumerators,
scheduleItem,
new PlayoutItem
{
MediaItemId = 1,
Start = startState.CurrentTime.UtcDateTime,
Finish = startState.CurrentTime.AddHours(1).UtcDateTime
},
new List<MediaChapter>
{
new() { StartTime = TimeSpan.Zero, EndTime = TimeSpan.FromMinutes(6) },
new() { StartTime = TimeSpan.FromMinutes(6), EndTime = TimeSpan.FromMinutes(45) }
});
playoutItems.Count.Should().Be(5);
// content chapter 1
playoutItems[0].MediaItemId.Should().Be(1);
playoutItems[0].StartOffset.Should().Be(startState.CurrentTime);
// mid-roll 1
playoutItems[1].MediaItemId.Should().Be(3);
playoutItems[1].StartOffset.Should().Be(startState.CurrentTime + TimeSpan.FromMinutes(6));
// content chapter 2
playoutItems[2].MediaItemId.Should().Be(1);
playoutItems[2].StartOffset.Should().Be(startState.CurrentTime + TimeSpan.FromMinutes(11));
// post-roll 1
playoutItems[3].MediaItemId.Should().Be(5);
playoutItems[3].StartOffset.Should().Be(startState.CurrentTime + TimeSpan.FromMinutes(50));
// post-roll 2
playoutItems[4].MediaItemId.Should().Be(6);
playoutItems[4].StartOffset.Should().Be(startState.CurrentTime + TimeSpan.FromMinutes(55));
}
}
[TestFixture]
@@ -1,13 +1,19 @@
using Bugsnag;
using Dapper;
using Destructurama;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Repositories.Caching;
using ErsatzTV.Core.Interfaces.Scheduling;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Metadata;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Data.Repositories;
using ErsatzTV.Infrastructure.Data.Repositories.Caching;
using ErsatzTV.Infrastructure.Extensions;
using ErsatzTV.Infrastructure.Search;
using LanguageExt.UnsafeValueAccess;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
@@ -27,7 +33,7 @@ public class ScheduleIntegrationTests
public ScheduleIntegrationTests()
{
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.MinimumLevel.Debug()
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
.MinimumLevel.Override("System.Net.Http.HttpClient", LogEventLevel.Warning)
.WriteTo.Console()
@@ -36,7 +42,123 @@ public class ScheduleIntegrationTests
}
[Test]
public async Task Test()
public async Task TestExistingData()
{
const string DB_FILE_NAME = "/tmp/whatever.sqlite3";
const int PLAYOUT_ID = 39;
var start = new DateTimeOffset(2023, 1, 18, 11, 0, 0, TimeSpan.FromHours(-5));
DateTimeOffset finish = start.AddDays(2);
IServiceCollection services = new ServiceCollection()
.AddLogging();
var connectionString = $"Data Source={DB_FILE_NAME};foreign keys=true;";
services.AddDbContext<TvContext>(
options => options.UseSqlite(
connectionString,
o =>
{
o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery);
o.MigrationsAssembly("ErsatzTV.Infrastructure");
}),
ServiceLifetime.Scoped,
ServiceLifetime.Singleton);
services.AddDbContextFactory<TvContext>(
options => options.UseSqlite(
connectionString,
o =>
{
o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery);
o.MigrationsAssembly("ErsatzTV.Infrastructure");
}));
SqlMapper.AddTypeHandler(new DateTimeOffsetHandler());
SqlMapper.AddTypeHandler(new GuidHandler());
SqlMapper.AddTypeHandler(new TimeSpanHandler());
services.AddSingleton((Func<IServiceProvider, ILoggerFactory>)(_ => new SerilogLoggerFactory()));
services.AddScoped<ISearchRepository, SearchRepository>();
services.AddScoped<ICachingSearchRepository, CachingSearchRepository>();
services.AddScoped<IConfigElementRepository, ConfigElementRepository>();
services.AddScoped<IFallbackMetadataProvider, FallbackMetadataProvider>();
services.AddSingleton<ISearchIndex, SearchIndex>();
services.AddSingleton(_ => new Mock<IClient>().Object);
ServiceProvider provider = services.BuildServiceProvider();
IDbContextFactory<TvContext> factory = provider.GetRequiredService<IDbContextFactory<TvContext>>();
ILogger<ScheduleIntegrationTests> logger = provider.GetRequiredService<ILogger<ScheduleIntegrationTests>>();
logger.LogInformation("Database is at {File}", DB_FILE_NAME);
await using TvContext dbContext = await factory.CreateDbContextAsync(CancellationToken.None);
await dbContext.Database.MigrateAsync(CancellationToken.None);
await DbInitializer.Initialize(dbContext, CancellationToken.None);
ISearchIndex searchIndex = provider.GetRequiredService<ISearchIndex>();
await searchIndex.Initialize(
new LocalFileSystem(
provider.GetRequiredService<IClient>(),
provider.GetRequiredService<ILogger<LocalFileSystem>>()),
provider.GetRequiredService<IConfigElementRepository>());
await searchIndex.Rebuild(
provider.GetRequiredService<ICachingSearchRepository>(),
provider.GetRequiredService<IFallbackMetadataProvider>());
var builder = new PlayoutBuilder(
new ConfigElementRepository(factory),
new MediaCollectionRepository(new Mock<IClient>().Object, searchIndex, factory),
new TelevisionRepository(factory),
new ArtistRepository(factory),
new Mock<IMultiEpisodeShuffleCollectionEnumeratorFactory>().Object,
new Mock<ILocalFileSystem>().Object,
provider.GetRequiredService<ILogger<PlayoutBuilder>>());
{
await using TvContext context = await factory.CreateDbContextAsync();
Option<Playout> maybePlayout = await GetPlayout(context, PLAYOUT_ID);
Playout playout = maybePlayout.ValueUnsafe();
await builder.Build(playout, PlayoutBuildMode.Reset, start, finish);
await context.SaveChangesAsync();
}
for (var i = 1; i <= (24 * 1); i++)
{
await using TvContext context = await factory.CreateDbContextAsync();
Option<Playout> maybePlayout = await GetPlayout(context, PLAYOUT_ID);
Playout playout = maybePlayout.ValueUnsafe();
await builder.Build(playout, PlayoutBuildMode.Continue, start.AddHours(i), finish.AddHours(i));
await context.SaveChangesAsync();
}
for (var i = 25; i <= 26; i++)
{
await using TvContext context = await factory.CreateDbContextAsync();
Option<Playout> maybePlayout = await GetPlayout(context, PLAYOUT_ID);
Playout playout = maybePlayout.ValueUnsafe();
await builder.Build(playout, PlayoutBuildMode.Continue, start.AddHours(i), finish.AddHours(i));
await context.SaveChangesAsync();
}
}
[Test]
public async Task TestMockData()
{
string dbFileName = Path.GetTempFileName() + ".sqlite3";
@@ -152,7 +274,7 @@ 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,
@@ -220,8 +342,41 @@ public class ScheduleIntegrationTests
return await dbContext.Playouts
.Include(p => p.Channel)
.Include(p => p.Items)
.Include(p => p.ProgramScheduleAlternates)
.ThenInclude(a => a.ProgramSchedule)
.ThenInclude(ps => ps.Items)
.ThenInclude(psi => psi.Collection)
.Include(p => p.ProgramScheduleAlternates)
.ThenInclude(a => a.ProgramSchedule)
.ThenInclude(ps => ps.Items)
.ThenInclude(psi => psi.MediaItem)
.Include(p => p.ProgramScheduleAlternates)
.ThenInclude(a => a.ProgramSchedule)
.ThenInclude(ps => ps.Items)
.ThenInclude(psi => psi.PreRollFiller)
.Include(p => p.ProgramScheduleAlternates)
.ThenInclude(a => a.ProgramSchedule)
.ThenInclude(ps => ps.Items)
.ThenInclude(psi => psi.MidRollFiller)
.Include(p => p.ProgramScheduleAlternates)
.ThenInclude(a => a.ProgramSchedule)
.ThenInclude(ps => ps.Items)
.ThenInclude(psi => psi.PostRollFiller)
.Include(p => p.ProgramScheduleAlternates)
.ThenInclude(a => a.ProgramSchedule)
.ThenInclude(ps => ps.Items)
.ThenInclude(psi => psi.TailFiller)
.Include(p => p.ProgramScheduleAlternates)
.ThenInclude(a => a.ProgramSchedule)
.ThenInclude(ps => ps.Items)
.ThenInclude(psi => psi.FallbackFiller)
.Include(p => p.ProgramScheduleAnchors)
.ThenInclude(a => a.EnumeratorState)
.Include(p => p.ProgramScheduleAnchors)
.ThenInclude(a => a.MediaItem)
.Include(p => p.ProgramSchedule)
.ThenInclude(ps => ps.Items)
.ThenInclude(psi => psi.Collection)
+1
View File
@@ -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; }
@@ -0,0 +1,7 @@
namespace ErsatzTV.Core.Domain;
public enum FFmpegProfileBitDepth
{
EightBit = 0,
TenBit = 1
}
-9
View File
@@ -1,9 +0,0 @@
namespace ErsatzTV.Core.Domain;
public record LogEntry(
int Id,
DateTime Timestamp,
string Level,
string Exception,
string RenderedMessage,
string Properties);
+1 -1
View File
@@ -2,7 +2,7 @@
namespace ErsatzTV.Core.Domain;
[DebuggerDisplay("{EpisodeMetadata[0].Title ?? \"[unknown episode]\"}")]
[DebuggerDisplay("{EpisodeMetadata != null && EpisodeMetadata.Count > 0 ? EpisodeMetadata[0].Title : \"[unknown episode]\"}")]
public class Episode : MediaItem
{
public int SeasonId { get; set; }
@@ -4,5 +4,6 @@ public enum MediaItemState
{
Normal = 0,
FileNotFound = 1,
Unavailable = 2
Unavailable = 2,
RemoteOnly = 3
}
@@ -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; }

Some files were not shown because too many files have changed in this diff Show More