Compare commits
134 Commits
v0.2.2-alp
...
v0.4.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17bc988b49 | ||
|
|
749eea836b | ||
|
|
37c52c4cb4 | ||
|
|
33ba58aa68 | ||
|
|
5f6043e593 | ||
|
|
96e95a21fb | ||
|
|
9168fd6bf2 | ||
|
|
14413f62a7 | ||
|
|
34c71a0c12 | ||
|
|
a487e7fe15 | ||
|
|
cd4ea42597 | ||
|
|
a3d42145f7 | ||
|
|
261cf5052a | ||
|
|
de9af2f0f6 | ||
|
|
8d4e18ed2f | ||
|
|
1ee01c1d78 | ||
|
|
7de50dd916 | ||
|
|
744fd3beaa | ||
|
|
861c95e1bd | ||
|
|
bb5b9f9be4 | ||
|
|
135628441a | ||
|
|
4aa7204984 | ||
|
|
1af59a0337 | ||
|
|
c4c97fcc8c | ||
|
|
9c46e42792 | ||
|
|
efa803aab6 | ||
|
|
6ea02a2d77 | ||
|
|
631f7d2d5e | ||
|
|
e44a4cb2e1 | ||
|
|
f4b95419a6 | ||
|
|
1a5cf49563 | ||
|
|
efef0b0fee | ||
|
|
ee7b8a71ab | ||
|
|
e7c9a51e96 | ||
|
|
78a954f365 | ||
|
|
355c0b7be9 | ||
|
|
3bcb2d36f9 | ||
|
|
b240de9d4a | ||
|
|
f5001837cb | ||
|
|
6ea916b1f0 | ||
|
|
db6fd22215 | ||
|
|
691842008d | ||
|
|
685f78bef8 | ||
|
|
3ce267863b | ||
|
|
e4231cb57d | ||
|
|
03946b13ca | ||
|
|
f1a81bf086 | ||
|
|
7a88374362 | ||
|
|
663a62431b | ||
|
|
1d4acc284d | ||
|
|
0440f7643b | ||
|
|
0f4219f731 | ||
|
|
cbe5d47611 | ||
|
|
afa52ccc89 | ||
|
|
7d1163c68f | ||
|
|
883492bd33 | ||
|
|
a4eac4feea | ||
|
|
dab58f5840 | ||
|
|
176f136c23 | ||
|
|
816d77e15b | ||
|
|
7c4d47a211 | ||
|
|
d9d2cfa8be | ||
|
|
8036e46966 | ||
|
|
594ce437fb | ||
|
|
004c43f895 | ||
|
|
257384ea9b | ||
|
|
637f3a0c8b | ||
|
|
7346808059 | ||
|
|
4210d97ee2 | ||
|
|
6a8ecd2532 | ||
|
|
9b834f7cbe | ||
|
|
7b73677bad | ||
|
|
85b2a46353 | ||
|
|
6f40f2cbd6 | ||
|
|
b62ee4dee9 | ||
|
|
a6e7f192cc | ||
|
|
59a1a4a8dc | ||
|
|
85a9afb51c | ||
|
|
246b4d7591 | ||
|
|
ae2c6350e1 | ||
|
|
ce228604e8 | ||
|
|
3656e932d3 | ||
|
|
73887706ed | ||
|
|
abc103308b | ||
|
|
3773bbec19 | ||
|
|
e223d6a43f | ||
|
|
8369111e31 | ||
|
|
35ba2bab2c | ||
|
|
094ed71ad0 | ||
|
|
89e24b2b78 | ||
|
|
848795af32 | ||
|
|
56f94f489a | ||
|
|
475dc7660b | ||
|
|
db3dfbd446 | ||
|
|
b4c9cdbbfa | ||
|
|
7f84933c0b | ||
|
|
1e35e9a5b0 | ||
|
|
7edf6f5d13 | ||
|
|
919325033d | ||
|
|
2cb5252320 | ||
|
|
015232fad6 | ||
|
|
af51b790b6 | ||
|
|
9195ef7878 | ||
|
|
dfc4c7a284 | ||
|
|
a6b15f68c9 | ||
|
|
0edfb71f8d | ||
|
|
21b90a1b6c | ||
|
|
1582f5dd15 | ||
|
|
fd3b72525d | ||
|
|
55d1871d94 | ||
|
|
a90eb2d4de | ||
|
|
ed3f1b1dad | ||
|
|
8e08ff059f | ||
|
|
fb8c3a0453 | ||
|
|
e45fb67769 | ||
|
|
3a40d6ce77 | ||
|
|
ac048b72ae | ||
|
|
852728c816 | ||
|
|
096f2d42e8 | ||
|
|
1b29e252ff | ||
|
|
a4dc9bfb31 | ||
|
|
184c21a91b | ||
|
|
6ea3191cf8 | ||
|
|
d487bbca08 | ||
|
|
05034b47e2 | ||
|
|
b0c85b6478 | ||
|
|
f1356563da | ||
|
|
c0aad028a8 | ||
|
|
dae06ec0ef | ||
|
|
72f452fd36 | ||
|
|
aaf832c0b6 | ||
|
|
08a18daf23 | ||
|
|
90c1c61a09 | ||
|
|
053db71d44 |
@@ -3,7 +3,7 @@
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"jetbrains.resharper.globaltools": {
|
||||
"version": "2021.1.3",
|
||||
"version": "2021.2.2",
|
||||
"commands": [
|
||||
"jb"
|
||||
]
|
||||
|
||||
218
.github/workflows/artifacts.yml
vendored
Normal file
218
.github/workflows/artifacts.yml
vendored
Normal 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 }}
|
||||
130
.github/workflows/ci.yml
vendored
130
.github/workflows/ci.yml
vendored
@@ -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: 5.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
.github/workflows/docker.yml
vendored
Normal file
88
.github/workflows/docker.yml
vendored
Normal 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
|
||||
3
.github/workflows/docs.yml
vendored
3
.github/workflows/docs.yml
vendored
@@ -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
.github/workflows/pr.yml
vendored
Normal file
30
.github/workflows/pr.yml
vendored
Normal 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
|
||||
167
.github/workflows/release.yml
vendored
167
.github/workflows/release.yml
vendored
@@ -1,142 +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: maxOS
|
||||
target: osx-x64
|
||||
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: 5.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 net5.0 --runtime "${{ matrix.target }}" -c Release -o "$release_name" /property:InformationalVersion="${tag:1}-${{ matrix.target }}" /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
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
[submodule "ErsatzTV-macOS"]
|
||||
path = ErsatzTV-macOS
|
||||
url = git@github.com:jasongdove/ErsatzTV-macOS.git
|
||||
185
CHANGELOG.md
185
CHANGELOG.md
@@ -5,6 +5,175 @@ 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`
|
||||
- The proper fix requires rebuilding all playouts, which will happen on startup after upgrading
|
||||
- Fix local library locking/progress display when adding paths
|
||||
- Fix grouping duration items in EPG when custom title is configured
|
||||
|
||||
### Added
|
||||
- Add *experimental* `Songs` local libraries
|
||||
- Like `Other Videos`, `Songs` require no metadata or particular folder layout, and will have tags added for each containing folder
|
||||
- For Example, a song at `rock/band/1990 - Album/01 whatever.flac` will have the tags `rock`, `band` and `1990 - Album`, and the title `01 whatever`
|
||||
- Songs will also have basic metadata read from embedded tags (album, artist, title)
|
||||
- Video will be automatically generated for songs using metadata and cover art or watermarks if available
|
||||
- Add support for `.webm` video files
|
||||
|
||||
## [0.2.5-alpha] - 2021-11-21
|
||||
### Fixed
|
||||
- Include other video title in channel guide (xmltv)
|
||||
- Fix bug introduced with 0.2.4-alpha that caused some playouts to build from year 0
|
||||
- Use less memory matching Trakt list items
|
||||
|
||||
### Added
|
||||
- Build osx-arm64 packages on release
|
||||
|
||||
### Changed
|
||||
- No longer warn about local Plex guids; they aren't used for Trakt matching and can be ignored
|
||||
|
||||
## [0.2.4-alpha] - 2021-11-13
|
||||
### Changed
|
||||
- Upgrade to dotnet 6
|
||||
- Use `scale_cuda` instead of `scale_npp` for NVIDIA scaling in all cases
|
||||
|
||||
## [0.2.3-alpha] - 2021-11-03
|
||||
### Fixed
|
||||
- Fix bug with audio filter in cultures where `.` is a group/thousands separator
|
||||
- Fix bug where flood playout mode would only schedule one item
|
||||
- This would happen if the flood was followed by another flood with a fixed start time
|
||||
|
||||
### Added
|
||||
- Support empty `.etvignore` file to instruct local movie scanner to ignore the containing folder
|
||||
|
||||
## [0.2.2-alpha] - 2021-10-30
|
||||
### Fixed
|
||||
- Fix EPG entries for Duration schedule items that play multiple items
|
||||
@@ -776,7 +945,21 @@ 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.2.1-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
|
||||
[0.2.3-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.2.2-alpha...v0.2.3-alpha
|
||||
[0.2.2-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.2.1-alpha...v0.2.2-alpha
|
||||
[0.2.1-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.2.0-alpha...v0.2.1-alpha
|
||||
[0.2.0-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.1.5-alpha...v0.2.0-alpha
|
||||
[0.1.5-alpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.1.4-alpha...v0.1.5-alpha
|
||||
|
||||
33
ErsatzTV-Windows/ErsatzTV-Windows.csproj
Normal file
33
ErsatzTV-Windows/ErsatzTV-Windows.csproj
Normal 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>
|
||||
BIN
ErsatzTV-Windows/Ersatztv.ico
Normal file
BIN
ErsatzTV-Windows/Ersatztv.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
14
ErsatzTV-Windows/Program.cs
Normal file
14
ErsatzTV-Windows/Program.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
77
ErsatzTV-Windows/TrayApplicationContext.cs
Normal file
77
ErsatzTV-Windows/TrayApplicationContext.cs
Normal file
@@ -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
ErsatzTV-macOS
Submodule
1
ErsatzTV-macOS
Submodule
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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<NoWarn>VSTHRD200</NoWarn>
|
||||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MediatR" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="5.0.0" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.0.63">
|
||||
<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>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
@@ -37,7 +36,7 @@ namespace ErsatzTV.Application.Libraries.Commands
|
||||
CreateLocalLibrary request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, LocalLibrary> validation = await Validate(dbContext, request);
|
||||
return await validation.Apply(localLibrary => PersistLocalLibrary(dbContext, localLibrary));
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ namespace ErsatzTV.Application.Libraries.Commands
|
||||
UpdateLocalLibrary request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Parameters> validation = await Validate(dbContext, request);
|
||||
return await validation.Apply(parameters => UpdateLocalLibrary(dbContext, parameters));
|
||||
}
|
||||
@@ -53,7 +53,6 @@ namespace ErsatzTV.Application.Libraries.Commands
|
||||
(LocalLibrary existing, LocalLibrary incoming) = parameters;
|
||||
existing.Name = incoming.Name;
|
||||
|
||||
// toAdd
|
||||
var toAdd = incoming.Paths
|
||||
.Filter(p => existing.Paths.All(ep => NormalizePath(ep.Path) != NormalizePath(p.Path)))
|
||||
.ToList();
|
||||
@@ -77,7 +76,7 @@ namespace ErsatzTV.Application.Libraries.Commands
|
||||
_searchIndex.Commit();
|
||||
}
|
||||
|
||||
if (toAdd.Count > 0 || toRemove.Count > 0 && _entityLocker.LockLibrary(existing.Id))
|
||||
if ((toAdd.Count > 0 || toRemove.Count > 0) && _entityLocker.LockLibrary(existing.Id))
|
||||
{
|
||||
await _workerChannel.WriteAsync(new ForceScanLocalLibrary(existing.Id));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@ namespace ErsatzTV.Application.MediaCards
|
||||
List<TelevisionEpisodeCardViewModel> EpisodeCards,
|
||||
List<ArtistCardViewModel> ArtistCards,
|
||||
List<MusicVideoCardViewModel> MusicVideoCards,
|
||||
List<OtherVideoCardViewModel> OtherVideoCards)
|
||||
List<OtherVideoCardViewModel> OtherVideoCards,
|
||||
List<SongCardViewModel> SongCards)
|
||||
{
|
||||
public bool UseCustomPlaybackOrder { get; set; }
|
||||
}
|
||||
|
||||
@@ -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,29 @@ 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)
|
||||
{
|
||||
string album = string.IsNullOrWhiteSpace(songMetadata.Album) ? "" : $" - {songMetadata.Album}";
|
||||
return new SongCardViewModel(
|
||||
songMetadata.SongId,
|
||||
songMetadata.Title,
|
||||
songMetadata.Artist + album,
|
||||
songMetadata.SortTitle,
|
||||
GetThumbnail(songMetadata, None, None),
|
||||
songMetadata.Song.State);
|
||||
}
|
||||
|
||||
internal static ArtistCardViewModel ProjectToViewModel(ArtistMetadata artistMetadata) =>
|
||||
new(
|
||||
@@ -116,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(
|
||||
@@ -141,7 +164,9 @@ namespace ErsatzTV.Application.MediaCards
|
||||
collection.MediaItems.OfType<Artist>().Map(a => ProjectToViewModel(a.ArtistMetadata.Head())).ToList(),
|
||||
collection.MediaItems.OfType<MusicVideo>().Map(mv => ProjectToViewModel(mv.MusicVideoMetadata.Head()))
|
||||
.ToList(),
|
||||
collection.MediaItems.OfType<OtherVideo>().Map(mv => ProjectToViewModel(mv.OtherVideoMetadata.Head()))
|
||||
collection.MediaItems.OfType<OtherVideo>().Map(ov => ProjectToViewModel(ov.OtherVideoMetadata.Head()))
|
||||
.ToList(),
|
||||
collection.MediaItems.OfType<Song>().Map(s => ProjectToViewModel(s.SongMetadata.Head()))
|
||||
.ToList()) { UseCustomPlaybackOrder = collection.UseCustomPlaybackOrder };
|
||||
|
||||
internal static ActorCardViewModel ProjectToViewModel(
|
||||
@@ -162,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; }
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ namespace ErsatzTV.Application.MediaCards.Queries
|
||||
GetCollectionCards 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());
|
||||
@@ -83,6 +83,9 @@ namespace ErsatzTV.Application.MediaCards.Queries
|
||||
.Include(c => c.MediaItems)
|
||||
.ThenInclude(i => (i as OtherVideo).OtherVideoMetadata)
|
||||
.ThenInclude(ovm => ovm.Artwork)
|
||||
.Include(c => c.MediaItems)
|
||||
.ThenInclude(i => (i as Song).SongMetadata)
|
||||
.ThenInclude(ovm => ovm.Artwork)
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == request.Id)
|
||||
.Map(c => c.ToEither(BaseError.New("Unable to load collection")))
|
||||
.MapT(c => ProjectToViewModel(c, maybeJellyfin, maybeEmby));
|
||||
|
||||
11
ErsatzTV.Application/MediaCards/SongCardResultsViewModel.cs
Normal file
11
ErsatzTV.Application/MediaCards/SongCardResultsViewModel.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using System.Collections.Generic;
|
||||
using ErsatzTV.Core.Search;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCards
|
||||
{
|
||||
public record SongCardResultsViewModel(
|
||||
int Count,
|
||||
List<SongCardViewModel> Cards,
|
||||
Option<SearchPageMap> PageMap);
|
||||
}
|
||||
22
ErsatzTV.Application/MediaCards/SongCardViewModel.cs
Normal file
22
ErsatzTV.Application/MediaCards/SongCardViewModel.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCards
|
||||
{
|
||||
public record SongCardViewModel
|
||||
(
|
||||
int SongId,
|
||||
string Title,
|
||||
string Subtitle,
|
||||
string SortTitle,
|
||||
string Poster,
|
||||
MediaItemState State) : MediaCardViewModel(
|
||||
SongId,
|
||||
Title,
|
||||
Subtitle,
|
||||
SortTitle,
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -13,5 +13,6 @@ namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
List<int> EpisodeIds,
|
||||
List<int> ArtistIds,
|
||||
List<int> MusicVideoIds,
|
||||
List<int> OtherVideoIds) : MediatR.IRequest<Either<BaseError, Unit>>;
|
||||
List<int> OtherVideoIds,
|
||||
List<int> SongIds) : MediatR.IRequest<Either<BaseError, Unit>>;
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
.Append(request.ArtistIds)
|
||||
.Append(request.MusicVideoIds)
|
||||
.Append(request.OtherVideoIds)
|
||||
.Append(request.SongIds)
|
||||
.ToList();
|
||||
|
||||
var toAddIds = allItems.Where(item => collection.MediaItems.All(mi => mi.Id != item)).ToList();
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
{
|
||||
public record AddSongToCollection
|
||||
(int CollectionId, int SongId) : MediatR.IRequest<Either<BaseError, Unit>>;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Application.Playouts.Commands;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using LanguageExt;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
{
|
||||
public class AddSongToCollectionHandler :
|
||||
MediatR.IRequestHandler<AddSongToCollection, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IMediaCollectionRepository _mediaCollectionRepository;
|
||||
|
||||
public AddSongToCollectionHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IMediaCollectionRepository mediaCollectionRepository,
|
||||
ChannelWriter<IBackgroundServiceRequest> channel)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_mediaCollectionRepository = mediaCollectionRepository;
|
||||
_channel = channel;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
AddSongToCollection request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Parameters> validation = await Validate(dbContext, request);
|
||||
return await validation.Apply(parameters => ApplyAddSongRequest(dbContext, parameters));
|
||||
}
|
||||
|
||||
private async Task<Unit> ApplyAddSongRequest(TvContext dbContext, Parameters parameters)
|
||||
{
|
||||
parameters.Collection.MediaItems.Add(parameters.Song);
|
||||
if (await dbContext.SaveChangesAsync() > 0)
|
||||
{
|
||||
// rebuild all playouts that use this collection
|
||||
foreach (int playoutId in await _mediaCollectionRepository
|
||||
.PlayoutIdsUsingCollection(parameters.Collection.Id))
|
||||
{
|
||||
await _channel.WriteAsync(new BuildPlayout(playoutId, true));
|
||||
}
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private static async Task<Validation<BaseError, Parameters>> Validate(
|
||||
TvContext dbContext,
|
||||
AddSongToCollection request) =>
|
||||
(await CollectionMustExist(dbContext, request), await ValidateSong(dbContext, request))
|
||||
.Apply((collection, episode) => new Parameters(collection, episode));
|
||||
|
||||
private static Task<Validation<BaseError, Collection>> CollectionMustExist(
|
||||
TvContext dbContext,
|
||||
AddSongToCollection request) =>
|
||||
dbContext.Collections
|
||||
.Include(c => c.MediaItems)
|
||||
.SelectOneAsync(c => c.Id, c => c.Id == request.CollectionId)
|
||||
.Map(o => o.ToValidation<BaseError>("Collection does not exist."));
|
||||
|
||||
private static Task<Validation<BaseError, Song>> ValidateSong(
|
||||
TvContext dbContext,
|
||||
AddSongToCollection request) =>
|
||||
dbContext.Songs
|
||||
.SelectOneAsync(m => m.Id, e => e.Id == request.SongId)
|
||||
.Map(o => o.ToValidation<BaseError>("Song does not exist"));
|
||||
|
||||
private record Parameters(Collection Collection, Song Song);
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
{
|
||||
try
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
Validation<BaseError, TraktList> validation = await TraktListMustExist(dbContext, request.TraktListId);
|
||||
return await validation.Match(
|
||||
|
||||
@@ -208,6 +208,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
var guids = item.Guids.Map(g => g.Guid).ToList();
|
||||
|
||||
Option<int> maybeMovieByGuid = await dbContext.MovieMetadata
|
||||
.AsNoTracking()
|
||||
.Filter(mm => mm.Guids.Any(g => guids.Contains(g.Guid)))
|
||||
.FirstOrDefaultAsync()
|
||||
.Map(Optional)
|
||||
@@ -220,6 +221,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
}
|
||||
|
||||
Option<int> maybeMovieByTitleYear = await dbContext.MovieMetadata
|
||||
.AsNoTracking()
|
||||
.Filter(mm => mm.Title == item.Title && mm.Year == item.Year)
|
||||
.FirstOrDefaultAsync()
|
||||
.Map(Optional)
|
||||
@@ -241,6 +243,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
var guids = item.Guids.Map(g => g.Guid).ToList();
|
||||
|
||||
Option<int> maybeShowByGuid = await dbContext.ShowMetadata
|
||||
.AsNoTracking()
|
||||
.Filter(sm => sm.Guids.Any(g => guids.Contains(g.Guid)))
|
||||
.FirstOrDefaultAsync()
|
||||
.Map(Optional)
|
||||
@@ -253,6 +256,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
}
|
||||
|
||||
Option<int> maybeShowByTitleYear = await dbContext.ShowMetadata
|
||||
.AsNoTracking()
|
||||
.Filter(sm => sm.Title == item.Title && sm.Year == item.Year)
|
||||
.FirstOrDefaultAsync()
|
||||
.Map(Optional)
|
||||
@@ -274,6 +278,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
var guids = item.Guids.Map(g => g.Guid).ToList();
|
||||
|
||||
Option<int> maybeSeasonByGuid = await dbContext.SeasonMetadata
|
||||
.AsNoTracking()
|
||||
.Filter(sm => sm.Guids.Any(g => guids.Contains(g.Guid)))
|
||||
.FirstOrDefaultAsync()
|
||||
.Map(Optional)
|
||||
@@ -286,6 +291,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
}
|
||||
|
||||
Option<int> maybeSeasonByTitleYear = await dbContext.SeasonMetadata
|
||||
.AsNoTracking()
|
||||
.Filter(sm => sm.Season.Show.ShowMetadata.Any(s => s.Title == item.Title && s.Year == item.Year))
|
||||
.Filter(sm => sm.Season.SeasonNumber == item.Season)
|
||||
.FirstOrDefaultAsync()
|
||||
@@ -308,6 +314,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
var guids = item.Guids.Map(g => g.Guid).ToList();
|
||||
|
||||
Option<int> maybeEpisodeByGuid = await dbContext.EpisodeMetadata
|
||||
.AsNoTracking()
|
||||
.Filter(em => em.Guids.Any(g => guids.Contains(g.Guid)))
|
||||
.FirstOrDefaultAsync()
|
||||
.Map(Optional)
|
||||
@@ -320,6 +327,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
|
||||
}
|
||||
|
||||
Option<int> maybeEpisodeByTitleYear = await dbContext.EpisodeMetadata
|
||||
.AsNoTracking()
|
||||
.Filter(sm => sm.Episode.Season.Show.ShowMetadata.Any(s => s.Title == item.Title && s.Year == item.Year))
|
||||
.Filter(em => em.Episode.Season.SeasonNumber == item.Season)
|
||||
.Filter(sm => sm.Episode.EpisodeMetadata.Any(e => e.EpisodeNumber == item.Episode))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ namespace ErsatzTV.Application.MediaSources.Commands
|
||||
private readonly IMovieFolderScanner _movieFolderScanner;
|
||||
private readonly IMusicVideoFolderScanner _musicVideoFolderScanner;
|
||||
private readonly IOtherVideoFolderScanner _otherVideoFolderScanner;
|
||||
private readonly ISongFolderScanner _songFolderScanner;
|
||||
private readonly ITelevisionFolderScanner _televisionFolderScanner;
|
||||
|
||||
public ScanLocalLibraryHandler(
|
||||
@@ -36,6 +37,7 @@ namespace ErsatzTV.Application.MediaSources.Commands
|
||||
ITelevisionFolderScanner televisionFolderScanner,
|
||||
IMusicVideoFolderScanner musicVideoFolderScanner,
|
||||
IOtherVideoFolderScanner otherVideoFolderScanner,
|
||||
ISongFolderScanner songFolderScanner,
|
||||
IEntityLocker entityLocker,
|
||||
IMediator mediator,
|
||||
ILogger<ScanLocalLibraryHandler> logger)
|
||||
@@ -46,6 +48,7 @@ namespace ErsatzTV.Application.MediaSources.Commands
|
||||
_televisionFolderScanner = televisionFolderScanner;
|
||||
_musicVideoFolderScanner = musicVideoFolderScanner;
|
||||
_otherVideoFolderScanner = otherVideoFolderScanner;
|
||||
_songFolderScanner = songFolderScanner;
|
||||
_entityLocker = entityLocker;
|
||||
_mediator = mediator;
|
||||
_logger = logger;
|
||||
@@ -67,7 +70,8 @@ namespace ErsatzTV.Application.MediaSources.Commands
|
||||
|
||||
private async Task<Unit> PerformScan(RequestParameters parameters)
|
||||
{
|
||||
(LocalLibrary localLibrary, string ffprobePath, bool forceScan, int libraryRefreshInterval) = parameters;
|
||||
(LocalLibrary localLibrary, string ffprobePath, string ffmpegPath, bool forceScan,
|
||||
int libraryRefreshInterval) = parameters;
|
||||
|
||||
var sw = new Stopwatch();
|
||||
sw.Start();
|
||||
@@ -117,6 +121,14 @@ namespace ErsatzTV.Application.MediaSources.Commands
|
||||
progressMin,
|
||||
progressMax);
|
||||
break;
|
||||
case LibraryMediaKind.Songs:
|
||||
await _songFolderScanner.ScanFolder(
|
||||
libraryPath,
|
||||
ffprobePath,
|
||||
ffmpegPath,
|
||||
progressMin,
|
||||
progressMax);
|
||||
break;
|
||||
}
|
||||
|
||||
libraryPath.LastScan = DateTime.UtcNow;
|
||||
@@ -148,14 +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 ValidateLibraryRefreshInterval())
|
||||
.Apply(
|
||||
(library, ffprobePath, libraryRefreshInterval) => new RequestParameters(
|
||||
library,
|
||||
ffprobePath,
|
||||
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) =>
|
||||
@@ -170,6 +204,13 @@ namespace ErsatzTV.Application.MediaSources.Commands
|
||||
ffprobePath =>
|
||||
ffprobePath.ToValidation<BaseError>("FFprobe path does not exist on the file system"));
|
||||
|
||||
private Task<Validation<BaseError, string>> ValidateFFmpegPath() =>
|
||||
_configElementRepository.GetValue<string>(ConfigElementKey.FFmpegPath)
|
||||
.FilterT(File.Exists)
|
||||
.Map(
|
||||
ffmpegPath =>
|
||||
ffmpegPath.ToValidation<BaseError>("FFmpeg path does not exist on the file system"));
|
||||
|
||||
private Task<Validation<BaseError, int>> ValidateLibraryRefreshInterval() =>
|
||||
_configElementRepository.GetValue<int>(ConfigElementKey.LibraryRefreshInterval)
|
||||
.FilterT(lri => lri > 0)
|
||||
@@ -178,6 +219,7 @@ namespace ErsatzTV.Application.MediaSources.Commands
|
||||
private record RequestParameters(
|
||||
LocalLibrary LocalLibrary,
|
||||
string FFprobePath,
|
||||
string FFmpegPath,
|
||||
bool ForceScan,
|
||||
int LibraryRefreshInterval);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -48,6 +48,14 @@ namespace ErsatzTV.Application.Playouts
|
||||
.Map(ovm => ovm.Title ?? string.Empty)
|
||||
.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 => $"{songArtist}{sm.Title ?? string.Empty}")
|
||||
.Map(t => string.IsNullOrWhiteSpace(playoutItem.ChapterTitle) ? t : $"{s} ({playoutItem.ChapterTitle})")
|
||||
.IfNone("[unknown song]");
|
||||
default:
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ namespace ErsatzTV.Application.Playouts.Queries
|
||||
GetFuturePlayoutItemsById request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
DateTime now = DateTimeOffset.Now.UtcDateTime;
|
||||
|
||||
@@ -57,6 +57,10 @@ namespace ErsatzTV.Application.Playouts.Queries
|
||||
.ThenInclude(mi => (mi as OtherVideo).OtherVideoMetadata)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(mi => (mi as OtherVideo).MediaVersions)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(mi => (mi as Song).SongMetadata)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(mi => (mi as Song).MediaVersions)
|
||||
.Filter(i => i.PlayoutId == request.PlayoutId)
|
||||
.Filter(i => i.Finish >= now)
|
||||
.Filter(i => request.ShowFiller || i.FillerKind == FillerKind.None)
|
||||
|
||||
@@ -26,7 +26,8 @@ namespace ErsatzTV.Application.Search.Queries
|
||||
await GetIds(SearchIndex.EpisodeType, request.Query),
|
||||
await GetIds(SearchIndex.ArtistType, request.Query),
|
||||
await GetIds(SearchIndex.MusicVideoType, request.Query),
|
||||
await GetIds(SearchIndex.OtherVideoType, request.Query));
|
||||
await GetIds(SearchIndex.OtherVideoType, request.Query),
|
||||
await GetIds(SearchIndex.SongType, request.Query));
|
||||
|
||||
private Task<List<int>> GetIds(string type, string query) =>
|
||||
_searchIndex.Search($"type:{type} AND ({query})", 0, 0)
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
using ErsatzTV.Application.MediaCards;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Search.Queries
|
||||
{
|
||||
public record QuerySearchIndexSongs
|
||||
(string Query, int PageNumber, int PageSize) : IRequest<SongCardResultsViewModel>;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Application.MediaCards;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Search;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using static ErsatzTV.Application.MediaCards.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Search.Queries
|
||||
{
|
||||
public class
|
||||
QuerySearchIndexSongsHandler : IRequestHandler<QuerySearchIndexSongs,
|
||||
SongCardResultsViewModel>
|
||||
{
|
||||
private readonly ISongRepository _songRepository;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
|
||||
public QuerySearchIndexSongsHandler(ISearchIndex searchIndex, ISongRepository songRepository)
|
||||
{
|
||||
_searchIndex = searchIndex;
|
||||
_songRepository = songRepository;
|
||||
}
|
||||
|
||||
public async Task<SongCardResultsViewModel> Handle(
|
||||
QuerySearchIndexSongs request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
SearchResult searchResult = await _searchIndex.Search(
|
||||
request.Query,
|
||||
(request.PageNumber - 1) * request.PageSize,
|
||||
request.PageSize);
|
||||
|
||||
List<SongCardViewModel> items = await _songRepository
|
||||
.GetSongsForCards(searchResult.Items.Map(i => i.Id).ToList())
|
||||
.Map(list => list.Map(ProjectToViewModel).ToList());
|
||||
|
||||
return new SongCardResultsViewModel(searchResult.TotalCount, items, searchResult.PageMap);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,5 +9,6 @@ namespace ErsatzTV.Application.Search
|
||||
List<int> EpisodeIds,
|
||||
List<int> ArtistIds,
|
||||
List<int> MusicVideoIds,
|
||||
List<int> OtherVideoIds);
|
||||
List<int> OtherVideoIds,
|
||||
List<int> SongIds);
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
12
ErsatzTV.Application/Streaming/PtsAndDuration.cs
Normal file
12
ErsatzTV.Application/Streaming/PtsAndDuration.cs
Normal file
@@ -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.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Runtime;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using LanguageExt;
|
||||
@@ -15,17 +13,14 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
{
|
||||
public class GetConcatProcessByChannelNumberHandler : FFmpegProcessHandler<GetConcatProcessByChannelNumber>
|
||||
{
|
||||
private readonly FFmpegProcessService _ffmpegProcessService;
|
||||
private readonly IRuntimeInfo _runtimeInfo;
|
||||
private readonly IFFmpegProcessService _ffmpegProcessService;
|
||||
|
||||
public GetConcatProcessByChannelNumberHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
FFmpegProcessService 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);
|
||||
}
|
||||
|
||||
@@ -2,19 +2,18 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.Interfaces.Emby;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
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;
|
||||
@@ -31,15 +30,15 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
private readonly IMediaCollectionRepository _mediaCollectionRepository;
|
||||
private readonly ITelevisionRepository _televisionRepository;
|
||||
private readonly IArtistRepository _artistRepository;
|
||||
private readonly FFmpegProcessService _ffmpegProcessService;
|
||||
private readonly IFFmpegProcessService _ffmpegProcessService;
|
||||
private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly IPlexPathReplacementService _plexPathReplacementService;
|
||||
private readonly IRuntimeInfo _runtimeInfo;
|
||||
private readonly ISongVideoGenerator _songVideoGenerator;
|
||||
|
||||
public GetPlayoutItemProcessByChannelNumberHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
FFmpegProcessService ffmpegProcessService,
|
||||
IFFmpegProcessService ffmpegProcessService,
|
||||
ILocalFileSystem localFileSystem,
|
||||
IPlexPathReplacementService plexPathReplacementService,
|
||||
IJellyfinPathReplacementService jellyfinPathReplacementService,
|
||||
@@ -47,7 +46,7 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
IMediaCollectionRepository mediaCollectionRepository,
|
||||
ITelevisionRepository televisionRepository,
|
||||
IArtistRepository artistRepository,
|
||||
IRuntimeInfo runtimeInfo)
|
||||
ISongVideoGenerator songVideoGenerator)
|
||||
: base(dbContextFactory)
|
||||
{
|
||||
_ffmpegProcessService = ffmpegProcessService;
|
||||
@@ -58,7 +57,7 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
_mediaCollectionRepository = mediaCollectionRepository;
|
||||
_televisionRepository = televisionRepository;
|
||||
_artistRepository = artistRepository;
|
||||
_runtimeInfo = runtimeInfo;
|
||||
_songVideoGenerator = songVideoGenerator;
|
||||
}
|
||||
|
||||
protected override async Task<Either<BaseError, PlayoutItemProcessModel>> GetProcess(
|
||||
@@ -94,6 +93,15 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(mi => (mi as OtherVideo).MediaVersions)
|
||||
.ThenInclude(ov => ov.Streams)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(mi => (mi as Song).MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(mi => (mi as Song).MediaVersions)
|
||||
.ThenInclude(mv => mv.Streams)
|
||||
.Include(i => i.MediaItem)
|
||||
.ThenInclude(mi => (mi as Song).SongMetadata)
|
||||
.ThenInclude(sm => sm.Artwork)
|
||||
.ForChannelAndTime(channel.Id, now)
|
||||
.Map(o => o.ToEither<BaseError>(new UnableToLocatePlayoutItem()))
|
||||
.BindT(ValidatePlayoutItemPath);
|
||||
@@ -106,18 +114,13 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
return await maybePlayoutItem.Match(
|
||||
async playoutItemWithPath =>
|
||||
{
|
||||
MediaVersion version = playoutItemWithPath.PlayoutItem.MediaItem switch
|
||||
{
|
||||
Movie m => m.MediaVersions.Head(),
|
||||
Episode e => e.MediaVersions.Head(),
|
||||
MusicVideo mv => mv.MediaVersions.Head(),
|
||||
OtherVideo ov => ov.MediaVersions.Head(),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(playoutItemWithPath))
|
||||
};
|
||||
MediaVersion version = playoutItemWithPath.PlayoutItem.MediaItem.GetHeadVersion();
|
||||
|
||||
bool saveReports = !_runtimeInfo.IsOSPlatform(OSPlatform.Windows) && await dbContext.ConfigElements
|
||||
.GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
|
||||
.Map(result => result.IfNone(false));
|
||||
string videoPath = playoutItemWithPath.Path;
|
||||
MediaVersion videoVersion = version;
|
||||
|
||||
string audioPath = playoutItemWithPath.Path;
|
||||
MediaVersion audioVersion = version;
|
||||
|
||||
Option<ChannelWatermark> maybeGlobalWatermark = await dbContext.ConfigElements
|
||||
.GetValue<int>(ConfigElementKey.FFmpegGlobalWatermarkId)
|
||||
@@ -125,12 +128,27 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
watermarkId => dbContext.ChannelWatermarks
|
||||
.SelectOneAsync(w => w.Id, w => w.Id == watermarkId));
|
||||
|
||||
if (playoutItemWithPath.PlayoutItem.MediaItem is Song song)
|
||||
{
|
||||
(videoPath, videoVersion) = await _songVideoGenerator.GenerateSongVideo(
|
||||
song,
|
||||
channel,
|
||||
maybeGlobalWatermark,
|
||||
ffmpegPath);
|
||||
}
|
||||
|
||||
bool saveReports = await dbContext.ConfigElements
|
||||
.GetValue<bool>(ConfigElementKey.FFmpegSaveReports)
|
||||
.Map(result => result.IfNone(false));
|
||||
|
||||
Process process = await _ffmpegProcessService.ForPlayoutItem(
|
||||
ffmpegPath,
|
||||
saveReports,
|
||||
channel,
|
||||
version,
|
||||
playoutItemWithPath.Path,
|
||||
videoVersion,
|
||||
audioVersion,
|
||||
videoPath,
|
||||
audioPath,
|
||||
playoutItemWithPath.PlayoutItem.StartOffset,
|
||||
playoutItemWithPath.PlayoutItem.FinishOffset,
|
||||
request.StartAtZero ? playoutItemWithPath.PlayoutItem.StartOffset : now,
|
||||
@@ -140,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);
|
||||
|
||||
@@ -170,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);
|
||||
}
|
||||
@@ -189,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);
|
||||
}
|
||||
@@ -208,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);
|
||||
}
|
||||
@@ -271,14 +293,7 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
.MapT(pi => pi.StartOffset - now),
|
||||
() => Option<TimeSpan>.None.AsTask());
|
||||
|
||||
MediaVersion version = item switch
|
||||
{
|
||||
Movie m => m.MediaVersions.Head(),
|
||||
Episode e => e.MediaVersions.Head(),
|
||||
MusicVideo mv => mv.MediaVersions.Head(),
|
||||
OtherVideo ov => ov.MediaVersions.Head(),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(item))
|
||||
};
|
||||
MediaVersion version = item.GetHeadVersion();
|
||||
|
||||
version.MediaFiles = await dbContext.MediaFiles
|
||||
.AsNoTracking()
|
||||
@@ -331,14 +346,7 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
|
||||
private async Task<string> GetPlayoutItemPath(PlayoutItem playoutItem)
|
||||
{
|
||||
MediaVersion version = playoutItem.MediaItem switch
|
||||
{
|
||||
Movie m => m.MediaVersions.Head(),
|
||||
Episode e => e.MediaVersions.Head(),
|
||||
MusicVideo mv => mv.MediaVersions.Head(),
|
||||
OtherVideo ov => ov.MediaVersions.Head(),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(playoutItem))
|
||||
};
|
||||
MediaVersion version = playoutItem.MediaItem.GetHeadVersion();
|
||||
|
||||
MediaFile file = version.MediaFiles.Head();
|
||||
string path = file.Path;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
namespace ErsatzTV.CommandLine
|
||||
{
|
||||
public class Config
|
||||
{
|
||||
public string ServerUrl { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net5.0</TargetFramework>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<NoWarn>VSTHRD200</NoWarn>
|
||||
</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="5.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.0.63">
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.0.64">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Moq" Version="4.16.1" />
|
||||
<PackageReference Include="NUnit" Version="3.13.2" />
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="4.0.0" />
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="4.2.1" />
|
||||
<PackageReference Include="Serilog" Version="2.10.0" />
|
||||
<PackageReference Include="Serilog.Extensions.Logging" Version="3.0.1" />
|
||||
<PackageReference Include="Serilog.Extensions.Logging" Version="3.1.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Debug" Version="2.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
{
|
||||
var builder = new FFmpegComplexFilterBuilder();
|
||||
|
||||
Option<FFmpegComplexFilter> result = builder.Build(0, 1);
|
||||
Option<FFmpegComplexFilter> result = builder.Build(false, 0, 0, 0, 1, false);
|
||||
|
||||
result.IsNone.Should().BeTrue();
|
||||
}
|
||||
@@ -27,17 +27,38 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
[Test]
|
||||
public void Should_Return_Audio_Filter_With_AudioDuration()
|
||||
{
|
||||
var duration = TimeSpan.FromMinutes(54);
|
||||
var duration = TimeSpan.FromMilliseconds(1000.1);
|
||||
FFmpegComplexFilterBuilder builder = new FFmpegComplexFilterBuilder()
|
||||
.WithAlignedAudio(duration);
|
||||
|
||||
Option<FFmpegComplexFilter> result = builder.Build(0, 1);
|
||||
Option<FFmpegComplexFilter> result = builder.Build(false, 0, 0, 0, 1, false);
|
||||
|
||||
result.IsSome.Should().BeTrue();
|
||||
result.IfSome(
|
||||
filter =>
|
||||
{
|
||||
filter.ComplexFilter.Should().Be($"[0:1]apad=whole_dur={duration.TotalMilliseconds}ms[a]");
|
||||
filter.ComplexFilter.Should().Be("[0:1]apad=whole_dur=1000.1ms[a]");
|
||||
filter.AudioLabel.Should().Be("[a]");
|
||||
filter.VideoLabel.Should().Be("0:0");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
// this needs to be a culture where '.' is a group separator
|
||||
[SetCulture("it-IT")]
|
||||
public void Should_Return_Audio_Filter_With_AudioDuration_Decimal()
|
||||
{
|
||||
var duration = TimeSpan.FromMilliseconds(1000.1);
|
||||
FFmpegComplexFilterBuilder builder = new FFmpegComplexFilterBuilder()
|
||||
.WithAlignedAudio(duration);
|
||||
|
||||
Option<FFmpegComplexFilter> result = builder.Build(false, 0, 0, 0, 1, false);
|
||||
|
||||
result.IsSome.Should().BeTrue();
|
||||
result.IfSome(
|
||||
filter =>
|
||||
{
|
||||
filter.ComplexFilter.Should().Be("[0:1]apad=whole_dur=1000.1ms[a]");
|
||||
filter.AudioLabel.Should().Be("[a]");
|
||||
filter.VideoLabel.Should().Be("0:0");
|
||||
});
|
||||
@@ -51,7 +72,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
.WithAlignedAudio(duration)
|
||||
.WithDeinterlace(true);
|
||||
|
||||
Option<FFmpegComplexFilter> result = builder.Build(0, 1);
|
||||
Option<FFmpegComplexFilter> result = builder.Build(false, 0, 0, 0, 1, false);
|
||||
|
||||
result.IsSome.Should().BeTrue();
|
||||
result.IfSome(
|
||||
@@ -102,7 +123,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
builder = builder.WithBlackBars(new Resolution { Width = 1920, Height = 1080 });
|
||||
}
|
||||
|
||||
Option<FFmpegComplexFilter> result = builder.Build(0, 1);
|
||||
Option<FFmpegComplexFilter> result = builder.Build(false, 0, 0, 0, 1, false);
|
||||
|
||||
result.IsSome.Should().BeTrue();
|
||||
result.IfSome(
|
||||
@@ -253,11 +274,12 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
HorizontalMarginPercent = 7,
|
||||
VerticalMarginPercent = 5
|
||||
}),
|
||||
new Resolution { Width = 1920, Height = 1080 })
|
||||
new Resolution { Width = 1920, Height = 1080 },
|
||||
None)
|
||||
.WithDeinterlace(deinterlace)
|
||||
.WithAlignedAudio(alignAudio ? Some(TimeSpan.FromMinutes(55)) : None);
|
||||
|
||||
Option<FFmpegComplexFilter> result = builder.Build(0, 1);
|
||||
Option<FFmpegComplexFilter> result = builder.Build(false, 0, 0, 0, 1, false);
|
||||
|
||||
result.IsSome.Should().BeTrue();
|
||||
result.IfSome(
|
||||
@@ -275,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,
|
||||
@@ -328,7 +350,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
builder = builder.WithBlackBars(new Resolution { Width = 1920, Height = 1080 });
|
||||
}
|
||||
|
||||
Option<FFmpegComplexFilter> result = builder.Build(0, 1);
|
||||
Option<FFmpegComplexFilter> result = builder.Build(false, 0, 0, 0, 1, false);
|
||||
|
||||
result.IsSome.Should().BeTrue();
|
||||
result.IfSome(
|
||||
@@ -346,37 +368,37 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
"[0:0]yadif_cuda,scale_npp=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_npp=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_npp=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_npp=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,
|
||||
@@ -387,7 +409,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
{
|
||||
FFmpegComplexFilterBuilder builder = new FFmpegComplexFilterBuilder()
|
||||
.WithHardwareAcceleration(HardwareAccelerationKind.Nvenc)
|
||||
.WithDeinterlace(deinterlace);
|
||||
.WithDeinterlace(deinterlace)
|
||||
.WithInputPixelFormat("h264");
|
||||
|
||||
if (scale)
|
||||
{
|
||||
@@ -399,7 +422,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
builder = builder.WithBlackBars(new Resolution { Width = 1920, Height = 1080 });
|
||||
}
|
||||
|
||||
Option<FFmpegComplexFilter> result = builder.Build(0, 1);
|
||||
Option<FFmpegComplexFilter> result = builder.Build(false, 0, 0, 0, 1, false);
|
||||
|
||||
result.IsSome.Should().BeTrue();
|
||||
result.IfSome(
|
||||
@@ -418,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(
|
||||
@@ -461,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",
|
||||
@@ -496,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,
|
||||
@@ -521,7 +544,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
builder = builder.WithBlackBars(new Resolution { Width = 1920, Height = 1080 });
|
||||
}
|
||||
|
||||
Option<FFmpegComplexFilter> result = builder.Build(0, 1);
|
||||
Option<FFmpegComplexFilter> result = builder.Build(false, 0, 0, 0, 1, false);
|
||||
|
||||
result.IsSome.Should().BeTrue();
|
||||
result.IfSome(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
public void HlsPlaylistFilter_ShouldRewriteProgramDateTime()
|
||||
{
|
||||
var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5));
|
||||
string[] input = @"#EXTM3U
|
||||
string[] input = NormalizeLineEndings(@"#EXTM3U
|
||||
#EXT-X-VERSION:6
|
||||
#EXT-X-TARGETDURATION:4
|
||||
#EXT-X-MEDIA-SEQUENCE:1137
|
||||
@@ -26,13 +26,13 @@ live001137.ts
|
||||
live001138.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500
|
||||
live001139.ts".Split(Environment.NewLine);
|
||||
live001139.ts").Split(Environment.NewLine);
|
||||
|
||||
TrimPlaylistResult result = HlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(-30), input);
|
||||
|
||||
result.PlaylistStart.Should().Be(start);
|
||||
result.Sequence.Should().Be(1137);
|
||||
result.Playlist.Should().Be(
|
||||
result.Playlist.Should().Be(NormalizeLineEndings(
|
||||
@"#EXTM3U
|
||||
#EXT-X-VERSION:6
|
||||
#EXT-X-TARGETDURATION:4
|
||||
@@ -49,14 +49,14 @@ live001138.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:08.000-0500
|
||||
live001139.ts
|
||||
");
|
||||
"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void HlsPlaylistFilter_ShouldLimitSegments()
|
||||
{
|
||||
var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5));
|
||||
string[] input = @"#EXTM3U
|
||||
string[] input = NormalizeLineEndings(@"#EXTM3U
|
||||
#EXT-X-VERSION:6
|
||||
#EXT-X-TARGETDURATION:4
|
||||
#EXT-X-MEDIA-SEQUENCE:1137
|
||||
@@ -70,13 +70,13 @@ live001137.ts
|
||||
live001138.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500
|
||||
live001139.ts".Split(Environment.NewLine);
|
||||
live001139.ts").Split(Environment.NewLine);
|
||||
|
||||
TrimPlaylistResult result = HlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(-30), input, 2);
|
||||
|
||||
result.PlaylistStart.Should().Be(start);
|
||||
result.Sequence.Should().Be(1137);
|
||||
result.Playlist.Should().Be(
|
||||
result.Playlist.Should().Be(NormalizeLineEndings(
|
||||
@"#EXTM3U
|
||||
#EXT-X-VERSION:6
|
||||
#EXT-X-TARGETDURATION:4
|
||||
@@ -90,14 +90,14 @@ live001137.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:04.000-0500
|
||||
live001138.ts
|
||||
");
|
||||
"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void HlsPlaylistFilter_ShouldAddDiscontinuity()
|
||||
{
|
||||
var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5));
|
||||
string[] input = @"#EXTM3U
|
||||
string[] input = NormalizeLineEndings(@"#EXTM3U
|
||||
#EXT-X-VERSION:6
|
||||
#EXT-X-TARGETDURATION:4
|
||||
#EXT-X-MEDIA-SEQUENCE:1137
|
||||
@@ -111,7 +111,7 @@ live001137.ts
|
||||
live001138.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500
|
||||
live001139.ts".Split(Environment.NewLine);
|
||||
live001139.ts").Split(Environment.NewLine);
|
||||
|
||||
TrimPlaylistResult result = HlsPlaylistFilter.TrimPlaylist(
|
||||
start,
|
||||
@@ -122,7 +122,7 @@ live001139.ts".Split(Environment.NewLine);
|
||||
|
||||
result.PlaylistStart.Should().Be(start);
|
||||
result.Sequence.Should().Be(1137);
|
||||
result.Playlist.Should().Be(
|
||||
result.Playlist.Should().Be(NormalizeLineEndings(
|
||||
@"#EXTM3U
|
||||
#EXT-X-VERSION:6
|
||||
#EXT-X-TARGETDURATION:4
|
||||
@@ -140,14 +140,14 @@ live001138.ts
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:08.000-0500
|
||||
live001139.ts
|
||||
#EXT-X-DISCONTINUITY
|
||||
");
|
||||
"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void HlsPlaylistFilter_ShouldFilterOldSegments()
|
||||
{
|
||||
var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5));
|
||||
string[] input = @"#EXTM3U
|
||||
string[] input = NormalizeLineEndings(@"#EXTM3U
|
||||
#EXT-X-VERSION:6
|
||||
#EXT-X-TARGETDURATION:4
|
||||
#EXT-X-MEDIA-SEQUENCE:1137
|
||||
@@ -161,13 +161,13 @@ live001137.ts
|
||||
live001138.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500
|
||||
live001139.ts".Split(Environment.NewLine);
|
||||
live001139.ts").Split(Environment.NewLine);
|
||||
|
||||
TrimPlaylistResult result = HlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(6), input);
|
||||
|
||||
result.PlaylistStart.Should().Be(start.AddSeconds(8));
|
||||
result.Sequence.Should().Be(1139);
|
||||
result.Playlist.Should().Be(
|
||||
result.Playlist.Should().Be(NormalizeLineEndings(
|
||||
@"#EXTM3U
|
||||
#EXT-X-VERSION:6
|
||||
#EXT-X-TARGETDURATION:4
|
||||
@@ -178,14 +178,14 @@ live001139.ts".Split(Environment.NewLine);
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:08.000-0500
|
||||
live001139.ts
|
||||
");
|
||||
"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void HlsPlaylistFilter_ShouldFilterOldDiscontinuity()
|
||||
{
|
||||
var start = new DateTimeOffset(2021, 10, 9, 8, 0, 0, TimeSpan.FromHours(-5));
|
||||
string[] input = @"#EXTM3U
|
||||
string[] input = NormalizeLineEndings(@"#EXTM3U
|
||||
#EXT-X-VERSION:6
|
||||
#EXT-X-TARGETDURATION:4
|
||||
#EXT-X-MEDIA-SEQUENCE:1137
|
||||
@@ -200,13 +200,13 @@ live001137.ts
|
||||
live001138.ts
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-08T08:34:57.320-0500
|
||||
live001139.ts".Split(Environment.NewLine);
|
||||
live001139.ts").Split(Environment.NewLine);
|
||||
|
||||
TrimPlaylistResult result = HlsPlaylistFilter.TrimPlaylist(start, start.AddSeconds(6), input);
|
||||
|
||||
result.PlaylistStart.Should().Be(start.AddSeconds(8));
|
||||
result.Sequence.Should().Be(1139);
|
||||
result.Playlist.Should().Be(
|
||||
result.Playlist.Should().Be(NormalizeLineEndings(
|
||||
@"#EXTM3U
|
||||
#EXT-X-VERSION:6
|
||||
#EXT-X-TARGETDURATION:4
|
||||
@@ -217,7 +217,15 @@ live001139.ts".Split(Environment.NewLine);
|
||||
#EXTINF:4.000000,
|
||||
#EXT-X-PROGRAM-DATE-TIME:2021-10-09T08:00:08.000-0500
|
||||
live001139.ts
|
||||
");
|
||||
"));
|
||||
}
|
||||
|
||||
private static string NormalizeLineEndings(string str)
|
||||
{
|
||||
return str
|
||||
.Replace("\r\n", "\n")
|
||||
.Replace("\r", "\n")
|
||||
.Replace("\n", Environment.NewLine);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
@@ -93,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]
|
||||
@@ -103,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
|
||||
@@ -138,14 +156,15 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
new FFmpegPlaybackSettingsCalculator(),
|
||||
new FakeStreamSelector(),
|
||||
new Mock<IImageCache>().Object,
|
||||
new Mock<ITempFilePool>().Object,
|
||||
new Mock<ILogger<FFmpegProcessService>>().Object);
|
||||
|
||||
MediaVersion v = new MediaVersion();
|
||||
|
||||
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,
|
||||
@@ -183,6 +202,8 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
StreamingMode = StreamingMode.TransportStream
|
||||
},
|
||||
v,
|
||||
v,
|
||||
file,
|
||||
file,
|
||||
now,
|
||||
now + TimeSpan.FromSeconds(5),
|
||||
@@ -193,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();
|
||||
|
||||
@@ -237,7 +261,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
using var sha = new System.Security.Cryptography.SHA256Managed();
|
||||
using var sha = SHA256.Create();
|
||||
byte[] textData = System.Text.Encoding.UTF8.GetBytes(text);
|
||||
byte[] hash = sha.ComputeHash(textData);
|
||||
return BitConverter.ToString(hash).Replace("-", string.Empty);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
new Mock<ILogger<LocalStatisticsProvider>>().Object);
|
||||
|
||||
var input = new LocalStatisticsProvider.FFprobe(
|
||||
new LocalStatisticsProvider.FFprobeFormat("123.45"),
|
||||
new LocalStatisticsProvider.FFprobeFormat("123.45", null),
|
||||
new List<LocalStatisticsProvider.FFprobeStream>(),
|
||||
new List<LocalStatisticsProvider.FFprobeChapter>());
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ using System.Runtime.InteropServices;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
@@ -52,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>();
|
||||
|
||||
@@ -71,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;
|
||||
@@ -533,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");
|
||||
|
||||
@@ -555,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");
|
||||
@@ -568,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>() };
|
||||
@@ -584,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)),
|
||||
@@ -600,7 +610,28 @@ 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,
|
||||
new Mock<ILogger<MovieFolderScanner>>().Object
|
||||
);
|
||||
}
|
||||
|
||||
@@ -76,6 +76,72 @@ namespace ErsatzTV.Core.Tests.Scheduling
|
||||
playoutItems[2].GuideFinish.HasValue.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Fill_Exact_Duration_CustomTitle()
|
||||
{
|
||||
Collection collectionOne = TwoItemCollection(1, 2, TimeSpan.FromHours(1));
|
||||
|
||||
var scheduleItem = new ProgramScheduleItemDuration
|
||||
{
|
||||
Id = 1,
|
||||
Index = 1,
|
||||
Collection = collectionOne,
|
||||
CollectionId = collectionOne.Id,
|
||||
StartTime = null,
|
||||
PlayoutDuration = TimeSpan.FromHours(3),
|
||||
TailMode = TailMode.None,
|
||||
PlaybackOrder = PlaybackOrder.Chronological,
|
||||
CustomTitle = "Custom Title"
|
||||
};
|
||||
|
||||
var enumerator = new ChronologicalMediaCollectionEnumerator(
|
||||
collectionOne.MediaItems,
|
||||
new CollectionEnumeratorState());
|
||||
|
||||
var scheduler = new PlayoutModeSchedulerDuration(new Mock<ILogger>().Object);
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
|
||||
StartState,
|
||||
CollectionEnumerators(scheduleItem, enumerator),
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop);
|
||||
|
||||
playoutBuilderState.CurrentTime.Should().Be(StartState.CurrentTime.AddHours(3));
|
||||
playoutItems.Last().FinishOffset.Should().Be(playoutBuilderState.CurrentTime);
|
||||
|
||||
playoutBuilderState.NextGuideGroup.Should().Be(2);
|
||||
playoutBuilderState.DurationFinish.IsNone.Should().BeTrue();
|
||||
playoutBuilderState.InFlood.Should().BeFalse();
|
||||
playoutBuilderState.MultipleRemaining.IsNone.Should().BeTrue();
|
||||
playoutBuilderState.InDurationFiller.Should().BeFalse();
|
||||
playoutBuilderState.ScheduleItemIndex.Should().Be(1);
|
||||
|
||||
enumerator.State.Index.Should().Be(1);
|
||||
|
||||
playoutItems.Count.Should().Be(3);
|
||||
|
||||
playoutItems[0].MediaItemId.Should().Be(1);
|
||||
playoutItems[0].StartOffset.Should().Be(StartState.CurrentTime);
|
||||
playoutItems[0].GuideGroup.Should().Be(1);
|
||||
playoutItems[0].FillerKind.Should().Be(FillerKind.None);
|
||||
playoutItems[0].GuideFinish.HasValue.Should().BeFalse();
|
||||
playoutItems[0].CustomTitle.Should().Be("Custom Title");
|
||||
|
||||
playoutItems[1].MediaItemId.Should().Be(2);
|
||||
playoutItems[1].StartOffset.Should().Be(StartState.CurrentTime.AddHours(1));
|
||||
playoutItems[1].GuideGroup.Should().Be(1);
|
||||
playoutItems[1].FillerKind.Should().Be(FillerKind.None);
|
||||
playoutItems[1].GuideFinish.HasValue.Should().BeFalse();
|
||||
playoutItems[1].CustomTitle.Should().Be("Custom Title");
|
||||
|
||||
playoutItems[2].MediaItemId.Should().Be(1);
|
||||
playoutItems[2].StartOffset.Should().Be(StartState.CurrentTime.AddHours(2));
|
||||
playoutItems[2].GuideGroup.Should().Be(1);
|
||||
playoutItems[2].FillerKind.Should().Be(FillerKind.None);
|
||||
playoutItems[2].GuideFinish.HasValue.Should().BeTrue();
|
||||
playoutItems[2].CustomTitle.Should().Be("Custom Title");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Not_Have_Gap_Duration_Tail_Mode_None()
|
||||
{
|
||||
|
||||
@@ -79,6 +79,75 @@ namespace ErsatzTV.Core.Tests.Scheduling
|
||||
playoutItems[2].FillerKind.Should().Be(FillerKind.None);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Fill_Exactly_To_Next_Schedule_Item_Flood()
|
||||
{
|
||||
Collection collectionOne = TwoItemCollection(1, 2, TimeSpan.FromHours(1));
|
||||
|
||||
var scheduleItem = new ProgramScheduleItemFlood
|
||||
{
|
||||
Id = 1,
|
||||
Index = 1,
|
||||
Collection = collectionOne,
|
||||
CollectionId = collectionOne.Id,
|
||||
StartTime = null,
|
||||
PlaybackOrder = PlaybackOrder.Chronological,
|
||||
TailFiller = null,
|
||||
FallbackFiller = null
|
||||
};
|
||||
|
||||
var enumerator = new ChronologicalMediaCollectionEnumerator(
|
||||
collectionOne.MediaItems,
|
||||
new CollectionEnumeratorState());
|
||||
|
||||
var sortedScheduleItems = new List<ProgramScheduleItem>
|
||||
{
|
||||
scheduleItem,
|
||||
// this caused trouble with the peek logic and the IsFlood flag
|
||||
new ProgramScheduleItemFlood
|
||||
{
|
||||
StartTime = TimeSpan.FromHours(3),
|
||||
}
|
||||
};
|
||||
|
||||
var scheduler = new PlayoutModeSchedulerFlood(sortedScheduleItems, new Mock<ILogger>().Object);
|
||||
(PlayoutBuilderState playoutBuilderState, List<PlayoutItem> playoutItems) = scheduler.Schedule(
|
||||
StartState,
|
||||
CollectionEnumerators(scheduleItem, enumerator),
|
||||
scheduleItem,
|
||||
NextScheduleItem,
|
||||
HardStop);
|
||||
|
||||
playoutBuilderState.CurrentTime.Should().Be(StartState.CurrentTime.AddHours(3));
|
||||
playoutItems.Last().FinishOffset.Should().Be(playoutBuilderState.CurrentTime);
|
||||
|
||||
playoutBuilderState.NextGuideGroup.Should().Be(4);
|
||||
playoutBuilderState.DurationFinish.IsNone.Should().BeTrue();
|
||||
playoutBuilderState.InFlood.Should().BeFalse();
|
||||
playoutBuilderState.MultipleRemaining.IsNone.Should().BeTrue();
|
||||
playoutBuilderState.InDurationFiller.Should().BeFalse();
|
||||
playoutBuilderState.ScheduleItemIndex.Should().Be(1);
|
||||
|
||||
enumerator.State.Index.Should().Be(1);
|
||||
|
||||
playoutItems.Count.Should().Be(3);
|
||||
|
||||
playoutItems[0].MediaItemId.Should().Be(1);
|
||||
playoutItems[0].StartOffset.Should().Be(StartState.CurrentTime);
|
||||
playoutItems[0].GuideGroup.Should().Be(1);
|
||||
playoutItems[0].FillerKind.Should().Be(FillerKind.None);
|
||||
|
||||
playoutItems[1].MediaItemId.Should().Be(2);
|
||||
playoutItems[1].StartOffset.Should().Be(StartState.CurrentTime.AddHours(1));
|
||||
playoutItems[1].GuideGroup.Should().Be(2);
|
||||
playoutItems[1].FillerKind.Should().Be(FillerKind.None);
|
||||
|
||||
playoutItems[2].MediaItemId.Should().Be(1);
|
||||
playoutItems[2].StartOffset.Should().Be(StartState.CurrentTime.AddHours(2));
|
||||
playoutItems[2].GuideGroup.Should().Be(3);
|
||||
playoutItems[2].FillerKind.Should().Be(FillerKind.None);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_Fill_Exactly_To_Next_Schedule_Item_With_Post_Roll_Multiple_One()
|
||||
{
|
||||
@@ -709,7 +778,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
|
||||
|
||||
protected override ProgramScheduleItem NextScheduleItem => new ProgramScheduleItemOne
|
||||
{
|
||||
StartTime = TimeSpan.FromHours(3)
|
||||
StartTime = TimeSpan.FromHours(3),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
Movies = 1,
|
||||
Shows = 2,
|
||||
MusicVideos = 3,
|
||||
OtherVideos = 4
|
||||
OtherVideos = 4,
|
||||
Songs = 5
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class BackgroundImageMediaVersion : MediaVersion
|
||||
{
|
||||
}
|
||||
}
|
||||
6
ErsatzTV.Core/Domain/MediaItem/CoverArtMediaVersion.cs
Normal file
6
ErsatzTV.Core/Domain/MediaItem/CoverArtMediaVersion.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class CoverArtMediaVersion : MediaVersion
|
||||
{
|
||||
}
|
||||
}
|
||||
6
ErsatzTV.Core/Domain/MediaItem/FallbackMediaVersion.cs
Normal file
6
ErsatzTV.Core/Domain/MediaItem/FallbackMediaVersion.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class FallbackMediaVersion : MediaVersion
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
7
ErsatzTV.Core/Domain/MediaItem/MediaItemState.cs
Normal file
7
ErsatzTV.Core/Domain/MediaItem/MediaItemState.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
|
||||
public enum MediaItemState
|
||||
{
|
||||
Normal = 0,
|
||||
FileNotFound = 1
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user