Compare commits

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

* fix file not found health check
2022-01-26 08:37:40 -06:00
Jason DoveandGitHub 861c95e1bd fix m3u mode override (#590) 2022-01-25 18:36:54 -06:00
Jason Dove bb5b9f9be4 update changelog for release v0.3.8-alpha [no ci] 2022-01-23 21:46:00 -06:00
Jason Dove 135628441a re-add mac launcher script 2022-01-23 21:09:04 -06:00
Jason DoveandGitHub 4aa7204984 fix ts mode with hdhr clients (#588) 2022-01-23 18:58:15 -06:00
Jason Dove 1af59a0337 don't use macos launcher script 2022-01-23 18:47:00 -06:00
Jason Dove c4c97fcc8c customize mac dmg 2022-01-23 18:36:31 -06:00
Jason Dove 9c46e42792 fix gon variables 2022-01-23 14:21:13 -06:00
Jason Dove efa803aab6 split mac artifacts job 2022-01-23 14:10:57 -06:00
Jason Dove 6ea02a2d77 use proper version number in ci artifacts [no docs] [no build] 2022-01-23 14:04:13 -06:00
Jason Dove 631f7d2d5e don't use reserved secret name 2022-01-23 13:54:37 -06:00
Jason Dove e44a4cb2e1 properly pass secrets between workflows 2022-01-23 13:53:31 -06:00
Jason Dove f4b95419a6 properly pass data between jobs 2022-01-23 13:46:47 -06:00
Jason DoveandGitHub 1a5cf49563 refactor reusable docker workflow (#587)
* refactor reusable docker workflow

* refactor reusable artifacts workflow

* fix name

* try to fix

* fix
2022-01-23 13:42:01 -06:00
Jason Dove efef0b0fee don't use single file for mac bundles 2022-01-22 17:29:35 -06:00
Jason Dove ee7b8a71ab fix docker builds [no build] 2022-01-22 14:19:49 -06:00
Jason DoveandGitHub e7c9a51e96 macos app bundle (#585)
* test signed app bundle

* fix vars

* fix condition

* typo

* fix quoting

* use recursive signing script

* fix release cleanup

* restore proper ci action
2022-01-22 14:03:42 -06:00
Jason Dove 78a954f365 link to development builds in install docs 2022-01-21 20:55:38 -06:00
Jason Dove 355c0b7be9 try to fix deleting old assets 2022-01-21 20:29:50 -06:00
Jason Dove 3bcb2d36f9 another attempt at publishing artifacts 2022-01-21 20:21:26 -06:00
Jason Dove b240de9d4a publish develop artifacts to stable release url 2022-01-21 18:44:25 -06:00
Jason Dove f5001837cb properly separate build artifacts 2022-01-21 18:20:51 -06:00
Jason Dove 6ea916b1f0 fix fetch depth 2022-01-21 15:30:40 -06:00
Jason Dove db6fd22215 try to fix build 2022-01-21 15:25:03 -06:00
Jason DoveandGitHub 691842008d upload develop binaries for every merge to main (#584)
* upload develop binaries for every merge to main

* rename step
2022-01-21 15:18:08 -06:00
Jason DoveandGitHub 685f78bef8 fix search results bug (#583) 2022-01-21 14:05:09 -06:00
Jason DoveandGitHub 3ce267863b fix hls segmenter in some cultures (#582) 2022-01-21 10:42:33 -06:00
Jason DoveandGitHub e4231cb57d upgrade from ffmpeg 4.4 to 5.0 (#581) 2022-01-20 20:57:38 -06:00
Jason DoveandGitHub 03946b13ca always use a single ffmpeg thread with realtime (#580) 2022-01-20 14:53:13 -06:00
Jason DoveandGitHub f1a81bf086 clarify library kind/media kind support (#579) [no docker] 2022-01-19 09:11:23 -06:00
Jason Dove 7a88374362 clarify flood scheduling calc [no ci] 2022-01-18 18:22:46 -06:00
Jason DoveandGitHub 663a62431b properly fix startup paths (#576) 2022-01-17 16:31:22 -06:00
Jason Dove 1d4acc284d Update changelog for release v0.3.7-alpha [no ci] 2022-01-17 15:23:39 -06:00
Jason DoveandGitHub 0440f7643b add videotoolbox acceleration (#575) 2022-01-17 15:05:23 -06:00
Jason DoveandGitHub 0f4219f731 properly unlock libraries after failed scans (#574) 2022-01-14 13:03:15 -06:00
Jason DoveandGitHub cbe5d47611 fix trakt list sync when show does not contain a year (#572) 2022-01-12 21:09:26 -06:00
Jason DoveandGitHub afa52ccc89 add trash system for local libraries (#571)
* flag local movies as file not found

* show warning icon on cards

* unflag movie that is found during scan

* skip missing files when building playouts

* add state to search index

* add file not found health check

* link to search from file not found health check

* support flagging other media kinds as file not found

* continue to schedule missing items

* support episode files not found

* wip trash page

* fix trash url

* trash page is functional

* update changelog

* fix changelog merge
2022-01-12 20:27:53 -06:00
Jason DoveandGitHub 7d1163c68f fix double-click startup on mac (#570) 2022-01-11 15:36:12 -06:00
Jason Dove 883492bd33 update changelog for release v0.3.6-alpha [no ci] 2022-01-10 19:51:01 -06:00
Jason DoveandGitHub a4eac4feea properly overwrite environment variables (#567) 2022-01-08 10:01:22 -06:00
Jason Dove dab58f5840 fix tests 2022-01-07 19:18:31 -06:00
Jason DoveandGitHub 176f136c23 fix some nvenc edge cases where only padding is needed for normalization (#565) 2022-01-07 18:53:28 -06:00
Jason Dove 816d77e15b update changelog [no ci] 2022-01-06 12:01:25 -06:00
Jason Dove 7c4d47a211 update changelog [no ci] 2022-01-06 10:31:18 -06:00
Jason DoveandGitHub d9d2cfa8be search index fixes (#559)
* add music video artist to search index

* properly index minutes field when adding from scan

* bump search index version
2022-01-06 10:28:53 -06:00
Jason DoveandGitHub 8036e46966 update streaming mode docs (#558) [no docker] 2022-01-06 09:10:12 -06:00
Jason DoveandGitHub 594ce437fb rework mpeg-ts mode (#557) 2022-01-05 21:27:28 -06:00
Jason Dove 004c43f895 update changelog for release v0.3.5-alpha [no ci] 2022-01-05 09:26:58 -06:00
Jason DoveandGitHub 257384ea9b fix health checks (#556)
* update bundled ffmpeg version in health check

* recognize qsv acceleration on linux

* update changelog
2022-01-05 08:29:52 -06:00
Jason Dove 637f3a0c8b update docs [no docker] 2022-01-04 22:38:52 -06:00
Jason DoveandGitHub 7346808059 update dependencies (#555) 2022-01-04 22:35:14 -06:00
Jason DoveandGitHub 4210d97ee2 optimize setsar filter (#553) 2022-01-02 23:47:07 -06:00
Jason DoveandGitHub 6a8ecd2532 use software decoding for mpeg4 with vaapi (#550) 2022-01-02 10:59:08 -06:00
Jason Dove 9b834f7cbe update changelog for release v0.3.4-alpha [no ci] 2021-12-21 09:46:43 -06:00
Jason DoveandGitHub 7b73677bad allow ffmpeg reports on windows (#547)
* enable troubleshooting reports on windows

* update changelog

* tweak changelog
2021-12-21 09:27:49 -06:00
Jason DoveandGitHub 85b2a46353 update dependencies (#546) 2021-12-21 08:52:51 -06:00
Jason Dove 6f40f2cbd6 fix songs docs [no docker] 2021-12-17 08:48:40 -06:00
Jason DoveandGitHub b62ee4dee9 add files from top-level folder (#541) 2021-12-14 14:27:12 -06:00
Jason Dove a6e7f192cc add jellyfin path replacement tests [no ci] 2021-12-13 06:25:37 -06:00
Jason Dove 59a1a4a8dc update changelog for release v0.3.3-alpha [no ci] 2021-12-12 23:53:12 -06:00
Jason DoveandGitHub 85a9afb51c update dependencies (#538) 2021-12-12 23:51:57 -06:00
Jason DoveandGitHub 246b4d7591 properly sort channels in m3u (#537) 2021-12-10 20:22:52 -06:00
Jason DoveandGitHub ae2c6350e1 sync virtual shows and season from jellyfin (#536) 2021-12-10 14:41:47 -06:00
Jason DoveandGitHub ce228604e8 use select controls instead of autocomplete (#532)
* use select instead of autocomplete for playout editor

* use select instead of autocomplete for filler preset editor

* reset selected collection when changing collection type

* use select instead of autocomplete for multi collection editor

* more select

* more select controls
2021-12-06 12:49:48 -06:00
Jason DoveandGitHub 3656e932d3 more song fixes (#529)
* use blurhash for default etv song backgrounds

* fix saving artwork blurhash

* fix song detail alignment

* rename song background files

* watermark path is always none here
2021-12-04 13:30:25 -06:00
Jason Dove 73887706ed update changelog for release v0.3.2-alpha [no ci] 2021-12-03 14:57:19 -06:00
Jason DoveandGitHub abc103308b optimize song artwork scanning (#527) 2021-12-03 13:40:55 -06:00
Jason DoveandGitHub 3773bbec19 use blurhash for song backgrounds (#526)
* generate blurhash for all local artwork

* use blurhash song background if available

* only write blur hash to disk once

* use multiple blur hashes

* update changelog

* fix song detail outline

* reset song metadata (artwork)
2021-12-03 12:30:47 -06:00
Jason DoveandGitHub e223d6a43f remove unused cli project (#525) [no ci] 2021-12-02 09:01:47 -06:00
Jason DoveandGitHub 8369111e31 update dependencies (#524) 2021-12-02 08:45:43 -06:00
Jason DoveandGitHub 35ba2bab2c fix unicode song metadata on windows (#523)
* test setting utf8 encoding with ffprobe

* use utf8 encoding for console (logging) output

* use proper sink package

* reset song metadata on windows

* fix nfo processing with missing year

* update changelog
2021-12-01 13:43:09 -06:00
Jason DoveandGitHub 094ed71ad0 fix docker builds with custom (local) nuget package (#520) 2021-11-30 20:49:02 -06:00
Jason DoveandGitHub 89e24b2b78 use custom log database backend that is more portable (#519) 2021-11-30 20:34:14 -06:00
Jason DoveandGitHub 848795af32 fix artwork upload on windows (#518)
* update changelog for release v0.3.1-alpha [no ci]

* fix artwork upload on windows
2021-11-30 12:23:24 -06:00
Jason DoveandGitHub 56f94f489a fix filler playout crash (#517) 2021-11-30 10:38:42 -06:00
Jason DoveandGitHub 475dc7660b fix artwork uploads (#516) 2021-11-29 14:34:18 -06:00
Jason DoveandGitHub db3dfbd446 disambiguate song search results (#515) 2021-11-27 21:37:55 -06:00
Jason DoveandGitHub b4c9cdbbfa use embedded song cover art (#514) 2021-11-27 21:08:18 -06:00
Jason DoveandGitHub 7f84933c0b index song genres (#513)
* add song genres to search index

* reset all song genre metadata

* update changelog and docs
2021-11-27 18:08:55 -06:00
Jason DoveandGitHub 1e35e9a5b0 use subtitles to display errors (#512)
* use subtitles to display errors

* fix margin calculation
2021-11-27 12:25:30 -06:00
Jason DoveandGitHub 7edf6f5d13 song cleanup (#511)
* refactor song background logic

* move song video generation

* move subtitle generation

* build ASS subtitles

* randomize song detail layout

* update changelog
2021-11-27 11:15:53 -06:00
Jason DoveandGitHub 919325033d use subtitles instead of drawtext for songs (#510) 2021-11-26 21:39:10 -06:00
Jason DoveandGitHub 2cb5252320 fix song banding (#509)
* increase spacing in song details; uniformly darken to eliminate banding

* this isn't needed anymore
2021-11-26 15:20:41 -06:00
Jason DoveandGitHub 015232fad6 song improvements (#508)
* fix song details margin and use dynamic font size

* sometimes use cover art color for song background
2021-11-26 13:23:28 -06:00
Jason DoveandGitHub af51b790b6 randomize cover art placement (#507) 2021-11-26 09:21:00 -06:00
Jason DoveandGitHub 9195ef7878 song fixes (#506)
* fix song page links

* show song artist in playout detail

* show more song details in channel guide
2021-11-26 08:49:04 -06:00
223 changed files with 43319 additions and 2133 deletions
+218
View File
@@ -0,0 +1,218 @@
name: Build Artifacts
on:
workflow_call:
inputs:
release_tag:
description: 'Release tag'
required: true
type: string
release_version:
description: 'Release version number (e.g. v0.3.7-alpha)'
required: true
type: string
info_version:
description: 'Informational version number (e.g. 0.3.7-alpha)'
required: true
type: string
secrets:
apple_developer_certificate_p12_base64:
required: true
apple_developer_certificate_password:
required: true
ac_username:
required: true
ac_password:
required: true
gh_token:
required: true
jobs:
build_and_upload_mac:
name: Mac Build & Upload
runs-on: ${{ matrix.os }}
if: contains(github.event.head_commit.message, '[no build]') == false
strategy:
matrix:
include:
- os: macos-latest
kind: macOS
target: osx-x64
- os: macos-latest
kind: macOS
target: osx-arm64
steps:
- name: Get the sources
uses: actions/checkout@v2
with:
fetch-depth: 0
submodules: true
- name: Setup .NET Core
uses: actions/setup-dotnet@v1
with:
dotnet-version: 6.0.x
- name: Clean
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
- name: Install dependencies
run: dotnet restore -r "${{ matrix.target}}"
- name: Import Code-Signing Certificates
uses: Apple-Actions/import-codesign-certs@v1
with:
p12-file-base64: ${{ secrets.apple_developer_certificate_p12_base64 }}
p12-password: ${{ secrets.apple_developer_certificate_password }}
- name: Calculate Release Name
shell: bash
run: |
release_name="ErsatzTV-${{ inputs.release_version }}-${{ matrix.target }}"
echo "RELEASE_NAME=${release_name}" >> $GITHUB_ENV
- 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
- name: Bundle
shell: bash
run: |
brew install coreutils
plutil -replace CFBundleShortVersionString -string "${{ inputs.info_version }}" ErsatzTV-macOS/ErsatzTV-macOS/Info.plist
plutil -replace CFBundleVersion -string "${{ inputs.info_version }}" ErsatzTV-macOS/ErsatzTV-macOS/Info.plist
scripts/macOS/bundle.sh
- name: Sign
shell: bash
run: scripts/macOS/sign.sh
- name: Create DMG
shell: bash
run: |
brew install create-dmg
create-dmg \
--volname "ErsatzTV" \
--volicon "artwork/ErsatzTV.icns" \
--window-pos 200 120 \
--window-size 800 400 \
--icon-size 100 \
--icon "ErsatzTV.app" 200 190 \
--hide-extension "ErsatzTV.app" \
--app-drop-link 600 185 \
"ErsatzTV.dmg" \
"ErsatzTV.app/"
- name: Notarize
shell: bash
run: |
brew tap mitchellh/gon
brew install mitchellh/gon/gon
gon -log-level=debug -log-json ./gon.json
env:
AC_USERNAME: ${{ secrets.ac_username }}
AC_PASSWORD: ${{ secrets.ac_password }}
- name: Cleanup
shell: bash
run: |
mv ErsatzTV.dmg "${{ env.RELEASE_NAME }}.dmg"
rm -r publish
rm -r ErsatzTV.app
- name: Delete old release assets
uses: mknejp/delete-release-assets@v1
with:
token: ${{ secrets.gh_token }}
tag: ${{ inputs.release_tag }}
fail-if-no-assets: false
assets: |
*${{ matrix.target }}.dmg
- name: Publish
uses: softprops/action-gh-release@v1
with:
prerelease: true
tag_name: ${{ inputs.release_tag }}
files: |
${{ env.RELEASE_NAME }}.dmg
env:
GITHUB_TOKEN: ${{ secrets.gh_token }}
build_and_upload:
name: Build & Upload
runs-on: ${{ matrix.os }}
if: contains(github.event.head_commit.message, '[no build]') == false
strategy:
matrix:
include:
- os: ubuntu-latest
kind: linux
target: linux-x64
- os: ubuntu-latest
kind: linux
target: linux-arm
- os: windows-latest
kind: windows
target: win-x64
steps:
- name: Get the sources
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Setup .NET Core
uses: actions/setup-dotnet@v1
with:
dotnet-version: 6.0.x
- name: Clean
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
- name: Install dependencies
run: dotnet restore -r "${{ matrix.target}}"
- name: Build
shell: bash
run: |
# Define some variables for things we need
release_name="ErsatzTV-${{ inputs.release_version }}-${{ matrix.target }}"
echo "RELEASE_NAME=${release_name}" >> $GITHUB_ENV
# Build everything
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net6.0 --runtime "${{ matrix.target }}" -c Release -o "$release_name" -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
# Build Windows launcher
if [ "${{ matrix.kind }}" == "windows" ]; then
dotnet publish ErsatzTV-Windows/ErsatzTV-Windows.csproj --framework net6.0-windows --runtime "${{ matrix.target }}" -c Release -o "$release_name" -p:InformationalVersion="${{ inputs.release_version }}-${{ matrix.target }}" -p:EnableCompressionInSingleFile=true -p:DebugType=Embedded -p:PublishSingleFile=true --self-contained true
fi
# Pack files
if [ "${{ matrix.kind }}" == "windows" ]; then
7z a -tzip "${release_name}.zip" "./${release_name}/*"
else
tar czvf "${release_name}.tar.gz" "$release_name"
fi
# Delete output directory
rm -r "$release_name"
env:
AC_USERNAME: ${{ secrets.ac_username }}
AC_PASSWORD: ${{ secrets.ac_password }}
- name: Delete old release assets
uses: mknejp/delete-release-assets@v1
with:
token: ${{ secrets.gh_token }}
tag: ${{ inputs.release_tag }}
fail-if-no-assets: false
assets: |
*${{ matrix.target }}.zip
*${{ matrix.target }}.tar.gz
- name: Publish
uses: softprops/action-gh-release@v1
with:
prerelease: true
tag_name: ${{ inputs.release_tag }}
files: |
${{ env.RELEASE_NAME }}.zip
${{ env.RELEASE_NAME }}.tar.gz
env:
GITHUB_TOKEN: ${{ secrets.gh_token }}
+39 -91
View File
@@ -1,49 +1,19 @@
name: Build
on:
workflow_dispatch:
pull_request:
push:
branches:
- main
jobs:
build_and_test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ windows-latest, ubuntu-latest, macos-latest ]
calculate_version:
name: Calculate version information
runs-on: ubuntu-latest
steps:
- name: Get the sources
uses: actions/checkout@v2
- name: Setup .NET Core
uses: actions/setup-dotnet@v1
with:
dotnet-version: 6.0.x
- name: Clean
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
- name: Install dependencies
run: dotnet restore
- name: Build
run: dotnet build --configuration Release --no-restore
- name: Test
run: dotnet test --no-restore --verbosity normal
build_and_push:
name: Build & Publish to Docker Hub
needs: build_and_test
runs-on: ubuntu-latest
if: github.event_name == 'push' && !contains(github.event.head_commit.message, '[no docker]')
steps:
- name: Checkout
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Extract Git Tag
- name: Extract Docker Tag
shell: bash
run: |
tag=$(git describe --tags --abbrev=0)
@@ -51,60 +21,38 @@ jobs:
short=$(git rev-parse --short HEAD)
final="${tag2/alpha/$short}"
echo "GIT_TAG=${final}" >> $GITHUB_ENV
- name: Set up Docker Buildx Base
uses: docker/setup-buildx-action@v1
id: builder-base
- name: Set up Docker Buildx NVIDIA
uses: docker/setup-buildx-action@v1
id: builder-nvidia
- name: Set up Docker Buildx VAAPI
uses: docker/setup-buildx-action@v1
id: builder-vaapi
- name: Login to DockerHub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Build and push base
uses: docker/build-push-action@v2
with:
builder: ${{ steps.builder-base.outputs.name }}
context: .
file: ./docker/Dockerfile
push: true
build-args: |
INFO_VERSION=${{ env.GIT_TAG }}-docker
tags: |
jasongdove/ersatztv:develop
jasongdove/ersatztv:${{ github.sha }}
- name: Build and push nvidia
uses: docker/build-push-action@v2
with:
builder: ${{ steps.builder-nvidia.outputs.name }}
context: .
file: ./docker/nvidia/Dockerfile
push: true
build-args: |
INFO_VERSION=${{ env.GIT_TAG }}-docker-nvidia
tags: |
jasongdove/ersatztv:develop-nvidia
jasongdove/ersatztv:${{ github.sha }}-nvidia
- name: Build and push vaapi
uses: docker/build-push-action@v2
with:
builder: ${{ steps.builder-vaapi.outputs.name }}
context: .
file: ./docker/vaapi/Dockerfile
push: true
build-args: |
INFO_VERSION=${{ env.GIT_TAG }}-docker-vaapi
tags: |
jasongdove/ersatztv:develop-vaapi
jasongdove/ersatztv:${{ github.sha }}-vaapi
- name: Extract Artifacts Version
shell: bash
run: |
tag=$(git describe --tags --abbrev=0)
short=$(git rev-parse --short HEAD)
final="${tag/alpha/$short}"
echo "ARTIFACTS_VERSION=${final}" >> $GITHUB_ENV
echo "INFO_VERSION=${tag:1}" >> $GITHUB_ENV
outputs:
git_tag: ${{ env.GIT_TAG }}
artifacts_version: ${{ env.ARTIFACTS_VERSION }}
info_version: ${{ env.INFO_VERSION }}
build_and_upload:
uses: jasongdove/ersatztv/.github/workflows/artifacts.yml@main
needs: calculate_version
with:
release_tag: develop
release_version: ${{ needs.calculate_version.outputs.artifacts_version }}
info_version: ${{ needs.calculate_version.outputs.info_version }}
secrets:
apple_developer_certificate_p12_base64: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_P12_BASE64 }}
apple_developer_certificate_password: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_PASSWORD }}
ac_username: ${{ secrets.AC_USERNAME }}
ac_password: ${{ secrets.AC_PASSWORD }}
gh_token: ${{ secrets.GITHUB_TOKEN }}
build_and_push:
uses: jasongdove/ersatztv/.github/workflows/docker.yml@main
needs: calculate_version
with:
base_version: develop
info_version: ${{ needs.calculate_version.outputs.git_tag }}
tag_version: ${{ github.sha }}
secrets:
docker_hub_username: ${{ secrets.DOCKER_HUB_USERNAME }}
docker_hub_access_token: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
+88
View File
@@ -0,0 +1,88 @@
name: Build & Publish to Docker Hub
on:
workflow_call:
inputs:
base_version:
description: 'Base version (latest or develop)'
required: true
type: string
info_version:
description: 'Informational version number (e.g. 0.3.7-alpha)'
required: true
type: string
tag_version:
description: 'Docker tag version (e.g. v0.3.7)'
required: true
type: string
secrets:
docker_hub_username:
required: true
docker_hub_access_token:
required: true
jobs:
build_and_push:
name: Build & Publish
runs-on: ubuntu-latest
if: contains(github.event.head_commit.message, '[no build]') == false
steps:
- name: Checkout
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Set up Docker Buildx Base
uses: docker/setup-buildx-action@v1
id: builder-base
- name: Set up Docker Buildx NVIDIA
uses: docker/setup-buildx-action@v1
id: builder-nvidia
- name: Set up Docker Buildx VAAPI
uses: docker/setup-buildx-action@v1
id: builder-vaapi
- name: Login to DockerHub
uses: docker/login-action@v1
with:
username: ${{ secrets.docker_hub_username }}
password: ${{ secrets.docker_hub_access_token }}
- name: Build and push base
uses: docker/build-push-action@v2
with:
builder: ${{ steps.builder-base.outputs.name }}
context: .
file: ./docker/Dockerfile
push: true
build-args: |
INFO_VERSION=${{ inputs.info_version }}-docker
tags: |
jasongdove/ersatztv:${{ inputs.base_version }}
jasongdove/ersatztv:${{ inputs.tag_version }}
- name: Build and push nvidia
uses: docker/build-push-action@v2
with:
builder: ${{ steps.builder-nvidia.outputs.name }}
context: .
file: ./docker/nvidia/Dockerfile
push: true
build-args: |
INFO_VERSION=${{ inputs.info_version }}-docker-nvidia
tags: |
jasongdove/ersatztv:${{ inputs.base_version }}-nvidia
jasongdove/ersatztv:${{ inputs.tag_version }}-nvidia
- name: Build and push vaapi
uses: docker/build-push-action@v2
with:
builder: ${{ steps.builder-vaapi.outputs.name }}
context: .
file: ./docker/vaapi/Dockerfile
push: true
build-args: |
INFO_VERSION=${{ inputs.info_version }}-docker-vaapi
tags: |
jasongdove/ersatztv:${{ inputs.base_version }}-vaapi
jasongdove/ersatztv:${{ inputs.tag_version }}-vaapi
+1 -2
View File
@@ -3,14 +3,13 @@ on:
push:
branches:
- main
jobs:
build:
name: Deploy docs
runs-on: ubuntu-latest
steps:
- name: Checkout master
uses: actions/checkout@v1
uses: actions/checkout@v2
- name: Deploy docs
uses: mhausenblas/mkdocs-deploy-gh-pages@master
+30
View File
@@ -0,0 +1,30 @@
name: Pull Request
on:
pull_request:
jobs:
build_and_test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ windows-latest, ubuntu-latest, macos-latest ]
steps:
- name: Get the sources
uses: actions/checkout@v2
- name: Setup .NET Core
uses: actions/setup-dotnet@v1
with:
dotnet-version: 6.0.x
- name: Clean
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
- name: Install dependencies
run: dotnet restore
- name: Build
run: dotnet build --configuration Release --no-restore
- name: Test
run: dotnet test --no-restore --verbosity normal
+39 -131
View File
@@ -1,145 +1,53 @@
name: Publish
name: Release
on:
release:
types: [ published ]
jobs:
release:
name: Release
strategy:
matrix:
include:
- os: ubuntu-latest
kind: linux
target: linux-x64
- os: ubuntu-latest
kind: linux
target: linux-arm
- os: windows-latest
kind: windows
target: win-x64
- os: macos-latest
kind: macOS
target: osx-x64
- os: macos-latest
kind: macOS
target: osx-arm64
runs-on: ${{ matrix.os }}
calculate_version:
name: Calculate version information
runs-on: ubuntu-latest
steps:
- name: Get the sources
uses: actions/checkout@v2
- name: Setup .NET Core
uses: actions/setup-dotnet@v1
with:
dotnet-version: 6.0.x
- name: Clean
run: dotnet clean --configuration Release && dotnet nuget locals all --clear
- name: Install dependencies
run: dotnet restore
- name: Build
shell: bash
run: |
# Define some variables for things we need
tag=$(git describe --tags --abbrev=0)
release_name="ErsatzTV-$tag-${{ matrix.target }}"
# Build everything
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net6.0 --runtime "${{ matrix.target }}" -c Release -o "$release_name" /property:InformationalVersion="${tag:1}-${{ matrix.target }}" /property:EnableCompressionInSingleFile=true /property:PublishSingleFile=true --self-contained true
# Pack files
if [ "${{ matrix.target }}" == "win-x64" ]; then
7z a -tzip "${release_name}.zip" "./${release_name}/*"
elif [ "${{ matrix.target }}" == "linux-arm" ]; then
cp lib/linux-arm/* "$release_name/"
tar czvf "${release_name}.tar.gz" "$release_name"
else
tar czvf "${release_name}.tar.gz" "$release_name"
fi
# Delete output directory
rm -r "$release_name"
- name: Publish
uses: softprops/action-gh-release@v1
with:
prerelease: true
files: |
ErsatzTV*.zip
ErsatzTV*.tar.gz
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build_and_push:
name: Build & Publish to Docker Hub
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Extract Git Tag
- name: Extract Docker Tag
shell: bash
run: |
tag=$(git describe --tags --abbrev=0)
echo "GIT_TAG=${tag:1}" >> $GITHUB_ENV
echo "DOCKER_TAG=${tag/-alpha/}" >> $GITHUB_ENV
- name: Set up Docker Buildx Base
uses: docker/setup-buildx-action@v1
id: builder-base
- name: Set up Docker Buildx NVIDIA
uses: docker/setup-buildx-action@v1
id: builder-nvidia
- name: Set up Docker Buildx VAAPI
uses: docker/setup-buildx-action@v1
id: builder-vaapi
- name: Login to DockerHub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Build and push base
uses: docker/build-push-action@v2
with:
builder: ${{ steps.builder-base.outputs.name }}
context: .
file: ./docker/Dockerfile
push: true
build-args: |
INFO_VERSION=${{ env.GIT_TAG }}-docker
tags: |
jasongdove/ersatztv:latest
jasongdove/ersatztv:${{ env.DOCKER_TAG }}
- name: Build and push nvidia
uses: docker/build-push-action@v2
with:
builder: ${{ steps.builder-nvidia.outputs.name }}
context: .
file: ./docker/nvidia/Dockerfile
push: true
build-args: |
INFO_VERSION=${{ env.GIT_TAG }}-docker-nvidia
tags: |
jasongdove/ersatztv:latest-nvidia
jasongdove/ersatztv:${{ env.DOCKER_TAG }}-nvidia
- name: Build and push vaapi
uses: docker/build-push-action@v2
with:
builder: ${{ steps.builder-vaapi.outputs.name }}
context: .
file: ./docker/vaapi/Dockerfile
push: true
build-args: |
INFO_VERSION=${{ env.GIT_TAG }}-docker-vaapi
tags: |
jasongdove/ersatztv:latest-vaapi
jasongdove/ersatztv:${{ env.DOCKER_TAG }}-vaapi
- name: Extract Artifacts Version
shell: bash
run: |
tag=$(git describe --tags --abbrev=0)
echo "ARTIFACTS_VERSION=${tag}" >> $GITHUB_ENV
echo "INFO_VERSION=${tag:1}" >> $GITHUB_ENV
outputs:
git_tag: ${{ env.GIT_TAG }}
docker_tag: ${{ env.DOCKER_TAG }}
artifacts_version: ${{ env.ARTIFACTS_VERSION }}
info_version: ${{ env.INFO_VERSION }}
build_and_upload:
uses: jasongdove/ersatztv/.github/workflows/artifacts.yml@main
needs: calculate_version
with:
release_tag: ${{ needs.calculate_version.outputs.artifacts_version }}
release_version: ${{ needs.calculate_version.outputs.artifacts_version }}
info_version: ${{ needs.calculate_version.outputs.info_version }}
secrets:
apple_developer_certificate_p12_base64: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_P12_BASE64 }}
apple_developer_certificate_password: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_PASSWORD }}
ac_username: ${{ secrets.AC_USERNAME }}
ac_password: ${{ secrets.AC_PASSWORD }}
gh_token: ${{ secrets.GITHUB_TOKEN }}
build_and_push:
uses: jasongdove/ersatztv/.github/workflows/docker.yml@main
needs: calculate_version
with:
base_version: latest
info_version: ${{ needs.calculate_version.outputs.git_tag }}
tag_version: ${{ needs.calculate_version.outputs.docker_tag }}
secrets:
docker_hub_username: ${{ secrets.DOCKER_HUB_USERNAME }}
docker_hub_access_token: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
+3
View File
@@ -0,0 +1,3 @@
[submodule "ErsatzTV-macOS"]
path = ErsatzTV-macOS
url = git@github.com:jasongdove/ErsatzTV-macOS.git
+138 -1
View File
@@ -5,6 +5,134 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
## [0.4.0-alpha] - 2022-01-29
### Fixed
- Fix m3u `mode` query param to properly override streaming mode for all channels
- `segmenter` for `HLS Segmenter`
- `hls-direct` for `HLS Direct`
- `ts` for `MPEG-TS`
- `ts-legacy` for `MPEG-TS (Legacy)`
- omitting the `mode` parameter returns each channel as configured
- Link `File Not Found` health check to `Trash` page to allow deletion
- Fix `HLS Segmenter` streaming mode with multiple ffmpeg-based clients
- Jellyfin (web) and TiviMate (Android) were specifically tested
### Added
- Hide console window on macOS and Windows; tray menu can be used to access UI, logs and to stop the app
- Also write logs to text files in the `logs` config subfolder
- Add `added_date` to search index
- This requires rebuilding the search index and search results may be empty or incomplete until the rebuild is complete
- Add `added_inthelast`, `added_notinthelast` search field for relative added date queries
- Syntax is a number and a unit (days, weeks, months, years) like `1 week` or `2 years`
## [0.3.8-alpha] - 2022-01-23
### Fixed
- Fix issue preventing some versions of ffmpeg (usually 4.4.x) from streaming MPEG-TS (Legacy) channels at all
- The issue appears to be caused by using a thread count other than `1`
- Thread count is now forced to `1` for all streaming modes other than HLS Segmenter
- Fix bug with HLS Segmenter in cultures where `.` is a group/thousands separator
- Fix search results page crashing with some media kinds
- Always use MPEG-TS or MPEG-TS (Legacy) streaming mode with HDHR (Plex)
- Other configured modes will fall back to MPEG-TS when accessed by Plex
### Changed
- Upgrade ffmpeg from 4.4 to 5.0 in all docker images
- Upgrading from 4.4 to 5.0 is recommended for all installations
## [0.3.7-alpha] - 2022-01-17
### Fixed
- Fix local folder scanners to properly detect removed/re-added folders with unchanged contents
- Fix double-click startup on mac
- Fix trakt list sync when show does not contain a year
- Properly unlock libraries when a scan is unable to be performed because ffmpeg or ffprobe have not been found
### Added
- Add trash system for local libraries to maintain collection and schedule integrity through media share outages
- When items are missing from disk, they will be flagged and present in the `Media` > `Trash` page
- The trash page can be used to permanently remove missing items from the database
- When items reappear at the expected location on disk, they will be unflagged and removed from the trash
- Add basic Mac hardware acceleration using VideoToolbox
### Changed
- Local libraries only: when items are missing from disk, they will be added to the trash and no longer removed from collections, etc.
- Show song thumbnail in song list
## [0.3.6-alpha] - 2022-01-10
### Fixed
- Properly index `minutes` field when adding new items during scan (vs when rebuilding index)
- Fix some nvenc edge cases where only padding is needed for normalization
- Properly overwrite environment variables for ffmpeg processes (`LIBVA_DRIVER_NAME`, `FFREPORT`)
### Added
- Add music video `artist` to search index
- This requires rebuilding the search index and search results may be empty or incomplete until the rebuild is complete
### Changed
- Remove `HLS Hybrid` streaming mode; all channels have been reconfigured to use the superior `HLS Segmenter` streaming mode
- Update `MPEG-TS` streaming mode to internally use the HLS segmenter
- This improves compatibility with many clients and also improves performance at program boundaries
- Renamed existing `MPEG-TS` mode as `MPEG-TS (Legacy)`
- This mode will be removed in a future release
## [0.3.5-alpha] - 2022-01-05
### Fixed
- Fix bundled ffmpeg version in base docker image (NOT nvidia or vaapi) which prevented playback since v0.3.0-alpha
- Use software decoding for mpeg4 content when VAAPI acceleration is enabled
- Fix hardware acceleration health check to recognize QSV on non-Windows platforms
### Changed
- Treat `setsar` as a hardware filter, avoiding unneeded `hwdownload` and `hwupload` steps when padding isn't required
## [0.3.4-alpha] - 2021-12-21
### Fixed
- Fix other video and song scanners to include videos contained directly in top-level folders that are added to a library
- Allow saving ffmpeg troubleshooting reports on Windows
## [0.3.3-alpha] - 2021-12-12
### Fixed
- Fix bug with saving multiple blurhash versions for cover art; all cover art will be automatically rescanned
- Fix song detail margin when no cover art exists and no watermark exists
- Fix synchronizing virtual shows and seasons from Jellyfin
- Properly sort channels in M3U
### Changed
- Use blurhash of ErsatzTV colors instead of solid colors for default song backgrounds
- Use select control instead of autocomplete control in many places
- The autocomplete control is not intuitive to use and has focus bugs
## [0.3.2-alpha] - 2021-12-03
### Fixed
- Fix artwork upload on Windows
- Fix unicode song metadata on Windows
- Fix unicode console output on Windows
- Fix TV Show NFO metadata processing when `year` is missing
- Fix song detail outline to help legibility on white backgrounds
- Optimize song artwork scanning to prevent re-processing album artwork for each song
### Changed
- Use custom log database backend which should be more portable (i.e. work in osx-arm64)
- Use cover art blurhashes for song backgrounds instead of solid colors or box blur
## [0.3.1-alpha] - 2021-11-30
### Fixed
- Fix song page links in UI
- Show song artist in playout detail
- Include song artist and cover art in channel guide (xmltv)
- Use subtitles to display errors, which fixes many edge cases of unescaped characters
- Properly split song genre tags
- Properly display all songs that have an identical album and title
- Fix channel logo and watermark uploads
- Fix regression introduced with `v0.2.4-alpha` that caused some filler edge cases to crash the playout builder
### Added
- Add song genres to search index
- Use embedded song cover art when sidecar cover art is unavailable
### Changed
- Randomly place song cover art on left or right side of screen
- Randomly use a solid color from the cover art instead of blurred cover art for song background
- Randomly select song detail layout (large title/small artist or small artist/title/album)
## [0.3.0-alpha] - 2021-11-25
### Fixed
- Properly fix database incompatibility introduced with `v0.2.4-alpha` and partially fixed with `v0.2.5-alpha`
@@ -817,7 +945,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Initial release to facilitate testing outside of Docker.
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.3.0-alpha...HEAD
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.4.0-alpha...HEAD
[0.4.0-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.3.8-alpha...v0.4.0-alpha
[0.3.8-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.3.7-alpha...v0.3.8-alpha
[0.3.7-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.3.6-alpha...v0.3.7-alpha
[0.3.6-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.3.5-alpha...v0.3.6-alpha
[0.3.5-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.3.4-alpha...v0.3.5-alpha
[0.3.4-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.3.3-alpha...v0.3.4-alpha
[0.3.3-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.3.2-alpha...v0.3.3-alpha
[0.3.2-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.3.1-alpha...v0.3.2-alpha
[0.3.1-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.3.0-alpha...v0.3.1-alpha
[0.3.0-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.2.5-alpha...v0.3.0-alpha
[0.2.5-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.2.4-alpha...v0.2.5-alpha
[0.2.4-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.2.3-alpha...v0.2.4-alpha
+33
View File
@@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net6.0-windows</TargetFramework>
<RootNamespace>ErsatzTV_Windows</RootNamespace>
<Nullable>enable</Nullable>
<UseWindowsForms>true</UseWindowsForms>
<ImplicitUsings>enable</ImplicitUsings>
<ApplicationIcon>Ersatztv.ico</ApplicationIcon>
</PropertyGroup>
<ItemGroup>
<Content Include="Ersatztv.ico">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Asmichi.ChildProcess" Version="0.11.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ErsatzTV.Core\ErsatzTV.Core.csproj" />
</ItemGroup>
<ItemGroup>
<Compile Update="Program.cs">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Compile>
</ItemGroup>
</Project>
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+14
View File
@@ -0,0 +1,14 @@
namespace ErsatzTV_Windows;
public static class Program
{
/// <summary>
/// The main entry point for the application.
/// </summary>
[STAThread]
public static void Main()
{
ApplicationConfiguration.Initialize();
Application.Run(new TrayApplicationContext());
}
}
@@ -0,0 +1,77 @@
using ErsatzTV.Core;
using System.Diagnostics;
using Asmichi.ProcessManagement;
using System.Reflection;
namespace ErsatzTV_Windows;
public class TrayApplicationContext : ApplicationContext
{
private readonly NotifyIcon _trayIcon;
private readonly IChildProcess? _childProcess;
public TrayApplicationContext()
{
_trayIcon = new NotifyIcon
{
Icon = new Icon("./Ersatztv.ico"),
ContextMenuStrip = new ContextMenuStrip(),
Visible = true
};
AddMenuItem("Launch Web UI", LaunchWebUI);
AddMenuItem("Show Logs", ShowLogs);
_trayIcon.ContextMenuStrip.Items.Add(new ToolStripSeparator());
AddMenuItem("Exit", Exit);
string folder = AppContext.BaseDirectory;
string exe = Path.Combine(folder, "ErsatzTV.exe");
if (File.Exists(exe))
{
var si = new ChildProcessStartInfo(exe);
_childProcess = ChildProcess.Start(si);
}
}
private void AddMenuItem(string name, EventHandler action)
{
var item = new ToolStripMenuItem(name);
item.Click += action;
_trayIcon.ContextMenuStrip.Items.Add(item);
}
private void LaunchWebUI(object? sender, EventArgs e)
{
var process = new Process();
process.StartInfo.UseShellExecute = true;
process.StartInfo.FileName = "http://localhost:8409";
process.Start();
}
private void ShowLogs(object? sender, EventArgs e)
{
if (!Directory.Exists(FileSystemLayout.LogsFolder))
{
Directory.CreateDirectory(FileSystemLayout.LogsFolder);
}
var process = new Process();
process.StartInfo.UseShellExecute = true;
process.StartInfo.FileName = FileSystemLayout.LogsFolder;
process.Start();
}
protected override void Dispose(bool disposing)
{
_childProcess?.Dispose();
base.Dispose(disposing);
}
private void Exit(object? sender, EventArgs e)
{
// Hide tray icon, otherwise it will remain shown until user mouses over it
_trayIcon.Visible = false;
Application.Exit();
}
}
+1
Submodule ErsatzTV-macOS added at 2f3ee16f11
@@ -11,5 +11,6 @@ namespace ErsatzTV.Application.Channels
string PreferredLanguageCode,
StreamingMode StreamingMode,
int? WatermarkId,
int? FallbackFillerId);
int? FallbackFillerId,
int PlayoutCount);
}
+2 -1
View File
@@ -16,7 +16,8 @@ namespace ErsatzTV.Application.Channels
channel.PreferredLanguageCode,
channel.StreamingMode,
channel.WatermarkId,
channel.FallbackFillerId);
channel.FallbackFillerId,
channel.Playouts?.Count ?? 0);
private static string GetLogo(Channel channel) =>
Optional(channel.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Logo))
@@ -36,10 +36,14 @@ namespace ErsatzTV.Application.Channels.Queries
channel.StreamingMode = StreamingMode.HttpLiveStreamingDirect;
result.Add(channel);
break;
case "ts":
case "ts-legacy":
channel.StreamingMode = StreamingMode.TransportStream;
result.Add(channel);
break;
case "ts":
channel.StreamingMode = StreamingMode.TransportStreamHybrid;
result.Add(channel);
break;
default:
result.Add(channel);
break;
@@ -3,11 +3,10 @@
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<NoWarn>VSTHRD200</NoWarn>
<DebugType>embedded</DebugType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MediatR" Version="9.0.0" />
<PackageReference Include="MediatR" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="6.0.0" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.0.64">
<PrivateAssets>all</PrivateAssets>
@@ -1,13 +1,11 @@
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Runtime;
using LanguageExt;
namespace ErsatzTV.Application.FFmpegProfiles.Commands
@@ -16,16 +14,13 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
{
private readonly IConfigElementRepository _configElementRepository;
private readonly ILocalFileSystem _localFileSystem;
private readonly IRuntimeInfo _runtimeInfo;
public UpdateFFmpegSettingsHandler(
IConfigElementRepository configElementRepository,
ILocalFileSystem localFileSystem,
IRuntimeInfo runtimeInfo)
ILocalFileSystem localFileSystem)
{
_configElementRepository = configElementRepository;
_localFileSystem = localFileSystem;
_runtimeInfo = runtimeInfo;
}
public Task<Either<BaseError, Unit>> Handle(
@@ -36,8 +31,8 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
.Bind(v => v.ToEitherAsync());
private async Task<Validation<BaseError, Unit>> Validate(UpdateFFmpegSettings request) =>
(await FFmpegMustExist(request), await FFprobeMustExist(request), ReportsAreNotSupportedOnWindows(request))
.Apply((_, _, _) => Unit.Default);
(await FFmpegMustExist(request), await FFprobeMustExist(request))
.Apply((_, _) => Unit.Default);
private Task<Validation<BaseError, Unit>> FFmpegMustExist(UpdateFFmpegSettings request) =>
ValidateToolPath(request.Settings.FFmpegPath, "ffmpeg");
@@ -45,16 +40,6 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
private Task<Validation<BaseError, Unit>> FFprobeMustExist(UpdateFFmpegSettings request) =>
ValidateToolPath(request.Settings.FFprobePath, "ffprobe");
private Validation<BaseError, Unit> ReportsAreNotSupportedOnWindows(UpdateFFmpegSettings request)
{
if (request.Settings.SaveReports && _runtimeInfo.IsOSPlatform(OSPlatform.Windows))
{
return BaseError.New("FFmpeg reports are not supported on Windows");
}
return Unit.Default;
}
private async Task<Validation<BaseError, Unit>> ValidateToolPath(string path, string name)
{
if (!_localFileSystem.FileExists(path))
@@ -1,4 +1,5 @@
using ErsatzTV.Core;
using System.IO;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using LanguageExt;
using MediatR;
@@ -6,5 +7,5 @@ using MediatR;
namespace ErsatzTV.Application.Images.Commands
{
// ReSharper disable once SuggestBaseTypeForParameter
public record SaveArtworkToDisk(byte[] Buffer, ArtworkKind ArtworkKind) : IRequest<Either<BaseError, string>>;
public record SaveArtworkToDisk(Stream Stream, ArtworkKind ArtworkKind) : IRequest<Either<BaseError, string>>;
}
@@ -14,6 +14,6 @@ namespace ErsatzTV.Application.Images.Commands
public SaveArtworkToDiskHandler(IImageCache imageCache) => _imageCache = imageCache;
public Task<Either<BaseError, string>> Handle(SaveArtworkToDisk request, CancellationToken cancellationToken) =>
_imageCache.SaveArtworkToCache(request.Buffer, request.ArtworkKind);
_imageCache.SaveArtworkToCache(request.Stream, request.ArtworkKind);
}
}
@@ -0,0 +1,9 @@
using System.Collections.Generic;
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Application.Maintenance.Commands;
public record DeleteItemsFromDatabase(List<int> MediaItemIds) : IRequest<Either<BaseError, Unit>>;
@@ -0,0 +1,38 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using LanguageExt;
namespace ErsatzTV.Application.Maintenance.Commands
{
public class
DeleteItemsFromDatabaseHandler : MediatR.IRequestHandler<DeleteItemsFromDatabase, Either<BaseError, Unit>>
{
private readonly IMediaItemRepository _mediaItemRepository;
private readonly ISearchIndex _searchIndex;
public DeleteItemsFromDatabaseHandler(
IMediaItemRepository mediaItemRepository,
ISearchIndex searchIndex)
{
_mediaItemRepository = mediaItemRepository;
_searchIndex = searchIndex;
}
public async Task<Either<BaseError, Unit>> Handle(
DeleteItemsFromDatabase request,
CancellationToken cancellationToken)
{
Either<BaseError, Unit> deleteResult = await _mediaItemRepository.DeleteItems(request.MediaItemIds);
if (deleteResult.IsRight)
{
await _searchIndex.RemoveItems(request.MediaItemIds);
_searchIndex.Commit();
}
return deleteResult;
}
}
}
@@ -1,5 +1,7 @@
namespace ErsatzTV.Application.MediaCards
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCards
{
public record ActorCardViewModel(int Id, string Name, string Role, string Thumb) :
MediaCardViewModel(Id, Name, Role, Name, Thumb);
public record ActorCardViewModel(int Id, string Name, string Role, string Thumb, MediaItemState State) :
MediaCardViewModel(Id, Name, Role, Name, Thumb, State);
}
@@ -1,10 +1,19 @@
namespace ErsatzTV.Application.MediaCards
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCards
{
public record ArtistCardViewModel
(int ArtistId, string Title, string Subtitle, string SortTitle, string Poster) : MediaCardViewModel(
ArtistId,
Title,
Subtitle,
SortTitle,
Poster);
(
int ArtistId,
string Title,
string Subtitle,
string SortTitle,
string Poster,
MediaItemState State) : MediaCardViewModel(
ArtistId,
Title,
Subtitle,
SortTitle,
Poster,
State);
}
+23 -10
View File
@@ -2,6 +2,7 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Jellyfin;
using LanguageExt;
using static LanguageExt.Prelude;
@@ -19,7 +20,8 @@ namespace ErsatzTV.Application.MediaCards
showMetadata.Title,
showMetadata.Year?.ToString(),
showMetadata.SortTitle,
GetPoster(showMetadata, maybeJellyfin, maybeEmby));
GetPoster(showMetadata, maybeJellyfin, maybeEmby),
showMetadata.Show.State);
internal static TelevisionSeasonCardViewModel ProjectToViewModel(
Season season,
@@ -34,7 +36,8 @@ namespace ErsatzTV.Application.MediaCards
GetSeasonName(season.SeasonNumber),
season.SeasonMetadata.HeadOrNone().Map(sm => GetPoster(sm, maybeJellyfin, maybeEmby))
.IfNone(string.Empty),
season.SeasonNumber == 0 ? "S" : season.SeasonNumber.ToString());
season.SeasonNumber == 0 ? "S" : season.SeasonNumber.ToString(),
season.State);
internal static TelevisionSeasonCardViewModel ProjectToViewModel(
SeasonMetadata seasonMetadata,
@@ -53,7 +56,8 @@ namespace ErsatzTV.Application.MediaCards
GetSeasonName(seasonMetadata.Season.SeasonNumber),
$"{showTitle}_{seasonMetadata.Season.SeasonNumber:0000}",
GetPoster(seasonMetadata, maybeJellyfin, maybeEmby),
seasonMetadata.Season.SeasonNumber == 0 ? "S" : seasonMetadata.Season.SeasonNumber.ToString());
seasonMetadata.Season.SeasonNumber == 0 ? "S" : seasonMetadata.Season.SeasonNumber.ToString(),
seasonMetadata.Season.State);
}
internal static TelevisionEpisodeCardViewModel ProjectToViewModel(
@@ -80,7 +84,9 @@ namespace ErsatzTV.Application.MediaCards
? GetEpisodePoster(episodeMetadata, maybeJellyfin, maybeEmby)
: GetThumbnail(episodeMetadata, maybeJellyfin, maybeEmby),
episodeMetadata.Directors.Map(d => d.Name).ToList(),
episodeMetadata.Writers.Map(w => w.Name).ToList());
episodeMetadata.Writers.Map(w => w.Name).ToList(),
episodeMetadata.Episode.State,
episodeMetadata.Episode.GetHeadVersion().MediaFiles.Head().Path);
internal static MovieCardViewModel ProjectToViewModel(
MovieMetadata movieMetadata,
@@ -91,7 +97,8 @@ namespace ErsatzTV.Application.MediaCards
movieMetadata.Title,
movieMetadata.Year?.ToString(),
movieMetadata.SortTitle,
GetPoster(movieMetadata, maybeJellyfin, maybeEmby));
GetPoster(movieMetadata, maybeJellyfin, maybeEmby),
movieMetadata.Movie.State);
internal static MusicVideoCardViewModel ProjectToViewModel(MusicVideoMetadata musicVideoMetadata) =>
new(
@@ -101,14 +108,17 @@ namespace ErsatzTV.Application.MediaCards
musicVideoMetadata.SortTitle,
musicVideoMetadata.Plot,
musicVideoMetadata.Album,
GetThumbnail(musicVideoMetadata, None, None));
GetThumbnail(musicVideoMetadata, None, None),
musicVideoMetadata.MusicVideo.State,
musicVideoMetadata.MusicVideo.GetHeadVersion().MediaFiles.Head().Path);
internal static OtherVideoCardViewModel ProjectToViewModel(OtherVideoMetadata otherVideoMetadata) =>
new(
otherVideoMetadata.OtherVideoId,
otherVideoMetadata.Title,
otherVideoMetadata.OriginalTitle,
otherVideoMetadata.SortTitle);
otherVideoMetadata.SortTitle,
otherVideoMetadata.OtherVideo.State);
internal static SongCardViewModel ProjectToViewModel(SongMetadata songMetadata)
{
@@ -117,7 +127,9 @@ namespace ErsatzTV.Application.MediaCards
songMetadata.SongId,
songMetadata.Title,
songMetadata.Artist + album,
songMetadata.SortTitle);
songMetadata.SortTitle,
GetThumbnail(songMetadata, None, None),
songMetadata.Song.State);
}
internal static ArtistCardViewModel ProjectToViewModel(ArtistMetadata artistMetadata) =>
@@ -126,7 +138,8 @@ namespace ErsatzTV.Application.MediaCards
artistMetadata.Title,
artistMetadata.Disambiguation,
artistMetadata.SortTitle,
GetThumbnail(artistMetadata, None, None));
GetThumbnail(artistMetadata, None, None),
artistMetadata.Artist.State);
internal static CollectionCardResultsViewModel
ProjectToViewModel(
@@ -174,7 +187,7 @@ namespace ErsatzTV.Application.MediaCards
.SetQueryParam("maxHeight", 440);
}
return new ActorCardViewModel(actor.Id, actor.Name, actor.Role, artwork);
return new ActorCardViewModel(actor.Id, actor.Name, actor.Role, artwork, MediaItemState.Normal);
}
private static int GetCustomIndex(Collection collection, int mediaItemId) =>
@@ -1,4 +1,12 @@
namespace ErsatzTV.Application.MediaCards
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCards
{
public record MediaCardViewModel(int MediaItemId, string Title, string Subtitle, string SortTitle, string Poster);
public record MediaCardViewModel(
int MediaItemId,
string Title,
string Subtitle,
string SortTitle,
string Poster,
MediaItemState State);
}
@@ -1,12 +1,21 @@
namespace ErsatzTV.Application.MediaCards
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCards
{
public record MovieCardViewModel
(int MovieId, string Title, string Subtitle, string SortTitle, string Poster) : MediaCardViewModel(
MovieId,
Title,
Subtitle,
SortTitle,
Poster)
(
int MovieId,
string Title,
string Subtitle,
string SortTitle,
string Poster,
MediaItemState State) : MediaCardViewModel(
MovieId,
Title,
Subtitle,
SortTitle,
Poster,
State)
{
public int CustomIndex { get; set; }
}
@@ -1,4 +1,6 @@
namespace ErsatzTV.Application.MediaCards
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCards
{
public record MusicVideoCardViewModel
(
@@ -8,12 +10,15 @@
string SortTitle,
string Plot,
string Album,
string Poster) : MediaCardViewModel(
string Poster,
MediaItemState State,
string Path) : MediaCardViewModel(
MusicVideoId,
Title,
Subtitle,
SortTitle,
Poster)
Poster,
State)
{
public int CustomIndex { get; set; }
}
@@ -1,16 +1,20 @@
namespace ErsatzTV.Application.MediaCards
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCards
{
public record OtherVideoCardViewModel
(
int OtherVideoId,
string Title,
string Subtitle,
string SortTitle) : MediaCardViewModel(
string SortTitle,
MediaItemState State) : MediaCardViewModel(
OtherVideoId,
Title,
Subtitle,
SortTitle,
null)
null,
State)
{
public int CustomIndex { get; set; }
}
@@ -1,16 +1,21 @@
namespace ErsatzTV.Application.MediaCards
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCards
{
public record SongCardViewModel
(
int SongId,
string Title,
string Subtitle,
string SortTitle) : MediaCardViewModel(
string SortTitle,
string Poster,
MediaItemState State) : MediaCardViewModel(
SongId,
Title,
Subtitle,
SortTitle,
null)
Poster,
State)
{
public int CustomIndex { get; set; }
}
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCards
{
@@ -17,10 +18,13 @@ namespace ErsatzTV.Application.MediaCards
string Plot,
string Poster,
List<string> Directors,
List<string> Writers) : MediaCardViewModel(
List<string> Writers,
MediaItemState State,
string Path) : MediaCardViewModel(
EpisodeId,
Title,
$"Episode {Episode}",
SortTitle,
Poster);
Poster,
State);
}
@@ -1,4 +1,6 @@
namespace ErsatzTV.Application.MediaCards
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCards
{
public record TelevisionSeasonCardViewModel
(
@@ -9,10 +11,12 @@
string Subtitle,
string SortTitle,
string Poster,
string Placeholder) : MediaCardViewModel(
string Placeholder,
MediaItemState State) : MediaCardViewModel(
TelevisionSeasonId,
Title,
Subtitle,
SortTitle,
Poster);
Poster,
State);
}
@@ -1,10 +1,19 @@
namespace ErsatzTV.Application.MediaCards
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCards
{
public record TelevisionShowCardViewModel
(int TelevisionShowId, string Title, string Subtitle, string SortTitle, string Poster) : MediaCardViewModel(
TelevisionShowId,
Title,
Subtitle,
SortTitle,
Poster);
(
int TelevisionShowId,
string Title,
string Subtitle,
string SortTitle,
string Poster,
MediaItemState State) : MediaCardViewModel(
TelevisionShowId,
Title,
Subtitle,
SortTitle,
Poster,
State);
}
@@ -7,7 +7,7 @@ namespace ErsatzTV.Application.MediaCollections
internal static class Mapper
{
internal static MediaCollectionViewModel ProjectToViewModel(Collection collection) =>
new(collection.Id, collection.Name, collection.UseCustomPlaybackOrder);
new(collection.Id, collection.Name, collection.UseCustomPlaybackOrder, MediaItemState.Normal);
internal static MultiCollectionViewModel ProjectToViewModel(MultiCollection multiCollection) =>
new(
@@ -1,11 +1,17 @@
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCollections
{
public record MediaCollectionViewModel(int Id, string Name, bool UseCustomPlaybackOrder) : MediaCardViewModel(
public record MediaCollectionViewModel(
int Id,
string Name,
bool UseCustomPlaybackOrder,
MediaItemState State) : MediaCardViewModel(
Id,
Name,
string.Empty,
Name,
string.Empty);
string.Empty,
State);
}
@@ -160,15 +160,36 @@ namespace ErsatzTV.Application.MediaSources.Commands
return Unit.Default;
}
private async Task<Validation<BaseError, RequestParameters>> Validate(IScanLocalLibrary request) =>
(await LocalLibraryMustExist(request), await ValidateFFprobePath(), await ValidateFFmpegPath(), await ValidateLibraryRefreshInterval())
.Apply(
(library, ffprobePath, ffmpegPath, libraryRefreshInterval) => new RequestParameters(
library,
ffprobePath,
ffmpegPath,
request.ForceScan,
libraryRefreshInterval));
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) =>
+4 -1
View File
@@ -4,6 +4,7 @@ using System.Globalization;
using System.Linq;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Jellyfin;
using Flurl;
using LanguageExt;
@@ -34,7 +35,9 @@ namespace ErsatzTV.Application.Movies
.Map(a => MediaCards.Mapper.ProjectToViewModel(a, maybeJellyfin, maybeEmby))
.ToList(),
metadata.Directors.Map(d => d.Name).ToList(),
metadata.Writers.Map(w => w.Name).ToList())
metadata.Writers.Map(w => w.Name).ToList(),
movie.GetHeadVersion().MediaFiles.Head().Path,
movie.State)
{
Poster = Artwork(metadata, ArtworkKind.Poster, maybeJellyfin, maybeEmby),
FanArt = Artwork(metadata, ArtworkKind.FanArt, maybeJellyfin, maybeEmby)
@@ -1,6 +1,7 @@
using System.Collections.Generic;
using System.Globalization;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Movies
{
@@ -15,7 +16,9 @@ namespace ErsatzTV.Application.Movies
List<CultureInfo> Languages,
List<ActorCardViewModel> Actors,
List<string> Directors,
List<string> Writers)
List<string> Writers,
string Path,
MediaItemState MediaItemState)
{
public string Poster { get; set; }
public string FanArt { get; set; }
@@ -33,7 +33,7 @@ namespace ErsatzTV.Application.Movies.Queries
GetMovieById request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<JellyfinMediaSource> maybeJellyfin = await _mediaSourceRepository.GetAllJellyfin()
.Map(list => list.HeadOrNone());
+4 -1
View File
@@ -49,8 +49,11 @@ namespace ErsatzTV.Application.Playouts
.Map(s => string.IsNullOrWhiteSpace(playoutItem.ChapterTitle) ? s : $"{s} ({playoutItem.ChapterTitle})")
.IfNone("[unknown video]");
case Song s:
string songArtist = s.SongMetadata.HeadOrNone()
.Map(sm => string.IsNullOrWhiteSpace(sm.Artist) ? string.Empty : $"{sm.Artist} - ")
.IfNone(string.Empty);
return s.SongMetadata.HeadOrNone()
.Map(sm => sm.Title ?? string.Empty)
.Map(sm => $"{songArtist}{sm.Title ?? string.Empty}")
.Map(t => string.IsNullOrWhiteSpace(playoutItem.ChapterTitle) ? t : $"{s} ({playoutItem.ChapterTitle})")
.IfNone("[unknown song]");
default:
@@ -1,6 +1,7 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Timers;
@@ -132,12 +133,16 @@ namespace ErsatzTV.Application.Streaming
using IServiceScope scope = _serviceScopeFactory.CreateScope();
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
long ptsOffset = await GetPtsOffset(mediator, channelNumber, cancellationToken);
// _logger.LogInformation("PTS offset: {PtsOffset}", ptsOffset);
var request = new GetPlayoutItemProcessByChannelNumber(
channelNumber,
"segmenter",
firstProcess ? DateTimeOffset.Now : _transcodedUntil.AddSeconds(1),
!firstProcess,
realtime);
realtime,
ptsOffset);
// _logger.LogInformation("Request {@Request}", request);
@@ -231,6 +236,28 @@ namespace ErsatzTV.Application.Streaming
_playlistStart = trimResult.PlaylistStart;
}
}
private async Task<long> GetPtsOffset(IMediator mediator, string channelNumber, CancellationToken cancellationToken)
{
var directory = new DirectoryInfo(Path.Combine(FileSystemLayout.TranscodeFolder, channelNumber));
Option<FileInfo> lastSegment =
Optional(directory.GetFiles("*.ts").OrderByDescending(f => f.Name).FirstOrDefault());
long result = 0;
foreach (FileInfo segment in lastSegment)
{
Either<BaseError, PtsAndDuration> queryResult = await mediator.Send(
new GetLastPtsDuration(segment.FullName),
cancellationToken);
foreach (PtsAndDuration ptsAndDuration in queryResult.RightToSeq())
{
result = ptsAndDuration.Pts + ptsAndDuration.Duration;
}
}
return result;
}
private async Task<int> GetWorkAheadLimit()
{
@@ -0,0 +1,12 @@
namespace ErsatzTV.Application.Streaming;
public record PtsAndDuration(long Pts, long Duration)
{
public static PtsAndDuration From(string ffprobeLine)
{
string[] split = ffprobeLine.Split("|");
var left = long.Parse(split[0]);
var right = long.Parse(split[1]);
return new PtsAndDuration(left, right);
}
}
@@ -23,7 +23,7 @@ namespace ErsatzTV.Application.Streaming.Queries
public async Task<Either<BaseError, PlayoutItemProcessModel>> Handle(T request, CancellationToken cancellationToken)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, Tuple<Channel, string>> validation = await Validate(dbContext, request);
return await validation.Match(
tuple => GetProcess(dbContext, request, tuple.Item1, tuple.Item2),
@@ -56,7 +56,8 @@ namespace ErsatzTV.Application.Streaming.Queries
{
"hls-direct" => StreamingMode.HttpLiveStreamingDirect,
"segmenter" => StreamingMode.HttpLiveStreamingSegmenter,
"ts" => StreamingMode.TransportStream,
"ts" => StreamingMode.TransportStreamHybrid,
"ts-legacy" => StreamingMode.TransportStream,
_ => channel.StreamingMode
};
@@ -11,5 +11,6 @@ namespace ErsatzTV.Application.Streaming.Queries
string Mode,
DateTimeOffset Now,
bool StartAtZero,
bool HlsRealtime) : IRequest<Either<BaseError, PlayoutItemProcessModel>>;
bool HlsRealtime,
long PtsOffset) : IRequest<Either<BaseError, PlayoutItemProcessModel>>;
}
@@ -6,10 +6,11 @@ namespace ErsatzTV.Application.Streaming.Queries
{
public GetConcatProcessByChannelNumber(string scheme, string host, string channelNumber) : base(
channelNumber,
"ts",
"ts-legacy",
DateTimeOffset.Now,
false,
true)
true,
0)
{
Scheme = scheme;
Host = host;
@@ -1,11 +1,9 @@
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Runtime;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using LanguageExt;
@@ -16,16 +14,13 @@ namespace ErsatzTV.Application.Streaming.Queries
public class GetConcatProcessByChannelNumberHandler : FFmpegProcessHandler<GetConcatProcessByChannelNumber>
{
private readonly IFFmpegProcessService _ffmpegProcessService;
private readonly IRuntimeInfo _runtimeInfo;
public GetConcatProcessByChannelNumberHandler(
IDbContextFactory<TvContext> dbContextFactory,
IFFmpegProcessService ffmpegProcessService,
IRuntimeInfo runtimeInfo)
IFFmpegProcessService ffmpegProcessService)
: base(dbContextFactory)
{
_ffmpegProcessService = ffmpegProcessService;
_runtimeInfo = runtimeInfo;
}
protected override async Task<Either<BaseError, PlayoutItemProcessModel>> GetProcess(
@@ -34,7 +29,7 @@ namespace ErsatzTV.Application.Streaming.Queries
Channel channel,
string ffmpegPath)
{
bool saveReports = !_runtimeInfo.IsOSPlatform(OSPlatform.Windows) && await dbContext.ConfigElements
bool saveReports = await dbContext.ConfigElements
.GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
.Map(result => result.IfNone(false));
@@ -0,0 +1,7 @@
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.Streaming.Queries;
public record GetLastPtsDuration(string FileName) : IRequest<Either<BaseError, PtsAndDuration>>;
@@ -0,0 +1,87 @@
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.Streaming.Queries;
public class GetLastPtsDurationHandler : IRequestHandler<GetLastPtsDuration, Either<BaseError, PtsAndDuration>>
{
private readonly IConfigElementRepository _configElementRepository;
public GetLastPtsDurationHandler(IConfigElementRepository configElementRepository)
{
_configElementRepository = configElementRepository;
}
public async Task<Either<BaseError, PtsAndDuration>> Handle(
GetLastPtsDuration request,
CancellationToken cancellationToken)
{
Validation<BaseError, RequestParameters> validation = await Validate(request);
return await validation.Match(
Handle,
error => Task.FromResult<Either<BaseError, PtsAndDuration>>(error.Join()));
}
private async Task<Validation<BaseError, RequestParameters>> Validate(GetLastPtsDuration request) =>
await ValidateFFprobePath()
.MapT(
ffprobePath => new RequestParameters(
request.FileName,
ffprobePath));
private async Task<Either<BaseError, PtsAndDuration>> Handle(RequestParameters parameters)
{
var startInfo = new ProcessStartInfo
{
FileName = parameters.FFprobePath,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8
};
startInfo.ArgumentList.Add("-v");
startInfo.ArgumentList.Add("0");
startInfo.ArgumentList.Add("-show_entries");
startInfo.ArgumentList.Add("packet=pts,duration");
startInfo.ArgumentList.Add("-of");
startInfo.ArgumentList.Add("compact=p=0:nk=1");
startInfo.ArgumentList.Add("-read_intervals");
startInfo.ArgumentList.Add("-999999");
startInfo.ArgumentList.Add(parameters.FileName);
var probe = new Process
{
StartInfo = startInfo
};
probe.Start();
return await probe.StandardOutput.ReadToEndAsync().MapAsync<string, Either<BaseError, PtsAndDuration>>(
async output =>
{
await probe.WaitForExitAsync();
return probe.ExitCode == 0
? PtsAndDuration.From(output.Split("\n").Filter(s => !string.IsNullOrWhiteSpace(s)).Last().Trim())
: BaseError.New($"FFprobe at {parameters.FFprobePath} exited with code {probe.ExitCode}");
});
}
private Task<Validation<BaseError, string>> ValidateFFprobePath() =>
_configElementRepository.GetValue<string>(ConfigElementKey.FFprobePath)
.FilterT(File.Exists)
.Map(
ffprobePath =>
ffprobePath.ToValidation<BaseError>("FFprobe path does not exist on the file system"));
private record RequestParameters(string FileName, string FFprobePath);
}
@@ -2,20 +2,15 @@
namespace ErsatzTV.Application.Streaming.Queries
{
public record GetPlayoutItemProcessByChannelNumber : FFmpegProcessRequest
{
public GetPlayoutItemProcessByChannelNumber(
string channelNumber,
string mode,
DateTimeOffset now,
bool startAtZero,
bool hlsRealtime) : base(
channelNumber,
mode,
now,
startAtZero,
hlsRealtime)
{
}
}
public record GetPlayoutItemProcessByChannelNumber(string ChannelNumber,
string Mode,
DateTimeOffset Now,
bool StartAtZero,
bool HlsRealtime,
long PtsOffset) : FFmpegProcessRequest(ChannelNumber,
Mode,
Now,
StartAtZero,
HlsRealtime,
PtsOffset);
}
@@ -1,25 +1,19 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.Emby;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Runtime;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
@@ -40,9 +34,7 @@ namespace ErsatzTV.Application.Streaming.Queries
private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService;
private readonly ILocalFileSystem _localFileSystem;
private readonly IPlexPathReplacementService _plexPathReplacementService;
private readonly IRuntimeInfo _runtimeInfo;
private readonly IImageCache _imageCache;
private readonly ITempFilePool _tempFilePool;
private readonly ISongVideoGenerator _songVideoGenerator;
public GetPlayoutItemProcessByChannelNumberHandler(
IDbContextFactory<TvContext> dbContextFactory,
@@ -54,9 +46,7 @@ namespace ErsatzTV.Application.Streaming.Queries
IMediaCollectionRepository mediaCollectionRepository,
ITelevisionRepository televisionRepository,
IArtistRepository artistRepository,
IRuntimeInfo runtimeInfo,
IImageCache imageCache,
ITempFilePool tempFilePool)
ISongVideoGenerator songVideoGenerator)
: base(dbContextFactory)
{
_ffmpegProcessService = ffmpegProcessService;
@@ -67,9 +57,7 @@ namespace ErsatzTV.Application.Streaming.Queries
_mediaCollectionRepository = mediaCollectionRepository;
_televisionRepository = televisionRepository;
_artistRepository = artistRepository;
_runtimeInfo = runtimeInfo;
_imageCache = imageCache;
_tempFilePool = tempFilePool;
_songVideoGenerator = songVideoGenerator;
}
protected override async Task<Either<BaseError, PlayoutItemProcessModel>> GetProcess(
@@ -142,124 +130,14 @@ namespace ErsatzTV.Application.Streaming.Queries
if (playoutItemWithPath.PlayoutItem.MediaItem is Song song)
{
Option<string> drawtextFile = None;
videoVersion = new FallbackMediaVersion
{
Id = -1,
Chapters = new List<MediaChapter>(),
Width = 192,
Height = 108,
SampleAspectRatio = "1:1",
Streams = new List<MediaStream>
{
new() { MediaStreamKind = MediaStreamKind.Video, Index = 0 }
}
};
string[] backgrounds =
{
"background_blank.png",
"background_e.png",
"background_t.png",
"background_v.png"
};
var random = new Random();
// use random ETV color by default
string artworkPath = Path.Combine(
FileSystemLayout.ResourcesCacheFolder,
backgrounds[random.Next() % backgrounds.Length]);
// use thumbnail (cover art) if present
foreach (SongMetadata metadata in song.SongMetadata)
{
string fileName = _tempFilePool.GetNextTempFile(TempFileCategory.DrawText);
drawtextFile = fileName;
var sb = new StringBuilder();
if (!string.IsNullOrWhiteSpace(metadata.Artist))
{
sb.AppendLine(metadata.Artist);
}
if (!string.IsNullOrWhiteSpace(metadata.Title))
{
sb.AppendLine($"\"{metadata.Title}\"");
}
if (!string.IsNullOrWhiteSpace(metadata.Album))
{
sb.AppendLine(metadata.Album);
}
await File.WriteAllTextAsync(fileName, sb.ToString());
foreach (Artwork artwork in Optional(
metadata.Artwork.Find(a => a.ArtworkKind == ArtworkKind.Thumbnail)))
{
string customPath = _imageCache.GetPathForImage(
artwork.Path,
ArtworkKind.Thumbnail,
Option<int>.None);
artworkPath = customPath;
// signal that we want to use cover art as watermark
videoVersion = new CoverArtMediaVersion
{
Chapters = new List<MediaChapter>(),
// always stretch cover art
Width = 192,
Height = 108,
SampleAspectRatio = "1:1",
Streams = new List<MediaStream>
{
new() { MediaStreamKind = MediaStreamKind.Video, Index = 0 }
}
};
}
}
videoPath = artworkPath;
videoVersion.MediaFiles = new List<MediaFile>
{
new() { Path = videoPath }
};
Either<BaseError, string> maybeSongImage = await _ffmpegProcessService.GenerateSongImage(
ffmpegPath,
drawtextFile,
(videoPath, videoVersion) = await _songVideoGenerator.GenerateSongVideo(
song,
channel,
maybeGlobalWatermark,
videoVersion,
videoPath);
foreach (string si in maybeSongImage.RightToSeq())
{
videoPath = si;
videoVersion = new BackgroundImageMediaVersion
{
Chapters = new List<MediaChapter>(),
// song image has been pre-generated with correct size
Height = channel.FFmpegProfile.Resolution.Height,
Width = channel.FFmpegProfile.Resolution.Width,
SampleAspectRatio = "1:1",
Streams = new List<MediaStream>
{
new() { MediaStreamKind = MediaStreamKind.Video, Index = 0 },
},
MediaFiles = new List<MediaFile>
{
new() { Path = si }
}
};
}
ffmpegPath);
}
bool saveReports = !_runtimeInfo.IsOSPlatform(OSPlatform.Windows) && await dbContext.ConfigElements
bool saveReports = await dbContext.ConfigElements
.GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
.Map(result => result.IfNone(false));
@@ -280,7 +158,8 @@ namespace ErsatzTV.Application.Streaming.Queries
request.HlsRealtime,
playoutItemWithPath.PlayoutItem.FillerKind,
playoutItemWithPath.PlayoutItem.InPoint,
playoutItemWithPath.PlayoutItem.OutPoint);
playoutItemWithPath.PlayoutItem.OutPoint,
request.PtsOffset);
var result = new PlayoutItemProcessModel(process, playoutItemWithPath.PlayoutItem.FinishOffset);
@@ -310,12 +189,13 @@ namespace ErsatzTV.Application.Streaming.Queries
case UnableToLocatePlayoutItem:
if (channel.FFmpegProfile.Transcode)
{
Process errorProcess = _ffmpegProcessService.ForError(
Process errorProcess = await _ffmpegProcessService.ForError(
ffmpegPath,
channel,
maybeDuration,
"Channel is Offline",
request.HlsRealtime);
request.HlsRealtime,
request.PtsOffset);
return new PlayoutItemProcessModel(errorProcess, finish);
}
@@ -329,12 +209,13 @@ namespace ErsatzTV.Application.Streaming.Queries
case PlayoutItemDoesNotExistOnDisk:
if (channel.FFmpegProfile.Transcode)
{
Process errorProcess = _ffmpegProcessService.ForError(
Process errorProcess = await _ffmpegProcessService.ForError(
ffmpegPath,
channel,
maybeDuration,
error.Value,
request.HlsRealtime);
request.HlsRealtime,
request.PtsOffset);
return new PlayoutItemProcessModel(errorProcess, finish);
}
@@ -348,12 +229,13 @@ namespace ErsatzTV.Application.Streaming.Queries
default:
if (channel.FFmpegProfile.Transcode)
{
Process errorProcess = _ffmpegProcessService.ForError(
Process errorProcess = await _ffmpegProcessService.ForError(
ffmpegPath,
channel,
maybeDuration,
"Channel is Offline",
request.HlsRealtime);
request.HlsRealtime,
request.PtsOffset);
return new PlayoutItemProcessModel(errorProcess, finish);
}
@@ -0,0 +1,22 @@
using System;
namespace ErsatzTV.Application.Streaming.Queries
{
public record GetWrappedProcessByChannelNumber : FFmpegProcessRequest
{
public GetWrappedProcessByChannelNumber(string scheme, string host, string channelNumber) : base(
channelNumber,
"ts",
DateTimeOffset.Now,
false,
true,
0)
{
Scheme = scheme;
Host = host;
}
public string Scheme { get; }
public string Host { get; }
}
}
@@ -0,0 +1,46 @@
using System;
using System.Diagnostics;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Streaming.Queries
{
public class GetWrappedProcessByChannelNumberHandler : FFmpegProcessHandler<GetWrappedProcessByChannelNumber>
{
private readonly IFFmpegProcessService _ffmpegProcessService;
public GetWrappedProcessByChannelNumberHandler(
IDbContextFactory<TvContext> dbContextFactory,
IFFmpegProcessService ffmpegProcessService)
: base(dbContextFactory)
{
_ffmpegProcessService = ffmpegProcessService;
}
protected override async Task<Either<BaseError, PlayoutItemProcessModel>> GetProcess(
TvContext dbContext,
GetWrappedProcessByChannelNumber request,
Channel channel,
string ffmpegPath)
{
bool saveReports = await dbContext.ConfigElements
.GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
.Map(result => result.IfNone(false));
Process process = _ffmpegProcessService.WrapSegmenter(
ffmpegPath,
saveReports,
channel,
request.Scheme,
request.Host);
return new PlayoutItemProcessModel(process, DateTimeOffset.MaxValue);
}
}
}
@@ -1,106 +0,0 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes;
using ErsatzTV.Api.Sdk.Api;
using ErsatzTV.Api.Sdk.Model;
using LanguageExt;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
namespace ErsatzTV.CommandLine.Commands
{
[Command("channel", Description = "Create or rename a channel")]
public class ChannelCommand : ICommand
{
private readonly ChannelsApi _channelsApi;
private readonly FFmpegProfileApi _ffmpegProfileApi;
private readonly ILogger<ChannelCommand> _logger;
public ChannelCommand(IConfiguration configuration, ILogger<ChannelCommand> logger)
{
_logger = logger;
_channelsApi = new ChannelsApi(configuration["ServerUrl"]);
_ffmpegProfileApi = new FFmpegProfileApi(configuration["ServerUrl"]);
}
[CommandParameter(0, Name = "channel-number", Description = "The channel number")]
public int Number { get; set; }
[CommandParameter(1, Name = "channel-name", Description = "The channel name")]
public string Name { get; set; }
[CommandParameter(2, Name = "streaming-mode", Description = "The streaming mode")]
public StreamingMode StreamingMode { get; set; }
[CommandOption("ffmpeg-profile", Description = "The ffmpeg profile name")]
public string FFmpegProfileName { get; set; }
public async ValueTask ExecuteAsync(IConsole console)
{
try
{
Option<ChannelViewModel> maybeChannel = await _channelsApi.ApiChannelsGetAsync()
.Map(list => Optional(list.SingleOrDefault(c => c.Number == Number)));
FFmpegProfileViewModel ffmpegProfile = await _ffmpegProfileApi.ApiFfmpegProfilesGetAsync()
.Map(
list => Optional(list.SingleOrDefault(p => p.Name == FFmpegProfileName))
.IfNone(new FFmpegProfileViewModel { Id = 1 }));
await maybeChannel.Match(
channel => RenameChannel(channel, ffmpegProfile),
() => AddChannel(ffmpegProfile));
}
catch (Exception ex)
{
_logger.LogError("Unable to synchronize channel: {Message}", ex.Message);
}
}
private async ValueTask RenameChannel(ChannelViewModel existing, FFmpegProfileViewModel ffmpegProfile)
{
int newFFmpegProfileId = string.IsNullOrWhiteSpace(FFmpegProfileName)
? existing.FfmpegProfileId
: ffmpegProfile.Id;
if (existing.Name != Name || existing.FfmpegProfileId != newFFmpegProfileId ||
existing.StreamingMode != StreamingMode)
{
var updateChannel = new UpdateChannel(
existing.Id,
Name,
existing.Number,
newFFmpegProfileId,
existing.Logo,
StreamingMode);
await _channelsApi.ApiChannelsPatchAsync(updateChannel);
}
_logger.LogInformation(
"Successfully synchronized channel {ChannelNumber} - {ChannelName}",
Number,
Name);
}
private async ValueTask AddChannel(FFmpegProfileViewModel ffmpegProfile)
{
var createChannel = new CreateChannel(
Name,
Number,
ffmpegProfile.Id,
null,
StreamingMode);
await _channelsApi.ApiChannelsPostAsync(createChannel);
_logger.LogInformation(
"Successfully created channel {ChannelNumber} - {ChannelName}",
Number,
Name);
}
}
}
@@ -1,31 +0,0 @@
using System;
using System.IO;
using System.Text.Json;
using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes;
namespace ErsatzTV.CommandLine.Commands
{
[Command("config", Description = "Configure ErsatzTV server url")]
public class ConfigCommand : ICommand
{
[CommandParameter(0, Name = "server-url", Description = "The url of the ErsatzTV server")]
public string ServerUrl { get; set; }
public async ValueTask ExecuteAsync(IConsole console)
{
// TODO: validate URL
string configFolder = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"ersatztv");
string configFile = Path.Combine(configFolder, "cli.json");
var config = new Config { ServerUrl = ServerUrl };
string contents = JsonSerializer.Serialize(config);
await File.WriteAllTextAsync(configFile, contents);
}
}
}
@@ -1,151 +0,0 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes;
using ErsatzTV.Api.Sdk.Api;
using ErsatzTV.Api.Sdk.Model;
using LanguageExt;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
namespace ErsatzTV.CommandLine.Commands
{
[Command("ffmpeg-profile", Description = "Synchronize an ffmpeg profile")]
public class FFmpegProfileCommand : ICommand
{
private readonly FFmpegProfileApi _ffmpegProfileApi;
private readonly ILogger<FFmpegProfileCommand> _logger;
public FFmpegProfileCommand(IConfiguration configuration, ILogger<FFmpegProfileCommand> logger)
{
_logger = logger;
_ffmpegProfileApi = new FFmpegProfileApi(configuration["ServerUrl"]);
}
[CommandParameter(0, Name = "profile-name", Description = "The ffmpeg profile name")]
public string Name { get; set; }
[CommandOption("thread-count", Description = "The number of threads")]
public int ThreadCount { get; set; } = 0;
[CommandOption("transcode", Description = "Whether to transcode all media")]
public bool Transcode { get; set; } = true;
// public int ResolutionId { get; set; } = resolution.Id;
// Resolution { get; set; } = resolution;
[CommandOption("resolution", Description = "The resolution")]
public DesiredResolution Resolution { get; set; } = DesiredResolution.W1920H1080;
[CommandOption("video-codec", Description = "The video codec")]
public string VideoCodec { get; set; } = "libx264";
[CommandOption("audio-codec", Description = "The audio codec")]
public string AudioCodec { get; set; } = "ac3";
[CommandOption("video-bitrate", Description = "The video bitrate in kBit/s")]
public int VideoBitrate { get; set; } = 2000;
[CommandOption("video-buffer-size", Description = "The video buffer size in kBit")]
public int VideoBufferSize { get; set; } = 2000;
[CommandOption("audio-bitrate", Description = "The audio bitrate in kBit/s")]
public int AudioBitrate { get; set; } = 192;
[CommandOption("audio-buffer-size", Description = "The audio buffer size in kBits")]
public int AudioBufferSize { get; set; } = 50;
[CommandOption("audio-volume", Description = "The audio volume as a whole number percent")]
public int AudioVolume { get; set; } = 100;
[CommandOption("audio-channels", Description = "The number of audio channels")]
public int AudioChannels { get; set; } = 2;
[CommandOption("audio-sample-rate", Description = "The audio sample rate in kHz")]
public int AudioSampleRate { get; set; } = 48;
[CommandOption("normalize-resolution", Description = "Whether to normalize the resolution of all media")]
public bool NormalizeResolution { get; set; } = true;
[CommandOption("normalize-video-codec", Description = "Whether to normalize the video codec of all media")]
public bool NormalizeVideoCodec { get; set; } = true;
[CommandOption("normalize-audio-codec", Description = "Whether to normalize the audio codec of all media")]
public bool NormalizeAudioCodec { get; set; } = true;
[CommandOption(
"normalize-audio",
Description = "Whether to normalize audio channels and sample rate of all media")]
public bool NormalizeAudio { get; set; } = true;
public async ValueTask ExecuteAsync(IConsole console)
{
try
{
Option<FFmpegProfileViewModel> maybeFFmpegProfile = await _ffmpegProfileApi.ApiFfmpegProfilesGetAsync()
.Map(list => Optional(list.SingleOrDefault(p => p.Name == Name)));
await maybeFFmpegProfile.Match(UpdateProfile, AddProfile);
}
catch (Exception ex)
{
_logger.LogError("Unable to synchronize ffmpeg profile: {Message}", ex.Message);
}
}
private async ValueTask UpdateProfile(FFmpegProfileViewModel existing)
{
var updateFFmpegProfile = new UpdateFFmpegProfile(
existing.Id,
Name,
ThreadCount,
Transcode,
(int) Resolution,
NormalizeResolution,
VideoCodec,
NormalizeVideoCodec,
VideoBitrate,
VideoBufferSize,
AudioCodec,
NormalizeAudioCodec,
AudioBitrate,
AudioBufferSize,
AudioVolume,
AudioChannels,
AudioSampleRate,
NormalizeAudio);
await _ffmpegProfileApi.ApiFfmpegProfilesPatchAsync(updateFFmpegProfile);
_logger.LogInformation("Successfully synchronized ffmpeg profile {ProfileName}", Name);
}
private async ValueTask AddProfile()
{
var createFFmpegProfile = new CreateFFmpegProfile(
Name,
ThreadCount,
Transcode,
(int) Resolution,
NormalizeResolution,
VideoCodec,
NormalizeVideoCodec,
VideoBitrate,
VideoBufferSize,
AudioCodec,
NormalizeAudioCodec,
AudioBitrate,
AudioBufferSize,
AudioVolume,
AudioChannels,
AudioSampleRate,
NormalizeAudio);
await _ffmpegProfileApi.ApiFfmpegProfilesPostAsync(createFFmpegProfile);
_logger.LogInformation("Successfully created ffmpeg profile {ProfileName}", Name);
}
}
}
@@ -1,86 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes;
using ErsatzTV.Api.Sdk.Api;
using ErsatzTV.Api.Sdk.Model;
using LanguageExt;
using LanguageExt.Common;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
namespace ErsatzTV.CommandLine.Commands.MediaCollections
{
[Command("collection clear", Description = "Removes all items from a media collection")]
public class MediaCollectionClearCommand : ICommand
{
private readonly ILogger<MediaCollectionClearCommand> _logger;
private readonly string _serverUrl;
public MediaCollectionClearCommand(IConfiguration configuration, ILogger<MediaCollectionClearCommand> logger)
{
_logger = logger;
_serverUrl = configuration["ServerUrl"];
}
[CommandParameter(0, Name = "collection-name", Description = "The name of the media collection")]
public string Name { get; set; }
public async ValueTask ExecuteAsync(IConsole console)
{
try
{
CancellationToken cancellationToken = console.GetCancellationToken();
Either<Error, Unit> result = await ClearMediaCollection(cancellationToken);
result.Match(
_ => _logger.LogInformation("Successfully cleared media collection {MediaCollection}", Name),
error => _logger.LogError(
"Unable to clear media collection: {Error}",
error.Message));
}
catch (Exception ex)
{
_logger.LogError("Unable to clear media collection: {Error}", ex.Message);
}
}
private async Task<Either<Error, Unit>> ClearMediaCollection(CancellationToken cancellationToken) =>
await EnsureMediaCollectionExists(cancellationToken)
.BindAsync(mediaCollectionId => ClearMediaCollectionImpl(mediaCollectionId, cancellationToken));
private async Task<Either<Error, int>> EnsureMediaCollectionExists(CancellationToken cancellationToken)
{
var mediaCollectionsApi = new MediaCollectionsApi(_serverUrl);
Option<MediaCollectionViewModel> maybeExisting =
(await mediaCollectionsApi.ApiMediaCollectionsGetAsync(cancellationToken))
.SingleOrDefault(mc => mc.Name == Name);
return await maybeExisting.MatchAsync(
existing => existing.Id,
async () =>
{
var data = new CreateSimpleMediaCollection(Name);
MediaCollectionViewModel result =
await mediaCollectionsApi.ApiMediaCollectionsPostAsync(data, cancellationToken);
return result.Id;
});
}
private async Task<Either<Error, Unit>> ClearMediaCollectionImpl(
int mediaCollectionId,
CancellationToken cancellationToken)
{
var mediaCollectionsApi = new MediaCollectionsApi(_serverUrl);
await mediaCollectionsApi.ApiMediaCollectionsIdItemsPutAsync(
mediaCollectionId,
new List<int>(),
cancellationToken);
return unit;
}
}
}
@@ -1,72 +0,0 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes;
using ErsatzTV.Api.Sdk.Api;
using ErsatzTV.Api.Sdk.Model;
using LanguageExt;
using LanguageExt.Common;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
namespace ErsatzTV.CommandLine.Commands.MediaCollections
{
[Command("collection create", Description = "Creates a new media collection")]
public class MediaCollectionCreateCommand : ICommand
{
private readonly ILogger<MediaCollectionCreateCommand> _logger;
private readonly string _serverUrl;
public MediaCollectionCreateCommand(IConfiguration configuration, ILogger<MediaCollectionCreateCommand> logger)
{
_logger = logger;
_serverUrl = configuration["ServerUrl"];
}
[CommandParameter(0, Name = "collection-name", Description = "The name of the media collection")]
public string Name { get; set; }
public async ValueTask ExecuteAsync(IConsole console)
{
try
{
CancellationToken cancellationToken = console.GetCancellationToken();
Either<Error, Unit> result = await CreateMediaCollection(cancellationToken);
result.IfLeft(error => _logger.LogError("Unable to create media collection: {Error}", error.Message));
}
catch (Exception ex)
{
_logger.LogError("Unable to create media collection: {Error}", ex.Message);
}
}
private async Task<Either<Error, Unit>> CreateMediaCollection(CancellationToken cancellationToken) =>
await EnsureMediaCollectionExists(cancellationToken);
private async Task<Either<Error, Unit>> EnsureMediaCollectionExists(CancellationToken cancellationToken)
{
var mediaCollectionsApi = new MediaCollectionsApi(_serverUrl);
bool needToAdd = await mediaCollectionsApi
.ApiMediaCollectionsGetAsync(cancellationToken)
.Map(list => list.All(mc => mc.Name != Name));
if (needToAdd)
{
var data = new CreateSimpleMediaCollection(Name);
await mediaCollectionsApi.ApiMediaCollectionsPostAsync(data, cancellationToken);
_logger.LogInformation("Successfully created media collection {MediaCollection}", Name);
}
else
{
_logger.LogInformation("Media collection {MediaCollection} is already present", Name);
}
return unit;
}
}
}
@@ -1,107 +0,0 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes;
using ErsatzTV.Api.Sdk.Api;
using ErsatzTV.Api.Sdk.Model;
using LanguageExt;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.CommandLine.Commands
{
[Command("playout build", Description = "Builds a playout with the requested channel and schedule")]
public class PlayoutCommand : ICommand
{
private readonly ILogger<PlayoutCommand> _logger;
private readonly string _serverUrl;
public PlayoutCommand(IConfiguration configuration, ILogger<PlayoutCommand> logger)
{
_logger = logger;
_serverUrl = configuration["ServerUrl"];
}
[CommandParameter(0, Name = "channel-number", Description = "The channel number")]
public int ChannelNumber { get; set; }
[CommandParameter(1, Name = "schedule-name", Description = "The schedule name")]
public string ScheduleName { get; set; }
// [Option("--type <type>")]
// [Required]
// public ProgramSchedulePlayoutType PlayoutType { get; set; }
public async ValueTask ExecuteAsync(IConsole console)
{
try
{
CancellationToken cancellationToken = console.GetCancellationToken();
var channelsApi = new ChannelsApi(_serverUrl);
Option<ChannelViewModel> maybeChannel = await channelsApi.ApiChannelsGetAsync(cancellationToken)
.Map(list => list.SingleOrDefault(c => c.Number == ChannelNumber));
await maybeChannel.Match(
channel => BuildPlayout(cancellationToken, channel),
() =>
{
_logger.LogError("Unable to locate channel number {ChannelNumber}", ChannelNumber);
return ValueTask.CompletedTask;
});
}
catch (Exception ex)
{
_logger.LogError("Unable to build playout: {Error}", ex.Message);
}
}
private async ValueTask BuildPlayout(CancellationToken cancellationToken, ChannelViewModel channel)
{
var programScheduleApi = new ProgramScheduleApi(_serverUrl);
Option<ProgramScheduleViewModel> maybeSchedule = await programScheduleApi
.ApiSchedulesGetAsync(cancellationToken)
.Map(list => list.SingleOrDefault(s => s.Name == ScheduleName));
await maybeSchedule.Match(
schedule => SynchronizePlayoutAsync(channel.Id, schedule.Id, cancellationToken),
() =>
{
_logger.LogError("Unable to locate schedule {Schedule}", ScheduleName);
return ValueTask.CompletedTask;
});
}
private async ValueTask SynchronizePlayoutAsync(
int channelId,
int scheduleId,
CancellationToken cancellationToken)
{
var playoutApi = new PlayoutApi(_serverUrl);
Option<PlayoutViewModel> maybeExisting = await playoutApi.ApiPlayoutsGetAsync(cancellationToken)
.Map(list => list.SingleOrDefault(p => p.Channel.Id == channelId));
await maybeExisting.Match(
existing =>
{
var data = new UpdatePlayout(existing.Id, channelId, scheduleId, ProgramSchedulePlayoutType.Flood);
if (existing.Channel.Id != data.ChannelId ||
existing.ProgramSchedule.Id != data.ProgramScheduleId ||
existing.ProgramSchedulePlayoutType != data.ProgramSchedulePlayoutType)
{
return playoutApi.ApiPlayoutsPatchAsync(data, cancellationToken);
}
return Task.CompletedTask;
},
() =>
{
var data = new CreatePlayout(channelId, scheduleId, ProgramSchedulePlayoutType.Flood);
return playoutApi.ApiPlayoutsPostAsync(data, cancellationToken);
});
_logger.LogInformation("Successfully built playout for schedule {Schedule}", ScheduleName);
}
}
}
@@ -1,140 +0,0 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes;
using ErsatzTV.Api.Sdk.Api;
using ErsatzTV.Api.Sdk.Model;
using LanguageExt;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.CommandLine.Commands.Schedules
{
[Command("schedule add-item", Description = "Adds an item to the end of a schedule")]
public class ScheduleAddItemCommand : ICommand
{
private readonly ILogger<ScheduleAddItemCommand> _logger;
private readonly string _serverUrl;
public ScheduleAddItemCommand(IConfiguration configuration, ILogger<ScheduleAddItemCommand> logger)
{
_logger = logger;
_serverUrl = configuration["ServerUrl"];
}
[CommandParameter(0, Name = "schedule-name", Description = "The schedule name")]
public string ScheduleName { get; set; }
[CommandParameter(1, Name = "collection-name", Description = "The media collection name")]
public string CollectionName { get; set; }
// [CommandParameter(2, Description = "The collection playback order")]
// public PlaybackOrder Order { get; set; }
[CommandOption("start-type", 's', Description = "The playout start type")]
public StartType StartType { get; set; } = StartType.Dynamic;
[CommandOption("start-time", 't', Description = "The playout start time (of day)")]
public string StartTime { get; set; } = null;
[CommandOption("playout-mode", 'm', Description = "The playout mode")]
public PlayoutMode PlayoutMode { get; set; } = PlayoutMode.Flood;
[CommandOption(
"multiple-count",
'c',
Description = "How many items to play from the collection (for Multiple playout mode)")]
public int? MultipleCount { get; set; } = null;
[CommandOption(
"playout-duration",
'd',
Description = "How long to play items from the collection (for Duration playout mode)")]
public string PlayoutDuration { get; set; } = null;
[CommandOption(
"offline-tail",
'o',
Description =
"Whether to remain offline for the entire duration, or to start the next item immediately (for Duration playout mode)")]
public bool? OfflineTail { get; set; } = null;
public async ValueTask ExecuteAsync(IConsole console)
{
try
{
CancellationToken cancellationToken = console.GetCancellationToken();
Option<ProgramScheduleViewModel> maybeSchedule = await GetSchedule(cancellationToken);
await maybeSchedule.Match(
programSchedule => AddItemToSchedule(cancellationToken, programSchedule),
() =>
{
_logger.LogError("Unable to locate schedule {Schedule}", ScheduleName);
return ValueTask.CompletedTask;
});
}
catch (Exception ex)
{
_logger.LogError("Unable to add item to schedule: {Error}", ex.Message);
}
}
private async ValueTask AddItemToSchedule(
CancellationToken cancellationToken,
ProgramScheduleViewModel programSchedule)
{
var mediaCollectionsApi = new MediaCollectionsApi(_serverUrl);
Option<MediaCollectionViewModel> maybeMediaCollection = await mediaCollectionsApi
.ApiMediaCollectionsGetAsync(cancellationToken)
.Map(list => list.SingleOrDefault(mc => mc.Name == CollectionName));
await maybeMediaCollection.Match(
collection =>
AddScheduleItem(programSchedule.Id, collection.Id, cancellationToken),
() =>
{
_logger.LogError(
"Unable to locate collection {Collection}",
CollectionName);
return Task.CompletedTask;
});
}
private async Task<Option<ProgramScheduleViewModel>> GetSchedule(CancellationToken cancellationToken)
{
var programScheduleApi = new ProgramScheduleApi(_serverUrl);
return await programScheduleApi.ApiSchedulesGetAsync(cancellationToken)
.Map(list => list.SingleOrDefault(schedule => schedule.Name == ScheduleName));
}
private async Task AddScheduleItem(
int programScheduleId,
int mediaCollectionId,
CancellationToken cancellationToken)
{
var programScheduleApi = new ProgramScheduleApi(_serverUrl);
var request = new AddProgramScheduleItem
{
ProgramScheduleId = programScheduleId,
StartType = StartType,
StartTime = StartTime,
PlayoutMode = PlayoutMode,
MediaCollectionId = mediaCollectionId,
PlayoutDuration = PlayoutDuration,
MultipleCount = MultipleCount,
OfflineTail = OfflineTail
};
await programScheduleApi.ApiSchedulesItemsAddPostAsync(request, cancellationToken);
_logger.LogInformation(
"Collection {Collection} has been added to schedule {Schedule}",
CollectionName,
ScheduleName);
}
}
}
@@ -1,86 +0,0 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes;
using ErsatzTV.Api.Sdk.Api;
using ErsatzTV.Api.Sdk.Model;
using LanguageExt;
using LanguageExt.Common;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
namespace ErsatzTV.CommandLine.Commands.Schedules
{
[Command("schedule create", Description = "Creates a new schedule")]
public class ScheduleCreateCommand : ICommand
{
private readonly ILogger<ScheduleCreateCommand> _logger;
private readonly string _serverUrl;
public ScheduleCreateCommand(IConfiguration configuration, ILogger<ScheduleCreateCommand> logger)
{
_logger = logger;
_serverUrl = configuration["ServerUrl"];
}
[CommandParameter(0, Name = "schedule-name", Description = "The schedule name")]
public string Name { get; set; }
[CommandParameter(1, Name = "playback-order", Description = "The collection playback order")]
public PlaybackOrder Order { get; set; }
[CommandOption("reset", Description = "Resets the schedule to contain no items")]
public bool Reset { get; set; }
public async ValueTask ExecuteAsync(IConsole console)
{
try
{
CancellationToken cancellationToken = console.GetCancellationToken();
Either<Error, Unit> result = await EnsureScheduleExistsAsync(cancellationToken);
result.IfLeft(error => _logger.LogError("Unable to create schedule: {Error}", error.Message));
}
catch (Exception ex)
{
_logger.LogError("Unable to create schedule: {Error}", ex.Message);
}
}
private async Task<Either<Error, Unit>> EnsureScheduleExistsAsync(CancellationToken cancellationToken)
{
var programScheduleApi = new ProgramScheduleApi(_serverUrl);
Option<ProgramScheduleViewModel> maybeExisting = await programScheduleApi
.ApiSchedulesGetAsync(cancellationToken)
.Map(list => list.SingleOrDefault(schedule => schedule.Name == Name));
await maybeExisting.Match(
existing =>
{
// TODO: update playback order if changed?
_logger.LogInformation("Schedule {Schedule} is already present", Name);
if (Reset)
{
return programScheduleApi
.ApiSchedulesProgramScheduleIdItemsDeleteAsync(existing.Id, cancellationToken)
.Iter(_ => _logger.LogInformation("Successfully reset schedule {Schedule}", Name));
}
return Task.CompletedTask;
},
() =>
{
var data = new CreateProgramSchedule(Name, Order);
return programScheduleApi.ApiSchedulesPostAsync(data, cancellationToken)
.Iter(_ => _logger.LogInformation("Successfully created schedule {Schedule}", Name));
});
return unit;
}
}
}
-7
View File
@@ -1,7 +0,0 @@
namespace ErsatzTV.CommandLine
{
public class Config
{
public string ServerUrl { get; set; }
}
}
-10
View File
@@ -1,10 +0,0 @@
namespace ErsatzTV.CommandLine
{
public enum DesiredResolution
{
W720H480 = 1,
W1280H720 = 2,
W1920H1080 = 3,
W3840H2160 = 4
}
}
@@ -1,35 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net5.0</TargetFramework>
<AssemblyName>ersatztv-cli</AssemblyName>
<LangVersion>9</LangVersion>
<PackageVersion>0.0.1</PackageVersion>
<AssemblyVersion>0.0.1</AssemblyVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CliFx" Version="1.6.0" />
<PackageReference Include="LanguageExt.Core" Version="3.4.15" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="5.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="5.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="5.0.0" />
<PackageReference Include="RestSharp" Version="106.11.7" />
<PackageReference Include="Serilog" Version="2.10.0" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="4.1.2" />
<PackageReference Include="Serilog.Extensions.Logging" Version="3.0.1" />
<PackageReference Include="Serilog.Sinks.Console" Version="3.1.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\generated\ErsatzTV.Api.Sdk\src\ErsatzTV.Api.Sdk\ErsatzTV.Api.Sdk.csproj" />
</ItemGroup>
<ItemGroup>
<Reference Include="Microsoft.Extensions.Hosting.Abstractions, Version=5.0.0.0, Culture=neutral, PublicKeyToken=adb9793829ddae60">
<HintPath>..\..\..\..\..\..\usr\share\dotnet\packs\Microsoft.AspNetCore.App.Ref\5.0.0\ref\net5.0\Microsoft.Extensions.Hosting.Abstractions.dll</HintPath>
</Reference>
</ItemGroup>
</Project>
-75
View File
@@ -1,75 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using CliFx;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Serilog;
using Serilog.Events;
namespace ErsatzTV.CommandLine
{
public class Program
{
public static async Task<int> Main(string[] args)
{
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
.Enrich.FromLogContext()
.WriteTo.Console()
.CreateLogger();
IHost host = CreateHostBuilder(args).Build();
try
{
return await new CliApplicationBuilder()
.AddCommandsFromThisAssembly()
.UseTypeActivator(host.Services.GetService)
.Build()
.RunAsync(args);
}
catch (Exception ex)
{
Log.Fatal(ex, "Host terminated unexpectedly");
return 1;
}
finally
{
Log.CloseAndFlush();
}
}
public static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
.ConfigureServices(
(_, services) =>
{
services.AddSingleton<IConsole, SystemConsole>();
IEnumerable<Type> typesThatImplementICommand = typeof(Program).Assembly.GetTypes()
.Where(x => typeof(ICommand).IsAssignableFrom(x))
.Where(x => !x.IsAbstract);
foreach (Type t in typesThatImplementICommand)
{
services.AddTransient(t);
}
})
.ConfigureAppConfiguration(
(_, configuration) =>
{
configuration.Sources.Clear();
string configFolder = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"ersatztv");
configuration.SetBasePath(configFolder);
configuration.AddJsonFile("cli.json", true, true);
})
.UseSerilog()
.UseConsoleLifetime();
}
}
@@ -6,7 +6,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.2.0" />
<PackageReference Include="FluentAssertions" Version="6.4.0" />
<PackageReference Include="LanguageExt.Core" Version="4.0.3" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" />
@@ -16,7 +16,7 @@
</PackageReference>
<PackageReference Include="Moq" Version="4.16.1" />
<PackageReference Include="NUnit" Version="3.13.2" />
<PackageReference Include="NUnit3TestAdapter" Version="4.1.0" />
<PackageReference Include="NUnit3TestAdapter" Version="4.2.1" />
<PackageReference Include="Serilog" Version="2.10.0" />
<PackageReference Include="Serilog.Extensions.Logging" Version="3.1.0" />
<PackageReference Include="Serilog.Sinks.Debug" Version="2.0.0" />
@@ -297,37 +297,37 @@ namespace ErsatzTV.Core.Tests.FFmpeg
true,
true,
false,
"[0:0]deinterlace_qsv,scale_qsv=w=1920:h=1000,hwdownload,format=nv12,setsar=1,hwupload=extra_hw_frames=64[v]",
"[0:0]deinterlace_qsv,scale_qsv=w=1920:h=1000,setsar=1[v]",
"[v]")]
[TestCase(
true,
false,
true,
"[0:0]deinterlace_qsv,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload=extra_hw_frames=64[v]",
"[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,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload=extra_hw_frames=64[v]",
"[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,hwdownload,format=nv12,setsar=1,hwupload=extra_hw_frames=64[v]",
"[0:0]scale_qsv=w=1920:h=1000,setsar=1[v]",
"[v]")]
[TestCase(
false,
false,
true,
"[0:0]hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload=extra_hw_frames=64[v]",
"[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,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload=extra_hw_frames=64[v]",
"[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,
@@ -368,37 +368,37 @@ namespace ErsatzTV.Core.Tests.FFmpeg
true,
true,
false,
"[0:0]yadif_cuda,scale_cuda=1920:1000,hwdownload,format=nv12,setsar=1,hwupload[v]",
"[0:0]yadif_cuda,scale_cuda=1920:1000,setsar=1[v]",
"[v]")]
[TestCase(
true,
false,
true,
"[0:0]yadif_cuda,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[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,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[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,hwdownload,format=nv12,setsar=1,hwupload[v]",
"[0:0]scale_cuda=1920:1000,setsar=1[v]",
"[v]")]
[TestCase(
false,
false,
true,
"[0:0]hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[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,hwdownload,format=nv12,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[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,
@@ -409,7 +409,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{
FFmpegComplexFilterBuilder builder = new FFmpegComplexFilterBuilder()
.WithHardwareAcceleration(HardwareAccelerationKind.Nvenc)
.WithDeinterlace(deinterlace);
.WithDeinterlace(deinterlace)
.WithInputPixelFormat("h264");
if (scale)
{
@@ -440,42 +441,42 @@ namespace ErsatzTV.Core.Tests.FFmpeg
true,
true,
false,
"[0:0]deinterlace_vaapi,scale_vaapi=format=nv12:w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
"[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,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[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,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[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,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
"[0:0]scale_vaapi=format=nv12:w=1920:h=1000,setsar=1[v]",
"[v]")]
[TestCase(
"h264",
false,
false,
true,
"[0:0]hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[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,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[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(
@@ -483,28 +484,28 @@ namespace ErsatzTV.Core.Tests.FFmpeg
true,
true,
false,
"[0:0]hwupload,deinterlace_vaapi,scale_vaapi=format=nv12:w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
"[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,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[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,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[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,hwdownload,format=nv12|vaapi,setsar=1,hwupload[v]",
"[0:0]hwupload,scale_vaapi=format=nv12:w=1920:h=1000,setsar=1[v]",
"[v]")]
[TestCase(
"mpeg4",
@@ -518,7 +519,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
false,
true,
true,
"[0:0]hwupload,scale_vaapi=format=nv12:w=1920:h=1000,hwdownload,format=nv12|vaapi,setsar=1,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,hwupload[v]",
"[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,
@@ -16,10 +16,32 @@ namespace ErsatzTV.Core.Tests.FFmpeg
private readonly FFmpegPlaybackSettingsCalculator _calculator;
public CalculateSettings() => _calculator = new FFmpegPlaybackSettingsCalculator();
[Test]
public void Should_Not_GenPts_ForHlsSegmenter()
{
FFmpegProfile ffmpegProfile = TestProfile();
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.HttpLiveStreamingSegmenter,
ffmpegProfile,
new MediaVersion(),
new MediaStream(),
new MediaStream(),
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero,
false);
actual.FormatFlags.Should().NotContain("+genpts");
}
[Test]
public void Should_UseSpecifiedThreadCount_ForTransportStream()
public void Should_Not_UseSpecifiedThreadCount_ForTransportStream()
{
// MPEG-TS requires realtime output which is hardcoded to a single thread
FFmpegProfile ffmpegProfile = TestProfile() with { ThreadCount = 7 };
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
@@ -31,18 +53,19 @@ namespace ErsatzTV.Core.Tests.FFmpeg
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero);
TimeSpan.Zero,
false);
actual.ThreadCount.Should().Be(7);
actual.ThreadCount.Should().Be(1);
}
[Test]
public void Should_UseSpecifiedThreadCount_ForHttpLiveStreaming()
public void Should_UseSpecifiedThreadCount_ForHttpLiveStreamingSegmenter()
{
FFmpegProfile ffmpegProfile = TestProfile() with { ThreadCount = 7 };
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.HttpLiveStreamingDirect,
StreamingMode.HttpLiveStreamingSegmenter,
ffmpegProfile,
new MediaVersion(),
new MediaStream(),
@@ -50,7 +73,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero);
TimeSpan.Zero,
false);
actual.ThreadCount.Should().Be(7);
}
@@ -69,7 +93,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero);
TimeSpan.Zero,
false);
string[] expected = { "+genpts", "+discardcorrupt", "+igndts" };
actual.FormatFlags.Count.Should().Be(expected.Length);
@@ -90,7 +115,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero);
TimeSpan.Zero,
false);
string[] expected = { "+genpts", "+discardcorrupt", "+igndts" };
actual.FormatFlags.Count.Should().Be(expected.Length);
@@ -111,7 +137,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero);
TimeSpan.Zero,
false);
actual.RealtimeOutput.Should().BeTrue();
}
@@ -130,7 +157,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero);
TimeSpan.Zero,
false);
actual.RealtimeOutput.Should().BeTrue();
}
@@ -151,7 +179,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
now,
now.AddMinutes(5),
TimeSpan.Zero,
TimeSpan.Zero);
TimeSpan.Zero,
false);
actual.StreamSeek.IsSome.Should().BeTrue();
actual.StreamSeek.IfNone(TimeSpan.Zero).Should().Be(TimeSpan.FromMinutes(5));
@@ -173,7 +202,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
now,
now.AddMinutes(5),
TimeSpan.Zero,
TimeSpan.Zero);
TimeSpan.Zero,
false);
actual.StreamSeek.IsSome.Should().BeTrue();
actual.StreamSeek.IfNone(TimeSpan.Zero).Should().Be(TimeSpan.FromMinutes(5));
@@ -193,7 +223,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero);
TimeSpan.Zero,
false);
actual.ScaledSize.IsNone.Should().BeTrue();
}
@@ -219,7 +250,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero);
TimeSpan.Zero,
false);
actual.ScaledSize.IsNone.Should().BeTrue();
}
@@ -245,7 +277,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero);
TimeSpan.Zero,
false);
actual.ScaledSize.IsNone.Should().BeTrue();
}
@@ -271,7 +304,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero);
TimeSpan.Zero,
false);
actual.ScaledSize.IsNone.Should().BeTrue();
actual.PadToDesiredResolution.Should().BeFalse();
@@ -299,7 +333,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero);
TimeSpan.Zero,
false);
actual.ScaledSize.IsNone.Should().BeTrue();
actual.PadToDesiredResolution.Should().BeTrue();
@@ -326,7 +361,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero);
TimeSpan.Zero,
false);
IDisplaySize scaledSize = actual.ScaledSize.IfNone(new MediaVersion { Width = 0, Height = 0 });
scaledSize.Width.Should().Be(1280);
@@ -356,7 +392,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero);
TimeSpan.Zero,
false);
actual.ScaledSize.IsNone.Should().BeTrue();
actual.PadToDesiredResolution.Should().BeFalse();
@@ -384,7 +421,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero);
TimeSpan.Zero,
false);
actual.ScaledSize.IsNone.Should().BeTrue();
actual.PadToDesiredResolution.Should().BeFalse();
@@ -413,7 +451,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero);
TimeSpan.Zero,
false);
actual.ScaledSize.IsNone.Should().BeTrue();
actual.PadToDesiredResolution.Should().BeTrue();
@@ -445,7 +484,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero);
TimeSpan.Zero,
false);
actual.ScaledSize.IsNone.Should().BeTrue();
actual.PadToDesiredResolution.Should().BeFalse();
@@ -477,7 +517,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero);
TimeSpan.Zero,
false);
actual.ScaledSize.IsNone.Should().BeTrue();
actual.PadToDesiredResolution.Should().BeFalse();
@@ -508,7 +549,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero);
TimeSpan.Zero,
false);
actual.ScaledSize.IsNone.Should().BeTrue();
actual.PadToDesiredResolution.Should().BeFalse();
@@ -540,7 +582,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero);
TimeSpan.Zero,
false);
actual.ScaledSize.IsNone.Should().BeTrue();
actual.PadToDesiredResolution.Should().BeFalse();
@@ -574,7 +617,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero);
TimeSpan.Zero,
false);
actual.ScaledSize.IsNone.Should().BeTrue();
actual.PadToDesiredResolution.Should().BeFalse();
@@ -606,7 +650,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero);
TimeSpan.Zero,
false);
actual.ScaledSize.IsNone.Should().BeTrue();
actual.PadToDesiredResolution.Should().BeTrue();
@@ -637,7 +682,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero);
TimeSpan.Zero,
false);
actual.ScaledSize.IsNone.Should().BeTrue();
actual.PadToDesiredResolution.Should().BeFalse();
@@ -667,7 +713,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero);
TimeSpan.Zero,
false);
actual.ScaledSize.IsNone.Should().BeTrue();
actual.PadToDesiredResolution.Should().BeTrue();
@@ -699,7 +746,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero);
TimeSpan.Zero,
false);
actual.ScaledSize.IsNone.Should().BeTrue();
actual.PadToDesiredResolution.Should().BeFalse();
@@ -727,7 +775,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero);
TimeSpan.Zero,
false);
actual.AudioCodec.Should().Be("aac");
}
@@ -752,7 +801,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero);
TimeSpan.Zero,
false);
actual.AudioCodec.Should().Be("copy");
}
@@ -778,7 +828,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero);
TimeSpan.Zero,
false);
actual.AudioCodec.Should().Be("aac");
}
@@ -804,7 +855,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero);
TimeSpan.Zero,
false);
actual.AudioCodec.Should().Be("copy");
}
@@ -831,7 +883,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero);
TimeSpan.Zero,
false);
actual.AudioBitrate.IfNone(0).Should().Be(2424);
}
@@ -858,7 +911,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero);
TimeSpan.Zero,
false);
actual.AudioBufferSize.IfNone(0).Should().Be(2424);
}
@@ -885,7 +939,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero);
TimeSpan.Zero,
false);
actual.AudioChannels.IfNone(0).Should().Be(6);
}
@@ -912,7 +967,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero);
TimeSpan.Zero,
false);
actual.AudioSampleRate.IfNone(0).Should().Be(48);
}
@@ -938,7 +994,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero);
TimeSpan.Zero,
false);
actual.AudioChannels.IfNone(0).Should().Be(6);
}
@@ -964,7 +1021,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero);
TimeSpan.Zero,
false);
actual.AudioSampleRate.IfNone(0).Should().Be(48);
}
@@ -991,7 +1049,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.FromMinutes(2));
TimeSpan.FromMinutes(2),
false);
actual.AudioDuration.IfNone(TimeSpan.MinValue).Should().Be(TimeSpan.FromMinutes(2));
}
@@ -1017,7 +1076,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero);
TimeSpan.Zero,
false);
actual.NormalizeLoudness.Should().BeTrue();
}
@@ -1043,7 +1103,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero);
TimeSpan.Zero,
false);
actual.NormalizeLoudness.Should().BeFalse();
}
@@ -1071,7 +1132,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
DateTimeOffset.Now,
DateTimeOffset.Now,
TimeSpan.Zero,
TimeSpan.Zero);
TimeSpan.Zero,
false);
actual.HardwareAcceleration.Should().Be(HardwareAccelerationKind.Qsv);
}
+27 -7
View File
@@ -94,6 +94,17 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{
HardwareAccelerationKind.Vaapi
};
public static string[] VideoToolboxCodecs =
{
"h264_videotoolbox",
"hevc_videotoolbox"
};
public static HardwareAccelerationKind[] VideoToolboxAcceleration =
{
HardwareAccelerationKind.VideoToolbox
};
}
[Test, Combinatorial]
@@ -104,21 +115,27 @@ namespace ErsatzTV.Core.Tests.FFmpeg
string inputPixelFormat,
[ValueSource(typeof(TestData), nameof(TestData.Resolutions))]
Resolution profileResolution,
[Values(true, false)]
bool pad,
// [ValueSource(typeof(TestData), nameof(TestData.SoftwareCodecs))] string profileCodec,
// [ValueSource(typeof(TestData), nameof(TestData.NoAcceleration))] HardwareAccelerationKind profileAcceleration)
[ValueSource(typeof(TestData), nameof(TestData.NvidiaCodecs))] string profileCodec,
[ValueSource(typeof(TestData), nameof(TestData.NvidiaAcceleration))] HardwareAccelerationKind profileAcceleration)
// [ValueSource(typeof(TestData), nameof(TestData.NvidiaCodecs))] string profileCodec,
// [ValueSource(typeof(TestData), nameof(TestData.NvidiaAcceleration))] HardwareAccelerationKind profileAcceleration)
// [ValueSource(typeof(TestData), nameof(TestData.VaapiCodecs))] string profileCodec,
// [ValueSource(typeof(TestData), nameof(TestData.VaapiAcceleration))] HardwareAccelerationKind profileAcceleration)
[ValueSource(typeof(TestData), nameof(TestData.VideoToolboxCodecs))] string profileCodec,
[ValueSource(typeof(TestData), nameof(TestData.VideoToolboxAcceleration))] HardwareAccelerationKind profileAcceleration)
{
string name = GetStringSha256Hash(
$"{inputCodec}_{inputPixelFormat}_{profileResolution}_{profileCodec}_{profileAcceleration}");
$"{inputCodec}_{inputPixelFormat}_{pad}_{profileResolution}_{profileCodec}_{profileAcceleration}");
string file = Path.Combine(TestContext.CurrentContext.TestDirectory, $"{name}.mkv");
if (!File.Exists(file))
{
string resolution = pad ? "1920x1060" : "1920x1080";
var args =
$"-y -f lavfi -i anullsrc=channel_layout=stereo:sample_rate=44100 -f lavfi -i testsrc=duration=1:size=1920x1080:rate=30 -c:a aac -c:v {inputCodec} -shortest -pix_fmt {inputPixelFormat} -strict -2 {file}";
$"-y -f lavfi -i anullsrc=channel_layout=stereo:sample_rate=44100 -f lavfi -i testsrc=duration=1:size={resolution}:rate=30 -c:a aac -c:v {inputCodec} -shortest -pix_fmt {inputPixelFormat} -strict -2 {file}";
var p1 = new Process
{
StartInfo = new ProcessStartInfo
@@ -146,8 +163,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
var metadataRepository = new Mock<IMetadataRepository>();
metadataRepository
.Setup(r => r.UpdateLocalStatistics(It.IsAny<int>(), It.IsAny<MediaVersion>(), It.IsAny<bool>()))
.Callback<int, MediaVersion, bool>((_, version, _) => v = version);
.Setup(r => r.UpdateLocalStatistics(It.IsAny<MediaItem>(), It.IsAny<MediaVersion>(), It.IsAny<bool>()))
.Callback<MediaItem, MediaVersion, bool>((_, version, _) => v = version);
var localStatisticsProvider = new LocalStatisticsProvider(
metadataRepository.Object,
@@ -197,9 +214,12 @@ namespace ErsatzTV.Core.Tests.FFmpeg
false,
FillerKind.None,
TimeSpan.Zero,
TimeSpan.FromSeconds(5));
TimeSpan.FromSeconds(5),
0);
process.StartInfo.RedirectStandardError = true;
// Console.WriteLine($"ffmpeg arguments {string.Join(" ", process.StartInfo.ArgumentList)}");
process.Start().Should().BeTrue();
@@ -44,7 +44,7 @@ namespace ErsatzTV.Core.Tests.Fakes
.IfNone(SystemTime.MinValueUtc);
public bool IsLibraryPathAccessible(LibraryPath libraryPath) =>
_files.Any(f => f.Path.StartsWith(libraryPath.Path + Path.DirectorySeparatorChar));
_folders.Any(f => f.Path == libraryPath.Path);
public IEnumerable<string> ListSubdirectories(string folder) =>
_folders.Map(f => f.Path).Filter(f => f.StartsWith(folder) && Directory.GetParent(f)?.FullName == folder);
@@ -53,6 +53,7 @@ namespace ErsatzTV.Core.Tests.Fakes
_files.Map(f => f.Path).Filter(f => Path.GetDirectoryName(f) == folder);
public bool FileExists(string path) => _files.Any(f => f.Path == path);
public bool FolderExists(string folder) => false;
public Task<byte[]> ReadAllBytes(string path) => TestBytes.AsTask();
@@ -0,0 +1,211 @@
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Runtime;
using ErsatzTV.Core.Jellyfin;
using FluentAssertions;
using LanguageExt;
using Microsoft.Extensions.Logging;
using Moq;
using NUnit.Framework;
namespace ErsatzTV.Core.Tests.Jellyfin
{
[TestFixture]
public class JellyfinPathReplacementServiceTests
{
[Test]
public async Task JellyfinWindows_To_EtvWindows()
{
var replacements = new List<JellyfinPathReplacement>
{
new()
{
Id = 1,
JellyfinPath = @"C:\Something\Some Shared Folder",
LocalPath = @"C:\Something Else\Some Shared Folder",
JellyfinMediaSource = new JellyfinMediaSource { OperatingSystem = "Windows" }
}
};
var repo = new Mock<IMediaSourceRepository>();
repo.Setup(x => x.GetJellyfinPathReplacementsByLibraryId(It.IsAny<int>())).Returns(replacements.AsTask());
var runtime = new Mock<IRuntimeInfo>();
runtime.Setup(x => x.IsOSPlatform(OSPlatform.Windows)).Returns(true);
var service = new JellyfinPathReplacementService(
repo.Object,
runtime.Object,
new Mock<ILogger<JellyfinPathReplacementService>>().Object);
string result = await service.GetReplacementJellyfinPath(
0,
@"C:\Something\Some Shared Folder\Some Movie\Some Movie.mkv");
result.Should().Be(@"C:\Something Else\Some Shared Folder\Some Movie\Some Movie.mkv");
}
[Test]
public async Task JellyfinWindows_To_EtvLinux()
{
var replacements = new List<JellyfinPathReplacement>
{
new()
{
Id = 1,
JellyfinPath = @"C:\Something\Some Shared Folder",
LocalPath = @"/mnt/something else/Some Shared Folder",
JellyfinMediaSource = new JellyfinMediaSource { OperatingSystem = "Windows" }
}
};
var repo = new Mock<IMediaSourceRepository>();
repo.Setup(x => x.GetJellyfinPathReplacementsByLibraryId(It.IsAny<int>())).Returns(replacements.AsTask());
var runtime = new Mock<IRuntimeInfo>();
runtime.Setup(x => x.IsOSPlatform(OSPlatform.Windows)).Returns(false);
var service = new JellyfinPathReplacementService(
repo.Object,
runtime.Object,
new Mock<ILogger<JellyfinPathReplacementService>>().Object);
string result = await service.GetReplacementJellyfinPath(
0,
@"C:\Something\Some Shared Folder\Some Movie\Some Movie.mkv");
result.Should().Be(@"/mnt/something else/Some Shared Folder/Some Movie/Some Movie.mkv");
}
[Test]
public async Task JellyfinWindows_To_EtvLinux_UncPath()
{
var replacements = new List<JellyfinPathReplacement>
{
new()
{
Id = 1,
JellyfinPath = @"\\192.168.1.100\Something\Some Shared Folder",
LocalPath = @"/mnt/something else/Some Shared Folder",
JellyfinMediaSource = new JellyfinMediaSource { OperatingSystem = "Windows" }
}
};
var repo = new Mock<IMediaSourceRepository>();
repo.Setup(x => x.GetJellyfinPathReplacementsByLibraryId(It.IsAny<int>())).Returns(replacements.AsTask());
var runtime = new Mock<IRuntimeInfo>();
runtime.Setup(x => x.IsOSPlatform(OSPlatform.Windows)).Returns(false);
var service = new JellyfinPathReplacementService(
repo.Object,
runtime.Object,
new Mock<ILogger<JellyfinPathReplacementService>>().Object);
string result = await service.GetReplacementJellyfinPath(
0,
@"\\192.168.1.100\Something\Some Shared Folder\Some Movie\Some Movie.mkv");
result.Should().Be(@"/mnt/something else/Some Shared Folder/Some Movie/Some Movie.mkv");
}
[Test]
public async Task JellyfinWindows_To_EtvLinux_UncPathWithTrailingSlash()
{
var replacements = new List<JellyfinPathReplacement>
{
new()
{
Id = 1,
JellyfinPath = @"\\192.168.1.100\Something\Some Shared Folder\",
LocalPath = @"/mnt/something else/Some Shared Folder/",
JellyfinMediaSource = new JellyfinMediaSource { OperatingSystem = "Windows" }
}
};
var repo = new Mock<IMediaSourceRepository>();
repo.Setup(x => x.GetJellyfinPathReplacementsByLibraryId(It.IsAny<int>())).Returns(replacements.AsTask());
var runtime = new Mock<IRuntimeInfo>();
runtime.Setup(x => x.IsOSPlatform(OSPlatform.Windows)).Returns(false);
var service = new JellyfinPathReplacementService(
repo.Object,
runtime.Object,
new Mock<ILogger<JellyfinPathReplacementService>>().Object);
string result = await service.GetReplacementJellyfinPath(
0,
@"\\192.168.1.100\Something\Some Shared Folder\Some Movie\Some Movie.mkv");
result.Should().Be(@"/mnt/something else/Some Shared Folder/Some Movie/Some Movie.mkv");
}
[Test]
public async Task JellyfinLinux_To_EtvWindows()
{
var replacements = new List<JellyfinPathReplacement>
{
new()
{
Id = 1,
JellyfinPath = @"/mnt/something/Some Shared Folder",
LocalPath = @"C:\Something Else\Some Shared Folder",
JellyfinMediaSource = new JellyfinMediaSource { OperatingSystem = "Linux" }
}
};
var repo = new Mock<IMediaSourceRepository>();
repo.Setup(x => x.GetJellyfinPathReplacementsByLibraryId(It.IsAny<int>())).Returns(replacements.AsTask());
var runtime = new Mock<IRuntimeInfo>();
runtime.Setup(x => x.IsOSPlatform(OSPlatform.Windows)).Returns(true);
var service = new JellyfinPathReplacementService(
repo.Object,
runtime.Object,
new Mock<ILogger<JellyfinPathReplacementService>>().Object);
string result = await service.GetReplacementJellyfinPath(
0,
@"/mnt/something/Some Shared Folder/Some Movie/Some Movie.mkv");
result.Should().Be(@"C:\Something Else\Some Shared Folder\Some Movie\Some Movie.mkv");
}
[Test]
public async Task JellyfinLinux_To_EtvLinux()
{
var replacements = new List<JellyfinPathReplacement>
{
new()
{
Id = 1,
JellyfinPath = @"/mnt/something/Some Shared Folder",
LocalPath = @"/mnt/something else/Some Shared Folder",
JellyfinMediaSource = new JellyfinMediaSource { OperatingSystem = "Linux" }
}
};
var repo = new Mock<IMediaSourceRepository>();
repo.Setup(x => x.GetJellyfinPathReplacementsByLibraryId(It.IsAny<int>())).Returns(replacements.AsTask());
var runtime = new Mock<IRuntimeInfo>();
runtime.Setup(x => x.IsOSPlatform(OSPlatform.Windows)).Returns(false);
var service = new JellyfinPathReplacementService(
repo.Object,
runtime.Object,
new Mock<ILogger<JellyfinPathReplacementService>>().Object);
string result = await service.GetReplacementJellyfinPath(
0,
@"/mnt/something/Some Shared Folder/Some Movie/Some Movie.mkv");
result.Should().Be(@"/mnt/something else/Some Shared Folder/Some Movie/Some Movie.mkv");
}
}
}
@@ -6,7 +6,6 @@ using System.Runtime.InteropServices;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata;
@@ -54,6 +53,10 @@ namespace ErsatzTV.Core.Tests.Metadata
_movieRepository.Setup(x => x.FindMoviePaths(It.IsAny<LibraryPath>()))
.Returns(new List<string>().AsEnumerable().AsTask());
_mediaItemRepository = new Mock<IMediaItemRepository>();
_mediaItemRepository.Setup(x => x.FlagFileNotFound(It.IsAny<LibraryPath>(), It.IsAny<string>()))
.Returns(new List<int>().AsTask());
_localStatisticsProvider = new Mock<ILocalStatisticsProvider>();
_localMetadataProvider = new Mock<ILocalMetadataProvider>();
@@ -73,6 +76,7 @@ namespace ErsatzTV.Core.Tests.Metadata
}
private Mock<IMovieRepository> _movieRepository;
private Mock<IMediaItemRepository> _mediaItemRepository;
private Mock<ILocalStatisticsProvider> _localStatisticsProvider;
private Mock<ILocalMetadataProvider> _localMetadataProvider;
private Mock<IImageCache> _imageCache;
@@ -535,6 +539,9 @@ namespace ErsatzTV.Core.Tests.Metadata
[Test]
public async Task RenamedMovie_Should_Delete_Old_Movie()
{
// TODO: handle this case more elegantly
// ideally, detect that the movie was renamed and still delete the old one (or update the path?)
string movieFolder = Path.Combine(FakeRoot, "Movie (2020)");
string oldMoviePath = Path.Combine(movieFolder, "Movie (2020).avi");
@@ -557,12 +564,14 @@ namespace ErsatzTV.Core.Tests.Metadata
result.IsRight.Should().BeTrue();
_movieRepository.Verify(x => x.DeleteByPath(It.IsAny<LibraryPath>(), It.IsAny<string>()), Times.Once);
_movieRepository.Verify(x => x.DeleteByPath(libraryPath, oldMoviePath), Times.Once);
_mediaItemRepository.Verify(
x => x.FlagFileNotFound(It.IsAny<LibraryPath>(), It.IsAny<string>()),
Times.Once);
_mediaItemRepository.Verify(x => x.FlagFileNotFound(libraryPath, oldMoviePath), Times.Once);
}
[Test]
public async Task DeletedMovieAndFolder_Should_Delete_Old_Movie()
public async Task DeletedMovieAndFolder_Should_Flag_File_Not_Found()
{
string movieFolder = Path.Combine(FakeRoot, "Movie (2020)");
string oldMoviePath = Path.Combine(movieFolder, "Movie (2020).avi");
@@ -570,10 +579,8 @@ namespace ErsatzTV.Core.Tests.Metadata
_movieRepository.Setup(x => x.FindMoviePaths(It.IsAny<LibraryPath>()))
.Returns(new List<string> { oldMoviePath }.AsEnumerable().AsTask());
string moviePath = Path.Combine(movieFolder, "Movie (2020).mkv");
MovieFolderScanner service = GetService(
new FakeFileEntry(moviePath) { LastWriteTime = DateTime.Now }
new FakeFolderEntry(FakeRoot)
);
var libraryPath = new LibraryPath
{ Id = 1, Path = FakeRoot, LibraryFolders = new List<LibraryFolder>() };
@@ -586,11 +593,12 @@ namespace ErsatzTV.Core.Tests.Metadata
result.IsRight.Should().BeTrue();
_movieRepository.Verify(x => x.DeleteByPath(It.IsAny<LibraryPath>(), It.IsAny<string>()), Times.Once);
_movieRepository.Verify(x => x.DeleteByPath(libraryPath, oldMoviePath), Times.Once);
_mediaItemRepository.Verify(
x => x.FlagFileNotFound(It.IsAny<LibraryPath>(), It.IsAny<string>()),
Times.Once);
_mediaItemRepository.Verify(x => x.FlagFileNotFound(libraryPath, oldMoviePath), Times.Once);
}
private MovieFolderScanner GetService(params FakeFileEntry[] files) =>
new(
new FakeLocalFileSystem(new List<FakeFileEntry>(files)),
@@ -602,6 +610,25 @@ namespace ErsatzTV.Core.Tests.Metadata
new Mock<ISearchIndex>().Object,
new Mock<ISearchRepository>().Object,
new Mock<ILibraryRepository>().Object,
_mediaItemRepository.Object,
new Mock<IMediator>().Object,
null,
new Mock<ITempFilePool>().Object,
new Mock<ILogger<MovieFolderScanner>>().Object
);
private MovieFolderScanner GetService(params FakeFolderEntry[] folders) =>
new(
new FakeLocalFileSystem(new List<FakeFileEntry>(), new List<FakeFolderEntry>(folders)),
_movieRepository.Object,
_localStatisticsProvider.Object,
_localMetadataProvider.Object,
new Mock<IMetadataRepository>().Object,
_imageCache.Object,
new Mock<ISearchIndex>().Object,
new Mock<ISearchRepository>().Object,
new Mock<ILibraryRepository>().Object,
_mediaItemRepository.Object,
new Mock<IMediator>().Object,
null,
new Mock<ITempFilePool>().Object,
@@ -79,7 +79,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
playoutItems[2].FillerKind.Should().Be(FillerKind.None);
}
[Test]
[Test]
public void Should_Fill_Exactly_To_Next_Schedule_Item_Flood()
{
Collection collectionOne = TwoItemCollection(1, 2, TimeSpan.FromHours(1));
@@ -0,0 +1,90 @@
using System.Collections.Generic;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Scheduling;
using FluentAssertions;
using LanguageExt;
using LanguageExt.UnsafeValueAccess;
using NUnit.Framework;
namespace ErsatzTV.Core.Tests.Scheduling
{
[TestFixture]
public class ShuffledMediaCollectionEnumeratorTests
{
private readonly List<GroupedMediaItem> _mediaItems = new()
{
new GroupedMediaItem(new MediaItem { Id = 1 }, new List<MediaItem>()),
new GroupedMediaItem(new MediaItem { Id = 2 }, new List<MediaItem>()),
new GroupedMediaItem(new MediaItem { Id = 3 }, new List<MediaItem>())
};
[Test]
public void Peek_Zero_Should_Match_Current()
{
var state = new CollectionEnumeratorState { Index = 0, Seed = 0 };
var enumerator = new ShuffledMediaCollectionEnumerator(_mediaItems, state);
Option<MediaItem> peek = enumerator.Peek(0);
Option<MediaItem> current = enumerator.Current;
peek.IsSome.Should().BeTrue();
current.IsSome.Should().BeTrue();
peek.ValueUnsafe().Id.Should().Be(1);
current.ValueUnsafe().Id.Should().Be(1);
}
[Test]
public void Peek_One_Should_Match_Next()
{
var state = new CollectionEnumeratorState { Index = 0, Seed = 0 };
var enumerator = new ShuffledMediaCollectionEnumerator(_mediaItems, state);
Option<MediaItem> peek = enumerator.Peek(1);
enumerator.MoveNext();
Option<MediaItem> next = enumerator.Current;
peek.IsSome.Should().BeTrue();
next.IsSome.Should().BeTrue();
peek.ValueUnsafe().Id.Should().Be(2);
next.ValueUnsafe().Id.Should().Be(2);
}
[Test]
public void Peek_Two_Should_Match_NextNext()
{
var state = new CollectionEnumeratorState { Index = 0, Seed = 0 };
var enumerator = new ShuffledMediaCollectionEnumerator(_mediaItems, state);
Option<MediaItem> peek = enumerator.Peek(2);
enumerator.MoveNext();
enumerator.MoveNext();
Option<MediaItem> next = enumerator.Current;
peek.IsSome.Should().BeTrue();
next.IsSome.Should().BeTrue();
peek.ValueUnsafe().Id.Should().Be(3);
next.ValueUnsafe().Id.Should().Be(3);
}
[Test]
public void Peek_Three_Should_Match_NextNextNext()
{
var state = new CollectionEnumeratorState { Index = 0, Seed = 0 };
var enumerator = new ShuffledMediaCollectionEnumerator(_mediaItems, state);
Option<MediaItem> peek = enumerator.Peek(3);
enumerator.MoveNext();
enumerator.MoveNext();
enumerator.MoveNext();
Option<MediaItem> next = enumerator.Current;
peek.IsSome.Should().BeTrue();
next.IsSome.Should().BeTrue();
peek.ValueUnsafe().Id.Should().Be(2);
next.ValueUnsafe().Id.Should().Be(2);
}
}
}
@@ -5,6 +5,7 @@
None = 0,
Qsv = 1,
Nvenc = 2,
Vaapi = 3
Vaapi = 3,
VideoToolbox = 4
}
}
+11 -11
View File
@@ -1,14 +1,14 @@
using System.Collections.Generic;
namespace ErsatzTV.Core.Domain
namespace ErsatzTV.Core.Domain;
public class MediaItem
{
public class MediaItem
{
public int Id { get; set; }
public int LibraryPathId { get; set; }
public LibraryPath LibraryPath { get; set; }
public List<Collection> Collections { get; set; }
public List<CollectionItem> CollectionItems { get; set; }
public List<TraktListItem> TraktListItems { get; set; }
}
}
public int Id { get; set; }
public int LibraryPathId { get; set; }
public LibraryPath LibraryPath { get; set; }
public List<Collection> Collections { get; set; }
public List<CollectionItem> CollectionItems { get; set; }
public List<TraktListItem> TraktListItems { get; set; }
public MediaItemState State { get; set; }
}
@@ -0,0 +1,7 @@
namespace ErsatzTV.Core.Domain;
public enum MediaItemState
{
Normal = 0,
FileNotFound = 1
}
+4
View File
@@ -6,6 +6,10 @@ namespace ErsatzTV.Core.Domain
{
public int Id { get; set; }
public string Path { get; set; }
public string SourcePath { get; set; }
public string BlurHash43 { get; set; }
public string BlurHash54 { get; set; }
public string BlurHash64 { get; set; }
public ArtworkKind ArtworkKind { get; set; }
public DateTime DateAdded { get; set; }
public DateTime DateUpdated { get; set; }
+3 -2
View File
@@ -4,7 +4,8 @@
{
TransportStream = 1,
HttpLiveStreamingDirect = 2,
HttpLiveStreamingHybrid = 3,
HttpLiveStreamingSegmenter = 4
// HttpLiveStreamingHybrid = 3,
HttpLiveStreamingSegmenter = 4,
TransportStreamHybrid = 5
}
}
+2 -3
View File
@@ -3,13 +3,12 @@
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<NoWarn>VSTHRD200</NoWarn>
<DebugType>embedded</DebugType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Flurl" Version="3.0.2" />
<PackageReference Include="Flurl" Version="3.0.4" />
<PackageReference Include="LanguageExt.Core" Version="4.0.3" />
<PackageReference Include="MediatR" Version="9.0.0" />
<PackageReference Include="MediatR" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Http" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="6.0.0" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.0.64">
@@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
@@ -26,7 +25,8 @@ namespace ErsatzTV.Core.FFmpeg
private Option<int> _watermarkIndex;
private string _pixelFormat;
private string _videoEncoder;
private Option<string> _drawtext;
private Option<string> _subtitle;
private bool _boxBlur;
public FFmpegComplexFilterBuilder WithHardwareAcceleration(HardwareAccelerationKind hardwareAccelerationKind)
{
@@ -94,34 +94,32 @@ namespace ErsatzTV.Core.FFmpeg
_watermarkIndex = watermarkIndex;
return this;
}
public FFmpegComplexFilterBuilder WithDrawtextFile(
MediaVersion videoVersion,
Option<string> drawtextFile)
public FFmpegComplexFilterBuilder WithBoxBlur(bool boxBlur)
{
foreach (string file in drawtextFile)
_boxBlur = boxBlur;
return this;
}
public FFmpegComplexFilterBuilder WithSubtitleFile(Option<string> subtitleFile)
{
foreach (string file in subtitleFile)
{
string effectiveFile = file;
string fontsDir = FileSystemLayout.ResourcesCacheFolder;
if (videoVersion is FallbackMediaVersion or CoverArtMediaVersion)
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
string fontPath = Path.Combine(FileSystemLayout.ResourcesCacheFolder, "OPTIKabel-Heavy.otf");
fontsDir = fontsDir
.Replace(@"\", @"/\")
.Replace(@":/", @"\\:/");
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
fontPath = fontPath
.Replace(@"\", @"/\")
.Replace(@":/", @"\\:/");
effectiveFile = effectiveFile
.Replace(@"\", @"/\")
.Replace(@":/", @"\\:/");
}
// TODO: calculate by percent
_drawtext =
$"drawtext=fontfile={fontPath}:textfile={effectiveFile}:x=50:y=H-175:fontsize=36:fontcolor=white";
effectiveFile = effectiveFile
.Replace(@"\", @"/\")
.Replace(@":/", @"\\:/");
}
_subtitle = $"subtitles={effectiveFile}:fontsdir={fontsDir}";
}
return this;
@@ -144,8 +142,14 @@ namespace ErsatzTV.Core.FFmpeg
bool isHardwareDecode = acceleration switch
{
HardwareAccelerationKind.Vaapi => !isSong && _inputCodec != "mpeg4",
// we need an initial hwupload_cuda when only padding with these pixel formats
HardwareAccelerationKind.Nvenc when _scaleToSize.IsNone && _padToSize.IsSome =>
!isSong && !_pixelFormat.Contains("p10le") && !_pixelFormat.Contains("444"),
HardwareAccelerationKind.Nvenc => !isSong,
HardwareAccelerationKind.Qsv => !isSong,
HardwareAccelerationKind.VideoToolbox => false,
_ => false
};
@@ -166,9 +170,11 @@ namespace ErsatzTV.Core.FFmpeg
audioFilterQueue.Add($"apad=whole_dur={durationString}ms");
});
bool usesHardwareFilters = acceleration != HardwareAccelerationKind.None && !isHardwareDecode &&
bool usesHardwareFilters = acceleration != HardwareAccelerationKind.None &&
acceleration != HardwareAccelerationKind.VideoToolbox &&
!isHardwareDecode &&
(_deinterlace || _scaleToSize.IsSome);
if (isSong)
{
switch (acceleration)
@@ -252,7 +258,12 @@ namespace ErsatzTV.Core.FFmpeg
});
bool scaleOrPad = _scaleToSize.IsSome || _padToSize.IsSome;
bool usesSoftwareFilters = scaleOrPad || _watermark.IsSome;
bool usesSoftwareFilters = _padToSize.IsSome || _watermark.IsSome;
if (scaleOrPad && _boxBlur == false)
{
videoFilterQueue.Add("setsar=1");
}
if (usesSoftwareFilters)
{
@@ -270,20 +281,15 @@ namespace ErsatzTV.Core.FFmpeg
};
videoFilterQueue.Add(format);
}
if (scaleOrPad)
if (_boxBlur)
{
videoFilterQueue.Add("setsar=1");
videoFilterQueue.Add("boxblur=40");
}
if (videoOnly)
{
videoFilterQueue.Add("boxblur=40[b];[b]split[b1][b2];[b1]format=rgba,geq=r=0:g=0:b=0:a=120*(Y/H)[fg];[b2][fg]overlay=format=auto");
}
if (isSong)
{
videoFilterQueue.Add("fps=30");
videoFilterQueue.Add("deband");
}
foreach (ChannelWatermark watermark in _watermark)
@@ -332,9 +338,9 @@ namespace ErsatzTV.Core.FFmpeg
_padToSize.IfSome(size => videoFilterQueue.Add($"pad={size.Width}:{size.Height}:(ow-iw)/2:(oh-ih)/2"));
foreach (string drawtext in _drawtext)
foreach (string subtitle in _subtitle)
{
videoFilterQueue.Add(drawtext);
videoFilterQueue.Add(subtitle);
}
string outputPixelFormat = null;
@@ -12,7 +12,7 @@ namespace ErsatzTV.Core.FFmpeg
public List<string> FormatFlags { get; set; }
public HardwareAccelerationKind HardwareAcceleration { get; set; }
public string VideoDecoder { get; set; }
public bool RealtimeOutput => true;
public bool RealtimeOutput { get; set; }
public Option<TimeSpan> StreamSeek { get; set; }
public Option<IDisplaySize> ScaledSize { get; set; }
public bool PadToDesiredResolution { get; set; }
@@ -36,6 +36,12 @@ namespace ErsatzTV.Core.FFmpeg
"+igndts"
};
private static readonly List<string> SegmenterFormatFlags = new()
{
"+discardcorrupt",
"+igndts"
};
public FFmpegPlaybackSettings ConcatSettings => new()
{
ThreadCount = 1,
@@ -51,14 +57,26 @@ namespace ErsatzTV.Core.FFmpeg
DateTimeOffset start,
DateTimeOffset now,
TimeSpan inPoint,
TimeSpan outPoint)
TimeSpan outPoint,
bool hlsRealtime)
{
var result = new FFmpegPlaybackSettings
{
ThreadCount = ffmpegProfile.ThreadCount,
FormatFlags = CommonFormatFlags
FormatFlags = streamingMode switch
{
StreamingMode.HttpLiveStreamingSegmenter => SegmenterFormatFlags,
_ => CommonFormatFlags,
},
RealtimeOutput = streamingMode switch
{
StreamingMode.HttpLiveStreamingSegmenter => hlsRealtime,
_ => true
}
};
// always use one thread with realtime output
result.ThreadCount = result.RealtimeOutput ? 1 : ffmpegProfile.ThreadCount;
if (now != start || inPoint != TimeSpan.Zero)
{
result.StreamSeek = now - start + inPoint;
@@ -71,7 +89,7 @@ namespace ErsatzTV.Core.FFmpeg
result.VideoCodec = "copy";
result.Deinterlace = false;
break;
case StreamingMode.HttpLiveStreamingHybrid:
case StreamingMode.TransportStreamHybrid:
case StreamingMode.HttpLiveStreamingSegmenter:
case StreamingMode.TransportStream:
result.HardwareAcceleration = ffmpegProfile.HardwareAcceleration;
@@ -121,6 +139,11 @@ namespace ErsatzTV.Core.FFmpeg
(HardwareAccelerationKind.Qsv, "h264", _) => "h264_qsv",
(HardwareAccelerationKind.Qsv, "hevc", _) => "hevc_qsv",
(HardwareAccelerationKind.Qsv, "mpeg2video", _) => "mpeg2_qsv",
// temp disable mpeg4 hardware decoding for all vaapi
// TODO: check for codec support
(HardwareAccelerationKind.Vaapi, "mpeg4", _) => "mpeg4",
_ => null
};
}
+60 -53
View File
@@ -21,7 +21,9 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
@@ -107,6 +109,10 @@ namespace ErsatzTV.Core.FFmpeg
_arguments.Add("-hwaccel_output_format");
_arguments.Add("vaapi");
break;
case HardwareAccelerationKind.VideoToolbox:
_arguments.Add("-hwaccel");
_arguments.Add("videotoolbox");
break;
}
_complexFilterBuilder = _complexFilterBuilder.WithHardwareAcceleration(hwAccel);
@@ -190,7 +196,21 @@ namespace ErsatzTV.Core.FFmpeg
public FFmpegProcessBuilder WithInput(string input)
{
_arguments.Add("-i");
_arguments.Add($"{input}");
_arguments.Add(input);
return this;
}
public FFmpegProcessBuilder WithMap(string map)
{
_arguments.Add("-map");
_arguments.Add(map);
return this;
}
public FFmpegProcessBuilder WithCopyCodec()
{
_arguments.Add("-c");
_arguments.Add("copy");
return this;
}
@@ -221,14 +241,9 @@ namespace ErsatzTV.Core.FFmpeg
return this;
}
public FFmpegProcessBuilder WithDrawtextFile(
MediaVersion videoVersion,
Option<string> drawtextFile)
public FFmpegProcessBuilder WithSubtitleFile(Option<string> subtitleFile)
{
_complexFilterBuilder = _complexFilterBuilder.WithDrawtextFile(
videoVersion,
drawtextFile);
_complexFilterBuilder = _complexFilterBuilder.WithSubtitleFile(subtitleFile);
return this;
}
@@ -282,14 +297,16 @@ namespace ErsatzTV.Core.FFmpeg
public FFmpegProcessBuilder WithSongInput(
string videoPath,
Option<string> codec,
Option<string> pixelFormat)
Option<string> pixelFormat,
bool boxBlur)
{
_noAutoScale = true;
_outputFramerate = 30;
_complexFilterBuilder = _complexFilterBuilder
.WithInputCodec(codec)
.WithInputPixelFormat(pixelFormat);
.WithInputPixelFormat(pixelFormat)
.WithBoxBlur(boxBlur);
_arguments.Add("-i");
_arguments.Add(videoPath);
@@ -297,24 +314,6 @@ namespace ErsatzTV.Core.FFmpeg
return this;
}
public FFmpegProcessBuilder WithFiltergraph(string graph)
{
_arguments.Add("-vf");
_arguments.Add($"{graph}");
return this;
}
public FFmpegProcessBuilder WithFilterComplex(string filter, string finalVideo, string finalAudio)
{
_arguments.Add("-filter_complex");
_arguments.Add($"{filter}");
_arguments.Add("-map");
_arguments.Add(finalVideo);
_arguments.Add("-map");
_arguments.Add(finalAudio);
return this;
}
public FFmpegProcessBuilder WithConcat(string concatPlaylist)
{
_isConcat = true;
@@ -368,22 +367,6 @@ namespace ErsatzTV.Core.FFmpeg
return this;
}
public FFmpegProcessBuilder WithErrorText(IDisplaySize desiredResolution, string text)
{
string fontPath = Path.Combine(FileSystemLayout.ResourcesCacheFolder, "Roboto-Regular.ttf");
var fontFile = $"fontfile={fontPath}";
const string FONT_COLOR = "fontcolor=white";
const string X = "x=(w-text_w)/2";
const string Y = "y=(h-text_h)/3*2";
string fontSize = text.Length > 80 ? "fontsize=30" : text.Length > 60 ? "fontsize=40" : "fontsize=60";
return WithFilterComplex(
$"[0:0]scale={desiredResolution.Width}:{desiredResolution.Height},drawtext={fontFile}:{fontSize}:{FONT_COLOR}:{X}:{Y}:text='{text}'[v]",
"[v]",
"1:a");
}
public FFmpegProcessBuilder WithDuration(TimeSpan duration)
{
_arguments.Add("-t");
@@ -397,8 +380,15 @@ namespace ErsatzTV.Core.FFmpeg
_arguments.Add($"{format}");
return this;
}
public FFmpegProcessBuilder WithHls(string channelNumber, Option<MediaVersion> mediaVersion)
public FFmpegProcessBuilder WithInitialDiscontinuity()
{
_arguments.Add("-mpegts_flags");
_arguments.Add("+initial_discontinuity");
return this;
}
public FFmpegProcessBuilder WithHls(string channelNumber, Option<MediaVersion> mediaVersion, long ptsOffset, Option<int> maybeTimeScale)
{
const int SEGMENT_SECONDS = 4;
@@ -423,10 +413,15 @@ namespace ErsatzTV.Core.FFmpeg
frameRate = fr;
}
foreach (int timescale in maybeTimeScale)
{
_arguments.Add("-output_ts_offset");
_arguments.Add($"{(ptsOffset / (double)timescale).ToString(NumberFormatInfo.InvariantInfo)}");
}
_arguments.AddRange(
new[]
{
"-use_wallclock_as_timestamps", "1",
"-g", $"{frameRate * SEGMENT_SECONDS}",
"-keyint_min", $"{frameRate * SEGMENT_SECONDS}",
"-force_key_frames", $"expr:gte(t,n_forced*{SEGMENT_SECONDS})",
@@ -436,7 +431,8 @@ namespace ErsatzTV.Core.FFmpeg
"-segment_list_flags", "+live",
"-hls_segment_filename",
Path.Combine(FileSystemLayout.TranscodeFolder, channelNumber, "live%06d.ts"),
"-hls_flags", "program_date_time+append_list+omit_endlist+independent_segments",
"-hls_flags", "program_date_time+append_list+discont_start+omit_endlist+independent_segments",
"-mpegts_flags", "+initial_discontinuity",
Path.Combine(FileSystemLayout.TranscodeFolder, channelNumber, "live.m3u8")
});
@@ -655,13 +651,13 @@ namespace ErsatzTV.Core.FFmpeg
switch (_vaapiDriver)
{
case VaapiDriver.i965:
startInfo.EnvironmentVariables.Add("LIBVA_DRIVER_NAME", "i965");
startInfo.EnvironmentVariables["LIBVA_DRIVER_NAME"] = "i965";
break;
case VaapiDriver.iHD:
startInfo.EnvironmentVariables.Add("LIBVA_DRIVER_NAME", "iHD");
startInfo.EnvironmentVariables["LIBVA_DRIVER_NAME"] = "iHD";
break;
case VaapiDriver.RadeonSI:
startInfo.EnvironmentVariables.Add("LIBVA_DRIVER_NAME", "radeonsi");
startInfo.EnvironmentVariables["LIBVA_DRIVER_NAME"] = "radeonsi";
break;
}
}
@@ -671,7 +667,18 @@ namespace ErsatzTV.Core.FFmpeg
string fileName = _isConcat
? Path.Combine(FileSystemLayout.FFmpegReportsFolder, "ffmpeg-%t-concat.log")
: Path.Combine(FileSystemLayout.FFmpegReportsFolder, "ffmpeg-%t-transcode.log");
startInfo.EnvironmentVariables.Add("FFREPORT", $"file={fileName}:level=32");
// rework filename in a format that works on windows
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
// \ is escape, so use / for directory separators
fileName = fileName.Replace(@"\", @"/");
// colon after drive letter needs to be escaped
fileName = fileName.Replace(@":/", @"\:/");
}
startInfo.EnvironmentVariables["FFREPORT"] = $"file={fileName}:level=32";
}
startInfo.ArgumentList.Add("-nostdin");
+95 -33
View File
@@ -51,7 +51,8 @@ namespace ErsatzTV.Core.FFmpeg
bool hlsRealtime,
FillerKind fillerKind,
TimeSpan inPoint,
TimeSpan outPoint)
TimeSpan outPoint,
long ptsOffset)
{
MediaStream videoStream = await _ffmpegStreamSelector.SelectVideoStream(channel, videoVersion);
Option<MediaStream> maybeAudioStream = await _ffmpegStreamSelector.SelectAudioStream(channel, audioVersion);
@@ -65,10 +66,11 @@ namespace ErsatzTV.Core.FFmpeg
start,
now,
inPoint,
outPoint);
outPoint,
hlsRealtime);
Option<WatermarkOptions> watermarkOptions =
await GetWatermarkOptions(channel, globalWatermark, videoVersion);
await GetWatermarkOptions(channel, globalWatermark, videoVersion, None, None);
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath, saveReports, _logger)
.WithThreads(playbackSettings.ThreadCount)
@@ -157,28 +159,45 @@ namespace ErsatzTV.Core.FFmpeg
{
// HLS needs to segment and generate playlist
case StreamingMode.HttpLiveStreamingSegmenter:
return builder.WithHls(channel.Number, videoVersion)
.WithRealtimeOutput(hlsRealtime)
return builder.WithHls(channel.Number, videoVersion, ptsOffset, playbackSettings.VideoTrackTimeScale)
.Build();
default:
return builder.WithFormat("mpegts")
.WithInitialDiscontinuity()
.WithPipe()
.Build();
}
}
public Process ForError(
public async Task<Process> ForError(
string ffmpegPath,
Channel channel,
Option<TimeSpan> duration,
string errorMessage,
bool hlsRealtime)
bool hlsRealtime,
long ptsOffset)
{
FFmpegPlaybackSettings playbackSettings =
_playbackSettingsCalculator.CalculateErrorSettings(channel.FFmpegProfile);
IDisplaySize desiredResolution = channel.FFmpegProfile.Resolution;
var fontSize = (int)Math.Round(channel.FFmpegProfile.Resolution.Height / 20.0);
var margin = (int)Math.Round(channel.FFmpegProfile.Resolution.Height * 0.05);
string subtitleFile = await new SubtitleBuilder(_tempFilePool)
.WithResolution(desiredResolution)
.WithFontName("Roboto")
.WithFontSize(fontSize)
.WithAlignment(2)
.WithMarginV(margin)
.WithPrimaryColor("&HFFFFFF")
.WithFormattedContent(errorMessage.Replace(Environment.NewLine, "\\N"))
.BuildFile();
var videoStream = new MediaStream { Index = 0 };
var audioStream = new MediaStream { Index = 0 };
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath, false, _logger)
.WithThreads(1)
.WithQuiet()
@@ -187,19 +206,24 @@ namespace ErsatzTV.Core.FFmpeg
.WithLoopedImage(Path.Combine(FileSystemLayout.ResourcesCacheFolder, "background.png"))
.WithLibavfilter()
.WithInput("anullsrc")
.WithErrorText(desiredResolution, errorMessage)
.WithSubtitleFile(subtitleFile)
.WithFilterComplex(
videoStream,
audioStream,
Path.Combine(FileSystemLayout.ResourcesCacheFolder, "background.png"),
"fake-audio-path",
playbackSettings.VideoCodec)
.WithPixfmt("yuv420p")
.WithPlaybackArgs(playbackSettings)
.WithMetadata(channel, None);
duration.IfSome(d => builder = builder.WithDuration(d));
await duration.IfSomeAsync(d => builder = builder.WithDuration(d));
switch (channel.StreamingMode)
{
// HLS needs to segment and generate playlist
case StreamingMode.HttpLiveStreamingSegmenter:
return builder.WithHls(channel.Number, None)
.WithRealtimeOutput(hlsRealtime)
return builder.WithHls(channel.Number, None, ptsOffset, playbackSettings.VideoTrackTimeScale)
.Build();
default:
return builder.WithFormat("mpegts")
@@ -225,6 +249,24 @@ namespace ErsatzTV.Core.FFmpeg
.Build();
}
public Process WrapSegmenter(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host)
{
FFmpegPlaybackSettings playbackSettings = _playbackSettingsCalculator.ConcatSettings;
return new FFmpegProcessBuilder(ffmpegPath, saveReports, _logger)
.WithThreads(1)
.WithQuiet()
.WithFormatFlags(playbackSettings.FormatFlags)
.WithRealtimeOutput(true)
.WithInput($"http://localhost:{Settings.ListenPort}/iptv/channel/{channel.Number}.m3u8?mode=segmenter")
.WithMap("0")
.WithCopyCodec()
.WithMetadata(channel, None)
.WithFormat("mpegts")
.WithPipe()
.Build();
}
public Process ConvertToPng(string ffmpegPath, string inputFile, string outputFile)
{
return new FFmpegProcessBuilder(ffmpegPath, false, _logger)
@@ -235,13 +277,30 @@ namespace ErsatzTV.Core.FFmpeg
.Build();
}
public Process ExtractAttachedPicAsPng(string ffmpegPath, string inputFile, int streamIndex, string outputFile)
{
return new FFmpegProcessBuilder(ffmpegPath, false, _logger)
.WithThreads(1)
.WithQuiet()
.WithInput(inputFile)
.WithMap($"0:{streamIndex}")
.WithOutputFormat("apng", outputFile)
.Build();
}
public async Task<Either<BaseError, string>> GenerateSongImage(
string ffmpegPath,
Option<string> drawtextFile,
Option<string> subtitleFile,
Channel channel,
Option<ChannelWatermark> globalWatermark,
MediaVersion videoVersion,
string videoPath)
string videoPath,
bool boxBlur,
Option<string> watermarkPath,
ChannelWatermarkLocation watermarkLocation,
int horizontalMarginPercent,
int verticalMarginPercent,
int watermarkWidthPercent)
{
try
{
@@ -249,8 +308,22 @@ namespace ErsatzTV.Core.FFmpeg
MediaStream videoStream = await _ffmpegStreamSelector.SelectVideoStream(channel, videoVersion);
Option<ChannelWatermark> watermarkOverride =
videoVersion is FallbackMediaVersion or CoverArtMediaVersion
? new ChannelWatermark
{
Mode = ChannelWatermarkMode.Permanent,
HorizontalMarginPercent = horizontalMarginPercent,
VerticalMarginPercent = verticalMarginPercent,
Location = watermarkLocation,
Size = ChannelWatermarkSize.Scaled,
WidthPercent = watermarkWidthPercent,
Opacity = 100
}
: None;
Option<WatermarkOptions> watermarkOptions =
await GetWatermarkOptions(channel, globalWatermark, videoVersion);
await GetWatermarkOptions(channel, globalWatermark, videoVersion, watermarkOverride, watermarkPath);
FFmpegPlaybackSettings playbackSettings =
_playbackSettingsCalculator.CalculateErrorSettings(channel.FFmpegProfile);
@@ -264,16 +337,16 @@ namespace ErsatzTV.Core.FFmpeg
DateTimeOffset.UnixEpoch,
DateTimeOffset.UnixEpoch,
TimeSpan.Zero,
TimeSpan.Zero);
TimeSpan.Zero,
false);
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath, false, _logger)
.WithThreads(1)
.WithQuiet()
.WithFormatFlags(playbackSettings.FormatFlags)
.WithRealtimeOutput(playbackSettings.RealtimeOutput)
.WithSongInput(videoPath, videoStream.Codec, videoStream.PixelFormat)
.WithSongInput(videoPath, videoStream.Codec, videoStream.PixelFormat, boxBlur)
.WithWatermark(watermarkOptions, channel.FFmpegProfile.Resolution)
.WithDrawtextFile(videoVersion, drawtextFile);
.WithSubtitleFile(subtitleFile);
foreach (IDisplaySize scaledSize in scalePlaybackSettings.ScaledSize)
{
@@ -317,26 +390,15 @@ namespace ErsatzTV.Core.FFmpeg
private async Task<WatermarkOptions> GetWatermarkOptions(
Channel channel,
Option<ChannelWatermark> globalWatermark,
MediaVersion videoVersion)
MediaVersion videoVersion,
Option<ChannelWatermark> watermarkOverride,
Option<string> watermarkPath)
{
if (videoVersion is BackgroundImageMediaVersion)
{
return new WatermarkOptions(None, None, None, false);
}
Option<ChannelWatermark> watermarkOverride = videoVersion is FallbackMediaVersion or CoverArtMediaVersion
? new ChannelWatermark
{
Mode = ChannelWatermarkMode.Permanent,
HorizontalMarginPercent = 3,
VerticalMarginPercent = 5,
Location = ChannelWatermarkLocation.BottomRight,
Size = ChannelWatermarkSize.Scaled,
WidthPercent = 25,
Opacity = 100
}
: None;
if (channel.StreamingMode != StreamingMode.HttpLiveStreamingDirect && channel.FFmpegProfile.Transcode &&
channel.FFmpegProfile.NormalizeVideo)
{
@@ -344,7 +406,7 @@ namespace ErsatzTV.Core.FFmpeg
{
return new WatermarkOptions(
watermarkOverride,
videoVersion.MediaFiles.Head().Path,
await watermarkPath.IfNoneAsync(videoVersion.MediaFiles.Head().Path),
0,
false);
}
+6 -1
View File
@@ -1,4 +1,5 @@
using System;
using System.Globalization;
using System.Text;
namespace ErsatzTV.Core.FFmpeg
@@ -55,7 +56,11 @@ namespace ErsatzTV.Core.FFmpeg
continue;
}
var duration = TimeSpan.FromSeconds(double.Parse(lines[i].TrimEnd(',').Split(':')[1]));
var duration = TimeSpan.FromSeconds(
double.Parse(
lines[i].TrimEnd(',').Split(':')[1],
NumberStyles.Number,
CultureInfo.InvariantCulture));
if (currentTime < filterBefore)
{
currentTime += duration;
+257
View File
@@ -0,0 +1,257 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images;
using LanguageExt;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.FFmpeg
{
public class SongVideoGenerator : ISongVideoGenerator
{
private static readonly Random Random = new();
private static readonly object RandomLock = new();
private readonly ITempFilePool _tempFilePool;
private readonly IImageCache _imageCache;
private readonly IFFmpegProcessService _ffmpegProcessService;
public SongVideoGenerator(
ITempFilePool tempFilePool,
IImageCache imageCache,
IFFmpegProcessService ffmpegProcessService)
{
_tempFilePool = tempFilePool;
_imageCache = imageCache;
_ffmpegProcessService = ffmpegProcessService;
}
public async Task<Tuple<string, MediaVersion>> GenerateSongVideo(
Song song,
Channel channel,
Option<ChannelWatermark> maybeGlobalWatermark,
string ffmpegPath)
{
Option<string> subtitleFile = None;
MediaVersion videoVersion = new FallbackMediaVersion
{
Id = -1,
Chapters = new List<MediaChapter>(),
Width = 192,
Height = 108,
SampleAspectRatio = "1:1",
Streams = new List<MediaStream>
{
new() { MediaStreamKind = MediaStreamKind.Video, Index = 0 }
}
};
string[] backgrounds =
{
"song_background_1.png",
"song_background_2.png",
"song_background_3.png"
};
// use random ETV color by default
string backgroundPath = Path.Combine(
FileSystemLayout.ResourcesCacheFolder,
backgrounds[NextRandom(backgrounds.Length)]);
Option<string> watermarkPath = None;
var boxBlur = false;
const int HORIZONTAL_MARGIN_PERCENT = 3;
const int VERTICAL_MARGIN_PERCENT = 5;
const int WATERMARK_WIDTH_PERCENT = 25;
ChannelWatermarkLocation watermarkLocation = NextRandom(2) == 0
? ChannelWatermarkLocation.BottomLeft
: ChannelWatermarkLocation.BottomRight;
foreach (SongMetadata metadata in song.SongMetadata)
{
var fontSize = (int)Math.Round(channel.FFmpegProfile.Resolution.Height / 20.0);
var largeFontSize = (int)Math.Round(channel.FFmpegProfile.Resolution.Height / 10.0);
bool detailsStyle = NextRandom(2) == 0;
var sb = new StringBuilder();
if (detailsStyle)
{
if (!string.IsNullOrWhiteSpace(metadata.Title))
{
sb.Append($"{{\\fs{largeFontSize}}}{metadata.Title}");
}
if (!string.IsNullOrWhiteSpace(metadata.Artist))
{
sb.Append($"\\N{{\\fs{fontSize}}}{metadata.Artist}");
}
}
else
{
if (!string.IsNullOrWhiteSpace(metadata.Artist))
{
sb.Append(metadata.Artist);
}
if (!string.IsNullOrWhiteSpace(metadata.Title))
{
sb.Append($"\\N\"{metadata.Title}\"");
}
if (!string.IsNullOrWhiteSpace(metadata.Album))
{
sb.Append($"\\N{metadata.Album}");
}
}
int leftMarginPercent = HORIZONTAL_MARGIN_PERCENT;
int rightMarginPercent = HORIZONTAL_MARGIN_PERCENT;
if (metadata.Artwork.Any(a => a.ArtworkKind == ArtworkKind.Thumbnail))
{
switch (watermarkLocation)
{
case ChannelWatermarkLocation.BottomLeft:
leftMarginPercent += WATERMARK_WIDTH_PERCENT + HORIZONTAL_MARGIN_PERCENT;
break;
case ChannelWatermarkLocation.BottomRight:
leftMarginPercent = rightMarginPercent = HORIZONTAL_MARGIN_PERCENT;
rightMarginPercent += WATERMARK_WIDTH_PERCENT + HORIZONTAL_MARGIN_PERCENT;
break;
}
}
var leftMargin = (int)Math.Round(leftMarginPercent / 100.0 * channel.FFmpegProfile.Resolution.Width);
var rightMargin = (int)Math.Round(rightMarginPercent / 100.0 * channel.FFmpegProfile.Resolution.Width);
var verticalMargin = (int)Math.Round(VERTICAL_MARGIN_PERCENT / 100.0 * channel.FFmpegProfile.Resolution.Height);
subtitleFile = await new SubtitleBuilder(_tempFilePool)
.WithResolution(channel.FFmpegProfile.Resolution)
.WithFontName("OPTIKabel-Heavy")
.WithFontSize(fontSize)
.WithPrimaryColor("&HFFFFFF")
.WithOutlineColor("&H444444")
.WithAlignment(0)
.WithMarginRight(rightMargin)
.WithMarginLeft(leftMargin)
.WithMarginV(verticalMargin)
.WithBorderStyle(1)
.WithShadow(3)
.WithFormattedContent(sb.ToString())
.BuildFile();
// use thumbnail (cover art) if present
foreach (Artwork artwork in Optional(
metadata.Artwork.Find(a => a.ArtworkKind == ArtworkKind.Thumbnail)))
{
// signal that we want to use cover art as watermark
videoVersion = new CoverArtMediaVersion
{
Chapters = new List<MediaChapter>(),
// always stretch cover art
Width = 192,
Height = 108,
SampleAspectRatio = "1:1",
Streams = new List<MediaStream>
{
new() { MediaStreamKind = MediaStreamKind.Video, Index = 0 }
}
};
string customPath = _imageCache.GetPathForImage(
artwork.Path,
ArtworkKind.Thumbnail,
Option<int>.None);
watermarkPath = customPath;
// randomize selected blur hash
var hashes = new List<string>
{
artwork.BlurHash43,
artwork.BlurHash54,
artwork.BlurHash64
}.Filter(s => !string.IsNullOrWhiteSpace(s)).ToList();
if (hashes.Any())
{
string hash = hashes[NextRandom(hashes.Count)];
backgroundPath = await _imageCache.WriteBlurHash(
hash,
channel.FFmpegProfile.Resolution);
videoVersion.Height = channel.FFmpegProfile.Resolution.Height;
videoVersion.Width = channel.FFmpegProfile.Resolution.Width;
}
else
{
backgroundPath = customPath;
boxBlur = true;
}
}
}
string videoPath = backgroundPath;
videoVersion.MediaFiles = new List<MediaFile>
{
new() { Path = videoPath }
};
Either<BaseError, string> maybeSongImage = await _ffmpegProcessService.GenerateSongImage(
ffmpegPath,
subtitleFile,
channel,
maybeGlobalWatermark,
videoVersion,
videoPath,
boxBlur,
watermarkPath,
watermarkLocation,
HORIZONTAL_MARGIN_PERCENT,
VERTICAL_MARGIN_PERCENT,
WATERMARK_WIDTH_PERCENT);
foreach (string si in maybeSongImage.RightToSeq())
{
videoPath = si;
videoVersion = new BackgroundImageMediaVersion
{
Chapters = new List<MediaChapter>(),
// song image has been pre-generated with correct size
Height = channel.FFmpegProfile.Resolution.Height,
Width = channel.FFmpegProfile.Resolution.Width,
SampleAspectRatio = "1:1",
Streams = new List<MediaStream>
{
new() { MediaStreamKind = MediaStreamKind.Video, Index = 0 },
},
MediaFiles = new List<MediaFile>
{
new() { Path = si }
}
};
}
return Tuple(videoPath, videoVersion);
}
private static int NextRandom(int max)
{
lock (RandomLock)
{
return Random.Next() % max;
}
}
}
}
+138
View File
@@ -0,0 +1,138 @@
using System.IO;
using System.Text;
using System.Threading.Tasks;
using ErsatzTV.Core.Interfaces.FFmpeg;
using LanguageExt;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.FFmpeg
{
public class SubtitleBuilder
{
private readonly ITempFilePool _tempFilePool;
private string _content;
private Option<IDisplaySize> _resolution = None;
private Option<string> _fontName;
private Option<int> _fontSize;
private Option<string> _primaryColor;
private Option<string> _outlineColor;
private Option<int> _alignment;
private int _marginRight;
private int _marginLeft;
private int _marginV;
private Option<int> _borderStyle;
private Option<int> _shadow;
public SubtitleBuilder(ITempFilePool tempFilePool)
{
_tempFilePool = tempFilePool;
}
public SubtitleBuilder WithResolution(IDisplaySize resolution)
{
_resolution = Some(resolution);
return this;
}
public SubtitleBuilder WithFontName(string fontName)
{
_fontName = fontName;
return this;
}
public SubtitleBuilder WithFontSize(int fontSize)
{
_fontSize = fontSize;
return this;
}
public SubtitleBuilder WithPrimaryColor(string primaryColor)
{
_primaryColor = primaryColor;
return this;
}
public SubtitleBuilder WithOutlineColor(string outlineColor)
{
_outlineColor = outlineColor;
return this;
}
public SubtitleBuilder WithAlignment(int alignment)
{
_alignment = alignment;
return this;
}
public SubtitleBuilder WithMarginRight(int marginRight)
{
_marginRight = marginRight;
return this;
}
public SubtitleBuilder WithMarginLeft(int marginLeft)
{
_marginLeft = marginLeft;
return this;
}
public SubtitleBuilder WithMarginV(int marginV)
{
_marginV = marginV;
return this;
}
public SubtitleBuilder WithBorderStyle(int borderStyle)
{
_borderStyle = borderStyle;
return this;
}
public SubtitleBuilder WithShadow(int shadow)
{
_shadow = shadow;
return this;
}
public SubtitleBuilder WithFormattedContent(string content)
{
_content = content;
return this;
}
public async Task<string> BuildFile()
{
string fileName = _tempFilePool.GetNextTempFile(TempFileCategory.Subtitle);
var sb = new StringBuilder();
sb.AppendLine("[Script Info]");
sb.AppendLine("ScriptType: v4.00+");
sb.AppendLine("WrapStyle: 0");
sb.AppendLine("ScaledBorderAndShadow: yes");
sb.AppendLine("YCbCr Matrix: None");
foreach (IDisplaySize resolution in _resolution)
{
sb.AppendLine($"PlayResX: {resolution.Width}");
sb.AppendLine($"PlayResY: {resolution.Height}");
}
sb.AppendLine("[V4+ Styles]");
sb.AppendLine("Format: Name, Fontname, Fontsize, PrimaryColour, OutlineColour, BorderStyle, Outline, Shadow, Alignment, Encoding");
sb.AppendLine($"Style: Default,{await _fontName.IfNoneAsync("")},{await _fontSize.IfNoneAsync(32)},{await _primaryColor.IfNoneAsync("")},{await _outlineColor.IfNoneAsync("")},{await _borderStyle.IfNoneAsync(0)},1,{await _shadow.IfNoneAsync(0)},{await _alignment.IfNoneAsync(0)},1");
sb.AppendLine("[Events]");
sb.AppendLine("Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text");
sb.AppendLine($"Dialogue: 0,0:00:00.00,99:99:99.99,Default,,{_marginLeft},{_marginRight},{_marginV},,{_content}");
if (!string.IsNullOrWhiteSpace(_content))
{
sb.AppendLine(_content);
}
await File.WriteAllTextAsync(fileName, sb.ToString());
return fileName;
}
}
}
+3 -2
View File
@@ -2,8 +2,9 @@
{
public enum TempFileCategory
{
DrawText = 0,
Subtitle = 0,
SongBackground = 1,
CoverArt = 2
CoverArt = 2,
CachedArtwork = 3
}
}
+3
View File
@@ -18,9 +18,12 @@ namespace ErsatzTV.Core
Environment.SpecialFolderOption.Create),
"etv-transcode");
public static readonly string LogsFolder = Path.Combine(AppDataFolder, "logs");
public static readonly string DatabasePath = Path.Combine(AppDataFolder, "ersatztv.sqlite3");
public static readonly string LogDatabasePath = Path.Combine(AppDataFolder, "logs.sqlite3");
public static readonly string LogFilePath = Path.Combine(LogsFolder, "ersatztv.log");
public static readonly string LegacyImageCacheFolder = Path.Combine(AppDataFolder, "cache", "images");
public static readonly string ResourcesCacheFolder = Path.Combine(AppDataFolder, "cache", "resources");
+1 -2
View File
@@ -22,8 +22,7 @@ namespace ErsatzTV.Core.Hdhr
public string URL => _channel.StreamingMode switch
{
StreamingMode.HttpLiveStreamingDirect or StreamingMode.HttpLiveStreamingHybrid =>
$"{_scheme}://{_host}/iptv/channel/{_channel.Number}.m3u8",
StreamingMode.TransportStream => $"{_scheme}://{_host}/iptv/channel/{_channel.Number}.ts?mode=ts-legacy",
_ => $"{_scheme}://{_host}/iptv/channel/{_channel.Number}.ts"
};
}
@@ -0,0 +1,5 @@
namespace ErsatzTV.Core.Health.Checks;
public interface IFileNotFoundHealthCheck : IHealthCheck
{
}
+4 -2
View File
@@ -1,4 +1,6 @@
namespace ErsatzTV.Core.Health
using LanguageExt;
namespace ErsatzTV.Core.Health
{
public record HealthCheckResult(string Title, HealthCheckStatus Status, string Message);
public record HealthCheckResult(string Title, HealthCheckStatus Status, string Message, Option<string> Link);
}
@@ -27,25 +27,37 @@ namespace ErsatzTV.Core.Interfaces.FFmpeg
bool hlsRealtime,
FillerKind fillerKind,
TimeSpan inPoint,
TimeSpan outPoint);
TimeSpan outPoint,
long ptsOffset);
Process ForError(
Task<Process> ForError(
string ffmpegPath,
Channel channel,
Option<TimeSpan> duration,
string errorMessage,
bool hlsRealtime);
bool hlsRealtime,
long ptsOffset);
Process ConcatChannel(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host);
Process WrapSegmenter(string ffmpegPath, bool saveReports, Channel channel, string scheme, string host);
Process ConvertToPng(string ffmpegPath, string inputFile, string outputFile);
Process ExtractAttachedPicAsPng(string ffmpegPath, string inputFile, int streamIndex, string outputFile);
Task<Either<BaseError, string>> GenerateSongImage(
string ffmpegPath,
Option<string> drawtextFile,
Option<string> subtitleFile,
Channel channel,
Option<ChannelWatermark> globalWatermark,
MediaVersion videoVersion,
string videoPath);
string videoPath,
bool boxBlur,
Option<string> watermarkPath,
ChannelWatermarkLocation watermarkLocation,
int horizontalMarginPercent,
int verticalMarginPercent,
int watermarkWidthPercent);
}
}
@@ -0,0 +1,16 @@
using System;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.FFmpeg
{
public interface ISongVideoGenerator
{
Task<Tuple<string, MediaVersion>> GenerateSongVideo(
Song song,
Channel channel,
Option<ChannelWatermark> maybeGlobalWatermark,
string ffmpegPath);
}
}
@@ -1,5 +1,7 @@
using System.Threading.Tasks;
using System.IO;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.FFmpeg;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Images
@@ -7,9 +9,11 @@ namespace ErsatzTV.Core.Interfaces.Images
public interface IImageCache
{
Task<Either<BaseError, byte[]>> ResizeImage(byte[] imageBuffer, int height);
Task<Either<BaseError, string>> SaveArtworkToCache(byte[] imageBuffer, ArtworkKind artworkKind);
Task<Either<BaseError, string>> SaveArtworkToCache(Stream stream, ArtworkKind artworkKind);
Task<Either<BaseError, string>> CopyArtworkToCache(string path, ArtworkKind artworkKind);
string GetPathForImage(string fileName, ArtworkKind artworkKind, Option<int> maybeMaxHeight);
Task<bool> IsAnimated(string fileName);
Task<string> CalculateBlurHash(string fileName, ArtworkKind artworkKind, int x, int y);
Task<string> WriteBlurHash(string blurHash, IDisplaySize targetSize);
}
}
@@ -14,6 +14,7 @@ namespace ErsatzTV.Core.Interfaces.Metadata
IEnumerable<string> ListSubdirectories(string folder);
IEnumerable<string> ListFiles(string folder);
bool FileExists(string path);
bool FolderExists(string folder);
Task<Either<BaseError, Unit>> CopyFile(string source, string destination);
Unit EmptyFolder(string folder);
}
@@ -19,5 +19,6 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<List<int>> GetMediaIdsByLocalPath(int libraryPathId);
Task DeleteLocalPath(int libraryPathId);
Task<Unit> SetEtag(LibraryPath libraryPath, Option<LibraryFolder> knownFolder, string path, string etag);
Task<Unit> CleanEtagsForLibraryPath(LibraryPath libraryPath);
}
}
@@ -1,10 +1,15 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Repositories
{
public interface IMediaItemRepository
{
Task<List<string>> GetAllLanguageCodes();
Task<List<int>> FlagFileNotFound(LibraryPath libraryPath, string path);
Task<Unit> FlagNormal(MediaItem mediaItem);
Task<Either<BaseError, Unit>> DeleteItems(List<int> mediaItemIds);
}
}
@@ -15,11 +15,17 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<bool> RemoveActor(Actor actor);
Task<bool> Update(Domain.Metadata metadata);
Task<bool> Add(Domain.Metadata metadata);
Task<bool> UpdateLocalStatistics(int mediaVersionId, MediaVersion incoming, bool updateVersion = true);
Task<bool> UpdateLocalStatistics(MediaItem mediaItem, MediaVersion incoming, bool updateVersion = true);
Task<bool> UpdatePlexStatistics(int mediaVersionId, MediaVersion incoming);
Task<Unit> UpdateArtworkPath(Artwork artwork);
Task<Unit> AddArtwork(Domain.Metadata metadata, Artwork artwork);
Task<Unit> RemoveArtwork(Domain.Metadata metadata, ArtworkKind artworkKind);
Task<bool> CloneArtwork(
Domain.Metadata metadata,
Option<Artwork> maybeArtwork,
ArtworkKind artworkKind,
string sourcePath,
DateTime lastWriteTime);
Task<Unit> MarkAsUpdated(ShowMetadata metadata, DateTime dateUpdated);
Task<Unit> MarkAsUpdated(SeasonMetadata metadata, DateTime dateUpdated);
Task<Unit> MarkAsUpdated(MovieMetadata metadata, DateTime dateUpdated);
@@ -14,6 +14,7 @@ namespace ErsatzTV.Core.Interfaces.Search
public int Version { get; }
Task<bool> Initialize(ILocalFileSystem localFileSystem);
Task<Unit> Rebuild(ISearchRepository searchRepository, List<int> itemIds);
Task<Unit> RebuildItems(ISearchRepository searchRepository, List<int> itemIds);
Task<Unit> AddItems(ISearchRepository searchRepository, List<MediaItem> items);
Task<Unit> UpdateItems(ISearchRepository searchRepository, List<MediaItem> items);
Task<Unit> RemoveItems(List<int> ids);

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