Compare commits
177 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d4f9c12c22 | |||
| d46539b718 | |||
| 13f49305da | |||
| 7d4cef51f5 | |||
| a9aa2d53ed | |||
| 08b87504b4 | |||
| df27faaa41 | |||
| fe9ea18918 | |||
| e71efc3f97 | |||
| 9817185fa3 | |||
| c2a11f3e68 | |||
| d60ae43b59 | |||
| a187ab18b8 | |||
| cf5268c1e4 | |||
| f405e17c96 | |||
| 67300ec17f | |||
| 2b4a0a2314 | |||
| d84a37e1d2 | |||
| 465b6c5d9e | |||
| b975195252 | |||
| 1c28df61df | |||
| c69b8ccc53 | |||
| 138081bd8b | |||
| ea732b62d8 | |||
| e7c1451b6c | |||
| a49a766424 | |||
| ddf3b9f609 | |||
| 9c6fab8f12 | |||
| 992e591938 | |||
| 365036f9f2 | |||
| 93af601a5f | |||
| 894d837578 | |||
| e79caccab1 | |||
| 25b137e0ab | |||
| 75ef410437 | |||
| 849f0a06c8 | |||
| 2e5fb57052 | |||
| e72bde9c02 | |||
| 35a1ec8a5d | |||
| a0c38131c8 | |||
| 3bc45ff92d | |||
| 2d4d6d8c38 | |||
| 9a99572354 | |||
| b2aa80ce5c | |||
| ff365dae34 | |||
| 52aebfb7d3 | |||
| 66ea1b50e6 | |||
| 3f656ade7a | |||
| 8bf0d372c6 | |||
| 202d7b5829 | |||
| 352e4f3aba | |||
| c5f6d00c94 | |||
| e8d1d94436 | |||
| 50dc37065b | |||
| 7e88b18192 | |||
| 89e914c7f1 | |||
| 1932ac4765 | |||
| ec33c74ec4 | |||
| 2184ed1b16 | |||
| d3907afde7 | |||
| e12d933531 | |||
| c0ba29d917 | |||
| d1fd81c382 | |||
| e038045494 | |||
| e1691e649e | |||
| 8d28497d29 | |||
| fddd4e7e6b | |||
| 0581cd6610 | |||
| 0f1732e5f5 | |||
| 41c2d51d8c | |||
| 29b2361857 | |||
| ce867f9834 | |||
| 4034bf9d7e | |||
| 3d2658fa43 | |||
| 61b19688ff | |||
| e8d72bf6a3 | |||
| 348b14f7b7 | |||
| fda49a5a49 | |||
| 55c00d76bb | |||
| 519d2113eb | |||
| f34f6b6941 | |||
| 6864e108b8 | |||
| 09ba04662a | |||
| 9cd2418095 | |||
| b6a96513de | |||
| ca57166e95 | |||
| 33496c1693 | |||
| b65daeca0b | |||
| 286cc6d720 | |||
| aa4f09c799 | |||
| afd3c0d9f3 | |||
| 5597d8e1a7 | |||
| 0166362258 | |||
| 58c330b63d | |||
| be71295693 | |||
| 8cd3090cee | |||
| 7bf08daeec | |||
| 290463fe7b | |||
| 1b2d9c100a | |||
| caa05c1bf2 | |||
| a37ead86df | |||
| e65aff8bc6 | |||
| 9734494eb6 | |||
| d41e302418 | |||
| 80ba517294 | |||
| 95d08b264f | |||
| 893a849f28 | |||
| 673f617994 | |||
| 644327eb76 | |||
| 10662e75e4 | |||
| a2b1936e73 | |||
| 2df546af6d | |||
| 338b480217 | |||
| 2943bb6fdd | |||
| 94edcbd2d1 | |||
| a8d1cdefac | |||
| a518160a6f | |||
| b56de6493f | |||
| 093cfc3f3b | |||
| 49775b1f6a | |||
| 22d593b8e9 | |||
| 2cb7fb52d2 | |||
| 8433b6d8a4 | |||
| 32d2414de0 | |||
| 317a3a47c3 | |||
| 845b8cdc8f | |||
| c86f6439c5 | |||
| 559e0088e5 | |||
| adaca95590 | |||
| 09a1c31fa3 | |||
| e4b82025b8 | |||
| 78e3702cb0 | |||
| 01b20d3b75 | |||
| 156761405e | |||
| 1805f2259f | |||
| 4c587776d6 | |||
| 8379b4634a | |||
| 9470439cfa | |||
| 18096e48e0 | |||
| f2d0ac7b28 | |||
| 2ccf08f547 | |||
| 1e27f460fe | |||
| 4cdd8c8233 | |||
| 6e60634c9f | |||
| 12c5d6b636 | |||
| b617c62f8e | |||
| 035b5895b0 | |||
| 22da5187c8 | |||
| 5804d6840c | |||
| b50ce1ad6b | |||
| 481ee03f35 | |||
| d91adb5d54 | |||
| ef7f138a4e | |||
| 2e8d9a311b | |||
| 4c5a3fbff3 | |||
| 636908fc4d | |||
| 997362fc97 | |||
| c5147341e3 | |||
| ca33bcebf0 | |||
| d32f487e8e | |||
| fb65f8f853 | |||
| 2a0b90e385 | |||
| dde70fd8a2 | |||
| 98d1d0cb35 | |||
| ba76a8f3ad | |||
| 8cd5652157 | |||
| 8aff4227d9 | |||
| 026f7472cb | |||
| daca285568 | |||
| fbb9a0b2c7 | |||
| 29b3aa8543 | |||
| 94f3725208 | |||
| 0ee81e87be | |||
| c491a918c2 | |||
| 1e7e46cb82 | |||
| 5ae444d96d | |||
| ee7ad83427 |
@@ -3,7 +3,7 @@
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"dotnet-ef": {
|
||||
"version": "9.0.10",
|
||||
"version": "9.0.11",
|
||||
"commands": [
|
||||
"dotnet-ef"
|
||||
]
|
||||
|
||||
@@ -11,7 +11,9 @@ on:
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
# Disabled in fork — upstream CodeQL requires specific GitHub org permissions and .NET 10 support
|
||||
if: false
|
||||
runs-on: [self-hosted, k3s, linux, amd64]
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -20,18 +22,18 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
|
||||
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
|
||||
with:
|
||||
dotnet-version: '9.0.x'
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@16140ae1a102900babc80a33c44059580f687047 # v4.30.9
|
||||
uses: github/codeql-action/init@fe4161a26a8629af62121b670040955b330f9af2 # v4.31.6
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: +security-extended
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@16140ae1a102900babc80a33c44059580f687047 # v4.30.9
|
||||
uses: github/codeql-action/autobuild@fe4161a26a8629af62121b670040955b330f9af2 # v4.31.6
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@16140ae1a102900babc80a33c44059580f687047 # v4.30.9
|
||||
uses: github/codeql-action/analyze@fe4161a26a8629af62121b670040955b330f9af2 # v4.31.6
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: ABI Compatibility
|
||||
on:
|
||||
pull_request_target:
|
||||
pull_request:
|
||||
|
||||
permissions: {}
|
||||
|
||||
@@ -11,13 +11,13 @@ jobs:
|
||||
permissions: read-all
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
|
||||
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
|
||||
with:
|
||||
dotnet-version: '9.0.x'
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
dotnet build Jellyfin.Server -o ./out
|
||||
|
||||
- name: Upload Head
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: abi-head
|
||||
retention-days: 14
|
||||
@@ -40,14 +40,14 @@ jobs:
|
||||
permissions: read-all
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
|
||||
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
|
||||
with:
|
||||
dotnet-version: '9.0.x'
|
||||
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
dotnet build Jellyfin.Server -o ./out
|
||||
|
||||
- name: Upload Head
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: abi-base
|
||||
retention-days: 14
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
pull-requests: write # to create or update comment (peter-evans/create-or-update-comment)
|
||||
|
||||
name: ABI - Difference
|
||||
if: ${{ github.event_name == 'pull_request_target' }}
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- abi-head
|
||||
@@ -85,13 +85,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Download abi-head
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
with:
|
||||
name: abi-head
|
||||
path: abi-head
|
||||
|
||||
- name: Download abi-base
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
with:
|
||||
name: abi-base
|
||||
path: abi-base
|
||||
|
||||
@@ -5,7 +5,7 @@ on:
|
||||
- master
|
||||
tags:
|
||||
- 'v*'
|
||||
pull_request_target:
|
||||
pull_request:
|
||||
|
||||
permissions: {}
|
||||
|
||||
@@ -16,18 +16,18 @@ jobs:
|
||||
permissions: read-all
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
|
||||
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
|
||||
with:
|
||||
dotnet-version: '9.0.x'
|
||||
- name: Generate openapi.json
|
||||
run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests"
|
||||
- name: Upload openapi.json
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: openapi-head
|
||||
retention-days: 14
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
permissions: read-all
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
@@ -55,13 +55,13 @@ jobs:
|
||||
ANCESTOR_REF=$(git merge-base upstream/${{ github.base_ref }} origin/$HEAD_REF)
|
||||
git checkout --progress --force $ANCESTOR_REF
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
|
||||
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
|
||||
with:
|
||||
dotnet-version: '9.0.x'
|
||||
- name: Generate openapi.json
|
||||
run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests"
|
||||
- name: Upload openapi.json
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: openapi-base
|
||||
retention-days: 14
|
||||
@@ -73,19 +73,19 @@ jobs:
|
||||
pull-requests: write # to create or update comment (peter-evans/create-or-update-comment)
|
||||
|
||||
name: OpenAPI - Difference
|
||||
if: ${{ github.event_name == 'pull_request_target' }}
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- openapi-head
|
||||
- openapi-base
|
||||
steps:
|
||||
- name: Download openapi-head
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
with:
|
||||
name: openapi-head
|
||||
path: openapi-head
|
||||
- name: Download openapi-base
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
with:
|
||||
name: openapi-base
|
||||
path: openapi-base
|
||||
@@ -148,7 +148,7 @@ jobs:
|
||||
|
||||
publish-unstable:
|
||||
name: OpenAPI - Publish Unstable Spec
|
||||
if: ${{ github.event_name != 'pull_request_target' && !startsWith(github.ref, 'refs/tags/v') && contains(github.repository_owner, 'jellyfin') }}
|
||||
if: ${{ github.event_name != 'pull_request' && !startsWith(github.ref, 'refs/tags/v') && contains(github.repository_owner, 'jellyfin') }}
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- openapi-head
|
||||
@@ -158,7 +158,7 @@ jobs:
|
||||
run: |-
|
||||
echo "JELLYFIN_VERSION=$(date +'%Y%m%d%H%M%S')" >> $GITHUB_ENV
|
||||
- name: Download openapi-head
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
with:
|
||||
name: openapi-head
|
||||
path: openapi-head
|
||||
@@ -172,7 +172,7 @@ jobs:
|
||||
strip_components: 1
|
||||
target: "/srv/incoming/openapi/unstable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}"
|
||||
- name: Move openapi.json (unstable) into place
|
||||
uses: appleboy/ssh-action@2ead5e36573f08b82fbfce1504f1a4b05a647c6f # v1.2.2
|
||||
uses: appleboy/ssh-action@823bd89e131d8d508129f9443cad5855e9ba96f0 # v1.2.4
|
||||
with:
|
||||
host: "${{ secrets.REPO_HOST }}"
|
||||
username: "${{ secrets.REPO_USER }}"
|
||||
@@ -220,7 +220,7 @@ jobs:
|
||||
run: |-
|
||||
echo "JELLYFIN_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
|
||||
- name: Download openapi-head
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
with:
|
||||
name: openapi-head
|
||||
path: openapi-head
|
||||
@@ -234,7 +234,7 @@ jobs:
|
||||
strip_components: 1
|
||||
target: "/srv/incoming/openapi/stable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}"
|
||||
- name: Move openapi.json (stable) into place
|
||||
uses: appleboy/ssh-action@2ead5e36573f08b82fbfce1504f1a4b05a647c6f # v1.2.2
|
||||
uses: appleboy/ssh-action@823bd89e131d8d508129f9443cad5855e9ba96f0 # v1.2.4
|
||||
with:
|
||||
host: "${{ secrets.REPO_HOST }}"
|
||||
username: "${{ secrets.REPO_USER }}"
|
||||
|
||||
@@ -13,29 +13,32 @@ env:
|
||||
|
||||
jobs:
|
||||
run-tests:
|
||||
strategy:
|
||||
matrix:
|
||||
os: ["ubuntu-latest", "macos-latest", "windows-latest"]
|
||||
fail-fast: false
|
||||
|
||||
runs-on: "${{ matrix.os }}"
|
||||
runs-on: [self-hosted, k3s, linux, amd64]
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
|
||||
with:
|
||||
dotnet-version: ${{ env.SDK_VERSION }}
|
||||
# Install .NET — use GITHUB_ENV (not GITHUB_PATH) to set PATH because
|
||||
# ARC self-hosted runner pods don't pick up GITHUB_PATH between steps.
|
||||
- name: Install .NET SDK
|
||||
run: |
|
||||
DOTNET_INSTALL_DIR="$HOME/.dotnet"
|
||||
mkdir -p "$DOTNET_INSTALL_DIR"
|
||||
curl -fsSL https://dot.net/v1/dotnet-install.sh | bash /dev/stdin --channel 10.0 --install-dir "$DOTNET_INSTALL_DIR"
|
||||
echo "DOTNET_ROOT=$DOTNET_INSTALL_DIR" >> "$GITHUB_ENV"
|
||||
echo "PATH=$DOTNET_INSTALL_DIR:$PATH" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Run DotNet CLI Tests
|
||||
run: >
|
||||
dotnet test Jellyfin.sln
|
||||
--configuration Release
|
||||
--collect:"XPlat Code Coverage"
|
||||
--settings tests/coverletArgs.runsettings
|
||||
--verbosity minimal
|
||||
run: |
|
||||
export PATH="$HOME/.dotnet:$PATH"
|
||||
dotnet test Jellyfin.sln \
|
||||
--configuration Release \
|
||||
--collect:"XPlat Code Coverage" \
|
||||
--settings tests/coverletArgs.runsettings \
|
||||
--verbosity minimal \
|
||||
--filter "Category!=RequiresDocker&FullyQualifiedName!~Integration"
|
||||
|
||||
- name: Merge code coverage results
|
||||
uses: danielpalme/ReportGenerator-GitHub-Action@9870ed167742d546b99962ff815fcc1098355ed8 # v5.4.17
|
||||
uses: danielpalme/ReportGenerator-GitHub-Action@ee0ae774f6d3afedcbd1683c1ab21b83670bdf8e # v5.5.1
|
||||
with:
|
||||
reports: "**/coverage.cobertura.xml"
|
||||
targetdir: "merged/"
|
||||
@@ -43,3 +46,57 @@ jobs:
|
||||
|
||||
# TODO - which action / tool to use to publish code coverage results?
|
||||
# - name: Publish code coverage results
|
||||
|
||||
# Phase 5 transcode coverage gate — runs in parallel with run-tests.
|
||||
# Explicitly targets the three test assemblies most affected by Phase 5 HLS
|
||||
# session-sharing and PostgreSQL media-encoding changes so failures surface
|
||||
# with a dedicated check status independent of the full test matrix.
|
||||
run-phase5-tests:
|
||||
runs-on: [self-hosted, k3s, linux, amd64]
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install .NET SDK
|
||||
run: |
|
||||
DOTNET_INSTALL_DIR="$HOME/.dotnet"
|
||||
mkdir -p "$DOTNET_INSTALL_DIR"
|
||||
curl -fsSL https://dot.net/v1/dotnet-install.sh | bash /dev/stdin --channel 10.0 --install-dir "$DOTNET_INSTALL_DIR"
|
||||
echo "DOTNET_ROOT=$DOTNET_INSTALL_DIR" >> "$GITHUB_ENV"
|
||||
echo "PATH=$DOTNET_INSTALL_DIR:$PATH" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Run Phase 5 Transcode Tests (API)
|
||||
run: |
|
||||
export PATH="$HOME/.dotnet:$PATH"
|
||||
dotnet test tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj \
|
||||
--configuration Release \
|
||||
--collect:"XPlat Code Coverage" \
|
||||
--settings tests/coverletArgs.runsettings \
|
||||
--verbosity minimal \
|
||||
--filter "Category!=RequiresDocker"
|
||||
|
||||
- name: Run Phase 5 Transcode Tests (HLS)
|
||||
run: |
|
||||
export PATH="$HOME/.dotnet:$PATH"
|
||||
dotnet test tests/Jellyfin.MediaEncoding.Hls.Tests/Jellyfin.MediaEncoding.Hls.Tests.csproj \
|
||||
--configuration Release \
|
||||
--collect:"XPlat Code Coverage" \
|
||||
--settings tests/coverletArgs.runsettings \
|
||||
--verbosity minimal \
|
||||
--filter "Category!=RequiresDocker"
|
||||
|
||||
- name: Run Phase 5 Transcode Tests (Server.Implementations)
|
||||
run: |
|
||||
export PATH="$HOME/.dotnet:$PATH"
|
||||
dotnet test tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj \
|
||||
--configuration Release \
|
||||
--collect:"XPlat Code Coverage" \
|
||||
--settings tests/coverletArgs.runsettings \
|
||||
--verbosity minimal \
|
||||
--filter "Category!=RequiresDocker"
|
||||
|
||||
- name: Merge Phase 5 code coverage results
|
||||
uses: danielpalme/ReportGenerator-GitHub-Action@2a7030e9775aab6c78e80cb66843051acdacee3e # v5.5.2
|
||||
with:
|
||||
reports: "**/coverage.cobertura.xml"
|
||||
targetdir: "merged-phase5/"
|
||||
reporttypes: "Cobertura"
|
||||
|
||||
@@ -4,7 +4,7 @@ on:
|
||||
types:
|
||||
- created
|
||||
- edited
|
||||
pull_request_target:
|
||||
pull_request:
|
||||
types:
|
||||
- labeled
|
||||
- synchronize
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
reactions: '+1'
|
||||
|
||||
- name: Checkout the latest code
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
fetch-depth: 0
|
||||
@@ -40,11 +40,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: pull in script
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
repository: jellyfin/jellyfin-triage-script
|
||||
- name: install python
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
with:
|
||||
python-version: '3.14'
|
||||
cache: 'pip'
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
name: HA Build & Push to ECR
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- main
|
||||
- "feat/ha-*"
|
||||
- "feat/phase*"
|
||||
- "copilot/*"
|
||||
# pull_request intentionally removed: this workflow runs on self-hosted k3s
|
||||
# runners. Allowing pull_request events from a public repo would let any
|
||||
# internet user execute arbitrary code inside the cluster network.
|
||||
# CI build feedback on PRs is provided by ci-tests.yml (GitHub-hosted runners).
|
||||
|
||||
# Cancel in-progress runs when a new push arrives on the same branch.
|
||||
concurrency:
|
||||
group: ha-build-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: [self-hosted, k3s, linux, amd64]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
|
||||
with:
|
||||
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
aws-region: ${{ secrets.AWS_REGION }}
|
||||
|
||||
- name: Login to Amazon ECR
|
||||
id: ecr-login
|
||||
uses: aws-actions/amazon-ecr-login@062b18b96a7aff071d4dc91bc00c4c1a7945b076 # v2.0.1
|
||||
|
||||
- name: Set image metadata
|
||||
id: meta
|
||||
run: |
|
||||
REPO="${{ steps.ecr-login.outputs.registry }}/${{ secrets.ECR_REPOSITORY }}"
|
||||
SHORT_SHA="${GITHUB_SHA::7}"
|
||||
echo "image_repo=${REPO}" >> "$GITHUB_OUTPUT"
|
||||
echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Install .NET SDK
|
||||
# Build on the runner host filesystem (native I/O) to avoid DinD
|
||||
# overlay-on-overlay throttling which makes dotnet publish ~20x slower.
|
||||
run: |
|
||||
DOTNET_INSTALL_DIR="$HOME/.dotnet"
|
||||
mkdir -p "$DOTNET_INSTALL_DIR"
|
||||
if ! "$DOTNET_INSTALL_DIR/dotnet" --version 2>/dev/null | grep -q "^10\\."; then
|
||||
curl -fsSL https://dot.net/v1/dotnet-install.sh | bash /dev/stdin --channel 10.0 --install-dir "$DOTNET_INSTALL_DIR"
|
||||
fi
|
||||
echo "DOTNET_ROOT=$DOTNET_INSTALL_DIR" >> "$GITHUB_ENV"
|
||||
echo "PATH=$DOTNET_INSTALL_DIR:$PATH" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Restore NuGet packages
|
||||
run: |
|
||||
export PATH="$HOME/.dotnet:$PATH"
|
||||
dotnet restore Jellyfin.Server/Jellyfin.Server.csproj --runtime linux-x64
|
||||
|
||||
- name: Publish Jellyfin server
|
||||
run: |
|
||||
export PATH="$HOME/.dotnet:$PATH"
|
||||
dotnet publish Jellyfin.Server/Jellyfin.Server.csproj \
|
||||
--configuration Release \
|
||||
--runtime linux-x64 \
|
||||
--self-contained false \
|
||||
--no-restore \
|
||||
-p:TreatWarningsAsErrors=false \
|
||||
--output ./publish-output
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and push image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.runtime
|
||||
platforms: linux/amd64
|
||||
# Only push the image on direct branch pushes, not on pull_request events.
|
||||
push: ${{ github.event_name == 'push' }}
|
||||
provenance: false
|
||||
tags: |
|
||||
${{ steps.meta.outputs.image_repo }}:${{ steps.meta.outputs.short_sha }}
|
||||
${{ steps.meta.outputs.image_repo }}:latest
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ contains(github.repository, 'jellyfin/') }}
|
||||
steps:
|
||||
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
|
||||
- uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
|
||||
with:
|
||||
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
ascending: true
|
||||
|
||||
@@ -10,11 +10,11 @@ jobs:
|
||||
issues: write
|
||||
steps:
|
||||
- name: pull in script
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
repository: jellyfin/jellyfin-triage-script
|
||||
- name: install python
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
with:
|
||||
python-version: '3.14'
|
||||
cache: 'pip'
|
||||
|
||||
@@ -4,7 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request_target:
|
||||
pull_request:
|
||||
issue_comment:
|
||||
|
||||
permissions: {}
|
||||
|
||||
@@ -4,7 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request_target:
|
||||
pull_request:
|
||||
issue_comment:
|
||||
|
||||
permissions: {}
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
steps:
|
||||
- name: Apply label
|
||||
uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 # v3.0.3
|
||||
if: ${{ github.event_name == 'push' || github.event_name == 'pull_request_target'}}
|
||||
if: ${{ github.event_name == 'push' || github.event_name == 'pull_request'}}
|
||||
with:
|
||||
dirtyLabel: 'merge conflict'
|
||||
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ contains(github.repository, 'jellyfin/') }}
|
||||
steps:
|
||||
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
|
||||
- uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
|
||||
with:
|
||||
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
ascending: true
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
yq-version: v4.9.8
|
||||
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
ref: ${{ env.TAG_BRANCH }}
|
||||
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
NEXT_VERSION: ${{ github.event.inputs.NEXT_VERSION }}
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
ref: ${{ env.TAG_BRANCH }}
|
||||
|
||||
|
||||
@@ -117,6 +117,7 @@
|
||||
- [sachk](https://github.com/sachk)
|
||||
- [sammyrc34](https://github.com/sammyrc34)
|
||||
- [samuel9554](https://github.com/samuel9554)
|
||||
- [SapientGuardian](https://github.com/SapientGuardian)
|
||||
- [scheidleon](https://github.com/scheidleon)
|
||||
- [sebPomme](https://github.com/sebPomme)
|
||||
- [SegiH](https://github.com/SegiH)
|
||||
@@ -205,6 +206,8 @@
|
||||
- [theshoeshiner](https://github.com/theshoeshiner)
|
||||
- [TokerX](https://github.com/TokerX)
|
||||
- [GeneMarks](https://github.com/GeneMarks)
|
||||
- [martenumberto](https://github.com/martenumberto)
|
||||
- [MarcoCoreDuo](https://github.com/MarcoCoreDuo)
|
||||
|
||||
# Emby Contributors
|
||||
|
||||
|
||||
+36
-31
@@ -4,7 +4,8 @@
|
||||
</PropertyGroup>
|
||||
<!-- Run "dotnet list package (dash,dash)outdated" to see the latest versions of each package.-->
|
||||
<ItemGroup Label="Package Dependencies">
|
||||
<PackageVersion Include="AsyncKeyedLock" Version="7.1.7" />
|
||||
<PackageVersion Include="AsyncKeyedLock" Version="7.1.8" />
|
||||
<PackageVersion Include="AWSSDK.S3" Version="3.7.410.2" />
|
||||
<PackageVersion Include="AutoFixture.AutoMoq" Version="4.18.1" />
|
||||
<PackageVersion Include="AutoFixture.Xunit2" Version="4.18.1" />
|
||||
<PackageVersion Include="AutoFixture" Version="4.18.1" />
|
||||
@@ -17,7 +18,7 @@
|
||||
<PackageVersion Include="Diacritics" Version="4.0.17" />
|
||||
<PackageVersion Include="DiscUtils.Udf" Version="0.16.13" />
|
||||
<PackageVersion Include="DotNet.Glob" Version="3.1.3" />
|
||||
<PackageVersion Include="FsCheck.Xunit" Version="3.3.1" />
|
||||
<PackageVersion Include="FsCheck.Xunit" Version="3.3.2" />
|
||||
<PackageVersion Include="HarfBuzzSharp.NativeAssets.Linux" Version="8.3.1.1" />
|
||||
<PackageVersion Include="ICU4N.Transliterator" Version="60.1.0-alpha.356" />
|
||||
<PackageVersion Include="IDisposableAnalyzers" Version="4.0.8" />
|
||||
@@ -26,49 +27,51 @@
|
||||
<PackageVersion Include="libse" Version="4.0.12" />
|
||||
<PackageVersion Include="LrcParser" Version="2025.623.0" />
|
||||
<PackageVersion Include="MetaBrainz.MusicBrainz" Version="6.1.0" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="4.14.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="4.14.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" />
|
||||
<PackageVersion Include="Microsoft.Data.Sqlite" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.0" />
|
||||
<PackageVersion Include="Microsoft.Data.Sqlite" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="9.0.11" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||
<PackageVersion Include="MimeTypes" Version="2.5.2" />
|
||||
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
|
||||
<PackageVersion Include="Morestachio" Version="5.0.1.631" />
|
||||
<PackageVersion Include="Moq" Version="4.18.4" />
|
||||
<PackageVersion Include="NEbml" Version="1.1.0.5" />
|
||||
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<PackageVersion Include="Npgsql" Version="9.0.4" />
|
||||
<PackageVersion Include="PlaylistsNET" Version="1.4.1" />
|
||||
<PackageVersion Include="prometheus-net.AspNetCore" Version="8.2.1" />
|
||||
<PackageVersion Include="prometheus-net.DotNetRuntime" Version="4.4.1" />
|
||||
<PackageVersion Include="prometheus-net" Version="8.2.1" />
|
||||
<PackageVersion Include="Polly" Version="8.6.4" />
|
||||
<PackageVersion Include="Polly" Version="8.6.5" />
|
||||
<PackageVersion Include="Serilog.AspNetCore" Version="9.0.0" />
|
||||
<PackageVersion Include="Serilog.Enrichers.Thread" Version="4.0.0" />
|
||||
<PackageVersion Include="Serilog.Expressions" Version="5.0.0" />
|
||||
<PackageVersion Include="Serilog.Settings.Configuration" Version="9.0.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.Async" Version="2.1.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.Graylog" Version="3.1.1" />
|
||||
<PackageVersion Include="SerilogAnalyzer" Version="0.15.0" />
|
||||
@@ -84,13 +87,15 @@
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore" Version="6.2.3" />
|
||||
<PackageVersion Include="System.Globalization" Version="4.3.0" />
|
||||
<PackageVersion Include="System.Linq.Async" Version="6.0.3" />
|
||||
<PackageVersion Include="System.Text.Encoding.CodePages" Version="9.0.10" />
|
||||
<PackageVersion Include="System.Text.Json" Version="9.0.10" />
|
||||
<PackageVersion Include="System.Threading.Tasks.Dataflow" Version="9.0.10" />
|
||||
<PackageVersion Include="System.Text.Encoding.CodePages" Version="9.0.11" />
|
||||
<PackageVersion Include="System.Text.Json" Version="9.0.11" />
|
||||
<PackageVersion Include="System.Threading.Tasks.Dataflow" Version="9.0.11" />
|
||||
<PackageVersion Include="TagLibSharp" Version="2.3.0" />
|
||||
<PackageVersion Include="z440.atl.core" Version="7.6.0" />
|
||||
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.4.0" />
|
||||
<PackageVersion Include="z440.atl.core" Version="7.9.0" />
|
||||
<PackageVersion Include="TMDbLib" Version="2.3.0" />
|
||||
<PackageVersion Include="UTF.Unknown" Version="2.6.0" />
|
||||
<PackageVersion Include="StackExchange.Redis" Version="2.8.16" />
|
||||
<PackageVersion Include="Xunit.Priority" Version="1.1.6" />
|
||||
<PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageVersion Include="Xunit.SkippableFact" Version="1.5.23" />
|
||||
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# ── Build stage ──────────────────────────────────────────────────────────────
|
||||
FROM --platform=linux/amd64 mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
# Restore dependencies first (layer-cache friendly)
|
||||
COPY ["Jellyfin.sln", "global.json", "nuget.config", "Directory.Build.props", "Directory.Packages.props", "./"]
|
||||
COPY ["SharedVersion.cs", "BannedSymbols.txt", "stylecop.json", "./"]
|
||||
|
||||
# Copy all project files so dotnet restore can resolve the full dependency graph
|
||||
COPY Emby.Naming/ Emby.Naming/
|
||||
COPY Emby.Photos/ Emby.Photos/
|
||||
COPY Emby.Server.Implementations/ Emby.Server.Implementations/
|
||||
COPY Jellyfin.Api/ Jellyfin.Api/
|
||||
COPY Jellyfin.Data/ Jellyfin.Data/
|
||||
COPY Jellyfin.Server/ Jellyfin.Server/
|
||||
COPY Jellyfin.Server.Implementations/ Jellyfin.Server.Implementations/
|
||||
COPY MediaBrowser.Common/ MediaBrowser.Common/
|
||||
COPY MediaBrowser.Controller/ MediaBrowser.Controller/
|
||||
COPY MediaBrowser.LocalMetadata/ MediaBrowser.LocalMetadata/
|
||||
COPY MediaBrowser.MediaEncoding/ MediaBrowser.MediaEncoding/
|
||||
COPY MediaBrowser.Model/ MediaBrowser.Model/
|
||||
COPY MediaBrowser.Providers/ MediaBrowser.Providers/
|
||||
COPY MediaBrowser.XbmcMetadata/ MediaBrowser.XbmcMetadata/
|
||||
COPY src/ src/
|
||||
|
||||
RUN dotnet restore Jellyfin.Server/Jellyfin.Server.csproj \
|
||||
--runtime linux-x64
|
||||
|
||||
# Publish the server (and all transitive dependencies, including the
|
||||
# PostgreSQL provider assembly added by this fork).
|
||||
# Note: TreatWarningsAsErrors is disabled for the Docker build — StyleCop
|
||||
# analyzer violations in upstream src/ projects would otherwise block the
|
||||
# image build. StyleCop is enforced in the CI pipeline, not the Dockerfile.
|
||||
RUN dotnet publish Jellyfin.Server/Jellyfin.Server.csproj \
|
||||
--configuration Release \
|
||||
--runtime linux-x64 \
|
||||
--self-contained false \
|
||||
--no-restore \
|
||||
-p:TreatWarningsAsErrors=false \
|
||||
--output /app
|
||||
|
||||
# ── Web client stage ──────────────────────────────────────────────────────────
|
||||
# Install jellyfin-web via the official Jellyfin apt repo.
|
||||
# Package suffix in the bookworm repo is +deb12 (e.g. 10.11.6+deb12).
|
||||
# Web assets land at /usr/share/jellyfin/web/ — stable, prebuilt, no npm required.
|
||||
# 10.11.6 is the latest stable web client; API-compatible with the 10.12.0 server.
|
||||
FROM --platform=linux/amd64 debian:bookworm-slim AS webclient
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl gnupg ca-certificates \
|
||||
&& curl -fsSL https://repo.jellyfin.org/jellyfin_team.gpg.key \
|
||||
| gpg --dearmor -o /usr/share/keyrings/jellyfin.gpg \
|
||||
&& echo "deb [arch=amd64 signed-by=/usr/share/keyrings/jellyfin.gpg] https://repo.jellyfin.org/debian bookworm main" \
|
||||
> /etc/apt/sources.list.d/jellyfin.list \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends "jellyfin-web=10.11.6+deb12" \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& echo "Web client files:" && ls /usr/share/jellyfin/web/ | head -10
|
||||
|
||||
# ── Runtime stage ─────────────────────────────────────────────────────────────
|
||||
FROM --platform=linux/amd64 mcr.microsoft.com/dotnet/aspnet:10.0
|
||||
|
||||
# Install FFmpeg and native dependencies required by SkiaSharp and fontconfig.
|
||||
# libicu, libssl, and liblttng-ust are already present in the dotnet/aspnet base image.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
ffmpeg \
|
||||
fontconfig \
|
||||
libfontconfig1 \
|
||||
libfreetype6 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /jellyfin
|
||||
|
||||
COPY --from=build /app .
|
||||
COPY --from=webclient /usr/share/jellyfin/web ./jellyfin-web/
|
||||
|
||||
# Jellyfin default ports
|
||||
EXPOSE 8096
|
||||
EXPOSE 8920
|
||||
|
||||
# Data / config volumes
|
||||
VOLUME ["/config", "/cache", "/media"]
|
||||
|
||||
ENV JELLYFIN_DATA_DIR=/config \
|
||||
JELLYFIN_CACHE_DIR=/cache \
|
||||
JELLYFIN_LOG_DIR=/config/log \
|
||||
JELLYFIN_CONFIG_DIR=/config
|
||||
|
||||
ENTRYPOINT ["./jellyfin", \
|
||||
"--datadir", "/config", \
|
||||
"--cachedir", "/cache", \
|
||||
"--webdir", "/jellyfin/jellyfin-web"]
|
||||
@@ -0,0 +1,55 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# Runtime-only image — the .NET publish step runs on the CI host (runner),
|
||||
# not inside this Dockerfile.
|
||||
|
||||
# ── Web client stage ──────────────────────────────────────────────────────────
|
||||
# Install jellyfin-web via the official Jellyfin apt repo.
|
||||
# Package suffix in the bookworm repo is +deb12 (e.g. 10.11.6+deb12).
|
||||
# Web assets land at /usr/share/jellyfin/web/ — stable, prebuilt, no npm required.
|
||||
# 10.11.6 is the latest stable web client; API-compatible with the 10.12.0 server.
|
||||
FROM --platform=linux/amd64 debian:bookworm-slim AS webclient
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl gnupg ca-certificates \
|
||||
&& curl -fsSL https://repo.jellyfin.org/jellyfin_team.gpg.key \
|
||||
| gpg --dearmor -o /usr/share/keyrings/jellyfin.gpg \
|
||||
&& echo "deb [arch=amd64 signed-by=/usr/share/keyrings/jellyfin.gpg] https://repo.jellyfin.org/debian bookworm main" \
|
||||
> /etc/apt/sources.list.d/jellyfin.list \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends "jellyfin-web=10.11.6+deb12" \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ── Runtime stage ─────────────────────────────────────────────────────────────
|
||||
FROM --platform=linux/amd64 mcr.microsoft.com/dotnet/aspnet:10.0
|
||||
|
||||
# Install FFmpeg and native dependencies required by SkiaSharp and fontconfig.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
ffmpeg \
|
||||
fontconfig \
|
||||
libfontconfig1 \
|
||||
libfreetype6 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /jellyfin
|
||||
|
||||
# Copy the pre-built publish output produced by `dotnet publish` on the CI host.
|
||||
COPY publish-output/ .
|
||||
# Copy the jellyfin-web client assets from the webclient stage.
|
||||
COPY --from=webclient /usr/share/jellyfin/web ./jellyfin-web/
|
||||
|
||||
# Jellyfin default ports
|
||||
EXPOSE 8096
|
||||
EXPOSE 8920
|
||||
|
||||
# Data / config volumes
|
||||
VOLUME ["/config", "/cache", "/media"]
|
||||
|
||||
ENV JELLYFIN_DATA_DIR=/config \
|
||||
JELLYFIN_CACHE_DIR=/cache \
|
||||
JELLYFIN_LOG_DIR=/config/log
|
||||
|
||||
ENTRYPOINT ["./jellyfin", \
|
||||
"--datadir", "/config", \
|
||||
"--cachedir", "/cache", \
|
||||
"--webdir", "/jellyfin/jellyfin-web"]
|
||||
@@ -36,7 +36,7 @@
|
||||
<PropertyGroup>
|
||||
<Authors>Jellyfin Contributors</Authors>
|
||||
<PackageId>Jellyfin.Naming</PackageId>
|
||||
<VersionPrefix>10.11.3</VersionPrefix>
|
||||
<VersionPrefix>10.11.7</VersionPrefix>
|
||||
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
||||
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -1051,16 +1051,16 @@ namespace Emby.Server.Implementations.Dto
|
||||
|
||||
// Include artists that are not in the database yet, e.g., just added via metadata editor
|
||||
// var foundArtists = artistItems.Items.Select(i => i.Item1.Name).ToList();
|
||||
dto.ArtistItems = _libraryManager.GetArtists([.. hasArtist.Artists.Where(e => !string.IsNullOrWhiteSpace(e))])
|
||||
.Where(e => e.Value.Length > 0)
|
||||
.Select(i =>
|
||||
{
|
||||
return new NameGuidPair
|
||||
{
|
||||
Name = i.Key,
|
||||
Id = i.Value.First().Id
|
||||
};
|
||||
}).Where(i => i is not null).ToArray();
|
||||
var artistsLookup = _libraryManager.GetArtists([.. hasArtist.Artists.Where(e => !string.IsNullOrWhiteSpace(e))]);
|
||||
|
||||
dto.ArtistItems = hasArtist.Artists
|
||||
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||
.Distinct()
|
||||
.Select(name => artistsLookup.TryGetValue(name, out var artists) && artists.Length > 0
|
||||
? new NameGuidPair { Name = name, Id = artists[0].Id }
|
||||
: null)
|
||||
.Where(item => item is not null)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
if (item is IHasAlbumArtist hasAlbumArtist)
|
||||
@@ -1085,31 +1085,16 @@ namespace Emby.Server.Implementations.Dto
|
||||
// })
|
||||
// .ToList();
|
||||
|
||||
var albumArtistsLookup = _libraryManager.GetArtists([.. hasAlbumArtist.AlbumArtists.Where(e => !string.IsNullOrWhiteSpace(e))]);
|
||||
|
||||
dto.AlbumArtists = hasAlbumArtist.AlbumArtists
|
||||
// .Except(foundArtists, new DistinctNameComparer())
|
||||
.Select(i =>
|
||||
{
|
||||
// This should not be necessary but we're seeing some cases of it
|
||||
if (string.IsNullOrEmpty(i))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var artist = _libraryManager.GetArtist(i, new DtoOptions(false)
|
||||
{
|
||||
EnableImages = false
|
||||
});
|
||||
if (artist is not null)
|
||||
{
|
||||
return new NameGuidPair
|
||||
{
|
||||
Name = artist.Name,
|
||||
Id = artist.Id
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}).Where(i => i is not null).ToArray();
|
||||
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||
.Distinct()
|
||||
.Select(name => albumArtistsLookup.TryGetValue(name, out var albumArtists) && albumArtists.Length > 0
|
||||
? new NameGuidPair { Name = name, Id = albumArtists[0].Id }
|
||||
: null)
|
||||
.Where(item => item is not null)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
// Add video info
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Ignore" />
|
||||
<PackageReference Include="StackExchange.Redis" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -352,6 +352,12 @@ namespace Emby.Server.Implementations.IO
|
||||
return;
|
||||
}
|
||||
|
||||
var fileInfo = _fileSystem.GetFileSystemInfo(path);
|
||||
if (DotIgnoreIgnoreRule.IsIgnored(fileInfo, null))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignore certain files, If the parent of an ignored path has a change event, ignore that too
|
||||
foreach (var i in _tempIgnoredPaths.Keys)
|
||||
{
|
||||
|
||||
@@ -497,8 +497,17 @@ namespace Emby.Server.Implementations.IO
|
||||
/// <inheritdoc />
|
||||
public virtual bool AreEqual(string path1, string path2)
|
||||
{
|
||||
return Path.TrimEndingDirectorySeparator(path1).Equals(
|
||||
Path.TrimEndingDirectorySeparator(path2),
|
||||
if (string.IsNullOrWhiteSpace(path1) || string.IsNullOrWhiteSpace(path2))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var normalized1 = Path.TrimEndingDirectorySeparator(path1);
|
||||
var normalized2 = Path.TrimEndingDirectorySeparator(path2);
|
||||
|
||||
return string.Equals(
|
||||
normalized1,
|
||||
normalized2,
|
||||
_isEnvironmentCaseInsensitive ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
@@ -267,22 +267,24 @@ namespace Emby.Server.Implementations.Images
|
||||
{
|
||||
var image = item.GetImageInfo(type, 0);
|
||||
|
||||
if (image is not null)
|
||||
if (image is null)
|
||||
{
|
||||
if (!image.IsLocalFile)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return GetItemsWithImages(item).Count is not 0;
|
||||
}
|
||||
|
||||
if (!FileSystem.ContainsSubPath(item.GetInternalMetadataPath(), image.Path))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (!image.IsLocalFile)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!HasChangedByDate(item, image))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (!FileSystem.ContainsSubPath(item.GetInternalMetadataPath(), image.Path))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!HasChangedByDate(item, image))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
@@ -98,5 +98,11 @@ namespace Emby.Server.Implementations.Images
|
||||
|
||||
return base.CreateImage(item, itemsWithImages, outputPath, imageType, imageIndex);
|
||||
}
|
||||
|
||||
protected override bool HasChangedByDate(BaseItem item, ItemImageInfo image)
|
||||
{
|
||||
var age = DateTime.UtcNow - image.DateModified;
|
||||
return age.TotalDays > 7;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text.RegularExpressions;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.IO;
|
||||
using MediaBrowser.Controller.Resolvers;
|
||||
@@ -70,12 +71,55 @@ public class DotIgnoreIgnoreRule : IResolverIgnoreRule
|
||||
{
|
||||
// If file has content, base ignoring off the content .gitignore-style rules
|
||||
var rules = ignoreFileContent.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
return CheckIgnoreRules(path, rules, isDirectory);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a path should be ignored based on an array of ignore rules.
|
||||
/// </summary>
|
||||
/// <param name="path">The path to check.</param>
|
||||
/// <param name="rules">The array of ignore rules.</param>
|
||||
/// <param name="isDirectory">Whether the path is a directory.</param>
|
||||
/// <returns>True if the path should be ignored.</returns>
|
||||
internal static bool CheckIgnoreRules(string path, string[] rules, bool isDirectory)
|
||||
=> CheckIgnoreRules(path, rules, isDirectory, IsWindows);
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a path should be ignored based on an array of ignore rules.
|
||||
/// </summary>
|
||||
/// <param name="path">The path to check.</param>
|
||||
/// <param name="rules">The array of ignore rules.</param>
|
||||
/// <param name="isDirectory">Whether the path is a directory.</param>
|
||||
/// <param name="normalizePath">Whether to normalize backslashes to forward slashes (for Windows paths).</param>
|
||||
/// <returns>True if the path should be ignored.</returns>
|
||||
internal static bool CheckIgnoreRules(string path, string[] rules, bool isDirectory, bool normalizePath)
|
||||
{
|
||||
var ignore = new Ignore.Ignore();
|
||||
ignore.Add(rules);
|
||||
|
||||
// Add each rule individually to catch and skip invalid patterns
|
||||
var validRulesAdded = 0;
|
||||
foreach (var rule in rules)
|
||||
{
|
||||
try
|
||||
{
|
||||
ignore.Add(rule);
|
||||
validRulesAdded++;
|
||||
}
|
||||
catch (RegexParseException)
|
||||
{
|
||||
// Ignore invalid patterns
|
||||
}
|
||||
}
|
||||
|
||||
// If no valid rules were added, fall back to ignoring everything (like an empty .ignore file)
|
||||
if (validRulesAdded == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Mitigate the problem of the Ignore library not handling Windows paths correctly.
|
||||
// See https://github.com/jellyfin/jellyfin/issues/15484
|
||||
var pathToCheck = IsWindows ? path.NormalizePath('/') : path;
|
||||
var pathToCheck = normalizePath ? path.NormalizePath('/') : path;
|
||||
|
||||
// Add trailing slash for directories to match "folder/"
|
||||
if (isDirectory)
|
||||
|
||||
@@ -50,6 +50,10 @@ namespace Emby.Server.Implementations.Library
|
||||
"**/lost+found",
|
||||
"**/subs/**",
|
||||
"**/subs",
|
||||
"**/.snapshots/**",
|
||||
"**/.snapshots",
|
||||
"**/.snapshot/**",
|
||||
"**/.snapshot",
|
||||
|
||||
// Trickplay files
|
||||
"**/*.trickplay",
|
||||
|
||||
@@ -1058,6 +1058,7 @@ namespace Emby.Server.Implementations.Library
|
||||
{
|
||||
IncludeItemTypes = [BaseItemKind.MusicArtist],
|
||||
Name = name,
|
||||
UseRawName = true,
|
||||
DtoOptions = options
|
||||
}).Cast<MusicArtist>()
|
||||
.OrderBy(i => i.IsAccessedByName ? 1 : 0)
|
||||
@@ -2201,6 +2202,12 @@ namespace Emby.Server.Implementations.Library
|
||||
public Task UpdateItemAsync(BaseItem item, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken)
|
||||
=> UpdateItemsAsync([item], parent, updateReason, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task ReattachUserDataAsync(BaseItem item, CancellationToken cancellationToken)
|
||||
{
|
||||
await _itemRepository.ReattachUserDataAsync(item, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task RunMetadataSavers(BaseItem item, ItemUpdateType updateReason)
|
||||
{
|
||||
if (item.IsFileProtocol)
|
||||
@@ -3194,19 +3201,7 @@ namespace Emby.Server.Implementations.Library
|
||||
var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath;
|
||||
var virtualFolderPath = Path.Combine(rootFolderPath, virtualFolderName);
|
||||
|
||||
var shortcutFilename = Path.GetFileNameWithoutExtension(path);
|
||||
|
||||
var lnk = Path.Combine(virtualFolderPath, shortcutFilename + ShortcutFileExtension);
|
||||
|
||||
while (File.Exists(lnk))
|
||||
{
|
||||
shortcutFilename += "1";
|
||||
lnk = Path.Combine(virtualFolderPath, shortcutFilename + ShortcutFileExtension);
|
||||
}
|
||||
|
||||
_fileSystem.CreateShortcut(lnk, _appHost.ReverseVirtualPath(path));
|
||||
|
||||
RemoveContentTypeOverrides(path);
|
||||
CreateShortcut(virtualFolderPath, pathInfo);
|
||||
|
||||
if (saveLibraryOptions)
|
||||
{
|
||||
@@ -3371,5 +3366,24 @@ namespace Emby.Server.Implementations.Library
|
||||
|
||||
return item is UserRootFolder || item.IsVisibleStandalone(user);
|
||||
}
|
||||
|
||||
public void CreateShortcut(string virtualFolderPath, MediaPathInfo pathInfo)
|
||||
{
|
||||
var path = pathInfo.Path;
|
||||
var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath;
|
||||
|
||||
var shortcutFilename = Path.GetFileNameWithoutExtension(path);
|
||||
|
||||
var lnk = Path.Combine(virtualFolderPath, shortcutFilename + ShortcutFileExtension);
|
||||
|
||||
while (File.Exists(lnk))
|
||||
{
|
||||
shortcutFilename += "1";
|
||||
lnk = Path.Combine(virtualFolderPath, shortcutFilename + ShortcutFileExtension);
|
||||
}
|
||||
|
||||
_fileSystem.CreateShortcut(lnk, _appHost.ReverseVirtualPath(path));
|
||||
RemoveContentTypeOverrides(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ namespace Emby.Server.Implementations.Localization
|
||||
|
||||
private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
|
||||
|
||||
private readonly ConcurrentDictionary<string, CultureDto?> _cultureCache = new(StringComparer.OrdinalIgnoreCase);
|
||||
private List<CultureDto> _cultures = [];
|
||||
|
||||
private FrozenDictionary<string, string> _iso6392BtoT = null!;
|
||||
@@ -161,6 +162,7 @@ namespace Emby.Server.Implementations.Localization
|
||||
list.Add(new CultureDto(name, displayname, twoCharName, threeLetterNames));
|
||||
}
|
||||
|
||||
_cultureCache.Clear();
|
||||
_cultures = list;
|
||||
_iso6392BtoT = iso6392BtoTdict.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
@@ -169,20 +171,31 @@ namespace Emby.Server.Implementations.Localization
|
||||
/// <inheritdoc />
|
||||
public CultureDto? FindLanguageInfo(string language)
|
||||
{
|
||||
// TODO language should ideally be a ReadOnlySpan but moq cannot mock ref structs
|
||||
for (var i = 0; i < _cultures.Count; i++)
|
||||
if (string.IsNullOrEmpty(language))
|
||||
{
|
||||
var culture = _cultures[i];
|
||||
if (language.Equals(culture.DisplayName, StringComparison.OrdinalIgnoreCase)
|
||||
|| language.Equals(culture.Name, StringComparison.OrdinalIgnoreCase)
|
||||
|| culture.ThreeLetterISOLanguageNames.Contains(language, StringComparison.OrdinalIgnoreCase)
|
||||
|| language.Equals(culture.TwoLetterISOLanguageName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return culture;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return default;
|
||||
return _cultureCache.GetOrAdd(
|
||||
language,
|
||||
static (lang, cultures) =>
|
||||
{
|
||||
// TODO language should ideally be a ReadOnlySpan but moq cannot mock ref structs
|
||||
for (var i = 0; i < cultures.Count; i++)
|
||||
{
|
||||
var culture = cultures[i];
|
||||
if (lang.Equals(culture.DisplayName, StringComparison.OrdinalIgnoreCase)
|
||||
|| lang.Equals(culture.Name, StringComparison.OrdinalIgnoreCase)
|
||||
|| culture.ThreeLetterISOLanguageNames.Contains(lang, StringComparison.OrdinalIgnoreCase)
|
||||
|| lang.Equals(culture.TwoLetterISOLanguageName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return culture;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
_cultures);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -311,15 +324,19 @@ namespace Emby.Server.Implementations.Localization
|
||||
else
|
||||
{
|
||||
// Fall back to server default language for ratings check
|
||||
// If it has no ratings, use the US ratings
|
||||
var ratingsDictionary = GetParentalRatingsDictionary() ?? GetParentalRatingsDictionary("us");
|
||||
var ratingsDictionary = GetParentalRatingsDictionary();
|
||||
if (ratingsDictionary is not null && ratingsDictionary.TryGetValue(rating, out ParentalRatingScore? value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
// If we don't find anything, check all ratings systems
|
||||
// If we don't find anything, check all ratings systems, starting with US
|
||||
if (_allParentalRatings.TryGetValue("us", out var usRatings) && usRatings.TryGetValue(rating, out var usValue))
|
||||
{
|
||||
return usValue;
|
||||
}
|
||||
|
||||
foreach (var dictionary in _allParentalRatings.Values)
|
||||
{
|
||||
if (dictionary.TryGetValue(rating, out var value))
|
||||
|
||||
@@ -347,8 +347,8 @@ pli||pi|Pali|pali
|
||||
pol||pl|Polish|polonais
|
||||
pon|||Pohnpeian|pohnpei
|
||||
por||pt|Portuguese|portugais
|
||||
por||pt-pt|Portuguese (Portugal)|portugais (pt-pt)
|
||||
por||pt-br|Portuguese (Brazil)|portugais (pt-br)
|
||||
pop||pt-pt|Portuguese (Portugal)|portugais (pt-pt)
|
||||
pob||pt-br|Portuguese (Brazil)|portugais (pt-br)
|
||||
pra|||Prakrit languages|prâkrit, langues
|
||||
pro|||Provençal, Old (to 1500)|provençal ancien (jusqu'à 1500)
|
||||
pus||ps|Pushto; Pashto|pachto
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace Emby.Server.Implementations.MediaEncoding;
|
||||
|
||||
/// <summary>
|
||||
/// A Redis-backed implementation of <see cref="ITranscodeSessionStore"/> that provides
|
||||
/// durable, distributed session tracking with lease-based ownership between pods.
|
||||
/// </summary>
|
||||
public sealed class RedisTranscodeSessionStore : ITranscodeSessionStore
|
||||
{
|
||||
private const string KeyPrefix = "jellyfin:transcode:";
|
||||
private const string LiveStreamKeyPrefix = "jellyfin:livestream:";
|
||||
|
||||
/// <summary>
|
||||
/// Lua script for atomic takeover: reads the stored session, checks whether the lease has
|
||||
/// expired (comparing <c>LeaseExpiresUtc.Ticks</c> against the caller-supplied current ticks),
|
||||
/// and if expired, updates the owner and expiry before returning 1; returns 0 otherwise.
|
||||
/// </summary>
|
||||
private const string TakeoverScript = @"
|
||||
local raw = redis.call('GET', KEYS[1])
|
||||
if not raw then return 0 end
|
||||
local session = cjson.decode(raw)
|
||||
local currentTicks = tonumber(ARGV[1])
|
||||
if session['LeaseExpiresUtc'] > currentTicks then return 0 end
|
||||
session['OwnerPod'] = ARGV[2]
|
||||
local leaseDurationMs = tonumber(ARGV[3])
|
||||
local newTicks = currentTicks + (leaseDurationMs * 10000)
|
||||
session['LeaseExpiresUtc'] = newTicks
|
||||
redis.call('SET', KEYS[1], cjson.encode(session), 'PX', leaseDurationMs)
|
||||
return 1";
|
||||
|
||||
private readonly IConnectionMultiplexer _redis;
|
||||
private readonly IDatabase _db;
|
||||
private readonly TranscodeStoreOptions _options;
|
||||
private readonly ILogger<RedisTranscodeSessionStore> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RedisTranscodeSessionStore"/> class.
|
||||
/// </summary>
|
||||
/// <param name="redis">The Redis connection multiplexer.</param>
|
||||
/// <param name="options">The transcode store configuration options.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public RedisTranscodeSessionStore(
|
||||
IConnectionMultiplexer redis,
|
||||
IOptions<TranscodeStoreOptions> options,
|
||||
ILogger<RedisTranscodeSessionStore> logger)
|
||||
{
|
||||
_redis = redis;
|
||||
_db = redis.GetDatabase();
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SetAsync(TranscodeSession session, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = GetKey(session.PlaySessionId);
|
||||
var json = JsonSerializer.Serialize(session);
|
||||
var leaseDurationMs = (long)_options.LeaseDurationSeconds * 1000;
|
||||
await _db.StringSetAsync(key, json, TimeSpan.FromMilliseconds(leaseDurationMs)).ConfigureAwait(false);
|
||||
_logger.LogDebug("Set transcode session {PlaySessionId} in Redis.", session.PlaySessionId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TranscodeSession?> TryGetAsync(string playSessionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = GetKey(playSessionId);
|
||||
var raw = await _db.StringGetAsync(key).ConfigureAwait(false);
|
||||
if (!raw.HasValue)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var session = JsonSerializer.Deserialize<TranscodeSession>(raw.ToString());
|
||||
|
||||
// Check LeaseExpiresUtc in addition to Redis TTL to guard against the window between
|
||||
// Redis TTL evaluation and the GET result being returned to the caller.
|
||||
if (session is null || session.LeaseExpiresUtc <= DateTime.UtcNow)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RenewLeaseAsync(string playSessionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = GetKey(playSessionId);
|
||||
var raw = await _db.StringGetAsync(key).ConfigureAwait(false);
|
||||
if (!raw.HasValue)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var session = JsonSerializer.Deserialize<TranscodeSession>(raw.ToString());
|
||||
if (session is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var leaseDurationMs = (long)_options.LeaseDurationSeconds * 1000;
|
||||
session.LeaseExpiresUtc = DateTime.UtcNow.AddMilliseconds(leaseDurationMs);
|
||||
var json = JsonSerializer.Serialize(session);
|
||||
await _db.StringSetAsync(key, json, TimeSpan.FromMilliseconds(leaseDurationMs)).ConfigureAwait(false);
|
||||
_logger.LogDebug("Renewed lease for transcode session {PlaySessionId}.", playSessionId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteAsync(string playSessionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = GetKey(playSessionId);
|
||||
await _db.KeyDeleteAsync(key).ConfigureAwait(false);
|
||||
_logger.LogDebug("Deleted transcode session {PlaySessionId} from Redis.", playSessionId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> TryTakeoverAsync(string playSessionId, string claimingPod, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = GetKey(playSessionId);
|
||||
var leaseDurationMs = (long)_options.LeaseDurationSeconds * 1000;
|
||||
var currentTicks = DateTime.UtcNow.Ticks;
|
||||
|
||||
var result = (long?)await _db.ScriptEvaluateAsync(
|
||||
TakeoverScript,
|
||||
keys: new RedisKey[] { key },
|
||||
values: new RedisValue[] { currentTicks, claimingPod, leaseDurationMs }).ConfigureAwait(false);
|
||||
|
||||
var succeeded = result == 1;
|
||||
if (succeeded)
|
||||
{
|
||||
_logger.LogInformation("Pod {ClaimingPod} successfully took over transcode session {PlaySessionId}.", claimingPod, playSessionId);
|
||||
}
|
||||
|
||||
return succeeded;
|
||||
}
|
||||
|
||||
private static string GetKey(string playSessionId) => KeyPrefix + playSessionId;
|
||||
|
||||
private static string GetLiveStreamKey(string liveStreamId, string sessionIdOrPlaySessionId)
|
||||
=> LiveStreamKeyPrefix + liveStreamId + ":" + sessionIdOrPlaySessionId;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IEnumerable<TranscodeSession>> GetActiveSessionsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sessions = new List<TranscodeSession>();
|
||||
var servers = _redis.GetServers();
|
||||
|
||||
foreach (var server in servers)
|
||||
{
|
||||
if (!server.IsConnected)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var keys = new List<RedisKey>();
|
||||
await foreach (var key in server.KeysAsync(database: _db.Database, pattern: KeyPrefix + "*", pageSize: 1000).WithCancellation(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
keys.Add(key);
|
||||
}
|
||||
|
||||
var tasks = keys.Select(key => _db.StringGetAsync(key)).ToList();
|
||||
var values = await Task.WhenAll(tasks).ConfigureAwait(false);
|
||||
|
||||
foreach (var raw in values)
|
||||
{
|
||||
if (!raw.HasValue)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
TranscodeSession? session;
|
||||
try
|
||||
{
|
||||
session = JsonSerializer.Deserialize<TranscodeSession>(raw.ToString());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to deserialize transcode session from Redis.");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (session is not null)
|
||||
{
|
||||
sessions.Add(session);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sessions;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SetLiveStreamAsync(LiveStreamSession session, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = GetLiveStreamKey(session.LiveStreamId, session.SessionId);
|
||||
var json = JsonSerializer.Serialize(session);
|
||||
// Live stream records use the same lease duration as transcode sessions.
|
||||
var leaseDurationMs = (long)_options.LeaseDurationSeconds * 1000;
|
||||
await _db.StringSetAsync(key, json, TimeSpan.FromMilliseconds(leaseDurationMs)).ConfigureAwait(false);
|
||||
|
||||
// Also index by play session id so the caller can look up by either key.
|
||||
if (!string.IsNullOrEmpty(session.PlaySessionId))
|
||||
{
|
||||
var playKey = GetLiveStreamKey(session.LiveStreamId, session.PlaySessionId);
|
||||
await _db.StringSetAsync(playKey, json, TimeSpan.FromMilliseconds(leaseDurationMs)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Set live stream session {LiveStreamId}/{SessionId} in Redis.",
|
||||
session.LiveStreamId,
|
||||
session.SessionId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<LiveStreamSession?> TryGetLiveStreamAsync(string liveStreamId, string sessionIdOrPlaySessionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = GetLiveStreamKey(liveStreamId, sessionIdOrPlaySessionId);
|
||||
var raw = await _db.StringGetAsync(key).ConfigureAwait(false);
|
||||
if (!raw.HasValue)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize<LiveStreamSession>(raw.ToString());
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteLiveStreamAsync(string liveStreamId, string sessionIdOrPlaySessionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = GetLiveStreamKey(liveStreamId, sessionIdOrPlaySessionId);
|
||||
var raw = await _db.StringGetAsync(key).ConfigureAwait(false);
|
||||
if (raw.HasValue)
|
||||
{
|
||||
var session = JsonSerializer.Deserialize<LiveStreamSession>(raw.ToString());
|
||||
if (session is not null)
|
||||
{
|
||||
// Remove both the session-id key and the play-session-id key if present.
|
||||
var keysToDelete = new System.Collections.Generic.List<RedisKey>
|
||||
{
|
||||
GetLiveStreamKey(liveStreamId, session.SessionId)
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(session.PlaySessionId))
|
||||
{
|
||||
keysToDelete.Add(GetLiveStreamKey(liveStreamId, session.PlaySessionId));
|
||||
}
|
||||
|
||||
await _db.KeyDeleteAsync(keysToDelete.ToArray()).ConfigureAwait(false);
|
||||
_logger.LogDebug(
|
||||
"Deleted live stream session {LiveStreamId}/{SessionId} from Redis.",
|
||||
liveStreamId,
|
||||
session.SessionId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: delete just the key that was supplied.
|
||||
await _db.KeyDeleteAsync(key).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Controller.IO;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
using MediaBrowser.Model.Globalization;
|
||||
using MediaBrowser.Model.IO;
|
||||
using MediaBrowser.Model.Tasks;
|
||||
@@ -21,6 +22,7 @@ public class DeleteTranscodeFileTask : IScheduledTask, IConfigurableScheduledTas
|
||||
private readonly IConfigurationManager _configurationManager;
|
||||
private readonly IFileSystem _fileSystem;
|
||||
private readonly ILocalizationManager _localization;
|
||||
private readonly ITranscodeSessionStore _sessionStore;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DeleteTranscodeFileTask"/> class.
|
||||
@@ -29,16 +31,19 @@ public class DeleteTranscodeFileTask : IScheduledTask, IConfigurableScheduledTas
|
||||
/// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
|
||||
/// <param name="configurationManager">Instance of the <see cref="IConfigurationManager"/> interface.</param>
|
||||
/// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
|
||||
/// <param name="sessionStore">Instance of the <see cref="ITranscodeSessionStore"/> interface.</param>
|
||||
public DeleteTranscodeFileTask(
|
||||
ILogger<DeleteTranscodeFileTask> logger,
|
||||
IFileSystem fileSystem,
|
||||
IConfigurationManager configurationManager,
|
||||
ILocalizationManager localization)
|
||||
ILocalizationManager localization,
|
||||
ITranscodeSessionStore sessionStore)
|
||||
{
|
||||
_logger = logger;
|
||||
_fileSystem = fileSystem;
|
||||
_configurationManager = configurationManager;
|
||||
_localization = localization;
|
||||
_sessionStore = sessionStore;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -78,25 +83,39 @@ public class DeleteTranscodeFileTask : IScheduledTask, IConfigurableScheduledTas
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
||||
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
||||
{
|
||||
var minDateModified = DateTime.UtcNow.AddDays(-1);
|
||||
progress.Report(50);
|
||||
|
||||
DeleteTempFilesFromDirectory(_configurationManager.GetTranscodePath(), minDateModified, progress, cancellationToken);
|
||||
IEnumerable<TranscodeSession> activeSessions;
|
||||
try
|
||||
{
|
||||
activeSessions = await _sessionStore.GetActiveSessionsAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to retrieve active transcode sessions. Skipping deletion to avoid removing files in use.");
|
||||
progress.Report(100);
|
||||
return;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
DeleteTempFilesFromDirectory(_configurationManager.GetTranscodePath(), minDateModified, activeSessions, progress, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the transcoded temp files from directory with a last write time less than a given date.
|
||||
/// Deletes the transcoded temp files from directory with a last write time less than a given date,
|
||||
/// skipping any files that belong to an active transcode session.
|
||||
/// </summary>
|
||||
/// <param name="directory">The directory.</param>
|
||||
/// <param name="minDateModified">The min date modified.</param>
|
||||
/// <param name="activeSessions">The currently active transcode sessions.</param>
|
||||
/// <param name="progress">The progress.</param>
|
||||
/// <param name="cancellationToken">The task cancellation token.</param>
|
||||
private void DeleteTempFilesFromDirectory(string directory, DateTime minDateModified, IProgress<double> progress, CancellationToken cancellationToken)
|
||||
private void DeleteTempFilesFromDirectory(string directory, DateTime minDateModified, IEnumerable<TranscodeSession> activeSessions, IProgress<double> progress, CancellationToken cancellationToken)
|
||||
{
|
||||
var activeSessionList = activeSessions.ToList();
|
||||
|
||||
var filesToDelete = _fileSystem.GetFiles(directory, true)
|
||||
.Where(f => _fileSystem.GetLastWriteTimeUtc(f) < minDateModified)
|
||||
.ToList();
|
||||
@@ -112,6 +131,13 @@ public class DeleteTranscodeFileTask : IScheduledTask, IConfigurableScheduledTas
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (IsFileProtectedByActiveSession(file.FullName, activeSessionList))
|
||||
{
|
||||
_logger.LogDebug("Skipping deletion of {FilePath} as it belongs to an active transcode session.", file.FullName);
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
FileSystemHelper.DeleteFile(_fileSystem, file.FullName, _logger);
|
||||
|
||||
index++;
|
||||
@@ -121,4 +147,24 @@ public class DeleteTranscodeFileTask : IScheduledTask, IConfigurableScheduledTas
|
||||
|
||||
progress.Report(100);
|
||||
}
|
||||
|
||||
private static bool IsFileProtectedByActiveSession(string filePath, IList<TranscodeSession> activeSessions)
|
||||
{
|
||||
foreach (var session in activeSessions)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(session.ManifestPath) &&
|
||||
string.Equals(filePath, session.ManifestPath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(session.SegmentPathPrefix) &&
|
||||
filePath.StartsWith(session.SegmentPathPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ using MediaBrowser.Controller.Events;
|
||||
using MediaBrowser.Controller.Events.Authentication;
|
||||
using MediaBrowser.Controller.Events.Session;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
using MediaBrowser.Controller.Net;
|
||||
using MediaBrowser.Controller.Session;
|
||||
using MediaBrowser.Model.Dto;
|
||||
@@ -60,6 +61,7 @@ namespace Emby.Server.Implementations.Session
|
||||
private readonly IMediaSourceManager _mediaSourceManager;
|
||||
private readonly IServerApplicationHost _appHost;
|
||||
private readonly IDeviceManager _deviceManager;
|
||||
private readonly ITranscodeSessionStore _transcodeSessionStore;
|
||||
private readonly CancellationTokenRegistration _shutdownCallback;
|
||||
private readonly ConcurrentDictionary<string, SessionInfo> _activeConnections
|
||||
= new(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -89,6 +91,7 @@ namespace Emby.Server.Implementations.Session
|
||||
/// <param name="deviceManager">Instance of <see cref="IDeviceManager"/> interface.</param>
|
||||
/// <param name="mediaSourceManager">Instance of <see cref="IMediaSourceManager"/> interface.</param>
|
||||
/// <param name="hostApplicationLifetime">Instance of <see cref="IHostApplicationLifetime"/> interface.</param>
|
||||
/// <param name="transcodeSessionStore">Instance of <see cref="ITranscodeSessionStore"/> interface.</param>
|
||||
public SessionManager(
|
||||
ILogger<SessionManager> logger,
|
||||
IEventManager eventManager,
|
||||
@@ -102,7 +105,8 @@ namespace Emby.Server.Implementations.Session
|
||||
IServerApplicationHost appHost,
|
||||
IDeviceManager deviceManager,
|
||||
IMediaSourceManager mediaSourceManager,
|
||||
IHostApplicationLifetime hostApplicationLifetime)
|
||||
IHostApplicationLifetime hostApplicationLifetime,
|
||||
ITranscodeSessionStore transcodeSessionStore)
|
||||
{
|
||||
_logger = logger;
|
||||
_eventManager = eventManager;
|
||||
@@ -116,6 +120,7 @@ namespace Emby.Server.Implementations.Session
|
||||
_appHost = appHost;
|
||||
_deviceManager = deviceManager;
|
||||
_mediaSourceManager = mediaSourceManager;
|
||||
_transcodeSessionStore = transcodeSessionStore;
|
||||
_shutdownCallback = hostApplicationLifetime.ApplicationStopping.Register(OnApplicationStopping);
|
||||
|
||||
_deviceManager.DeviceOptionsUpdated += OnDeviceManagerDeviceOptionsUpdated;
|
||||
@@ -343,9 +348,38 @@ namespace Emby.Server.Implementations.Session
|
||||
_activeLiveStreamSessions.TryRemove(liveStreamId, out _);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// In-memory state is absent — this pod may have taken over from a crashed pod.
|
||||
// Check the durable store to determine whether the live stream record exists.
|
||||
LiveStreamSession durableRecord = null;
|
||||
try
|
||||
{
|
||||
durableRecord = await _transcodeSessionStore.TryGetLiveStreamAsync(liveStreamId, sessionIdOrPlaySessionId).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to query live stream session {LiveStreamId}/{SessionId} from durable store.", liveStreamId, sessionIdOrPlaySessionId);
|
||||
}
|
||||
|
||||
if (durableRecord is not null)
|
||||
{
|
||||
liveStreamNeedsToBeClosed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the durable record regardless of which code path set liveStreamNeedsToBeClosed.
|
||||
if (liveStreamNeedsToBeClosed)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _transcodeSessionStore.DeleteLiveStreamAsync(liveStreamId, sessionIdOrPlaySessionId).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to delete live stream session {LiveStreamId}/{SessionId} from durable store.", liveStreamId, sessionIdOrPlaySessionId);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _mediaSourceManager.CloseLiveStream(liveStreamId).ConfigureAwait(false);
|
||||
@@ -776,7 +810,7 @@ namespace Emby.Server.Implementations.Session
|
||||
|
||||
if (!string.IsNullOrEmpty(info.LiveStreamId))
|
||||
{
|
||||
UpdateLiveStreamActiveSessionMappings(info.LiveStreamId, info.SessionId, info.PlaySessionId);
|
||||
await UpdateLiveStreamActiveSessionMappings(info.LiveStreamId, info.SessionId, info.PlaySessionId).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var eventArgs = new PlaybackStartEventArgs
|
||||
@@ -836,7 +870,7 @@ namespace Emby.Server.Implementations.Session
|
||||
return OnPlaybackProgress(info, false);
|
||||
}
|
||||
|
||||
private void UpdateLiveStreamActiveSessionMappings(string liveStreamId, string sessionId, string playSessionId)
|
||||
private async Task UpdateLiveStreamActiveSessionMappings(string liveStreamId, string sessionId, string playSessionId)
|
||||
{
|
||||
var activeSessionMappings = _activeLiveStreamSessions.GetOrAdd(liveStreamId, _ => new ConcurrentDictionary<string, string>());
|
||||
|
||||
@@ -860,6 +894,26 @@ namespace Emby.Server.Implementations.Session
|
||||
activeSessionMappings[sessionId] = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
// Persist to the durable store so a takeover pod can discover open live streams.
|
||||
var ownerPod = Environment.GetEnvironmentVariable("JELLYFIN_INSTANCE_ID") ?? Environment.MachineName;
|
||||
var liveStreamSession = new LiveStreamSession
|
||||
{
|
||||
LiveStreamId = liveStreamId,
|
||||
SessionId = sessionId,
|
||||
PlaySessionId = playSessionId ?? string.Empty,
|
||||
OwnerPod = ownerPod,
|
||||
OpenedAtUtc = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await _transcodeSessionStore.SetLiveStreamAsync(liveStreamSession).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to persist live stream session {LiveStreamId}/{SessionId} to durable store.", liveStreamId, sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -904,7 +958,7 @@ namespace Emby.Server.Implementations.Session
|
||||
|
||||
if (!string.IsNullOrEmpty(info.LiveStreamId))
|
||||
{
|
||||
UpdateLiveStreamActiveSessionMappings(info.LiveStreamId, info.SessionId, info.PlaySessionId);
|
||||
await UpdateLiveStreamActiveSessionMappings(info.LiveStreamId, info.SessionId, info.PlaySessionId).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var eventArgs = new PlaybackProgressEventArgs
|
||||
@@ -1175,7 +1229,8 @@ namespace Emby.Server.Implementations.Session
|
||||
return session;
|
||||
}
|
||||
|
||||
private SessionInfoDto ToSessionInfoDto(SessionInfo sessionInfo)
|
||||
/// <inheritdoc />
|
||||
public SessionInfoDto ToSessionInfoDto(SessionInfo sessionInfo)
|
||||
{
|
||||
return new SessionInfoDto
|
||||
{
|
||||
|
||||
@@ -156,6 +156,11 @@ namespace Emby.Server.Implementations.Updates
|
||||
_logger.LogError(ex, "The URL configured for the plugin repository manifest URL is not valid: {Manifest}", manifest);
|
||||
return Array.Empty<PackageInfo>();
|
||||
}
|
||||
catch (NotSupportedException ex)
|
||||
{
|
||||
_logger.LogError(ex, "The URL scheme configured for the plugin repository is not supported: {Manifest}", manifest);
|
||||
return Array.Empty<PackageInfo>();
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "An error occurred while accessing the plugin manifest: {Manifest}", manifest);
|
||||
|
||||
@@ -92,18 +92,18 @@ public class AudioController : BaseJellyfinApiController
|
||||
[ProducesAudioFile]
|
||||
public async Task<ActionResult> GetAudioStream(
|
||||
[FromRoute, Required] Guid itemId,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? container,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? container,
|
||||
[FromQuery] bool? @static,
|
||||
[FromQuery] string? @params,
|
||||
[FromQuery] string? tag,
|
||||
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
||||
[FromQuery] string? playSessionId,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
|
||||
[FromQuery] int? segmentLength,
|
||||
[FromQuery] int? minSegments,
|
||||
[FromQuery] string? mediaSourceId,
|
||||
[FromQuery] string? deviceId,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
|
||||
[FromQuery] bool? enableAutoStreamCopy,
|
||||
[FromQuery] bool? allowVideoStreamCopy,
|
||||
[FromQuery] bool? allowAudioStreamCopy,
|
||||
@@ -114,7 +114,7 @@ public class AudioController : BaseJellyfinApiController
|
||||
[FromQuery] int? audioChannels,
|
||||
[FromQuery] int? maxAudioChannels,
|
||||
[FromQuery] string? profile,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
|
||||
[FromQuery] float? framerate,
|
||||
[FromQuery] float? maxFramerate,
|
||||
[FromQuery] bool? copyTimestamps,
|
||||
@@ -133,8 +133,8 @@ public class AudioController : BaseJellyfinApiController
|
||||
[FromQuery] int? cpuCoreLimit,
|
||||
[FromQuery] string? liveStreamId,
|
||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
|
||||
[FromQuery] string? transcodeReasons,
|
||||
[FromQuery] int? audioStreamIndex,
|
||||
[FromQuery] int? videoStreamIndex,
|
||||
@@ -259,18 +259,18 @@ public class AudioController : BaseJellyfinApiController
|
||||
[ProducesAudioFile]
|
||||
public async Task<ActionResult> GetAudioStreamByContainer(
|
||||
[FromRoute, Required] Guid itemId,
|
||||
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string container,
|
||||
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string container,
|
||||
[FromQuery] bool? @static,
|
||||
[FromQuery] string? @params,
|
||||
[FromQuery] string? tag,
|
||||
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
||||
[FromQuery] string? playSessionId,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
|
||||
[FromQuery] int? segmentLength,
|
||||
[FromQuery] int? minSegments,
|
||||
[FromQuery] string? mediaSourceId,
|
||||
[FromQuery] string? deviceId,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
|
||||
[FromQuery] bool? enableAutoStreamCopy,
|
||||
[FromQuery] bool? allowVideoStreamCopy,
|
||||
[FromQuery] bool? allowAudioStreamCopy,
|
||||
@@ -281,7 +281,7 @@ public class AudioController : BaseJellyfinApiController
|
||||
[FromQuery] int? audioChannels,
|
||||
[FromQuery] int? maxAudioChannels,
|
||||
[FromQuery] string? profile,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
|
||||
[FromQuery] float? framerate,
|
||||
[FromQuery] float? maxFramerate,
|
||||
[FromQuery] bool? copyTimestamps,
|
||||
@@ -300,8 +300,8 @@ public class AudioController : BaseJellyfinApiController
|
||||
[FromQuery] int? cpuCoreLimit,
|
||||
[FromQuery] string? liveStreamId,
|
||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
|
||||
[FromQuery] string? transcodeReasons,
|
||||
[FromQuery] int? audioStreamIndex,
|
||||
[FromQuery] int? videoStreamIndex,
|
||||
|
||||
@@ -60,6 +60,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
private readonly IDynamicHlsPlaylistGenerator _dynamicHlsPlaylistGenerator;
|
||||
private readonly DynamicHlsHelper _dynamicHlsHelper;
|
||||
private readonly EncodingOptions _encodingOptions;
|
||||
private readonly ITranscodeSessionStore _transcodeSessionStore;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DynamicHlsController"/> class.
|
||||
@@ -75,6 +76,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
/// <param name="dynamicHlsHelper">Instance of <see cref="DynamicHlsHelper"/>.</param>
|
||||
/// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param>
|
||||
/// <param name="dynamicHlsPlaylistGenerator">Instance of <see cref="IDynamicHlsPlaylistGenerator"/>.</param>
|
||||
/// <param name="transcodeSessionStore">Instance of the <see cref="ITranscodeSessionStore"/> interface used to register and renew HLS transcoding session leases in the durable store.</param>
|
||||
public DynamicHlsController(
|
||||
ILibraryManager libraryManager,
|
||||
IUserManager userManager,
|
||||
@@ -86,7 +88,8 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
ILogger<DynamicHlsController> logger,
|
||||
DynamicHlsHelper dynamicHlsHelper,
|
||||
EncodingHelper encodingHelper,
|
||||
IDynamicHlsPlaylistGenerator dynamicHlsPlaylistGenerator)
|
||||
IDynamicHlsPlaylistGenerator dynamicHlsPlaylistGenerator,
|
||||
ITranscodeSessionStore transcodeSessionStore)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
_userManager = userManager;
|
||||
@@ -99,6 +102,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
_dynamicHlsHelper = dynamicHlsHelper;
|
||||
_encodingHelper = encodingHelper;
|
||||
_dynamicHlsPlaylistGenerator = dynamicHlsPlaylistGenerator;
|
||||
_transcodeSessionStore = transcodeSessionStore;
|
||||
|
||||
_encodingOptions = serverConfigurationManager.GetEncodingOptions();
|
||||
}
|
||||
@@ -167,18 +171,18 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
[ProducesPlaylistFile]
|
||||
public async Task<ActionResult> GetLiveHlsStream(
|
||||
[FromRoute, Required] Guid itemId,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? container,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? container,
|
||||
[FromQuery] bool? @static,
|
||||
[FromQuery] string? @params,
|
||||
[FromQuery] string? tag,
|
||||
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
||||
[FromQuery] string? playSessionId,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
|
||||
[FromQuery] int? segmentLength,
|
||||
[FromQuery] int? minSegments,
|
||||
[FromQuery] string? mediaSourceId,
|
||||
[FromQuery] string? deviceId,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
|
||||
[FromQuery] bool? enableAutoStreamCopy,
|
||||
[FromQuery] bool? allowVideoStreamCopy,
|
||||
[FromQuery] bool? allowAudioStreamCopy,
|
||||
@@ -189,7 +193,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
[FromQuery] int? audioChannels,
|
||||
[FromQuery] int? maxAudioChannels,
|
||||
[FromQuery] string? profile,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
|
||||
[FromQuery] float? framerate,
|
||||
[FromQuery] float? maxFramerate,
|
||||
[FromQuery] bool? copyTimestamps,
|
||||
@@ -208,8 +212,8 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
[FromQuery] int? cpuCoreLimit,
|
||||
[FromQuery] string? liveStreamId,
|
||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
|
||||
[FromQuery] string? transcodeReasons,
|
||||
[FromQuery] int? audioStreamIndex,
|
||||
[FromQuery] int? videoStreamIndex,
|
||||
@@ -309,15 +313,36 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
// If the playlist doesn't already exist, startup ffmpeg
|
||||
try
|
||||
{
|
||||
// Check whether this session is already registered in the HA store (takeover scenario).
|
||||
var isHaMode = false;
|
||||
if (!string.IsNullOrEmpty(playSessionId))
|
||||
{
|
||||
try
|
||||
{
|
||||
var existingSession = await _transcodeSessionStore.TryGetAsync(playSessionId, cancellationToken).ConfigureAwait(false);
|
||||
isHaMode = existingSession is not null;
|
||||
}
|
||||
catch (Exception haEx)
|
||||
{
|
||||
_logger.LogWarning(haEx, "Failed to check HA mode for live-stream session {PlaySessionId}.", playSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
job = await _transcodeManager.StartFfMpeg(
|
||||
state,
|
||||
playlistPath,
|
||||
GetCommandLineArguments(playlistPath, state, true, 0),
|
||||
GetCommandLineArguments(playlistPath, state, true, 0, isHaMode),
|
||||
Request.HttpContext.User.GetUserId(),
|
||||
TranscodingJobType,
|
||||
cancellationTokenSource)
|
||||
.ConfigureAwait(false);
|
||||
job.IsLiveOutput = true;
|
||||
await RegisterTranscodeSessionAsync(
|
||||
playSessionId ?? string.Empty,
|
||||
mediaSourceId ?? string.Empty,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
StartLeaseRenewal(playSessionId ?? string.Empty, cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -416,12 +441,12 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
[FromQuery] string? tag,
|
||||
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
||||
[FromQuery] string? playSessionId,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
|
||||
[FromQuery] int? segmentLength,
|
||||
[FromQuery] int? minSegments,
|
||||
[FromQuery, Required] string mediaSourceId,
|
||||
[FromQuery] string? deviceId,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
|
||||
[FromQuery] bool? enableAutoStreamCopy,
|
||||
[FromQuery] bool? allowVideoStreamCopy,
|
||||
[FromQuery] bool? allowAudioStreamCopy,
|
||||
@@ -432,7 +457,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
[FromQuery] int? audioChannels,
|
||||
[FromQuery] int? maxAudioChannels,
|
||||
[FromQuery] string? profile,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
|
||||
[FromQuery] float? framerate,
|
||||
[FromQuery] float? maxFramerate,
|
||||
[FromQuery] bool? copyTimestamps,
|
||||
@@ -453,8 +478,8 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
[FromQuery] int? cpuCoreLimit,
|
||||
[FromQuery] string? liveStreamId,
|
||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
|
||||
[FromQuery] string? transcodeReasons,
|
||||
[FromQuery] int? audioStreamIndex,
|
||||
[FromQuery] int? videoStreamIndex,
|
||||
@@ -592,12 +617,12 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
[FromQuery] string? tag,
|
||||
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
||||
[FromQuery] string? playSessionId,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
|
||||
[FromQuery] int? segmentLength,
|
||||
[FromQuery] int? minSegments,
|
||||
[FromQuery, Required] string mediaSourceId,
|
||||
[FromQuery] string? deviceId,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
|
||||
[FromQuery] bool? enableAutoStreamCopy,
|
||||
[FromQuery] bool? allowVideoStreamCopy,
|
||||
[FromQuery] bool? allowAudioStreamCopy,
|
||||
@@ -609,7 +634,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
[FromQuery] int? audioChannels,
|
||||
[FromQuery] int? maxAudioChannels,
|
||||
[FromQuery] string? profile,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
|
||||
[FromQuery] float? framerate,
|
||||
[FromQuery] float? maxFramerate,
|
||||
[FromQuery] bool? copyTimestamps,
|
||||
@@ -628,8 +653,8 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
[FromQuery] int? cpuCoreLimit,
|
||||
[FromQuery] string? liveStreamId,
|
||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
|
||||
[FromQuery] string? transcodeReasons,
|
||||
[FromQuery] int? audioStreamIndex,
|
||||
[FromQuery] int? videoStreamIndex,
|
||||
@@ -762,12 +787,12 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
[FromQuery] string? tag,
|
||||
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
||||
[FromQuery] string? playSessionId,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
|
||||
[FromQuery] int? segmentLength,
|
||||
[FromQuery] int? minSegments,
|
||||
[FromQuery] string? mediaSourceId,
|
||||
[FromQuery] string? deviceId,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
|
||||
[FromQuery] bool? enableAutoStreamCopy,
|
||||
[FromQuery] bool? allowVideoStreamCopy,
|
||||
[FromQuery] bool? allowAudioStreamCopy,
|
||||
@@ -778,7 +803,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
[FromQuery] int? audioChannels,
|
||||
[FromQuery] int? maxAudioChannels,
|
||||
[FromQuery] string? profile,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
|
||||
[FromQuery] float? framerate,
|
||||
[FromQuery] float? maxFramerate,
|
||||
[FromQuery] bool? copyTimestamps,
|
||||
@@ -799,8 +824,8 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
[FromQuery] int? cpuCoreLimit,
|
||||
[FromQuery] string? liveStreamId,
|
||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
|
||||
[FromQuery] string? transcodeReasons,
|
||||
[FromQuery] int? audioStreamIndex,
|
||||
[FromQuery] int? videoStreamIndex,
|
||||
@@ -934,12 +959,12 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
[FromQuery] string? tag,
|
||||
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
||||
[FromQuery] string? playSessionId,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
|
||||
[FromQuery] int? segmentLength,
|
||||
[FromQuery] int? minSegments,
|
||||
[FromQuery] string? mediaSourceId,
|
||||
[FromQuery] string? deviceId,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
|
||||
[FromQuery] bool? enableAutoStreamCopy,
|
||||
[FromQuery] bool? allowVideoStreamCopy,
|
||||
[FromQuery] bool? allowAudioStreamCopy,
|
||||
@@ -951,7 +976,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
[FromQuery] int? audioChannels,
|
||||
[FromQuery] int? maxAudioChannels,
|
||||
[FromQuery] string? profile,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
|
||||
[FromQuery] float? framerate,
|
||||
[FromQuery] float? maxFramerate,
|
||||
[FromQuery] bool? copyTimestamps,
|
||||
@@ -970,8 +995,8 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
[FromQuery] int? cpuCoreLimit,
|
||||
[FromQuery] string? liveStreamId,
|
||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
|
||||
[FromQuery] string? transcodeReasons,
|
||||
[FromQuery] int? audioStreamIndex,
|
||||
[FromQuery] int? videoStreamIndex,
|
||||
@@ -1107,7 +1132,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
[FromRoute, Required] Guid itemId,
|
||||
[FromRoute, Required] string playlistId,
|
||||
[FromRoute, Required] int segmentId,
|
||||
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string container,
|
||||
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string container,
|
||||
[FromQuery, Required] long runtimeTicks,
|
||||
[FromQuery, Required] long actualSegmentLengthTicks,
|
||||
[FromQuery] bool? @static,
|
||||
@@ -1115,12 +1140,12 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
[FromQuery] string? tag,
|
||||
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
||||
[FromQuery] string? playSessionId,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
|
||||
[FromQuery] int? segmentLength,
|
||||
[FromQuery] int? minSegments,
|
||||
[FromQuery] string? mediaSourceId,
|
||||
[FromQuery] string? deviceId,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
|
||||
[FromQuery] bool? enableAutoStreamCopy,
|
||||
[FromQuery] bool? allowVideoStreamCopy,
|
||||
[FromQuery] bool? allowAudioStreamCopy,
|
||||
@@ -1131,7 +1156,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
[FromQuery] int? audioChannels,
|
||||
[FromQuery] int? maxAudioChannels,
|
||||
[FromQuery] string? profile,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
|
||||
[FromQuery] float? framerate,
|
||||
[FromQuery] float? maxFramerate,
|
||||
[FromQuery] bool? copyTimestamps,
|
||||
@@ -1152,8 +1177,8 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
[FromQuery] int? cpuCoreLimit,
|
||||
[FromQuery] string? liveStreamId,
|
||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
|
||||
[FromQuery] string? transcodeReasons,
|
||||
[FromQuery] int? audioStreamIndex,
|
||||
[FromQuery] int? videoStreamIndex,
|
||||
@@ -1292,7 +1317,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
[FromRoute, Required] Guid itemId,
|
||||
[FromRoute, Required] string playlistId,
|
||||
[FromRoute, Required] int segmentId,
|
||||
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string container,
|
||||
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string container,
|
||||
[FromQuery, Required] long runtimeTicks,
|
||||
[FromQuery, Required] long actualSegmentLengthTicks,
|
||||
[FromQuery] bool? @static,
|
||||
@@ -1300,12 +1325,12 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
[FromQuery] string? tag,
|
||||
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
||||
[FromQuery] string? playSessionId,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
|
||||
[FromQuery] int? segmentLength,
|
||||
[FromQuery] int? minSegments,
|
||||
[FromQuery] string? mediaSourceId,
|
||||
[FromQuery] string? deviceId,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
|
||||
[FromQuery] bool? enableAutoStreamCopy,
|
||||
[FromQuery] bool? allowVideoStreamCopy,
|
||||
[FromQuery] bool? allowAudioStreamCopy,
|
||||
@@ -1317,7 +1342,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
[FromQuery] int? audioChannels,
|
||||
[FromQuery] int? maxAudioChannels,
|
||||
[FromQuery] string? profile,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
|
||||
[FromQuery] float? framerate,
|
||||
[FromQuery] float? maxFramerate,
|
||||
[FromQuery] bool? copyTimestamps,
|
||||
@@ -1336,8 +1361,8 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
[FromQuery] int? cpuCoreLimit,
|
||||
[FromQuery] string? liveStreamId,
|
||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
|
||||
[FromQuery] string? transcodeReasons,
|
||||
[FromQuery] int? audioStreamIndex,
|
||||
[FromQuery] int? videoStreamIndex,
|
||||
@@ -1421,10 +1446,20 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
cancellationTokenSource.Token)
|
||||
.ConfigureAwait(false);
|
||||
var mediaSourceId = state.BaseRequest.MediaSourceId;
|
||||
double fps = state.TargetFramerate ?? 0.0f;
|
||||
int segmentLength = state.SegmentLength * 1000;
|
||||
|
||||
// If video is transcoded and framerate is fractional (i.e. 23.976), we need to slightly adjust segment length
|
||||
if (!EncodingHelper.IsCopyCodec(state.OutputVideoCodec) && Math.Abs(fps - Math.Floor(fps + 0.001f)) > 0.001)
|
||||
{
|
||||
double nearestIntFramerate = Math.Ceiling(fps);
|
||||
segmentLength = (int)Math.Ceiling(segmentLength * (nearestIntFramerate / fps));
|
||||
}
|
||||
|
||||
var request = new CreateMainPlaylistRequest(
|
||||
mediaSourceId is null ? null : Guid.Parse(mediaSourceId),
|
||||
state.MediaPath,
|
||||
state.SegmentLength * 1000,
|
||||
segmentLength,
|
||||
state.RunTimeTicks ?? 0,
|
||||
state.Request.SegmentContainer ?? string.Empty,
|
||||
"hls1/main/",
|
||||
@@ -1525,14 +1560,35 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
|
||||
streamingRequest.StartTimeTicks = streamingRequest.CurrentRuntimeTicks;
|
||||
|
||||
// Check whether this session is already registered in the HA store (takeover scenario).
|
||||
var isHaMode = false;
|
||||
if (!string.IsNullOrEmpty(streamingRequest.PlaySessionId))
|
||||
{
|
||||
try
|
||||
{
|
||||
var existingSession = await _transcodeSessionStore.TryGetAsync(streamingRequest.PlaySessionId, cancellationToken).ConfigureAwait(false);
|
||||
isHaMode = existingSession is not null;
|
||||
}
|
||||
catch (Exception haEx)
|
||||
{
|
||||
_logger.LogWarning(haEx, "Failed to check HA mode for segment session {PlaySessionId}.", streamingRequest.PlaySessionId);
|
||||
}
|
||||
}
|
||||
|
||||
state.WaitForPath = segmentPath;
|
||||
job = await _transcodeManager.StartFfMpeg(
|
||||
state,
|
||||
playlistPath,
|
||||
GetCommandLineArguments(playlistPath, state, false, segmentId),
|
||||
GetCommandLineArguments(playlistPath, state, false, segmentId, isHaMode),
|
||||
Request.HttpContext.User.GetUserId(),
|
||||
TranscodingJobType,
|
||||
cancellationTokenSource).ConfigureAwait(false);
|
||||
await RegisterTranscodeSessionAsync(
|
||||
streamingRequest.PlaySessionId ?? string.Empty,
|
||||
streamingRequest.MediaSourceId ?? string.Empty,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
StartLeaseRenewal(streamingRequest.PlaySessionId ?? string.Empty, cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -1560,6 +1616,77 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
private static double[] GetSegmentLengths(StreamState state)
|
||||
=> GetSegmentLengthsInternal(state.RunTimeTicks ?? 0, state.SegmentLength);
|
||||
|
||||
private async Task RegisterTranscodeSessionAsync(string playSessionId, string mediaSourceId, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var session = new TranscodeSession
|
||||
{
|
||||
PlaySessionId = playSessionId,
|
||||
OwnerPod = Environment.GetEnvironmentVariable("JELLYFIN_INSTANCE_ID")
|
||||
?? Environment.MachineName,
|
||||
LeaseExpiresUtc = DateTime.UtcNow.AddSeconds(30),
|
||||
ManifestPath = string.Empty,
|
||||
SegmentPathPrefix = string.Empty,
|
||||
MediaSourceId = mediaSourceId,
|
||||
LastCompletedSegmentIndex = 0,
|
||||
LastDurablePlaybackOffset = 0L,
|
||||
};
|
||||
await _transcodeSessionStore.SetAsync(session, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to register HLS session {PlaySessionId} in durable store.", playSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
private void StartLeaseRenewal(string playSessionId, CancellationToken cancellationToken)
|
||||
{
|
||||
_ = Task.Run(
|
||||
async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _transcodeSessionStore.RenewLeaseAsync(playSessionId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to renew lease for HLS session {PlaySessionId}.", playSessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Lease renewal loop for HLS session {PlaySessionId} encountered an unexpected error.", playSessionId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
await _transcodeSessionStore.DeleteAsync(playSessionId, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to delete HLS session {PlaySessionId} from durable store.", playSessionId);
|
||||
}
|
||||
}
|
||||
},
|
||||
CancellationToken.None);
|
||||
}
|
||||
|
||||
internal static double[] GetSegmentLengthsInternal(long runtimeTicks, int segmentlength)
|
||||
{
|
||||
var segmentLengthTicks = TimeSpan.FromSeconds(segmentlength).Ticks;
|
||||
@@ -1581,7 +1708,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
return segments;
|
||||
}
|
||||
|
||||
private string GetCommandLineArguments(string outputPath, StreamState state, bool isEventPlaylist, int startNumber)
|
||||
private string GetCommandLineArguments(string outputPath, StreamState state, bool isEventPlaylist, int startNumber, bool isHaMode = false)
|
||||
{
|
||||
var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions);
|
||||
var threads = EncodingHelper.GetNumberOfThreads(state, _encodingOptions, videoCodec);
|
||||
@@ -1604,10 +1731,20 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
var outputExtension = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer);
|
||||
var outputTsArg = outputPrefix + "%d" + outputExtension;
|
||||
|
||||
// In HA mode, use shorter segments and a bounded rolling buffer for faster failover recovery.
|
||||
// state.SegmentLength is already validated by the streaming pipeline; RecoverySegmentLengthSeconds
|
||||
// comes from EncodingOptions (user-editable config) so it is clamped here.
|
||||
var effectiveSegmentLength = isHaMode
|
||||
? Math.Clamp(_encodingOptions.RecoverySegmentLengthSeconds, 1, 6)
|
||||
: state.SegmentLength;
|
||||
var hlsListSize = isHaMode
|
||||
? Math.Clamp(_encodingOptions.RecoverySegmentBufferCount, 2, 10)
|
||||
: 0;
|
||||
|
||||
var segmentFormat = string.Empty;
|
||||
var segmentContainer = outputExtension.TrimStart('.');
|
||||
var inputModifier = _encodingHelper.GetInputModifier(state, _encodingOptions, segmentContainer);
|
||||
var hlsArguments = $"-hls_playlist_type {(isEventPlaylist ? "event" : "vod")} -hls_list_size 0";
|
||||
var hlsArguments = $"-hls_playlist_type {(isEventPlaylist ? "event" : "vod")} -hls_list_size {hlsListSize}";
|
||||
|
||||
if (string.Equals(segmentContainer, "ts", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
@@ -1659,10 +1796,10 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
_encodingHelper.GetInputArgument(state, _encodingOptions, segmentContainer),
|
||||
threads,
|
||||
mapArgs,
|
||||
GetVideoArguments(state, startNumber, isEventPlaylist, segmentContainer),
|
||||
GetVideoArguments(state, startNumber, isEventPlaylist, segmentContainer, effectiveSegmentLength),
|
||||
GetAudioArguments(state),
|
||||
maxMuxingQueueSize,
|
||||
state.SegmentLength.ToString(CultureInfo.InvariantCulture),
|
||||
effectiveSegmentLength.ToString(CultureInfo.InvariantCulture),
|
||||
segmentFormat,
|
||||
startNumber.ToString(CultureInfo.InvariantCulture),
|
||||
baseUrlParam,
|
||||
@@ -1804,8 +1941,9 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
/// <param name="startNumber">The first number in the hls sequence.</param>
|
||||
/// <param name="isEventPlaylist">Whether the playlist is EVENT or VOD.</param>
|
||||
/// <param name="segmentContainer">The segment container.</param>
|
||||
/// <param name="segmentLength">The effective segment length in seconds (overrides <see cref="StreamState.SegmentLength"/> when HA mode is active).</param>
|
||||
/// <returns>The command line arguments for video transcoding.</returns>
|
||||
private string GetVideoArguments(StreamState state, int startNumber, bool isEventPlaylist, string segmentContainer)
|
||||
private string GetVideoArguments(StreamState state, int startNumber, bool isEventPlaylist, string segmentContainer, int? segmentLength = null)
|
||||
{
|
||||
if (state.VideoStream is null)
|
||||
{
|
||||
@@ -1839,8 +1977,9 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
{
|
||||
if (isActualOutputVideoCodecHevc)
|
||||
{
|
||||
// Prefer dvh1 to dvhe
|
||||
args += " -tag:v:0 dvh1 -strict -2";
|
||||
// Use hvc1 for 8.4. This is what Dolby uses for its official sample streams. Tagging with dvh1 would break some players with strict tag checking like Apple Safari.
|
||||
var codecTag = state.VideoStream.VideoRangeType == VideoRangeType.DOVIWithHLG ? "hvc1" : "dvh1";
|
||||
args += $" -tag:v:0 {codecTag} -strict -2";
|
||||
}
|
||||
else if (isActualOutputVideoCodecAv1)
|
||||
{
|
||||
@@ -1879,7 +2018,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
args += _encodingHelper.GetVideoQualityParam(state, codec, _encodingOptions, isEventPlaylist ? DefaultEventEncoderPreset : DefaultVodEncoderPreset);
|
||||
|
||||
// Set the key frame params for video encoding to match the hls segment time.
|
||||
args += _encodingHelper.GetHlsVideoKeyFrameArguments(state, codec, state.SegmentLength, isEventPlaylist, startNumber);
|
||||
args += _encodingHelper.GetHlsVideoKeyFrameArguments(state, codec, segmentLength ?? state.SegmentLength, isEventPlaylist, startNumber);
|
||||
|
||||
// Currently b-frames in libx265 breaks the FMP4-HLS playback on iOS, disable it for now.
|
||||
if (string.Equals(codec, "libx265", StringComparison.OrdinalIgnoreCase)
|
||||
|
||||
@@ -418,7 +418,7 @@ public class ItemUpdateController : BaseJellyfinApiController
|
||||
{
|
||||
if (item is IHasAlbumArtist hasAlbumArtists)
|
||||
{
|
||||
hasAlbumArtists.AlbumArtists = Array.ConvertAll(request.AlbumArtists, i => i.Name);
|
||||
hasAlbumArtists.AlbumArtists = Array.ConvertAll(request.AlbumArtists, i => i.Name.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -426,7 +426,7 @@ public class ItemUpdateController : BaseJellyfinApiController
|
||||
{
|
||||
if (item is IHasArtist hasArtists)
|
||||
{
|
||||
hasArtists.Artists = Array.ConvertAll(request.ArtistItems, i => i.Name);
|
||||
hasArtists.Artists = Array.ConvertAll(request.ArtistItems, i => i.Name.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.Audio;
|
||||
using MediaBrowser.Controller.Entities.Movies;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
using MediaBrowser.Controller.IO;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Model.Activity;
|
||||
@@ -700,7 +701,18 @@ public class LibraryController : BaseJellyfinApiController
|
||||
// Quotes are valid in linux. They'll possibly cause issues here.
|
||||
var filename = Path.GetFileName(item.Path)?.Replace("\"", string.Empty, StringComparison.Ordinal);
|
||||
|
||||
return PhysicalFile(item.Path, MimeTypes.GetMimeType(item.Path), filename, true);
|
||||
var filePath = item.Path;
|
||||
if (item.IsFileProtocol)
|
||||
{
|
||||
// PhysicalFile does not work well with symlinks at the moment.
|
||||
var resolved = FileSystemHelper.ResolveLinkTarget(filePath, returnFinalTarget: true);
|
||||
if (resolved is not null && resolved.Exists)
|
||||
{
|
||||
filePath = resolved.FullName;
|
||||
}
|
||||
}
|
||||
|
||||
return PhysicalFile(filePath, MimeTypes.GetMimeType(filePath), filename, true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -342,6 +342,17 @@ public class LibraryStructureController : BaseJellyfinApiController
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
LibraryOptions options = item.GetLibraryOptions();
|
||||
foreach (var mediaPath in request.LibraryOptions!.PathInfos)
|
||||
{
|
||||
if (options.PathInfos.Any(i => i.Path == mediaPath.Path))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_libraryManager.CreateShortcut(item.Path, mediaPath);
|
||||
}
|
||||
|
||||
item.UpdateLibraryOptions(request.LibraryOptions);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
@@ -458,7 +458,7 @@ public class LiveTvController : BaseJellyfinApiController
|
||||
/// <returns>A <see cref="NoContentResult"/>.</returns>
|
||||
[HttpPost("Tuners/{tunerId}/Reset")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[Authorize(Policy = Policies.LiveTvManagement)]
|
||||
[Authorize(Policy = Policies.RequiresElevation)]
|
||||
public async Task<ActionResult> ResetTuner([FromRoute, Required] string tunerId)
|
||||
{
|
||||
await _liveTvManager.ResetTuner(tunerId, CancellationToken.None).ConfigureAwait(false);
|
||||
@@ -983,7 +983,7 @@ public class LiveTvController : BaseJellyfinApiController
|
||||
/// <response code="200">Created tuner host returned.</response>
|
||||
/// <returns>A <see cref="OkResult"/> containing the created tuner host.</returns>
|
||||
[HttpPost("TunerHosts")]
|
||||
[Authorize(Policy = Policies.LiveTvManagement)]
|
||||
[Authorize(Policy = Policies.RequiresElevation)]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<TunerHostInfo>> AddTunerHost([FromBody] TunerHostInfo tunerHostInfo)
|
||||
=> await _tunerHostManager.SaveTunerHost(tunerHostInfo).ConfigureAwait(false);
|
||||
@@ -995,7 +995,7 @@ public class LiveTvController : BaseJellyfinApiController
|
||||
/// <response code="204">Tuner host deleted.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/>.</returns>
|
||||
[HttpDelete("TunerHosts")]
|
||||
[Authorize(Policy = Policies.LiveTvManagement)]
|
||||
[Authorize(Policy = Policies.RequiresElevation)]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public ActionResult DeleteTunerHost([FromQuery] string? id)
|
||||
{
|
||||
@@ -1028,7 +1028,7 @@ public class LiveTvController : BaseJellyfinApiController
|
||||
/// <response code="200">Created listings provider returned.</response>
|
||||
/// <returns>A <see cref="OkResult"/> containing the created listings provider.</returns>
|
||||
[HttpPost("ListingProviders")]
|
||||
[Authorize(Policy = Policies.LiveTvManagement)]
|
||||
[Authorize(Policy = Policies.RequiresElevation)]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[SuppressMessage("Microsoft.Performance", "CA5350:RemoveSha1", MessageId = "AddListingProvider", Justification = "Imported from ServiceStack")]
|
||||
public async Task<ActionResult<ListingsProviderInfo>> AddListingProvider(
|
||||
@@ -1054,7 +1054,7 @@ public class LiveTvController : BaseJellyfinApiController
|
||||
/// <response code="204">Listing provider deleted.</response>
|
||||
/// <returns>A <see cref="NoContentResult"/>.</returns>
|
||||
[HttpDelete("ListingProviders")]
|
||||
[Authorize(Policy = Policies.LiveTvManagement)]
|
||||
[Authorize(Policy = Policies.RequiresElevation)]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public ActionResult DeleteListingProvider([FromQuery] string? id)
|
||||
{
|
||||
@@ -1087,7 +1087,7 @@ public class LiveTvController : BaseJellyfinApiController
|
||||
/// <response code="200">Available countries returned.</response>
|
||||
/// <returns>A <see cref="FileResult"/> containing the available countries.</returns>
|
||||
[HttpGet("ListingProviders/SchedulesDirect/Countries")]
|
||||
[Authorize(Policy = Policies.LiveTvAccess)]
|
||||
[Authorize(Policy = Policies.RequiresElevation)]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesFile(MediaTypeNames.Application.Json)]
|
||||
public async Task<ActionResult> GetSchedulesDirectCountries()
|
||||
@@ -1108,7 +1108,7 @@ public class LiveTvController : BaseJellyfinApiController
|
||||
/// <response code="200">Channel mapping options returned.</response>
|
||||
/// <returns>An <see cref="OkResult"/> containing the channel mapping options.</returns>
|
||||
[HttpGet("ChannelMappingOptions")]
|
||||
[Authorize(Policy = Policies.LiveTvAccess)]
|
||||
[Authorize(Policy = Policies.RequiresElevation)]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public Task<ChannelMappingOptionsDto> GetChannelMappingOptions([FromQuery] string? providerId)
|
||||
=> _listingsManager.GetChannelMappingOptions(providerId);
|
||||
@@ -1120,7 +1120,7 @@ public class LiveTvController : BaseJellyfinApiController
|
||||
/// <response code="200">Created channel mapping returned.</response>
|
||||
/// <returns>An <see cref="OkResult"/> containing the created channel mapping.</returns>
|
||||
[HttpPost("ChannelMappings")]
|
||||
[Authorize(Policy = Policies.LiveTvManagement)]
|
||||
[Authorize(Policy = Policies.RequiresElevation)]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public Task<TunerChannelMapping> SetChannelMapping([FromBody, Required] SetChannelMappingDto dto)
|
||||
=> _listingsManager.SetChannelMapping(dto.ProviderId, dto.TunerChannelId, dto.ProviderChannelId);
|
||||
@@ -1144,7 +1144,7 @@ public class LiveTvController : BaseJellyfinApiController
|
||||
/// <returns>An <see cref="OkResult"/> containing the tuners.</returns>
|
||||
[HttpGet("Tuners/Discvover", Name = "DiscvoverTuners")]
|
||||
[HttpGet("Tuners/Discover")]
|
||||
[Authorize(Policy = Policies.LiveTvManagement)]
|
||||
[Authorize(Policy = Policies.RequiresElevation)]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public IAsyncEnumerable<TunerHostInfo> DiscoverTuners([FromQuery] bool newDevicesOnly = false)
|
||||
=> _tunerHostManager.DiscoverTuners(newDevicesOnly);
|
||||
@@ -1192,7 +1192,7 @@ public class LiveTvController : BaseJellyfinApiController
|
||||
[ProducesVideoFile]
|
||||
public ActionResult GetLiveStreamFile(
|
||||
[FromRoute, Required] string streamId,
|
||||
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string container)
|
||||
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string container)
|
||||
{
|
||||
var liveStreamInfo = _mediaSourceManager.GetLiveStreamInfoByUniqueId(streamId);
|
||||
if (liveStreamInfo is null)
|
||||
|
||||
@@ -58,7 +58,7 @@ public class SyncPlayController : BaseJellyfinApiController
|
||||
[FromBody, Required] NewGroupRequestDto requestData)
|
||||
{
|
||||
var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
|
||||
var syncPlayRequest = new NewGroupRequest(requestData.GroupName);
|
||||
var syncPlayRequest = new NewGroupRequest(requestData.GroupName.Trim());
|
||||
return Ok(_syncPlayManager.NewGroup(currentSession, syncPlayRequest, CancellationToken.None));
|
||||
}
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ public class TrickplayController : BaseJellyfinApiController
|
||||
[FromRoute, Required] int index,
|
||||
[FromQuery] Guid? mediaSourceId)
|
||||
{
|
||||
var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId());
|
||||
var item = _libraryManager.GetItemById<BaseItem>(mediaSourceId ?? itemId, User.GetUserId());
|
||||
if (item is null)
|
||||
{
|
||||
return NotFound();
|
||||
|
||||
@@ -102,13 +102,13 @@ public class UniversalAudioController : BaseJellyfinApiController
|
||||
[FromQuery] string? mediaSourceId,
|
||||
[FromQuery] string? deviceId,
|
||||
[FromQuery] Guid? userId,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
|
||||
[FromQuery] int? maxAudioChannels,
|
||||
[FromQuery] int? transcodingAudioChannels,
|
||||
[FromQuery] int? maxStreamingBitrate,
|
||||
[FromQuery] int? audioBitRate,
|
||||
[FromQuery] long? startTimeTicks,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? transcodingContainer,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? transcodingContainer,
|
||||
[FromQuery] MediaStreamProtocol? transcodingProtocol,
|
||||
[FromQuery] int? maxAudioSampleRate,
|
||||
[FromQuery] int? maxAudioBitDepth,
|
||||
|
||||
@@ -315,18 +315,18 @@ public class VideosController : BaseJellyfinApiController
|
||||
[ProducesVideoFile]
|
||||
public async Task<ActionResult> GetVideoStream(
|
||||
[FromRoute, Required] Guid itemId,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? container,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? container,
|
||||
[FromQuery] bool? @static,
|
||||
[FromQuery] string? @params,
|
||||
[FromQuery] string? tag,
|
||||
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
||||
[FromQuery] string? playSessionId,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
|
||||
[FromQuery] int? segmentLength,
|
||||
[FromQuery] int? minSegments,
|
||||
[FromQuery] string? mediaSourceId,
|
||||
[FromQuery] string? deviceId,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
|
||||
[FromQuery] bool? enableAutoStreamCopy,
|
||||
[FromQuery] bool? allowVideoStreamCopy,
|
||||
[FromQuery] bool? allowAudioStreamCopy,
|
||||
@@ -337,7 +337,7 @@ public class VideosController : BaseJellyfinApiController
|
||||
[FromQuery] int? audioChannels,
|
||||
[FromQuery] int? maxAudioChannels,
|
||||
[FromQuery] string? profile,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
|
||||
[FromQuery] float? framerate,
|
||||
[FromQuery] float? maxFramerate,
|
||||
[FromQuery] bool? copyTimestamps,
|
||||
@@ -358,8 +358,8 @@ public class VideosController : BaseJellyfinApiController
|
||||
[FromQuery] int? cpuCoreLimit,
|
||||
[FromQuery] string? liveStreamId,
|
||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
|
||||
[FromQuery] string? transcodeReasons,
|
||||
[FromQuery] int? audioStreamIndex,
|
||||
[FromQuery] int? videoStreamIndex,
|
||||
@@ -556,18 +556,18 @@ public class VideosController : BaseJellyfinApiController
|
||||
[ProducesVideoFile]
|
||||
public Task<ActionResult> GetVideoStreamByContainer(
|
||||
[FromRoute, Required] Guid itemId,
|
||||
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string container,
|
||||
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string container,
|
||||
[FromQuery] bool? @static,
|
||||
[FromQuery] string? @params,
|
||||
[FromQuery] string? tag,
|
||||
[FromQuery] string? deviceProfileId,
|
||||
[FromQuery] string? playSessionId,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
|
||||
[FromQuery] int? segmentLength,
|
||||
[FromQuery] int? minSegments,
|
||||
[FromQuery] string? mediaSourceId,
|
||||
[FromQuery] string? deviceId,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
|
||||
[FromQuery] bool? enableAutoStreamCopy,
|
||||
[FromQuery] bool? allowVideoStreamCopy,
|
||||
[FromQuery] bool? allowAudioStreamCopy,
|
||||
@@ -578,7 +578,7 @@ public class VideosController : BaseJellyfinApiController
|
||||
[FromQuery] int? audioChannels,
|
||||
[FromQuery] int? maxAudioChannels,
|
||||
[FromQuery] string? profile,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
|
||||
[FromQuery] float? framerate,
|
||||
[FromQuery] float? maxFramerate,
|
||||
[FromQuery] bool? copyTimestamps,
|
||||
@@ -599,8 +599,8 @@ public class VideosController : BaseJellyfinApiController
|
||||
[FromQuery] int? cpuCoreLimit,
|
||||
[FromQuery] string? liveStreamId,
|
||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
|
||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
|
||||
[FromQuery] string? transcodeReasons,
|
||||
[FromQuery] int? audioStreamIndex,
|
||||
[FromQuery] int? videoStreamIndex,
|
||||
|
||||
@@ -154,7 +154,7 @@ public class DynamicHlsHelper
|
||||
// from universal audio service, need to override the AudioCodec when the actual request differs from original query
|
||||
if (!string.Equals(state.OutputAudioCodec, _httpContextAccessor.HttpContext.Request.Query["AudioCodec"].ToString(), StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var newQuery = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(_httpContextAccessor.HttpContext.Request.QueryString.ToString());
|
||||
var newQuery = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(queryString);
|
||||
newQuery["AudioCodec"] = state.OutputAudioCodec;
|
||||
queryString = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(string.Empty, newQuery);
|
||||
}
|
||||
@@ -173,10 +173,21 @@ public class DynamicHlsHelper
|
||||
queryString += "&TranscodeReasons=" + state.Request.TranscodeReasons;
|
||||
}
|
||||
|
||||
// Main stream
|
||||
var playlistUrl = isLiveStream ? "live.m3u8" : "main.m3u8";
|
||||
// Video rotation metadata is only supported in fMP4 remuxing
|
||||
if (state.VideoStream is not null
|
||||
&& state.VideoRequest is not null
|
||||
&& (state.VideoStream?.Rotation ?? 0) != 0
|
||||
&& EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
|
||||
&& !string.IsNullOrWhiteSpace(state.Request.SegmentContainer)
|
||||
&& !string.Equals(state.Request.SegmentContainer, "mp4", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
queryString += "&AllowVideoStreamCopy=false";
|
||||
}
|
||||
|
||||
playlistUrl += queryString;
|
||||
// Main stream
|
||||
var baseUrl = isLiveStream ? "live.m3u8" : "main.m3u8";
|
||||
var playlistUrl = baseUrl + queryString;
|
||||
var playlistQuery = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(queryString);
|
||||
|
||||
var subtitleStreams = state.MediaSource
|
||||
.MediaStreams
|
||||
@@ -198,37 +209,36 @@ public class DynamicHlsHelper
|
||||
AddSubtitles(state, subtitleStreams, builder, _httpContextAccessor.HttpContext.User);
|
||||
}
|
||||
|
||||
// Video rotation metadata is only supported in fMP4 remuxing
|
||||
if (state.VideoStream is not null
|
||||
&& state.VideoRequest is not null
|
||||
&& (state.VideoStream?.Rotation ?? 0) != 0
|
||||
&& EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
|
||||
&& !string.IsNullOrWhiteSpace(state.Request.SegmentContainer)
|
||||
&& !string.Equals(state.Request.SegmentContainer, "mp4", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
playlistUrl += "&AllowVideoStreamCopy=false";
|
||||
}
|
||||
|
||||
var basicPlaylist = AppendPlaylist(builder, state, playlistUrl, totalBitrate, subtitleGroup);
|
||||
|
||||
if (state.VideoStream is not null && state.VideoRequest is not null)
|
||||
{
|
||||
var encodingOptions = _serverConfigurationManager.GetEncodingOptions();
|
||||
|
||||
// Provide SDR HEVC entrance for backward compatibility.
|
||||
if (encodingOptions.AllowHevcEncoding
|
||||
&& !encodingOptions.AllowAv1Encoding
|
||||
&& EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
|
||||
&& state.VideoStream.VideoRange == VideoRange.HDR
|
||||
&& string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase))
|
||||
// Provide AV1 and HEVC SDR entrances for backward compatibility.
|
||||
foreach (var sdrVideoCodec in new[] { "av1", "hevc" })
|
||||
{
|
||||
var requestedVideoProfiles = state.GetRequestedProfiles("hevc");
|
||||
if (requestedVideoProfiles is not null && requestedVideoProfiles.Length > 0)
|
||||
var isAv1EncodingAllowed = encodingOptions.AllowAv1Encoding
|
||||
&& string.Equals(sdrVideoCodec, "av1", StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(state.ActualOutputVideoCodec, "av1", StringComparison.OrdinalIgnoreCase);
|
||||
var isHevcEncodingAllowed = encodingOptions.AllowHevcEncoding
|
||||
&& string.Equals(sdrVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase);
|
||||
var isEncodingAllowed = isAv1EncodingAllowed || isHevcEncodingAllowed;
|
||||
|
||||
if (isEncodingAllowed
|
||||
&& EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
|
||||
&& state.VideoStream.VideoRange == VideoRange.HDR)
|
||||
{
|
||||
// Force HEVC Main Profile and disable video stream copy.
|
||||
state.OutputVideoCodec = "hevc";
|
||||
var sdrVideoUrl = ReplaceProfile(playlistUrl, "hevc", string.Join(',', requestedVideoProfiles), "main");
|
||||
sdrVideoUrl += "&AllowVideoStreamCopy=false";
|
||||
// Force AV1 and HEVC Main Profile and disable video stream copy.
|
||||
state.OutputVideoCodec = sdrVideoCodec;
|
||||
|
||||
var sdrPlaylistQuery = playlistQuery;
|
||||
sdrPlaylistQuery["VideoCodec"] = sdrVideoCodec;
|
||||
sdrPlaylistQuery[sdrVideoCodec + "-profile"] = "main";
|
||||
sdrPlaylistQuery["AllowVideoStreamCopy"] = "false";
|
||||
|
||||
var sdrVideoUrl = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(baseUrl, sdrPlaylistQuery);
|
||||
|
||||
// HACK: Use the same bitrate so that the client can choose by other attributes, such as color range.
|
||||
AppendPlaylist(builder, state, sdrVideoUrl, totalBitrate, subtitleGroup);
|
||||
@@ -238,12 +248,30 @@ public class DynamicHlsHelper
|
||||
}
|
||||
}
|
||||
|
||||
// Provide H.264 SDR entrance for backward compatibility.
|
||||
if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
|
||||
&& state.VideoStream.VideoRange == VideoRange.HDR)
|
||||
{
|
||||
// Force H.264 and disable video stream copy.
|
||||
state.OutputVideoCodec = "h264";
|
||||
|
||||
var sdrPlaylistQuery = playlistQuery;
|
||||
sdrPlaylistQuery["VideoCodec"] = "h264";
|
||||
sdrPlaylistQuery["AllowVideoStreamCopy"] = "false";
|
||||
|
||||
var sdrVideoUrl = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(baseUrl, sdrPlaylistQuery);
|
||||
|
||||
// HACK: Use the same bitrate so that the client can choose by other attributes, such as color range.
|
||||
AppendPlaylist(builder, state, sdrVideoUrl, totalBitrate, subtitleGroup);
|
||||
|
||||
// Restore the video codec
|
||||
state.OutputVideoCodec = "copy";
|
||||
}
|
||||
|
||||
// Provide Level 5.0 entrance for backward compatibility.
|
||||
// e.g. Apple A10 chips refuse the master playlist containing SDR HEVC Main Level 5.1 video,
|
||||
// but in fact it is capable of playing videos up to Level 6.1.
|
||||
if (encodingOptions.AllowHevcEncoding
|
||||
&& !encodingOptions.AllowAv1Encoding
|
||||
&& EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
|
||||
if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
|
||||
&& state.VideoStream.Level.HasValue
|
||||
&& state.VideoStream.Level > 150
|
||||
&& state.VideoStream.VideoRange == VideoRange.SDR
|
||||
@@ -273,12 +301,15 @@ public class DynamicHlsHelper
|
||||
var variation = GetBitrateVariation(totalBitrate);
|
||||
|
||||
var newBitrate = totalBitrate - variation;
|
||||
var variantUrl = ReplaceVideoBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation);
|
||||
var variantQuery = playlistQuery;
|
||||
variantQuery["VideoBitrate"] = (requestedVideoBitrate - variation).ToString(CultureInfo.InvariantCulture);
|
||||
var variantUrl = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(baseUrl, variantQuery);
|
||||
AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup);
|
||||
|
||||
variation *= 2;
|
||||
newBitrate = totalBitrate - variation;
|
||||
variantUrl = ReplaceVideoBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation);
|
||||
variantQuery["VideoBitrate"] = (requestedVideoBitrate - variation).ToString(CultureInfo.InvariantCulture);
|
||||
variantUrl = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(baseUrl, variantQuery);
|
||||
AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup);
|
||||
}
|
||||
|
||||
@@ -863,23 +894,6 @@ public class DynamicHlsHelper
|
||||
return variation;
|
||||
}
|
||||
|
||||
private string ReplaceVideoBitrate(string url, int oldValue, int newValue)
|
||||
{
|
||||
return url.Replace(
|
||||
"videobitrate=" + oldValue.ToString(CultureInfo.InvariantCulture),
|
||||
"videobitrate=" + newValue.ToString(CultureInfo.InvariantCulture),
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private string ReplaceProfile(string url, string codec, string oldValue, string newValue)
|
||||
{
|
||||
string profileStr = codec + "-profile=";
|
||||
return url.Replace(
|
||||
profileStr + oldValue,
|
||||
profileStr + newValue,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private string ReplacePlaylistCodecsField(StringBuilder playlist, StringBuilder oldValue, StringBuilder newValue)
|
||||
{
|
||||
var oldPlaylist = playlist.ToString();
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Api.Extensions;
|
||||
@@ -17,9 +18,7 @@ using MediaBrowser.Controller.MediaEncoding;
|
||||
using MediaBrowser.Controller.Streaming;
|
||||
using MediaBrowser.Model.Dlna;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.Net.Http.Headers;
|
||||
|
||||
namespace Jellyfin.Api.Helpers;
|
||||
@@ -159,6 +158,13 @@ public static class StreamingHelpers
|
||||
|
||||
string? containerInternal = Path.GetExtension(state.RequestedUrl);
|
||||
|
||||
if (string.IsNullOrEmpty(containerInternal)
|
||||
&& (!string.IsNullOrWhiteSpace(streamingRequest.LiveStreamId)
|
||||
|| (mediaSource != null && mediaSource.IsInfiniteStream)))
|
||||
{
|
||||
containerInternal = ".ts";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(streamingRequest.Container))
|
||||
{
|
||||
containerInternal = streamingRequest.Container;
|
||||
@@ -415,14 +421,18 @@ public static class StreamingHelpers
|
||||
request.Static = string.Equals("true", val, StringComparison.OrdinalIgnoreCase);
|
||||
break;
|
||||
case 4:
|
||||
if (videoRequest is not null)
|
||||
if (videoRequest is not null && IsValidCodecName(val))
|
||||
{
|
||||
videoRequest.VideoCodec = val;
|
||||
}
|
||||
|
||||
break;
|
||||
case 5:
|
||||
request.AudioCodec = val;
|
||||
if (IsValidCodecName(val))
|
||||
{
|
||||
request.AudioCodec = val;
|
||||
}
|
||||
|
||||
break;
|
||||
case 6:
|
||||
if (videoRequest is not null)
|
||||
@@ -476,7 +486,7 @@ public static class StreamingHelpers
|
||||
request.StartTimeTicks = long.Parse(val, CultureInfo.InvariantCulture);
|
||||
break;
|
||||
case 15:
|
||||
if (videoRequest is not null)
|
||||
if (videoRequest is not null && Regex.IsMatch(val, EncodingHelper.LevelValidationRegexStr))
|
||||
{
|
||||
videoRequest.Level = val;
|
||||
}
|
||||
@@ -497,7 +507,7 @@ public static class StreamingHelpers
|
||||
|
||||
break;
|
||||
case 18:
|
||||
if (videoRequest is not null)
|
||||
if (videoRequest is not null && IsValidCodecName(val))
|
||||
{
|
||||
videoRequest.Profile = val;
|
||||
}
|
||||
@@ -556,7 +566,11 @@ public static class StreamingHelpers
|
||||
|
||||
break;
|
||||
case 30:
|
||||
request.SubtitleCodec = val;
|
||||
if (IsValidCodecName(val))
|
||||
{
|
||||
request.SubtitleCodec = val;
|
||||
}
|
||||
|
||||
break;
|
||||
case 31:
|
||||
if (videoRequest is not null)
|
||||
@@ -579,6 +593,11 @@ public static class StreamingHelpers
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsValidCodecName(string val)
|
||||
{
|
||||
return EncodingHelper.ContainerValidationRegex().IsMatch(val);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses the container into its file extension.
|
||||
/// </summary>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Jellyfin.Api.Models.SyncPlayDtos;
|
||||
|
||||
/// <summary>
|
||||
@@ -17,5 +19,6 @@ public class NewGroupRequestDto
|
||||
/// Gets or sets the group name.
|
||||
/// </summary>
|
||||
/// <value>The name of the new group.</value>
|
||||
[StringLength(200, ErrorMessage = "Group name must not exceed 200 characters.")]
|
||||
public string GroupName { get; set; }
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ using MediaBrowser.Controller.Authentication;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Net;
|
||||
using MediaBrowser.Controller.Session;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.Session;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -15,7 +16,7 @@ namespace Jellyfin.Api.WebSocketListeners;
|
||||
/// <summary>
|
||||
/// Class SessionInfoWebSocketListener.
|
||||
/// </summary>
|
||||
public class SessionInfoWebSocketListener : BasePeriodicWebSocketListener<IEnumerable<SessionInfo>, WebSocketListenerState>
|
||||
public class SessionInfoWebSocketListener : BasePeriodicWebSocketListener<IEnumerable<SessionInfoDto>, WebSocketListenerState>
|
||||
{
|
||||
private readonly ISessionManager _sessionManager;
|
||||
private bool _disposed;
|
||||
@@ -52,24 +53,26 @@ public class SessionInfoWebSocketListener : BasePeriodicWebSocketListener<IEnume
|
||||
/// Gets the data to send.
|
||||
/// </summary>
|
||||
/// <returns>Task{SystemInfo}.</returns>
|
||||
protected override Task<IEnumerable<SessionInfo>> GetDataToSend()
|
||||
protected override Task<IEnumerable<SessionInfoDto>> GetDataToSend()
|
||||
{
|
||||
return Task.FromResult(_sessionManager.Sessions);
|
||||
return Task.FromResult(_sessionManager.Sessions.Select(_sessionManager.ToSessionInfoDto));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Task<IEnumerable<SessionInfo>> GetDataToSendForConnection(IWebSocketConnection connection)
|
||||
protected override Task<IEnumerable<SessionInfoDto>> GetDataToSendForConnection(IWebSocketConnection connection)
|
||||
{
|
||||
var sessions = _sessionManager.Sessions;
|
||||
|
||||
// For non-admin users, filter the sessions to only include their own sessions
|
||||
if (connection.AuthorizationInfo?.User is not null &&
|
||||
!connection.AuthorizationInfo.IsApiKey &&
|
||||
!connection.AuthorizationInfo.User.HasPermission(PermissionKind.IsAdministrator))
|
||||
{
|
||||
var userId = connection.AuthorizationInfo.User.Id;
|
||||
return Task.FromResult(_sessionManager.Sessions.Where(s => s.UserId.Equals(userId) || s.ContainsUser(userId)));
|
||||
sessions = sessions.Where(s => s.UserId.Equals(userId) || s.ContainsUser(userId));
|
||||
}
|
||||
|
||||
return Task.FromResult(_sessionManager.Sessions);
|
||||
return Task.FromResult(sessions.Select(_sessionManager.ToSessionInfoDto));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<PropertyGroup>
|
||||
<Authors>Jellyfin Contributors</Authors>
|
||||
<PackageId>Jellyfin.Data</PackageId>
|
||||
<VersionPrefix>10.11.3</VersionPrefix>
|
||||
<VersionPrefix>10.11.7</VersionPrefix>
|
||||
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
||||
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -185,7 +185,7 @@ public static class UserEntityExtensions
|
||||
entity.Permissions.Add(new Permission(PermissionKind.EnableSyncTranscoding, true));
|
||||
entity.Permissions.Add(new Permission(PermissionKind.EnableAudioPlaybackTranscoding, true));
|
||||
entity.Permissions.Add(new Permission(PermissionKind.EnableLiveTvAccess, true));
|
||||
entity.Permissions.Add(new Permission(PermissionKind.EnableLiveTvManagement, true));
|
||||
entity.Permissions.Add(new Permission(PermissionKind.EnableLiveTvManagement, false));
|
||||
entity.Permissions.Add(new Permission(PermissionKind.EnableSharedDeviceControl, true));
|
||||
entity.Permissions.Add(new Permission(PermissionKind.EnableVideoPlaybackTranscoding, true));
|
||||
entity.Permissions.Add(new Permission(PermissionKind.ForceRemoteSourceTranscoding, false));
|
||||
|
||||
@@ -6,12 +6,14 @@ using System.Reflection;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.DbConfiguration;
|
||||
using Jellyfin.Database.Implementations.Locking;
|
||||
using Jellyfin.Database.Providers.PostgreSQL;
|
||||
using Jellyfin.Database.Providers.Sqlite;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Npgsql;
|
||||
using JellyfinDbProviderFactory = System.Func<System.IServiceProvider, Jellyfin.Database.Implementations.IJellyfinDatabaseProvider>;
|
||||
|
||||
namespace Jellyfin.Server.Implementations.Extensions;
|
||||
@@ -24,6 +26,13 @@ public static class ServiceCollectionExtensions
|
||||
private static IEnumerable<Type> DatabaseProviderTypes()
|
||||
{
|
||||
yield return typeof(SqliteDatabaseProvider);
|
||||
yield return typeof(PostgreSqlDatabaseProvider);
|
||||
}
|
||||
|
||||
private static int GetPoolOption(IEnumerable<CustomDatabaseOption>? options, string key, int defaultValue)
|
||||
{
|
||||
var value = options?.FirstOrDefault(o => o.Key.Equals(key, StringComparison.OrdinalIgnoreCase))?.Value;
|
||||
return int.TryParse(value, out var parsed) ? parsed : defaultValue;
|
||||
}
|
||||
|
||||
private static IDictionary<string, JellyfinDbProviderFactory> GetSupportedDbProviders()
|
||||
@@ -123,6 +132,50 @@ public static class ServiceCollectionExtensions
|
||||
|
||||
serviceCollection.AddSingleton<IJellyfinDatabaseProvider>(providerFactory!);
|
||||
|
||||
if (efCoreConfiguration.DatabaseType.Equals("Jellyfin-PostgreSQL", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
serviceCollection.AddSingleton<NpgsqlDataSource>(static sp =>
|
||||
{
|
||||
var config = sp.GetRequiredService<IServerConfigurationManager>().GetConfiguration<DatabaseConfigurationOptions>("database");
|
||||
var options = config.CustomProviderOptions?.Options;
|
||||
|
||||
var connectionString =
|
||||
Environment.GetEnvironmentVariable("POSTGRES_CONNECTION_STRING")
|
||||
?? options
|
||||
?.FirstOrDefault(o => o.Key.Equals("ConnectionString", StringComparison.OrdinalIgnoreCase))
|
||||
?.Value
|
||||
?? config.CustomProviderOptions?.ConnectionString
|
||||
?? throw new InvalidOperationException(
|
||||
"No PostgreSQL connection string found. Set the POSTGRES_CONNECTION_STRING environment variable, " +
|
||||
"or provide it via CustomProviderOptions.Options[\"ConnectionString\"] or CustomProviderOptions.ConnectionString.");
|
||||
|
||||
// Support postgresql:// / postgres:// URI format (e.g. DATABASE_URL convention).
|
||||
// NpgsqlDataSourceBuilder requires ADO.NET key=value format; convert if needed.
|
||||
if (connectionString.StartsWith("postgresql://", StringComparison.OrdinalIgnoreCase)
|
||||
|| connectionString.StartsWith("postgres://", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var uri = new Uri(connectionString);
|
||||
var userInfoParts = uri.UserInfo.Split(':', 2);
|
||||
connectionString = new NpgsqlConnectionStringBuilder
|
||||
{
|
||||
Host = uri.Host,
|
||||
Port = uri.Port > 0 ? uri.Port : 5432,
|
||||
Database = uri.AbsolutePath.TrimStart('/'),
|
||||
Username = userInfoParts.Length > 0 ? Uri.UnescapeDataString(userInfoParts[0]) : null,
|
||||
Password = userInfoParts.Length > 1 ? Uri.UnescapeDataString(userInfoParts[1]) : null,
|
||||
}.ToString();
|
||||
}
|
||||
|
||||
var dataSourceBuilder = new NpgsqlDataSourceBuilder(connectionString);
|
||||
|
||||
dataSourceBuilder.ConnectionStringBuilder.MinPoolSize = GetPoolOption(options, "MinPoolSize", 2);
|
||||
dataSourceBuilder.ConnectionStringBuilder.MaxPoolSize = GetPoolOption(options, "MaxPoolSize", 20);
|
||||
dataSourceBuilder.ConnectionStringBuilder.CommandTimeout = GetPoolOption(options, "CommandTimeout", 30);
|
||||
|
||||
return dataSourceBuilder.Build();
|
||||
});
|
||||
}
|
||||
|
||||
switch (efCoreConfiguration.LockingBehavior)
|
||||
{
|
||||
case DatabaseLockingBehaviorTypes.NoLock:
|
||||
|
||||
@@ -118,15 +118,21 @@ public class BackupService : IBackupService
|
||||
throw new NotSupportedException($"The loaded archive '{archivePath}' is made for a newer version of Jellyfin ({manifest.ServerVersion}) and cannot be loaded in this version.");
|
||||
}
|
||||
|
||||
void CopyDirectory(string source, string target)
|
||||
void CopyDirectory(string source, string target, string[]? exclude = null)
|
||||
{
|
||||
var fullSourcePath = NormalizePathSeparator(Path.GetFullPath(source) + Path.DirectorySeparatorChar);
|
||||
var fullTargetRoot = Path.GetFullPath(target) + Path.DirectorySeparatorChar;
|
||||
var excludePaths = exclude?.Select(e => $"{source}/{e}/").ToArray();
|
||||
foreach (var item in zipArchive.Entries)
|
||||
{
|
||||
var sourcePath = NormalizePathSeparator(Path.GetFullPath(item.FullName));
|
||||
var targetPath = Path.GetFullPath(Path.Combine(target, Path.GetRelativePath(source, item.FullName)));
|
||||
|
||||
if (excludePaths is not null && excludePaths.Any(e => item.FullName.StartsWith(e, StringComparison.Ordinal)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!sourcePath.StartsWith(fullSourcePath, StringComparison.Ordinal)
|
||||
|| !targetPath.StartsWith(fullTargetRoot, StringComparison.Ordinal)
|
||||
|| Path.EndsInDirectorySeparator(item.FullName))
|
||||
@@ -142,8 +148,10 @@ public class BackupService : IBackupService
|
||||
}
|
||||
|
||||
CopyDirectory("Config", _applicationPaths.ConfigurationDirectoryPath);
|
||||
CopyDirectory("Data", _applicationPaths.DataPath);
|
||||
CopyDirectory("Data", _applicationPaths.DataPath, exclude: ["metadata", "metadata-default"]);
|
||||
CopyDirectory("Root", _applicationPaths.RootFolderPath);
|
||||
CopyDirectory("Data/metadata", _applicationPaths.InternalMetadataPath);
|
||||
CopyDirectory("Data/metadata-default", _applicationPaths.DefaultInternalMetadataPath);
|
||||
|
||||
if (manifest.Options.Database)
|
||||
{
|
||||
@@ -403,6 +411,15 @@ public class BackupService : IBackupService
|
||||
if (backupOptions.Metadata)
|
||||
{
|
||||
CopyDirectory(Path.Combine(_applicationPaths.InternalMetadataPath), Path.Combine("Data", "metadata"));
|
||||
|
||||
// If a custom metadata path is configured, the default location may still contain data.
|
||||
if (!string.Equals(
|
||||
Path.GetFullPath(_applicationPaths.DefaultInternalMetadataPath),
|
||||
Path.GetFullPath(_applicationPaths.InternalMetadataPath),
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
CopyDirectory(Path.Combine(_applicationPaths.DefaultInternalMetadataPath), Path.Combine("Data", "metadata-default"));
|
||||
}
|
||||
}
|
||||
|
||||
var manifestStream = zipArchive.CreateEntry(ManifestEntryName).Open();
|
||||
|
||||
@@ -295,6 +295,25 @@ public sealed class BaseItemRepository
|
||||
|
||||
dbQuery = ApplyGroupingFilter(context, dbQuery, filter);
|
||||
dbQuery = ApplyQueryPaging(dbQuery, filter);
|
||||
|
||||
var hasRandomSort = filter.OrderBy.Any(e => e.OrderBy == ItemSortBy.Random);
|
||||
if (hasRandomSort)
|
||||
{
|
||||
var orderedIds = dbQuery.Select(e => e.Id).ToList();
|
||||
if (orderedIds.Count == 0)
|
||||
{
|
||||
return Array.Empty<BaseItemDto>();
|
||||
}
|
||||
|
||||
var itemsById = ApplyNavigations(context.BaseItems.Where(e => orderedIds.Contains(e.Id)), filter)
|
||||
.AsEnumerable()
|
||||
.Select(w => DeserializeBaseItem(w, filter.SkipDeserialization))
|
||||
.Where(dto => dto is not null)
|
||||
.ToDictionary(i => i!.Id);
|
||||
|
||||
return orderedIds.Where(itemsById.ContainsKey).Select(id => itemsById[id]).ToArray()!;
|
||||
}
|
||||
|
||||
dbQuery = ApplyNavigations(dbQuery, filter);
|
||||
|
||||
return dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray();
|
||||
@@ -410,10 +429,25 @@ public sealed class BaseItemRepository
|
||||
|
||||
private static IQueryable<BaseItemEntity> ApplyNavigations(IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter)
|
||||
{
|
||||
dbQuery = dbQuery.Include(e => e.TrailerTypes)
|
||||
.Include(e => e.Provider)
|
||||
.Include(e => e.LockedFields)
|
||||
.Include(e => e.UserData);
|
||||
if (filter.TrailerTypes.Length > 0 || filter.IncludeItemTypes.Contains(BaseItemKind.Trailer))
|
||||
{
|
||||
dbQuery = dbQuery.Include(e => e.TrailerTypes);
|
||||
}
|
||||
|
||||
if (filter.DtoOptions.ContainsField(ItemFields.ProviderIds))
|
||||
{
|
||||
dbQuery = dbQuery.Include(e => e.Provider);
|
||||
}
|
||||
|
||||
if (filter.DtoOptions.ContainsField(ItemFields.Settings))
|
||||
{
|
||||
dbQuery = dbQuery.Include(e => e.LockedFields);
|
||||
}
|
||||
|
||||
if (filter.DtoOptions.EnableUserData)
|
||||
{
|
||||
dbQuery = dbQuery.Include(e => e.UserData);
|
||||
}
|
||||
|
||||
if (filter.DtoOptions.EnableImages)
|
||||
{
|
||||
@@ -602,7 +636,6 @@ public sealed class BaseItemRepository
|
||||
|
||||
var ids = tuples.Select(f => f.Item.Id).ToArray();
|
||||
var existingItems = context.BaseItems.Where(e => ids.Contains(e.Id)).Select(f => f.Id).ToArray();
|
||||
var newItems = tuples.Where(e => !existingItems.Contains(e.Item.Id)).ToArray();
|
||||
|
||||
foreach (var item in tuples)
|
||||
{
|
||||
@@ -618,31 +651,24 @@ public sealed class BaseItemRepository
|
||||
{
|
||||
context.BaseItemProviders.Where(e => e.ItemId == entity.Id).ExecuteDelete();
|
||||
context.BaseItemImageInfos.Where(e => e.ItemId == entity.Id).ExecuteDelete();
|
||||
context.BaseItemMetadataFields.Where(e => e.ItemId == entity.Id).ExecuteDelete();
|
||||
|
||||
if (entity.Images is { Count: > 0 })
|
||||
{
|
||||
context.BaseItemImageInfos.AddRange(entity.Images);
|
||||
}
|
||||
|
||||
if (entity.LockedFields is { Count: > 0 })
|
||||
{
|
||||
context.BaseItemMetadataFields.AddRange(entity.LockedFields);
|
||||
}
|
||||
|
||||
context.BaseItems.Attach(entity).State = EntityState.Modified;
|
||||
}
|
||||
}
|
||||
|
||||
context.SaveChanges();
|
||||
|
||||
foreach (var item in newItems)
|
||||
{
|
||||
// reattach old userData entries
|
||||
var userKeys = item.UserDataKey.ToArray();
|
||||
var retentionDate = (DateTime?)null;
|
||||
context.UserData
|
||||
.Where(e => e.ItemId == PlaceholderId)
|
||||
.Where(e => userKeys.Contains(e.CustomDataKey))
|
||||
.ExecuteUpdate(e => e
|
||||
.SetProperty(f => f.ItemId, item.Item.Id)
|
||||
.SetProperty(f => f.RetentionDate, retentionDate));
|
||||
}
|
||||
|
||||
var itemValueMaps = tuples
|
||||
.Select(e => (e.Item, Values: GetItemValuesToSave(e.Item, e.InheritedTags)))
|
||||
.ToArray();
|
||||
@@ -738,6 +764,43 @@ public sealed class BaseItemRepository
|
||||
transaction.Commit();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task ReattachUserDataAsync(BaseItemDto item, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(item);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using (transaction.ConfigureAwait(false))
|
||||
{
|
||||
var userKeys = item.GetUserDataKeys().ToArray();
|
||||
var retentionDate = (DateTime?)null;
|
||||
|
||||
await dbContext.UserData
|
||||
.Where(e => e.ItemId == PlaceholderId)
|
||||
.Where(e => userKeys.Contains(e.CustomDataKey))
|
||||
.ExecuteUpdateAsync(
|
||||
e => e
|
||||
.SetProperty(f => f.ItemId, item.Id)
|
||||
.SetProperty(f => f.RetentionDate, retentionDate),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Rehydrate the cached userdata
|
||||
item.UserData = await dbContext.UserData
|
||||
.AsNoTracking()
|
||||
.Where(e => e.ItemId == item.Id)
|
||||
.ToArrayAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public BaseItemDto? RetrieveItem(Guid id)
|
||||
{
|
||||
@@ -845,7 +908,7 @@ public sealed class BaseItemRepository
|
||||
}
|
||||
|
||||
dto.ExtraIds = string.IsNullOrWhiteSpace(entity.ExtraIds) ? [] : entity.ExtraIds.Split('|').Select(e => Guid.Parse(e)).ToArray();
|
||||
dto.ProductionLocations = entity.ProductionLocations?.Split('|') ?? [];
|
||||
dto.ProductionLocations = entity.ProductionLocations?.Split('|', StringSplitOptions.RemoveEmptyEntries) ?? [];
|
||||
dto.Studios = entity.Studios?.Split('|') ?? [];
|
||||
dto.Tags = string.IsNullOrWhiteSpace(entity.Tags) ? [] : entity.Tags.Split('|');
|
||||
|
||||
@@ -1007,7 +1070,7 @@ public sealed class BaseItemRepository
|
||||
}
|
||||
|
||||
entity.ExtraIds = dto.ExtraIds is not null ? string.Join('|', dto.ExtraIds) : null;
|
||||
entity.ProductionLocations = dto.ProductionLocations is not null ? string.Join('|', dto.ProductionLocations) : null;
|
||||
entity.ProductionLocations = dto.ProductionLocations is not null ? string.Join('|', dto.ProductionLocations.Where(p => !string.IsNullOrWhiteSpace(p))) : null;
|
||||
entity.Studios = dto.Studios is not null ? string.Join('|', dto.Studios) : null;
|
||||
entity.Tags = dto.Tags is not null ? string.Join('|', dto.Tags) : null;
|
||||
entity.LockedFields = dto.LockedFields is not null ? dto.LockedFields
|
||||
@@ -1523,43 +1586,50 @@ public sealed class BaseItemRepository
|
||||
|
||||
private IQueryable<BaseItemEntity> ApplyOrder(IQueryable<BaseItemEntity> query, InternalItemsQuery filter, JellyfinDbContext context)
|
||||
{
|
||||
var orderBy = filter.OrderBy;
|
||||
var orderBy = filter.OrderBy.Where(e => e.OrderBy != ItemSortBy.Default).ToArray();
|
||||
var hasSearch = !string.IsNullOrEmpty(filter.SearchTerm);
|
||||
|
||||
if (hasSearch)
|
||||
{
|
||||
orderBy = filter.OrderBy = [(ItemSortBy.SortName, SortOrder.Ascending), .. orderBy];
|
||||
orderBy = [(ItemSortBy.SortName, SortOrder.Ascending), .. orderBy];
|
||||
}
|
||||
else if (orderBy.Count == 0)
|
||||
else if (orderBy.Length == 0)
|
||||
{
|
||||
return query.OrderBy(e => e.SortName);
|
||||
}
|
||||
|
||||
IOrderedQueryable<BaseItemEntity>? orderedQuery = null;
|
||||
|
||||
// When searching, prioritize by match quality: exact match > prefix match > contains
|
||||
if (hasSearch)
|
||||
{
|
||||
orderedQuery = query.OrderBy(OrderMapper.MapSearchRelevanceOrder(filter.SearchTerm!));
|
||||
}
|
||||
|
||||
var firstOrdering = orderBy.FirstOrDefault();
|
||||
if (firstOrdering != default)
|
||||
{
|
||||
var expression = OrderMapper.MapOrderByField(firstOrdering.OrderBy, filter, context);
|
||||
if (firstOrdering.SortOrder == SortOrder.Ascending)
|
||||
if (orderedQuery is null)
|
||||
{
|
||||
orderedQuery = query.OrderBy(expression);
|
||||
// No search relevance ordering, start fresh
|
||||
orderedQuery = firstOrdering.SortOrder == SortOrder.Ascending
|
||||
? query.OrderBy(expression)
|
||||
: query.OrderByDescending(expression);
|
||||
}
|
||||
else
|
||||
{
|
||||
orderedQuery = query.OrderByDescending(expression);
|
||||
// Search relevance ordering already applied, chain with ThenBy
|
||||
orderedQuery = firstOrdering.SortOrder == SortOrder.Ascending
|
||||
? orderedQuery.ThenBy(expression)
|
||||
: orderedQuery.ThenByDescending(expression);
|
||||
}
|
||||
|
||||
if (firstOrdering.OrderBy is ItemSortBy.Default or ItemSortBy.SortName)
|
||||
{
|
||||
if (firstOrdering.SortOrder is SortOrder.Ascending)
|
||||
{
|
||||
orderedQuery = orderedQuery.ThenBy(e => e.Name);
|
||||
}
|
||||
else
|
||||
{
|
||||
orderedQuery = orderedQuery.ThenByDescending(e => e.Name);
|
||||
}
|
||||
orderedQuery = firstOrdering.SortOrder is SortOrder.Ascending
|
||||
? orderedQuery.ThenBy(e => e.Name)
|
||||
: orderedQuery.ThenByDescending(e => e.Name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1647,19 +1717,18 @@ public sealed class BaseItemRepository
|
||||
var tags = filter.Tags.ToList();
|
||||
var excludeTags = filter.ExcludeTags.ToList();
|
||||
|
||||
if (filter.IsMovie == true)
|
||||
if (filter.IsMovie.HasValue)
|
||||
{
|
||||
if (filter.IncludeItemTypes.Length == 0
|
||||
|| filter.IncludeItemTypes.Contains(BaseItemKind.Movie)
|
||||
|| filter.IncludeItemTypes.Contains(BaseItemKind.Trailer))
|
||||
var shouldIncludeAllMovieTypes = filter.IsMovie.Value
|
||||
&& (filter.IncludeItemTypes.Length == 0
|
||||
|| filter.IncludeItemTypes.Contains(BaseItemKind.Movie)
|
||||
|| filter.IncludeItemTypes.Contains(BaseItemKind.Trailer));
|
||||
|
||||
if (!shouldIncludeAllMovieTypes)
|
||||
{
|
||||
baseQuery = baseQuery.Where(e => e.IsMovie);
|
||||
baseQuery = baseQuery.Where(e => e.IsMovie == filter.IsMovie.Value);
|
||||
}
|
||||
}
|
||||
else if (filter.IsMovie.HasValue)
|
||||
{
|
||||
baseQuery = baseQuery.Where(e => e.IsMovie == filter.IsMovie);
|
||||
}
|
||||
|
||||
if (filter.IsSeries.HasValue)
|
||||
{
|
||||
@@ -1925,8 +1994,15 @@ public sealed class BaseItemRepository
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Name))
|
||||
{
|
||||
var cleanName = GetCleanValue(filter.Name);
|
||||
baseQuery = baseQuery.Where(e => e.CleanName == cleanName);
|
||||
if (filter.UseRawName == true)
|
||||
{
|
||||
baseQuery = baseQuery.Where(e => e.Name == filter.Name);
|
||||
}
|
||||
else
|
||||
{
|
||||
var cleanName = GetCleanValue(filter.Name);
|
||||
baseQuery = baseQuery.Where(e => e.CleanName == cleanName);
|
||||
}
|
||||
}
|
||||
|
||||
// These are the same, for now
|
||||
@@ -2420,35 +2496,24 @@ public sealed class BaseItemRepository
|
||||
|
||||
if (filter.ExcludeInheritedTags.Length > 0)
|
||||
{
|
||||
var excludedTags = filter.ExcludeInheritedTags;
|
||||
baseQuery = baseQuery.Where(e =>
|
||||
!e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && filter.ExcludeInheritedTags.Contains(f.ItemValue.CleanValue))
|
||||
&& (e.Type != _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode] || !e.SeriesId.HasValue ||
|
||||
!context.ItemValuesMap.Any(f => f.ItemId == e.SeriesId.Value && f.ItemValue.Type == ItemValueType.Tags && filter.ExcludeInheritedTags.Contains(f.ItemValue.CleanValue))));
|
||||
!e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && excludedTags.Contains(f.ItemValue.CleanValue))
|
||||
&& (!e.SeriesId.HasValue || !context.ItemValuesMap.Any(f => f.ItemId == e.SeriesId.Value && f.ItemValue.Type == ItemValueType.Tags && excludedTags.Contains(f.ItemValue.CleanValue))));
|
||||
}
|
||||
|
||||
if (filter.IncludeInheritedTags.Length > 0)
|
||||
{
|
||||
// For seasons and episodes, we also need to check the parent series' tags.
|
||||
if (includeTypes.Any(t => t == BaseItemKind.Episode || t == BaseItemKind.Season))
|
||||
{
|
||||
baseQuery = baseQuery.Where(e =>
|
||||
e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue))
|
||||
|| (e.SeriesId.HasValue && context.ItemValuesMap.Any(f => f.ItemId == e.SeriesId.Value && f.ItemValue.Type == ItemValueType.Tags && filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue))));
|
||||
}
|
||||
var includeTags = filter.IncludeInheritedTags;
|
||||
var isPlaylistOnlyQuery = includeTypes.Length == 1 && includeTypes.FirstOrDefault() == BaseItemKind.Playlist;
|
||||
baseQuery = baseQuery.Where(e =>
|
||||
e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && includeTags.Contains(f.ItemValue.CleanValue))
|
||||
|
||||
// A playlist should be accessible to its owner regardless of allowed tags.
|
||||
else if (includeTypes.Length == 1 && includeTypes.FirstOrDefault() is BaseItemKind.Playlist)
|
||||
{
|
||||
baseQuery = baseQuery.Where(e =>
|
||||
e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue))
|
||||
|| e.Data!.Contains($"OwnerUserId\":\"{filter.User!.Id:N}\""));
|
||||
// d ^^ this is stupid it hate this.
|
||||
}
|
||||
else
|
||||
{
|
||||
baseQuery = baseQuery.Where(e =>
|
||||
e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue)));
|
||||
}
|
||||
// For seasons and episodes, we also need to check the parent series' tags.
|
||||
|| (e.SeriesId.HasValue && context.ItemValuesMap.Any(f => f.ItemId == e.SeriesId.Value && f.ItemValue.Type == ItemValueType.Tags && includeTags.Contains(f.ItemValue.CleanValue)))
|
||||
|
||||
// A playlist should be accessible to its owner regardless of allowed tags
|
||||
|| (isPlaylistOnlyQuery && e.Data!.Contains($"OwnerUserId\":\"{filter.User!.Id:N}\"")));
|
||||
}
|
||||
|
||||
if (filter.SeriesStatuses.Length > 0)
|
||||
@@ -2602,6 +2667,21 @@ public sealed class BaseItemRepository
|
||||
.Where(e => artistNames.Contains(e.Name))
|
||||
.ToArray();
|
||||
|
||||
return artists.GroupBy(e => e.Name).ToDictionary(e => e.Key!, e => e.Select(f => DeserializeBaseItem(f)).Cast<MusicArtist>().ToArray());
|
||||
var lookup = artists
|
||||
.GroupBy(e => e.Name!)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => g.Select(f => DeserializeBaseItem(f)).Where(dto => dto is not null).Cast<MusicArtist>().ToArray());
|
||||
|
||||
var result = new Dictionary<string, MusicArtist[]>(artistNames.Count);
|
||||
foreach (var name in artistNames)
|
||||
{
|
||||
if (lookup.TryGetValue(name, out var artistArray))
|
||||
{
|
||||
result[name] = artistArray;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ using System.Linq.Expressions;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -68,4 +69,30 @@ public static class OrderMapper
|
||||
_ => e => e.SortName
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an expression to order search results by match quality.
|
||||
/// Prioritizes: exact match (0) > prefix match with word boundary (1) > prefix match (2) > contains (3).
|
||||
/// </summary>
|
||||
/// <param name="searchTerm">The search term to match against.</param>
|
||||
/// <returns>An expression that returns an integer representing match quality (lower is better).</returns>
|
||||
public static Expression<Func<BaseItemEntity, int>> MapSearchRelevanceOrder(string searchTerm)
|
||||
{
|
||||
var cleanSearchTerm = GetCleanValue(searchTerm);
|
||||
var searchPrefix = cleanSearchTerm + " ";
|
||||
return e =>
|
||||
e.CleanName == cleanSearchTerm ? 0 :
|
||||
e.CleanName!.StartsWith(searchPrefix) ? 1 :
|
||||
e.CleanName!.StartsWith(cleanSearchTerm) ? 2 : 3;
|
||||
}
|
||||
|
||||
private static string GetCleanValue(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
return value.RemoveDiacritics().ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
<ProjectReference Include="..\MediaBrowser.Controller\MediaBrowser.Controller.csproj" />
|
||||
<ProjectReference Include="..\MediaBrowser.Model\MediaBrowser.Model.csproj" />
|
||||
<ProjectReference Include="..\src\Jellyfin.Database\Jellyfin.Database.Implementations\Jellyfin.Database.Implementations.csproj" />
|
||||
<ProjectReference Include="..\src\Jellyfin.Database\Jellyfin.Database.Providers.PostgreSQL\Jellyfin.Database.Providers.PostgreSQL.csproj" />
|
||||
<ProjectReference Include="..\src\Jellyfin.Database\Jellyfin.Database.Providers.Sqlite\Jellyfin.Database.Providers.Sqlite.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -149,7 +149,7 @@ namespace Jellyfin.Server.Implementations.Users
|
||||
|
||||
ThrowIfInvalidUsername(newName);
|
||||
|
||||
if (user.Username.Equals(newName, StringComparison.OrdinalIgnoreCase))
|
||||
if (user.Username.Equals(newName, StringComparison.Ordinal))
|
||||
{
|
||||
throw new ArgumentException("The new and old names must be different.");
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using Emby.Server.Implementations;
|
||||
using Emby.Server.Implementations.MediaEncoding;
|
||||
using Emby.Server.Implementations.Session;
|
||||
using Jellyfin.Api.WebSocketListeners;
|
||||
using Jellyfin.Database.Implementations;
|
||||
@@ -23,6 +24,7 @@ using MediaBrowser.Controller.Drawing;
|
||||
using MediaBrowser.Controller.Events;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Lyrics;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
using MediaBrowser.Controller.Net;
|
||||
using MediaBrowser.Controller.Security;
|
||||
using MediaBrowser.Controller.Trickplay;
|
||||
@@ -31,6 +33,7 @@ using MediaBrowser.Providers.Lyric;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace Jellyfin.Server
|
||||
{
|
||||
@@ -39,6 +42,8 @@ namespace Jellyfin.Server
|
||||
/// </summary>
|
||||
public class CoreAppHost : ApplicationHost
|
||||
{
|
||||
private readonly IConfiguration _startupConfig;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CoreAppHost" /> class.
|
||||
/// </summary>
|
||||
@@ -57,6 +62,7 @@ namespace Jellyfin.Server
|
||||
options,
|
||||
startupConfig)
|
||||
{
|
||||
_startupConfig = startupConfig;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -98,6 +104,31 @@ namespace Jellyfin.Server
|
||||
|
||||
serviceCollection.AddScoped<IAuthenticationManager, AuthenticationManager>();
|
||||
|
||||
// Transcode session store: Redis-backed when configured, no-op otherwise.
|
||||
serviceCollection.Configure<TranscodeStoreOptions>(_startupConfig.GetSection("Jellyfin:TranscodeStore"));
|
||||
var redisConnectionString = _startupConfig["Jellyfin:TranscodeStore:RedisConnectionString"];
|
||||
if (!string.IsNullOrEmpty(redisConnectionString))
|
||||
{
|
||||
serviceCollection.AddSingleton<IConnectionMultiplexer>(sp =>
|
||||
{
|
||||
try
|
||||
{
|
||||
return ConnectionMultiplexer.Connect(redisConnectionString);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sp.GetRequiredService<ILogger<CoreAppHost>>()
|
||||
.LogError(ex, "Failed to connect to Redis. Check the Jellyfin:TranscodeStore:RedisConnectionString configuration.");
|
||||
throw;
|
||||
}
|
||||
});
|
||||
serviceCollection.AddSingleton<ITranscodeSessionStore, RedisTranscodeSessionStore>();
|
||||
}
|
||||
else
|
||||
{
|
||||
serviceCollection.AddSingleton<ITranscodeSessionStore, NullTranscodeSessionStore>();
|
||||
}
|
||||
|
||||
foreach (var type in GetExportTypes<ILyricProvider>())
|
||||
{
|
||||
serviceCollection.AddSingleton(typeof(ILyricProvider), type);
|
||||
|
||||
@@ -33,9 +33,11 @@ using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Cors.Infrastructure;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.OpenApi.Any;
|
||||
using Microsoft.OpenApi.Interfaces;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using Swashbuckle.AspNetCore.Swagger;
|
||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
using AuthenticationSchemes = Jellyfin.Api.Constants.AuthenticationSchemes;
|
||||
|
||||
@@ -259,7 +261,8 @@ namespace Jellyfin.Server.Extensions
|
||||
c.OperationFilter<FileRequestFilter>();
|
||||
c.OperationFilter<ParameterObsoleteFilter>();
|
||||
c.DocumentFilter<AdditionalModelFilter>();
|
||||
});
|
||||
})
|
||||
.Replace(ServiceDescriptor.Transient<ISwaggerProvider, CachingOpenApiProvider>());
|
||||
}
|
||||
|
||||
private static void AddPolicy(this AuthorizationOptions authorizationOptions, string policyName, IAuthorizationRequirement authorizationRequirement)
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using Microsoft.AspNetCore.Mvc.ApiExplorer;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using Swashbuckle.AspNetCore.Swagger;
|
||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
|
||||
namespace Jellyfin.Server.Filters;
|
||||
|
||||
/// <summary>
|
||||
/// OpenApi provider with caching.
|
||||
/// </summary>
|
||||
internal sealed class CachingOpenApiProvider : ISwaggerProvider
|
||||
{
|
||||
private const string CacheKey = "openapi.json";
|
||||
|
||||
private static readonly MemoryCacheEntryOptions _cacheOptions = new() { SlidingExpiration = TimeSpan.FromMinutes(5) };
|
||||
private static readonly SemaphoreSlim _lock = new(1, 1);
|
||||
private static readonly TimeSpan _lockTimeout = TimeSpan.FromSeconds(1);
|
||||
|
||||
private readonly IMemoryCache _memoryCache;
|
||||
private readonly SwaggerGenerator _swaggerGenerator;
|
||||
private readonly SwaggerGeneratorOptions _swaggerGeneratorOptions;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CachingOpenApiProvider"/> class.
|
||||
/// </summary>
|
||||
/// <param name="optionsAccessor">The options accessor.</param>
|
||||
/// <param name="apiDescriptionsProvider">The api descriptions provider.</param>
|
||||
/// <param name="schemaGenerator">The schema generator.</param>
|
||||
/// <param name="memoryCache">The memory cache.</param>
|
||||
public CachingOpenApiProvider(
|
||||
IOptions<SwaggerGeneratorOptions> optionsAccessor,
|
||||
IApiDescriptionGroupCollectionProvider apiDescriptionsProvider,
|
||||
ISchemaGenerator schemaGenerator,
|
||||
IMemoryCache memoryCache)
|
||||
{
|
||||
_swaggerGeneratorOptions = optionsAccessor.Value;
|
||||
_swaggerGenerator = new SwaggerGenerator(_swaggerGeneratorOptions, apiDescriptionsProvider, schemaGenerator);
|
||||
_memoryCache = memoryCache;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public OpenApiDocument GetSwagger(string documentName, string? host = null, string? basePath = null)
|
||||
{
|
||||
if (_memoryCache.TryGetValue(CacheKey, out OpenApiDocument? openApiDocument) && openApiDocument is not null)
|
||||
{
|
||||
return AdjustDocument(openApiDocument, host, basePath);
|
||||
}
|
||||
|
||||
var acquired = _lock.Wait(_lockTimeout);
|
||||
try
|
||||
{
|
||||
if (_memoryCache.TryGetValue(CacheKey, out openApiDocument) && openApiDocument is not null)
|
||||
{
|
||||
return AdjustDocument(openApiDocument, host, basePath);
|
||||
}
|
||||
|
||||
if (!acquired)
|
||||
{
|
||||
throw new InvalidOperationException("OpenApi document is generating");
|
||||
}
|
||||
|
||||
openApiDocument = _swaggerGenerator.GetSwagger(documentName);
|
||||
_memoryCache.Set(CacheKey, openApiDocument, _cacheOptions);
|
||||
return AdjustDocument(openApiDocument, host, basePath);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (acquired)
|
||||
{
|
||||
_lock.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private OpenApiDocument AdjustDocument(OpenApiDocument document, string? host, string? basePath)
|
||||
{
|
||||
document.Servers = _swaggerGeneratorOptions.Servers.Count != 0
|
||||
? _swaggerGeneratorOptions.Servers
|
||||
: string.IsNullOrEmpty(host) && string.IsNullOrEmpty(basePath)
|
||||
? []
|
||||
: [new OpenApiServer { Url = $"{host}{basePath}" }];
|
||||
|
||||
return document;
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,7 @@
|
||||
<PackageReference Include="Serilog.Sinks.Console" />
|
||||
<PackageReference Include="Serilog.Sinks.File" />
|
||||
<PackageReference Include="Serilog.Sinks.Graylog" />
|
||||
<PackageReference Include="StackExchange.Redis" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Server.ServerSetupApp;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.Globalization;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Server.Migrations.Routines;
|
||||
|
||||
/// <summary>
|
||||
/// Migration to fix broken library subtitle download languages.
|
||||
/// </summary>
|
||||
[JellyfinMigration("2026-02-06T20:00:00", nameof(FixLibrarySubtitleDownloadLanguages))]
|
||||
internal class FixLibrarySubtitleDownloadLanguages : IAsyncMigrationRoutine
|
||||
{
|
||||
private readonly ILocalizationManager _localizationManager;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FixLibrarySubtitleDownloadLanguages"/> class.
|
||||
/// </summary>
|
||||
/// <param name="localizationManager">The Localization manager.</param>
|
||||
/// <param name="startupLogger">The startup logger for Startup UI integration.</param>
|
||||
/// <param name="libraryManager">The Library manager.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public FixLibrarySubtitleDownloadLanguages(
|
||||
ILocalizationManager localizationManager,
|
||||
IStartupLogger<FixLibrarySubtitleDownloadLanguages> startupLogger,
|
||||
ILibraryManager libraryManager,
|
||||
ILogger<FixLibrarySubtitleDownloadLanguages> logger)
|
||||
{
|
||||
_localizationManager = localizationManager;
|
||||
_libraryManager = libraryManager;
|
||||
_logger = startupLogger.With(logger);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task PerformAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Starting to fix library subtitle download languages.");
|
||||
|
||||
var virtualFolders = _libraryManager.GetVirtualFolders(false);
|
||||
|
||||
foreach (var virtualFolder in virtualFolders)
|
||||
{
|
||||
var options = virtualFolder.LibraryOptions;
|
||||
if (options.SubtitleDownloadLanguages is null || options.SubtitleDownloadLanguages.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Some virtual folders don't have a proper item id.
|
||||
if (!Guid.TryParse(virtualFolder.ItemId, out var folderId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var collectionFolder = _libraryManager.GetItemById<CollectionFolder>(folderId);
|
||||
if (collectionFolder is null)
|
||||
{
|
||||
_logger.LogWarning("Could not find collection folder for virtual folder '{LibraryName}' with id '{FolderId}'. Skipping.", virtualFolder.Name, folderId);
|
||||
continue;
|
||||
}
|
||||
|
||||
var fixedLanguages = new List<string>();
|
||||
|
||||
foreach (var language in options.SubtitleDownloadLanguages)
|
||||
{
|
||||
var foundLanguage = _localizationManager.FindLanguageInfo(language)?.ThreeLetterISOLanguageName;
|
||||
if (foundLanguage is not null)
|
||||
{
|
||||
// Converted ISO 639-2/B to T (ger to deu)
|
||||
if (!string.Equals(foundLanguage, language, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogInformation("Converted '{Language}' to '{ResolvedLanguage}' in library '{LibraryName}'.", language, foundLanguage, virtualFolder.Name);
|
||||
}
|
||||
|
||||
if (fixedLanguages.Contains(foundLanguage, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogInformation("Language '{Language}' already exists for library '{LibraryName}'. Skipping duplicate.", foundLanguage, virtualFolder.Name);
|
||||
continue;
|
||||
}
|
||||
|
||||
fixedLanguages.Add(foundLanguage);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("Could not resolve language '{Language}' in library '{LibraryName}'. Skipping.", language, virtualFolder.Name);
|
||||
}
|
||||
}
|
||||
|
||||
options.SubtitleDownloadLanguages = [.. fixedLanguages];
|
||||
collectionFolder.UpdateLibraryOptions(options);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Library subtitle download languages fixed.");
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -464,6 +464,16 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
|
||||
|
||||
SqliteConnection.ClearAllPools();
|
||||
|
||||
using (var checkpointConnection = new SqliteConnection($"Filename={libraryDbPath}"))
|
||||
{
|
||||
checkpointConnection.Open();
|
||||
using var cmd = checkpointConnection.CreateCommand();
|
||||
cmd.CommandText = "PRAGMA wal_checkpoint(TRUNCATE);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
SqliteConnection.ClearAllPools();
|
||||
|
||||
_logger.LogInformation("Move {0} to {1}.", libraryDbPath, libraryDbPath + ".old");
|
||||
File.Move(libraryDbPath, libraryDbPath + ".old", true);
|
||||
}
|
||||
@@ -1163,7 +1173,9 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
|
||||
Item = null!,
|
||||
ProviderId = e[0],
|
||||
ProviderValue = string.Join('|', e.Skip(1))
|
||||
}).ToArray();
|
||||
})
|
||||
.DistinctBy(e => e.ProviderId)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
if (reader.TryGetString(index++, out var imageInfos))
|
||||
|
||||
@@ -24,6 +24,7 @@ using Jellyfin.Server.ServerSetupApp;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Net;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
@@ -285,6 +286,10 @@ namespace Jellyfin.Server
|
||||
.AddJellyfinDbContext(startupConfigurationManager, startupConfig)
|
||||
.AddSingleton<IApplicationPaths>(appPaths)
|
||||
.AddSingleton<ServerApplicationPaths>(appPaths)
|
||||
// Required by NpgsqlDataSource factory in AddJellyfinDbContext when
|
||||
// DatabaseType=Jellyfin-PostgreSQL — the factory resolves this from DI
|
||||
// to read CustomProviderOptions and pool settings.
|
||||
.AddSingleton<IServerConfigurationManager>(startupConfigurationManager)
|
||||
.RegisterStartupLogger();
|
||||
|
||||
migrationStartupServiceProvider.AddSingleton(migrationStartupServiceProvider);
|
||||
|
||||
@@ -67,6 +67,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Server.Tests", "te
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Server.Integration.Tests", "tests\Jellyfin.Server.Integration.Tests\Jellyfin.Server.Integration.Tests.csproj", "{68B0B823-A5AC-4E8B-82EA-965AAC7BF76E}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Database.Tests.PostgreSQL", "tests\Jellyfin.Database.Tests.PostgreSQL\Jellyfin.Database.Tests.PostgreSQL.csproj", "{B5F2C3A1-9D4E-4F8A-B123-456789ABCDEF}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Providers.Tests", "tests\Jellyfin.Providers.Tests\Jellyfin.Providers.Tests.csproj", "{A964008C-2136-4716-B6CB-B3426C22320A}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C}"
|
||||
@@ -94,10 +96,16 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Jellyfin.Database", "Jellyf
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Database.Providers.Sqlite", "src\Jellyfin.Database\Jellyfin.Database.Providers.Sqlite\Jellyfin.Database.Providers.Sqlite.csproj", "{A5590358-33CC-4B39-BDE7-DC62FEB03C76}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Database.Providers.PostgreSQL", "src\Jellyfin.Database\Jellyfin.Database.Providers.PostgreSQL\Jellyfin.Database.Providers.PostgreSQL.csproj", "{B3C4D5E6-F7A8-4B9C-0D1E-2F3A4B5C6D7E}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Database.Implementations", "src\Jellyfin.Database\Jellyfin.Database.Implementations\Jellyfin.Database.Implementations.csproj", "{8C9F9221-8415-496C-B1F5-E7756F03FA59}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.CodeAnalysis", "src\Jellyfin.CodeAnalysis\Jellyfin.CodeAnalysis.csproj", "{11643D0F-6761-4EF7-AB71-6F9F8DE00714}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tools", "tools", "{3C85DA50-31AC-40D3-BCF4-F1B14C420996}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.DbMigrator", "tools\Jellyfin.DbMigrator\Jellyfin.DbMigrator.csproj", "{6F7187CB-E1CB-4583-98CF-0FB87F21E844}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -216,6 +224,10 @@ Global
|
||||
{68B0B823-A5AC-4E8B-82EA-965AAC7BF76E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{68B0B823-A5AC-4E8B-82EA-965AAC7BF76E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{68B0B823-A5AC-4E8B-82EA-965AAC7BF76E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B5F2C3A1-9D4E-4F8A-B123-456789ABCDEF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B5F2C3A1-9D4E-4F8A-B123-456789ABCDEF}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B5F2C3A1-9D4E-4F8A-B123-456789ABCDEF}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B5F2C3A1-9D4E-4F8A-B123-456789ABCDEF}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{A964008C-2136-4716-B6CB-B3426C22320A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A964008C-2136-4716-B6CB-B3426C22320A}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A964008C-2136-4716-B6CB-B3426C22320A}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
@@ -256,6 +268,10 @@ Global
|
||||
{A5590358-33CC-4B39-BDE7-DC62FEB03C76}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A5590358-33CC-4B39-BDE7-DC62FEB03C76}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A5590358-33CC-4B39-BDE7-DC62FEB03C76}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B3C4D5E6-F7A8-4B9C-0D1E-2F3A4B5C6D7E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B3C4D5E6-F7A8-4B9C-0D1E-2F3A4B5C6D7E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B3C4D5E6-F7A8-4B9C-0D1E-2F3A4B5C6D7E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B3C4D5E6-F7A8-4B9C-0D1E-2F3A4B5C6D7E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{8C9F9221-8415-496C-B1F5-E7756F03FA59}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{8C9F9221-8415-496C-B1F5-E7756F03FA59}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{8C9F9221-8415-496C-B1F5-E7756F03FA59}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
@@ -264,6 +280,10 @@ Global
|
||||
{11643D0F-6761-4EF7-AB71-6F9F8DE00714}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{11643D0F-6761-4EF7-AB71-6F9F8DE00714}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{11643D0F-6761-4EF7-AB71-6F9F8DE00714}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{6F7187CB-E1CB-4583-98CF-0FB87F21E844}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{6F7187CB-E1CB-4583-98CF-0FB87F21E844}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{6F7187CB-E1CB-4583-98CF-0FB87F21E844}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{6F7187CB-E1CB-4583-98CF-0FB87F21E844}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@@ -283,6 +303,7 @@ Global
|
||||
{42816EA8-4511-4CBF-A9C7-7791D5DDDAE6} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
|
||||
{3ADBCD8C-C0F2-4956-8FDC-35D686B74CF9} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
|
||||
{68B0B823-A5AC-4E8B-82EA-965AAC7BF76E} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
|
||||
{B5F2C3A1-9D4E-4F8A-B123-456789ABCDEF} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
|
||||
{A964008C-2136-4716-B6CB-B3426C22320A} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
|
||||
{750B8757-BE3D-4F8C-941A-FBAD94904ADA} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C}
|
||||
{332A5C7A-F907-47CA-910E-BE6F7371B9E0} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
|
||||
@@ -294,8 +315,10 @@ Global
|
||||
{8C6B2B13-58A4-4506-9DAB-1F882A093FE0} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C}
|
||||
{4C54CE05-69C8-48FA-8785-39F7F6DB1CAD} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C}
|
||||
{A5590358-33CC-4B39-BDE7-DC62FEB03C76} = {4C54CE05-69C8-48FA-8785-39F7F6DB1CAD}
|
||||
{B3C4D5E6-F7A8-4B9C-0D1E-2F3A4B5C6D7E} = {4C54CE05-69C8-48FA-8785-39F7F6DB1CAD}
|
||||
{8C9F9221-8415-496C-B1F5-E7756F03FA59} = {4C54CE05-69C8-48FA-8785-39F7F6DB1CAD}
|
||||
{11643D0F-6761-4EF7-AB71-6F9F8DE00714} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C}
|
||||
{6F7187CB-E1CB-4583-98CF-0FB87F21E844} = {3C85DA50-31AC-40D3-BCF4-F1B14C420996}
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {3448830C-EBDC-426C-85CD-7BBB9651A7FE}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<PropertyGroup>
|
||||
<Authors>Jellyfin Contributors</Authors>
|
||||
<PackageId>Jellyfin.Common</PackageId>
|
||||
<VersionPrefix>10.11.3</VersionPrefix>
|
||||
<VersionPrefix>10.11.7</VersionPrefix>
|
||||
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
||||
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -1172,11 +1172,18 @@ namespace MediaBrowser.Controller.Entities
|
||||
info.Video3DFormat = video.Video3DFormat;
|
||||
info.Timestamp = video.Timestamp;
|
||||
|
||||
if (video.IsShortcut)
|
||||
if (video.IsShortcut && !string.IsNullOrEmpty(video.ShortcutPath))
|
||||
{
|
||||
info.IsRemote = true;
|
||||
info.Path = video.ShortcutPath;
|
||||
info.Protocol = MediaSourceManager.GetPathProtocol(info.Path);
|
||||
var shortcutProtocol = MediaSourceManager.GetPathProtocol(video.ShortcutPath);
|
||||
|
||||
// Only allow remote shortcut paths — local file paths in .strm files
|
||||
// could be used to read arbitrary files from the server.
|
||||
if (shortcutProtocol != MediaProtocol.File)
|
||||
{
|
||||
info.IsRemote = true;
|
||||
info.Path = video.ShortcutPath;
|
||||
info.Protocol = shortcutProtocol;
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(info.Container))
|
||||
@@ -1620,12 +1627,17 @@ namespace MediaBrowser.Controller.Entities
|
||||
return isAllowed;
|
||||
}
|
||||
|
||||
if (maxAllowedSubRating is not null)
|
||||
if (!maxAllowedRating.HasValue)
|
||||
{
|
||||
return (ratingScore.SubScore ?? 0) <= maxAllowedSubRating && ratingScore.Score <= maxAllowedRating.Value;
|
||||
return true;
|
||||
}
|
||||
|
||||
return !maxAllowedRating.HasValue || ratingScore.Score <= maxAllowedRating.Value;
|
||||
if (ratingScore.Score != maxAllowedRating.Value)
|
||||
{
|
||||
return ratingScore.Score < maxAllowedRating.Value;
|
||||
}
|
||||
|
||||
return !maxAllowedSubRating.HasValue || (ratingScore.SubScore ?? 0) <= maxAllowedSubRating.Value;
|
||||
}
|
||||
|
||||
public ParentalRatingScore GetParentalRatingScore()
|
||||
@@ -2048,6 +2060,9 @@ namespace MediaBrowser.Controller.Entities
|
||||
public virtual async Task UpdateToRepositoryAsync(ItemUpdateType updateReason, CancellationToken cancellationToken)
|
||||
=> await LibraryManager.UpdateItemAsync(this, GetParent(), updateReason, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
public async Task ReattachUserDataAsync(CancellationToken cancellationToken) =>
|
||||
await LibraryManager.ReattachUserDataAsync(this, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
/// <summary>
|
||||
/// Validates that images within the item are still on the filesystem.
|
||||
/// </summary>
|
||||
|
||||
@@ -452,6 +452,7 @@ namespace MediaBrowser.Controller.Entities
|
||||
// That's all the new and changed ones - now see if any have been removed and need cleanup
|
||||
var itemsRemoved = currentChildren.Values.Except(validChildren).ToList();
|
||||
var shouldRemove = !IsRoot || allowRemoveRoot;
|
||||
var actuallyRemoved = new List<BaseItem>();
|
||||
// If it's an AggregateFolder, don't remove
|
||||
if (shouldRemove && itemsRemoved.Count > 0)
|
||||
{
|
||||
@@ -467,6 +468,7 @@ namespace MediaBrowser.Controller.Entities
|
||||
{
|
||||
Logger.LogDebug("Removed item: {Path}", item.Path);
|
||||
|
||||
actuallyRemoved.Add(item);
|
||||
item.SetParent(null);
|
||||
LibraryManager.DeleteItem(item, new DeleteOptions { DeleteFileLocation = false }, this, false);
|
||||
}
|
||||
@@ -477,6 +479,20 @@ namespace MediaBrowser.Controller.Entities
|
||||
{
|
||||
LibraryManager.CreateItems(newItems, this, cancellationToken);
|
||||
}
|
||||
|
||||
// After removing items, reattach any detached user data to remaining children
|
||||
// that share the same user data keys (eg. same episode replaced with a new file).
|
||||
if (actuallyRemoved.Count > 0)
|
||||
{
|
||||
var removedKeys = actuallyRemoved.SelectMany(i => i.GetUserDataKeys()).ToHashSet();
|
||||
foreach (var child in validChildren)
|
||||
{
|
||||
if (child.GetUserDataKeys().Any(removedKeys.Contains))
|
||||
{
|
||||
await child.ReattachUserDataAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -1406,13 +1422,6 @@ namespace MediaBrowser.Controller.Entities
|
||||
.Where(e => query is null || UserViewBuilder.FilterItem(e, query))
|
||||
.ToArray();
|
||||
|
||||
if (this is BoxSet && (query.OrderBy is null || query.OrderBy.Count == 0))
|
||||
{
|
||||
realChildren = realChildren
|
||||
.OrderBy(e => e.ProductionYear ?? int.MaxValue)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
var childCount = realChildren.Length;
|
||||
if (result.Count < limit)
|
||||
{
|
||||
|
||||
@@ -125,6 +125,8 @@ namespace MediaBrowser.Controller.Entities
|
||||
|
||||
public string? Name { get; set; }
|
||||
|
||||
public bool? UseRawName { get; set; }
|
||||
|
||||
public string? Person { get; set; }
|
||||
|
||||
public Guid[] PersonIds { get; set; }
|
||||
|
||||
@@ -124,7 +124,7 @@ namespace MediaBrowser.Controller.Entities.Movies
|
||||
|
||||
if (sortBy == ItemSortBy.Default)
|
||||
{
|
||||
return items;
|
||||
return items;
|
||||
}
|
||||
|
||||
return LibraryManager.Sort(items, user, new[] { sortBy }, SortOrder.Ascending);
|
||||
@@ -136,6 +136,12 @@ namespace MediaBrowser.Controller.Entities.Movies
|
||||
return Sort(children, user).ToArray();
|
||||
}
|
||||
|
||||
public override IReadOnlyList<BaseItem> GetChildren(User user, bool includeLinkedChildren, out int totalItemCount, InternalItemsQuery query = null)
|
||||
{
|
||||
var children = base.GetChildren(user, includeLinkedChildren, out totalItemCount, query);
|
||||
return Sort(children, user).ToArray();
|
||||
}
|
||||
|
||||
public override IReadOnlyList<BaseItem> GetRecursiveChildren(User user, InternalItemsQuery query, out int totalCount)
|
||||
{
|
||||
var children = base.GetRecursiveChildren(user, query, out totalCount);
|
||||
|
||||
@@ -201,12 +201,17 @@ namespace MediaBrowser.Controller.Entities.TV
|
||||
|
||||
public List<BaseItem> GetEpisodes(Series series, User user, IEnumerable<Episode> allSeriesEpisodes, DtoOptions options, bool shouldIncludeMissingEpisodes)
|
||||
{
|
||||
if (series is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return series.GetSeasonEpisodes(this, user, allSeriesEpisodes, options, shouldIncludeMissingEpisodes);
|
||||
}
|
||||
|
||||
public List<BaseItem> GetEpisodes()
|
||||
{
|
||||
return Series.GetSeasonEpisodes(this, null, null, new DtoOptions(true), true);
|
||||
return GetEpisodes(Series, null, null, new DtoOptions(true), true);
|
||||
}
|
||||
|
||||
public override List<BaseItem> GetChildren(User user, bool includeLinkedChildren, InternalItemsQuery query)
|
||||
|
||||
@@ -214,7 +214,7 @@ namespace MediaBrowser.Controller.Entities.TV
|
||||
query.AncestorWithPresentationUniqueKey = null;
|
||||
query.SeriesPresentationUniqueKey = seriesKey;
|
||||
query.IncludeItemTypes = new[] { BaseItemKind.Season };
|
||||
query.OrderBy = new[] { (ItemSortBy.IndexNumber, SortOrder.Ascending) };
|
||||
query.OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) };
|
||||
|
||||
if (user is not null && !user.DisplayMissingEpisodes)
|
||||
{
|
||||
@@ -247,6 +247,10 @@ namespace MediaBrowser.Controller.Entities.TV
|
||||
|
||||
query.AncestorWithPresentationUniqueKey = null;
|
||||
query.SeriesPresentationUniqueKey = seriesKey;
|
||||
if (query.OrderBy.Count == 0)
|
||||
{
|
||||
query.OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) };
|
||||
}
|
||||
|
||||
if (query.IncludeItemTypes.Length == 0)
|
||||
{
|
||||
|
||||
@@ -63,6 +63,29 @@ public static class FileSystemHelper
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a single link hop for the specified path.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Returns <c>null</c> if the path is not a symbolic link or the filesystem does not support link resolution (e.g., exFAT).
|
||||
/// </remarks>
|
||||
/// <param name="path">The file path to resolve.</param>
|
||||
/// <returns>
|
||||
/// A <see cref="FileInfo"/> representing the next link target if the path is a link; otherwise, <c>null</c>.
|
||||
/// </returns>
|
||||
private static FileInfo? Resolve(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
return File.ResolveLinkTarget(path, returnFinalTarget: false) as FileInfo;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Filesystem doesn't support links (e.g., exFAT).
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the target of the specified file link.
|
||||
/// </summary>
|
||||
@@ -84,23 +107,26 @@ public static class FileSystemHelper
|
||||
|
||||
if (!returnFinalTarget)
|
||||
{
|
||||
return File.ResolveLinkTarget(linkPath, returnFinalTarget: false) as FileInfo;
|
||||
return Resolve(linkPath);
|
||||
}
|
||||
|
||||
if (File.ResolveLinkTarget(linkPath, returnFinalTarget: false) is not FileInfo targetInfo)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!targetInfo.Exists)
|
||||
var targetInfo = Resolve(linkPath);
|
||||
if (targetInfo is null || !targetInfo.Exists)
|
||||
{
|
||||
return targetInfo;
|
||||
}
|
||||
|
||||
var currentPath = targetInfo.FullName;
|
||||
var visited = new HashSet<string>(StringComparer.Ordinal) { linkPath, currentPath };
|
||||
while (File.ResolveLinkTarget(currentPath, returnFinalTarget: false) is FileInfo linkInfo)
|
||||
|
||||
while (true)
|
||||
{
|
||||
var linkInfo = Resolve(currentPath);
|
||||
if (linkInfo is null)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var targetPath = linkInfo.FullName;
|
||||
|
||||
// If an infinite loop is detected, return the file info for the
|
||||
|
||||
@@ -281,6 +281,14 @@ namespace MediaBrowser.Controller.Library
|
||||
/// <returns>Returns a Task that can be awaited.</returns>
|
||||
Task UpdateItemAsync(BaseItem item, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Reattaches the user data to the item.
|
||||
/// </summary>
|
||||
/// <param name="item">The item.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous reattachment operation.</returns>
|
||||
Task ReattachUserDataAsync(BaseItem item, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the item.
|
||||
/// </summary>
|
||||
@@ -652,5 +660,12 @@ namespace MediaBrowser.Controller.Library
|
||||
/// This exists so plugins can trigger a library scan.
|
||||
/// </remarks>
|
||||
void QueueLibraryScan();
|
||||
|
||||
/// <summary>
|
||||
/// Add mblink file for a media path.
|
||||
/// </summary>
|
||||
/// <param name="virtualFolderPath">The path to the virtualfolder.</param>
|
||||
/// <param name="pathInfo">The new virtualfolder.</param>
|
||||
public void CreateShortcut(string virtualFolderPath, MediaPathInfo pathInfo);
|
||||
}
|
||||
}
|
||||
|
||||
+13
-34
@@ -4,6 +4,7 @@ using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
@@ -29,7 +30,7 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr
|
||||
/// </summary>
|
||||
private readonly Lock _taskLock = new();
|
||||
|
||||
private readonly BlockingCollection<TaskQueueItem> _tasks = new();
|
||||
private readonly Channel<TaskQueueItem> _tasks = Channel.CreateUnbounded<TaskQueueItem>();
|
||||
|
||||
private volatile int _workCounter;
|
||||
private Task? _cleanupTask;
|
||||
@@ -77,7 +78,7 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr
|
||||
|
||||
lock (_taskLock)
|
||||
{
|
||||
if (_tasks.Count > 0 || _workCounter > 0)
|
||||
if (_tasks.Reader.Count > 0 || _workCounter > 0)
|
||||
{
|
||||
_logger.LogDebug("Delay cleanup task, operations still running.");
|
||||
// tasks are still there so its still in use. Reschedule cleanup task.
|
||||
@@ -144,9 +145,9 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr
|
||||
_deadlockDetector.Value = stopToken.TaskStop;
|
||||
try
|
||||
{
|
||||
foreach (var item in _tasks.GetConsumingEnumerable(stopToken.GlobalStop.Token))
|
||||
while (!stopToken.GlobalStop.Token.IsCancellationRequested)
|
||||
{
|
||||
stopToken.GlobalStop.Token.ThrowIfCancellationRequested();
|
||||
var item = await _tasks.Reader.ReadAsync(stopToken.GlobalStop.Token).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var newWorkerLimit = Interlocked.Increment(ref _workCounter) > 0;
|
||||
@@ -242,7 +243,7 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr
|
||||
};
|
||||
}).ToArray();
|
||||
|
||||
if (ShouldForceSequentialOperation())
|
||||
if (ShouldForceSequentialOperation() || _deadlockDetector.Value is not null)
|
||||
{
|
||||
_logger.LogDebug("Process sequentially.");
|
||||
try
|
||||
@@ -264,35 +265,14 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr
|
||||
for (var i = 0; i < workItems.Length; i++)
|
||||
{
|
||||
var item = workItems[i]!;
|
||||
_tasks.Add(item, CancellationToken.None);
|
||||
await _tasks.Writer.WriteAsync(item, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (_deadlockDetector.Value is not null)
|
||||
{
|
||||
_logger.LogDebug("Nested invocation detected, process in-place.");
|
||||
try
|
||||
{
|
||||
// we are in a nested loop. There is no reason to spawn a task here as that would just lead to deadlocks and no additional concurrency is achieved
|
||||
while (workItems.Any(e => !e.Done.Task.IsCompleted) && _tasks.TryTake(out var item, 200, _deadlockDetector.Value.Token))
|
||||
{
|
||||
await ProcessItem(item).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (_deadlockDetector.Value.IsCancellationRequested)
|
||||
{
|
||||
// operation is cancelled. Do nothing.
|
||||
}
|
||||
|
||||
_logger.LogDebug("process in-place done.");
|
||||
}
|
||||
else
|
||||
{
|
||||
Worker();
|
||||
_logger.LogDebug("Wait for {NoWorkers} to complete.", workItems.Length);
|
||||
await Task.WhenAll([.. workItems.Select(f => f.Done.Task)]).ConfigureAwait(false);
|
||||
_logger.LogDebug("{NoWorkers} completed.", workItems.Length);
|
||||
ScheduleTaskCleanup();
|
||||
}
|
||||
Worker();
|
||||
_logger.LogDebug("Wait for {NoWorkers} to complete.", workItems.Length);
|
||||
await Task.WhenAll([.. workItems.Select(f => f.Done.Task)]).ConfigureAwait(false);
|
||||
_logger.LogDebug("{NoWorkers} completed.", workItems.Length);
|
||||
ScheduleTaskCleanup();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -304,13 +284,12 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
_tasks.CompleteAdding();
|
||||
_tasks.Writer.Complete();
|
||||
foreach (var item in _taskRunners)
|
||||
{
|
||||
await item.Key.CancelAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_tasks.Dispose();
|
||||
if (_cleanupTask is not null)
|
||||
{
|
||||
await _cleanupTask.ConfigureAwait(false);
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<PropertyGroup>
|
||||
<Authors>Jellyfin Contributors</Authors>
|
||||
<PackageId>Jellyfin.Controller</PackageId>
|
||||
<VersionPrefix>10.11.3</VersionPrefix>
|
||||
<VersionPrefix>10.11.7</VersionPrefix>
|
||||
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
||||
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -33,18 +33,18 @@ namespace MediaBrowser.Controller.MediaEncoding
|
||||
public partial class EncodingHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// The codec validation regex.
|
||||
/// The codec validation regex string.
|
||||
/// This regular expression matches strings that consist of alphanumeric characters, hyphens,
|
||||
/// periods, underscores, commas, and vertical bars, with a length between 0 and 40 characters.
|
||||
/// This should matches all common valid codecs.
|
||||
/// </summary>
|
||||
public const string ContainerValidationRegex = @"^[a-zA-Z0-9\-\._,|]{0,40}$";
|
||||
public const string ContainerValidationRegexStr = @"^[a-zA-Z0-9\-\._,|]{0,40}$";
|
||||
|
||||
/// <summary>
|
||||
/// The level validation regex.
|
||||
/// The level validation regex string.
|
||||
/// This regular expression matches strings representing a double.
|
||||
/// </summary>
|
||||
public const string LevelValidationRegex = @"-?[0-9]+(?:\.[0-9]+)?";
|
||||
public const string LevelValidationRegexStr = @"-?[0-9]+(?:\.[0-9]+)?";
|
||||
|
||||
private const string _defaultMjpegEncoder = "mjpeg";
|
||||
|
||||
@@ -85,8 +85,7 @@ namespace MediaBrowser.Controller.MediaEncoding
|
||||
private readonly Version _minFFmpegVaapiDeviceVendorId = new Version(7, 0, 1);
|
||||
private readonly Version _minFFmpegQsvVppScaleModeOption = new Version(6, 0);
|
||||
private readonly Version _minFFmpegRkmppHevcDecDoviRpu = new Version(7, 1, 1);
|
||||
|
||||
private static readonly Regex _containerValidationRegex = new(ContainerValidationRegex, RegexOptions.Compiled);
|
||||
private readonly Version _minFFmpegReadrateCatchupOption = new Version(8, 0);
|
||||
|
||||
private static readonly string[] _videoProfilesH264 =
|
||||
[
|
||||
@@ -180,6 +179,15 @@ namespace MediaBrowser.Controller.MediaEncoding
|
||||
RemoveHdr10Plus,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The codec validation regex.
|
||||
/// This regular expression matches strings that consist of alphanumeric characters, hyphens,
|
||||
/// periods, underscores, commas, and vertical bars, with a length between 0 and 40 characters.
|
||||
/// This should matches all common valid codecs.
|
||||
/// </summary>
|
||||
[GeneratedRegex(@"^[a-zA-Z0-9\-\._,|]{0,40}$")]
|
||||
public static partial Regex ContainerValidationRegex();
|
||||
|
||||
[GeneratedRegex(@"\s+")]
|
||||
private static partial Regex WhiteSpaceRegex();
|
||||
|
||||
@@ -476,7 +484,7 @@ namespace MediaBrowser.Controller.MediaEncoding
|
||||
return GetMjpegEncoder(state, encodingOptions);
|
||||
}
|
||||
|
||||
if (_containerValidationRegex.IsMatch(codec))
|
||||
if (ContainerValidationRegex().IsMatch(codec))
|
||||
{
|
||||
return codec.ToLowerInvariant();
|
||||
}
|
||||
@@ -517,7 +525,7 @@ namespace MediaBrowser.Controller.MediaEncoding
|
||||
|
||||
public static string GetInputFormat(string container)
|
||||
{
|
||||
if (string.IsNullOrEmpty(container) || !_containerValidationRegex.IsMatch(container))
|
||||
if (string.IsNullOrEmpty(container) || !ContainerValidationRegex().IsMatch(container))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
@@ -735,7 +743,7 @@ namespace MediaBrowser.Controller.MediaEncoding
|
||||
{
|
||||
var codec = state.OutputAudioCodec;
|
||||
|
||||
if (!_containerValidationRegex.IsMatch(codec))
|
||||
if (!ContainerValidationRegex().IsMatch(codec))
|
||||
{
|
||||
codec = "aac";
|
||||
}
|
||||
@@ -1267,6 +1275,20 @@ namespace MediaBrowser.Controller.MediaEncoding
|
||||
}
|
||||
}
|
||||
|
||||
// Use analyzeduration also for subtitle streams to improve resolution detection with streams inside MKS files
|
||||
var analyzeDurationArgument = GetFfmpegAnalyzeDurationArg(state);
|
||||
if (!string.IsNullOrEmpty(analyzeDurationArgument))
|
||||
{
|
||||
arg.Append(' ').Append(analyzeDurationArgument);
|
||||
}
|
||||
|
||||
// Apply probesize, too, if configured
|
||||
var ffmpegProbeSizeArgument = GetFfmpegProbesizeArg();
|
||||
if (!string.IsNullOrEmpty(ffmpegProbeSizeArgument))
|
||||
{
|
||||
arg.Append(' ').Append(ffmpegProbeSizeArgument);
|
||||
}
|
||||
|
||||
// Also seek the external subtitles stream.
|
||||
var seekSubParam = GetFastSeekCommandLineParameter(state, options, segmentContainer);
|
||||
if (!string.IsNullOrEmpty(seekSubParam))
|
||||
@@ -1768,38 +1790,40 @@ namespace MediaBrowser.Controller.MediaEncoding
|
||||
|
||||
public static string NormalizeTranscodingLevel(EncodingJobInfo state, string level)
|
||||
{
|
||||
if (double.TryParse(level, CultureInfo.InvariantCulture, out double requestLevel))
|
||||
if (!double.TryParse(level, CultureInfo.InvariantCulture, out double requestLevel))
|
||||
{
|
||||
if (string.Equals(state.ActualOutputVideoCodec, "av1", StringComparison.OrdinalIgnoreCase))
|
||||
return null;
|
||||
}
|
||||
|
||||
if (string.Equals(state.ActualOutputVideoCodec, "av1", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Transcode to level 5.3 (15) and lower for maximum compatibility.
|
||||
// https://en.wikipedia.org/wiki/AV1#Levels
|
||||
if (requestLevel < 0 || requestLevel >= 15)
|
||||
{
|
||||
// Transcode to level 5.3 (15) and lower for maximum compatibility.
|
||||
// https://en.wikipedia.org/wiki/AV1#Levels
|
||||
if (requestLevel < 0 || requestLevel >= 15)
|
||||
{
|
||||
return "15";
|
||||
}
|
||||
return "15";
|
||||
}
|
||||
else if (string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase))
|
||||
}
|
||||
else if (string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(state.ActualOutputVideoCodec, "h265", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Transcode to level 5.0 and lower for maximum compatibility.
|
||||
// Level 5.0 is suitable for up to 4k 30fps hevc encoding, otherwise let the encoder to handle it.
|
||||
// https://en.wikipedia.org/wiki/High_Efficiency_Video_Coding_tiers_and_levels
|
||||
// MaxLumaSampleRate = 3840*2160*30 = 248832000 < 267386880.
|
||||
if (requestLevel < 0 || requestLevel >= 150)
|
||||
{
|
||||
// Transcode to level 5.0 and lower for maximum compatibility.
|
||||
// Level 5.0 is suitable for up to 4k 30fps hevc encoding, otherwise let the encoder to handle it.
|
||||
// https://en.wikipedia.org/wiki/High_Efficiency_Video_Coding_tiers_and_levels
|
||||
// MaxLumaSampleRate = 3840*2160*30 = 248832000 < 267386880.
|
||||
if (requestLevel < 0 || requestLevel >= 150)
|
||||
{
|
||||
return "150";
|
||||
}
|
||||
return "150";
|
||||
}
|
||||
else if (string.Equals(state.ActualOutputVideoCodec, "h264", StringComparison.OrdinalIgnoreCase))
|
||||
}
|
||||
else if (string.Equals(state.ActualOutputVideoCodec, "h264", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Transcode to level 5.1 and lower for maximum compatibility.
|
||||
// h264 4k 30fps requires at least level 5.1 otherwise it will break on safari fmp4.
|
||||
// https://en.wikipedia.org/wiki/Advanced_Video_Coding#Levels
|
||||
if (requestLevel < 0 || requestLevel >= 51)
|
||||
{
|
||||
// Transcode to level 5.1 and lower for maximum compatibility.
|
||||
// h264 4k 30fps requires at least level 5.1 otherwise it will break on safari fmp4.
|
||||
// https://en.wikipedia.org/wiki/Advanced_Video_Coding#Levels
|
||||
if (requestLevel < 0 || requestLevel >= 51)
|
||||
{
|
||||
return "51";
|
||||
}
|
||||
return "51";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2189,12 +2213,10 @@ namespace MediaBrowser.Controller.MediaEncoding
|
||||
}
|
||||
}
|
||||
|
||||
var level = state.GetRequestedLevel(targetVideoCodec);
|
||||
var level = NormalizeTranscodingLevel(state, state.GetRequestedLevel(targetVideoCodec));
|
||||
|
||||
if (!string.IsNullOrEmpty(level))
|
||||
{
|
||||
level = NormalizeTranscodingLevel(state, level);
|
||||
|
||||
// libx264, QSV, AMF can adjust the given level to match the output.
|
||||
if (string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(videoEncoder, "libx264", StringComparison.OrdinalIgnoreCase))
|
||||
@@ -2378,6 +2400,13 @@ namespace MediaBrowser.Controller.MediaEncoding
|
||||
var requestHasSDR = requestedRangeTypes.Contains(VideoRangeType.SDR.ToString(), StringComparison.OrdinalIgnoreCase);
|
||||
var requestHasDOVI = requestedRangeTypes.Contains(VideoRangeType.DOVI.ToString(), StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// If SDR is the only supported range, we should not copy any of the HDR streams.
|
||||
// All the following copy check assumes at least one HDR format is supported.
|
||||
if (requestedRangeTypes.Length == 1 && requestHasSDR && videoStream.VideoRangeType != VideoRangeType.SDR)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// If the client does not support DOVI and the video stream is DOVI without fallback, we should not copy it.
|
||||
if (!requestHasDOVI && videoStream.VideoRangeType == VideoRangeType.DOVI)
|
||||
{
|
||||
@@ -5942,28 +5971,37 @@ namespace MediaBrowser.Controller.MediaEncoding
|
||||
|
||||
var isFullAfbcPipeline = isEncoderSupportAfbc && isDrmInDrmOut && !doOclTonemap;
|
||||
var swapOutputWandH = doRkVppTranspose && swapWAndH;
|
||||
var outFormat = doOclTonemap ? "p010" : (isMjpegEncoder ? "bgra" : "nv12"); // RGA only support full range in rgb fmts
|
||||
var outFormat = doOclTonemap ? "p010" : "nv12";
|
||||
var hwScaleFilter = GetHwScaleFilter("vpp", "rkrga", outFormat, swapOutputWandH, swpInW, swpInH, reqW, reqH, reqMaxW, reqMaxH);
|
||||
var doScaling = GetHwScaleFilter("vpp", "rkrga", string.Empty, swapOutputWandH, swpInW, swpInH, reqW, reqH, reqMaxW, reqMaxH);
|
||||
var doScaling = !string.IsNullOrEmpty(GetHwScaleFilter("vpp", "rkrga", string.Empty, swapOutputWandH, swpInW, swpInH, reqW, reqH, reqMaxW, reqMaxH));
|
||||
|
||||
if (!hasSubs
|
||||
|| doRkVppTranspose
|
||||
|| !isFullAfbcPipeline
|
||||
|| !string.IsNullOrEmpty(doScaling))
|
||||
|| doScaling)
|
||||
{
|
||||
var isScaleRatioSupported = IsScaleRatioSupported(inW, inH, reqW, reqH, reqMaxW, reqMaxH, 8.0f);
|
||||
|
||||
// RGA3 hardware only support (1/8 ~ 8) scaling in each blit operation,
|
||||
// but in Trickplay there's a case: (3840/320 == 12), enable 2pass for it
|
||||
if (!string.IsNullOrEmpty(doScaling)
|
||||
&& !IsScaleRatioSupported(inW, inH, reqW, reqH, reqMaxW, reqMaxH, 8.0f))
|
||||
if (doScaling && !isScaleRatioSupported)
|
||||
{
|
||||
// Vendor provided BSP kernel has an RGA driver bug that causes the output to be corrupted for P010 format.
|
||||
// Use NV15 instead of P010 to avoid the issue.
|
||||
// SDR inputs are using BGRA formats already which is not affected.
|
||||
var intermediateFormat = string.Equals(outFormat, "p010", StringComparison.OrdinalIgnoreCase) ? "nv15" : outFormat;
|
||||
var intermediateFormat = doOclTonemap ? "nv15" : (isMjpegEncoder ? "bgra" : outFormat);
|
||||
var hwScaleFilterFirstPass = $"scale_rkrga=w=iw/7.9:h=ih/7.9:format={intermediateFormat}:force_original_aspect_ratio=increase:force_divisible_by=4:afbc=1";
|
||||
mainFilters.Add(hwScaleFilterFirstPass);
|
||||
}
|
||||
|
||||
// The RKMPP MJPEG encoder on some newer chip models no longer supports RGB input.
|
||||
// Use 2pass here to enable RGA output of full-range YUV in the 2nd pass.
|
||||
if (isMjpegEncoder && !doOclTonemap && ((doScaling && isScaleRatioSupported) || !doScaling))
|
||||
{
|
||||
var hwScaleFilterFirstPass = "vpp_rkrga=format=bgra:afbc=1";
|
||||
mainFilters.Add(hwScaleFilterFirstPass);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(hwScaleFilter) && doRkVppTranspose)
|
||||
{
|
||||
hwScaleFilter += $":transpose={transposeDir}";
|
||||
@@ -6343,6 +6381,19 @@ namespace MediaBrowser.Controller.MediaEncoding
|
||||
}
|
||||
}
|
||||
|
||||
// Block unsupported H.264 Hi422P and Hi444PP profiles, which can be encoded with 4:2:0 pixel format
|
||||
if (string.Equals(videoStream.Codec, "h264", StringComparison.OrdinalIgnoreCase)
|
||||
&& ((videoStream.Profile?.Contains("4:2:2", StringComparison.OrdinalIgnoreCase) ?? false)
|
||||
|| (videoStream.Profile?.Contains("4:4:4", StringComparison.OrdinalIgnoreCase) ?? false)))
|
||||
{
|
||||
// VideoToolbox on Apple Silicon has H.264 Hi444PP and theoretically also has Hi422P
|
||||
if (!(hardwareAccelerationType == HardwareAccelerationType.videotoolbox
|
||||
&& RuntimeInformation.OSArchitecture.Equals(Architecture.Arm64)))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
var decoder = hardwareAccelerationType switch
|
||||
{
|
||||
HardwareAccelerationType.vaapi => GetVaapiVidDecoder(state, options, videoStream, bitDepth),
|
||||
@@ -7023,8 +7074,8 @@ namespace MediaBrowser.Controller.MediaEncoding
|
||||
|
||||
if (string.Equals(videoStream.Codec, "av1", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var accelType = GetHwaccelType(state, options, "av1", bitDepth, hwSurface);
|
||||
return accelType + ((!string.IsNullOrEmpty(accelType) && isAfbcSupported) ? " -afbc rga" : string.Empty);
|
||||
// there's an issue about AV1 AFBC on RK3588, disable it for now until it's fixed upstream
|
||||
return GetHwaccelType(state, options, "av1", bitDepth, hwSurface);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7087,9 +7138,8 @@ namespace MediaBrowser.Controller.MediaEncoding
|
||||
}
|
||||
}
|
||||
|
||||
public string GetInputModifier(EncodingJobInfo state, EncodingOptions encodingOptions, string segmentContainer)
|
||||
private string GetFfmpegAnalyzeDurationArg(EncodingJobInfo state)
|
||||
{
|
||||
var inputModifier = string.Empty;
|
||||
var analyzeDurationArgument = string.Empty;
|
||||
|
||||
// Apply -analyzeduration as per the environment variable,
|
||||
@@ -7105,6 +7155,26 @@ namespace MediaBrowser.Controller.MediaEncoding
|
||||
analyzeDurationArgument = "-analyzeduration " + ffmpegAnalyzeDuration;
|
||||
}
|
||||
|
||||
return analyzeDurationArgument;
|
||||
}
|
||||
|
||||
private string GetFfmpegProbesizeArg()
|
||||
{
|
||||
var ffmpegProbeSize = _config.GetFFmpegProbeSize();
|
||||
|
||||
if (!string.IsNullOrEmpty(ffmpegProbeSize))
|
||||
{
|
||||
return $"-probesize {ffmpegProbeSize}";
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
public string GetInputModifier(EncodingJobInfo state, EncodingOptions encodingOptions, string segmentContainer)
|
||||
{
|
||||
var inputModifier = string.Empty;
|
||||
var analyzeDurationArgument = GetFfmpegAnalyzeDurationArg(state);
|
||||
|
||||
if (!string.IsNullOrEmpty(analyzeDurationArgument))
|
||||
{
|
||||
inputModifier += " " + analyzeDurationArgument;
|
||||
@@ -7113,11 +7183,11 @@ namespace MediaBrowser.Controller.MediaEncoding
|
||||
inputModifier = inputModifier.Trim();
|
||||
|
||||
// Apply -probesize if configured
|
||||
var ffmpegProbeSize = _config.GetFFmpegProbeSize();
|
||||
var ffmpegProbeSizeArgument = GetFfmpegProbesizeArg();
|
||||
|
||||
if (!string.IsNullOrEmpty(ffmpegProbeSize))
|
||||
if (!string.IsNullOrEmpty(ffmpegProbeSizeArgument))
|
||||
{
|
||||
inputModifier += $" -probesize {ffmpegProbeSize}";
|
||||
inputModifier += " " + ffmpegProbeSizeArgument;
|
||||
}
|
||||
|
||||
var userAgentParam = GetUserAgentParam(state);
|
||||
@@ -7157,8 +7227,10 @@ namespace MediaBrowser.Controller.MediaEncoding
|
||||
inputModifier += GetVideoSyncOption(state.InputVideoSync, _mediaEncoder.EncoderVersion);
|
||||
}
|
||||
|
||||
int readrate = 0;
|
||||
if (state.ReadInputAtNativeFramerate && state.InputProtocol != MediaProtocol.Rtsp)
|
||||
{
|
||||
readrate = 1;
|
||||
inputModifier += " -re";
|
||||
}
|
||||
else if (encodingOptions.EnableSegmentDeletion
|
||||
@@ -7169,7 +7241,15 @@ namespace MediaBrowser.Controller.MediaEncoding
|
||||
{
|
||||
// Set an input read rate limit 10x for using SegmentDeletion with stream-copy
|
||||
// to prevent ffmpeg from exiting prematurely (due to fast drive)
|
||||
inputModifier += " -readrate 10";
|
||||
readrate = 10;
|
||||
inputModifier += $" -readrate {readrate}";
|
||||
}
|
||||
|
||||
// Set a larger catchup value to revert to the old behavior,
|
||||
// otherwise, remuxing might stall due to this new option
|
||||
if (readrate > 0 && _mediaEncoder.EncoderVersion >= _minFFmpegReadrateCatchupOption)
|
||||
{
|
||||
inputModifier += $" -readrate_catchup {readrate * 100}";
|
||||
}
|
||||
|
||||
var flags = new List<string>();
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace MediaBrowser.Controller.MediaEncoding;
|
||||
|
||||
/// <summary>
|
||||
/// Provides a durable store for HLS transcoding session state, enabling
|
||||
/// HA recovery and lease-based ownership between pods.
|
||||
/// </summary>
|
||||
public interface ITranscodeSessionStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Attempts to retrieve a transcoding session by its play session identifier.
|
||||
/// </summary>
|
||||
/// <param name="playSessionId">The play session identifier.</param>
|
||||
/// <param name="cancellationToken">A cancellation token.</param>
|
||||
/// <returns>
|
||||
/// The <see cref="TranscodeSession"/> if it exists and its lease has not expired;
|
||||
/// otherwise <c>null</c>.
|
||||
/// </returns>
|
||||
Task<TranscodeSession?> TryGetAsync(string playSessionId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to take over ownership of an existing session by claiming the lease for
|
||||
/// <paramref name="claimingPod"/>. Takeover succeeds only when the session exists and
|
||||
/// its current lease has already expired.
|
||||
/// </summary>
|
||||
/// <param name="playSessionId">The play session identifier.</param>
|
||||
/// <param name="claimingPod">The name of the pod attempting to claim ownership.</param>
|
||||
/// <param name="cancellationToken">A cancellation token.</param>
|
||||
/// <returns>
|
||||
/// <c>true</c> if the takeover succeeded (the claiming pod now holds the lease);
|
||||
/// <c>false</c> if the session does not exist, its lease is still valid, or another
|
||||
/// concurrent caller already claimed it.
|
||||
/// </returns>
|
||||
Task<bool> TryTakeoverAsync(string playSessionId, string claimingPod, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Persists a new or updated transcoding session.
|
||||
/// </summary>
|
||||
/// <param name="session">The session to store.</param>
|
||||
/// <param name="cancellationToken">A cancellation token.</param>
|
||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||
Task SetAsync(TranscodeSession session, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Renews the lease for an existing session, extending its
|
||||
/// <see cref="TranscodeSession.LeaseExpiresUtc"/> by the store's configured lease duration.
|
||||
/// </summary>
|
||||
/// <param name="playSessionId">The play session identifier.</param>
|
||||
/// <param name="cancellationToken">A cancellation token.</param>
|
||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||
Task RenewLeaseAsync(string playSessionId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Removes a transcoding session from the store.
|
||||
/// </summary>
|
||||
/// <param name="playSessionId">The play session identifier.</param>
|
||||
/// <param name="cancellationToken">A cancellation token.</param>
|
||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||
Task DeleteAsync(string playSessionId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns all currently active transcoding sessions from the store.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A cancellation token.</param>
|
||||
/// <returns>
|
||||
/// An enumerable of <see cref="TranscodeSession"/> objects representing all active sessions.
|
||||
/// Returns an empty enumerable if no sessions are active or if the store cannot be reached.
|
||||
/// </returns>
|
||||
Task<IEnumerable<TranscodeSession>> GetActiveSessionsAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Persists a live stream session record so that takeover pods can identify and close
|
||||
/// streams that were opened on a pod that has since crashed or been evicted.
|
||||
/// </summary>
|
||||
/// <param name="session">The live stream session to store.</param>
|
||||
/// <param name="cancellationToken">A cancellation token.</param>
|
||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||
Task SetLiveStreamAsync(LiveStreamSession session, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to retrieve a live stream session by its live stream identifier and the
|
||||
/// session or play-session identifier that owns it.
|
||||
/// </summary>
|
||||
/// <param name="liveStreamId">The live stream identifier.</param>
|
||||
/// <param name="sessionIdOrPlaySessionId">The session identifier or play-session identifier.</param>
|
||||
/// <param name="cancellationToken">A cancellation token.</param>
|
||||
/// <returns>
|
||||
/// The <see cref="LiveStreamSession"/> if it exists; otherwise <c>null</c>.
|
||||
/// </returns>
|
||||
Task<LiveStreamSession?> TryGetLiveStreamAsync(string liveStreamId, string sessionIdOrPlaySessionId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Removes the live stream session record for the given live stream and session identifier.
|
||||
/// This is called when the stream is closed, either by the owning pod or a takeover pod.
|
||||
/// </summary>
|
||||
/// <param name="liveStreamId">The live stream identifier.</param>
|
||||
/// <param name="sessionIdOrPlaySessionId">The session identifier or play-session identifier.</param>
|
||||
/// <param name="cancellationToken">A cancellation token.</param>
|
||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||
Task DeleteLiveStreamAsync(string liveStreamId, string sessionIdOrPlaySessionId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using System;
|
||||
|
||||
namespace MediaBrowser.Controller.MediaEncoding;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a durable record of an open live stream session, enabling HA pod recovery
|
||||
/// when the owning pod crashes or is evicted.
|
||||
/// </summary>
|
||||
public sealed class LiveStreamSession
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the live stream identifier (e.g. a TV tuner channel token).
|
||||
/// </summary>
|
||||
public string LiveStreamId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the session identifier of the client that opened this live stream.
|
||||
/// </summary>
|
||||
public string SessionId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the play session identifier associated with this live stream,
|
||||
/// or an empty string when the client did not supply one.
|
||||
/// </summary>
|
||||
public string PlaySessionId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the name of the pod that currently holds this live stream open.
|
||||
/// </summary>
|
||||
public string OwnerPod { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the UTC time at which this record was created.
|
||||
/// </summary>
|
||||
public DateTime OpenedAtUtc { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace MediaBrowser.Controller.MediaEncoding;
|
||||
|
||||
/// <summary>
|
||||
/// A no-op implementation of <see cref="ITranscodeSessionStore"/> used in single-instance deployments
|
||||
/// where durable session tracking across pods is not required.
|
||||
/// </summary>
|
||||
public sealed class NullTranscodeSessionStore : ITranscodeSessionStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task<TranscodeSession?> TryGetAsync(string playSessionId, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<TranscodeSession?>(null);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> TryTakeoverAsync(string playSessionId, string claimingPod, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(false);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SetAsync(TranscodeSession session, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task RenewLeaseAsync(string playSessionId, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task DeleteAsync(string playSessionId, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IEnumerable<TranscodeSession>> GetActiveSessionsAsync(CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<IEnumerable<TranscodeSession>>(Array.Empty<TranscodeSession>());
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SetLiveStreamAsync(LiveStreamSession session, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<LiveStreamSession?> TryGetLiveStreamAsync(string liveStreamId, string sessionIdOrPlaySessionId, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<LiveStreamSession?>(null);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task DeleteLiveStreamAsync(string liveStreamId, string sessionIdOrPlaySessionId, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System;
|
||||
|
||||
namespace MediaBrowser.Controller.MediaEncoding;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a durable record of an HLS transcoding session for HA pod recovery.
|
||||
/// </summary>
|
||||
public sealed class TranscodeSession
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the unique play session identifier.
|
||||
/// </summary>
|
||||
public string PlaySessionId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the name of the pod that currently owns this session's lease.
|
||||
/// </summary>
|
||||
public string OwnerPod { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the UTC time at which the owning pod's lease expires.
|
||||
/// </summary>
|
||||
public DateTime LeaseExpiresUtc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the absolute path to the HLS manifest (.m3u8) file on shared storage.
|
||||
/// </summary>
|
||||
public string ManifestPath { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the path prefix for transcoded segment files on shared storage.
|
||||
/// </summary>
|
||||
public string SegmentPathPrefix { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the media source identifier associated with this session.
|
||||
/// </summary>
|
||||
public string MediaSourceId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the zero-based index of the last segment that was fully written to durable storage.
|
||||
/// </summary>
|
||||
public int LastCompletedSegmentIndex { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the last durable playback offset in ticks, used to resume playback after failover.
|
||||
/// </summary>
|
||||
public long LastDurablePlaybackOffset { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace MediaBrowser.Controller.MediaEncoding;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the transcode session store.
|
||||
/// </summary>
|
||||
public sealed class TranscodeStoreOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the Redis connection string.
|
||||
/// A <c>null</c> or empty value indicates single-instance mode, where
|
||||
/// <see cref="NullTranscodeSessionStore"/> is used instead of a Redis-backed store.
|
||||
/// </summary>
|
||||
public string? RedisConnectionString { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the duration in seconds for which a transcoding session lease is valid.
|
||||
/// </summary>
|
||||
public int LeaseDurationSeconds { get; set; } = 30;
|
||||
}
|
||||
@@ -35,6 +35,14 @@ public interface IItemRepository
|
||||
|
||||
void SaveImages(BaseItem item);
|
||||
|
||||
/// <summary>
|
||||
/// Reattaches the user data to the item.
|
||||
/// </summary>
|
||||
/// <param name="item">The item.</param>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <returns>A task that represents the asynchronous reattachment operation.</returns>
|
||||
Task ReattachUserDataAsync(BaseItem item, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the item.
|
||||
/// </summary>
|
||||
|
||||
@@ -350,5 +350,12 @@ namespace MediaBrowser.Controller.Session
|
||||
/// <param name="sessionIdOrPlaySessionId">The session id or playsession id.</param>
|
||||
/// <returns>Task.</returns>
|
||||
Task CloseLiveStreamIfNeededAsync(string liveStreamId, string sessionIdOrPlaySessionId);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the dto for session info.
|
||||
/// </summary>
|
||||
/// <param name="sessionInfo">The session info.</param>
|
||||
/// <returns><see cref="SessionInfoDto"/> of the session.</returns>
|
||||
SessionInfoDto ToSessionInfoDto(SessionInfo sessionInfo);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using BDInfo.IO;
|
||||
@@ -58,6 +59,8 @@ public class BdInfoDirectoryInfo : IDirectoryInfo
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsHidden(ReadOnlySpan<char> name) => name.StartsWith('.');
|
||||
|
||||
/// <summary>
|
||||
/// Gets the directories.
|
||||
/// </summary>
|
||||
@@ -65,6 +68,7 @@ public class BdInfoDirectoryInfo : IDirectoryInfo
|
||||
public IDirectoryInfo[] GetDirectories()
|
||||
{
|
||||
return _fileSystem.GetDirectories(_impl.FullName)
|
||||
.Where(d => !IsHidden(d.Name))
|
||||
.Select(x => new BdInfoDirectoryInfo(_fileSystem, x))
|
||||
.ToArray();
|
||||
}
|
||||
@@ -76,6 +80,7 @@ public class BdInfoDirectoryInfo : IDirectoryInfo
|
||||
public IFileInfo[] GetFiles()
|
||||
{
|
||||
return _fileSystem.GetFiles(_impl.FullName)
|
||||
.Where(d => !IsHidden(d.Name))
|
||||
.Select(x => new BdInfoFileInfo(x))
|
||||
.ToArray();
|
||||
}
|
||||
@@ -88,6 +93,7 @@ public class BdInfoDirectoryInfo : IDirectoryInfo
|
||||
public IFileInfo[] GetFiles(string searchPattern)
|
||||
{
|
||||
return _fileSystem.GetFiles(_impl.FullName, new[] { searchPattern }, false, false)
|
||||
.Where(d => !IsHidden(d.Name))
|
||||
.Select(x => new BdInfoFileInfo(x))
|
||||
.ToArray();
|
||||
}
|
||||
@@ -105,6 +111,7 @@ public class BdInfoDirectoryInfo : IDirectoryInfo
|
||||
new[] { searchPattern },
|
||||
false,
|
||||
searchOption == SearchOption.AllDirectories)
|
||||
.Where(d => !IsHidden(d.Name))
|
||||
.Select(x => new BdInfoFileInfo(x))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
@@ -693,7 +693,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
|
||||
[GeneratedRegex("^\\s\\S{6}\\s(?<codec>[\\w|-]+)\\s+.+$", RegexOptions.Multiline)]
|
||||
private static partial Regex CodecRegex();
|
||||
|
||||
[GeneratedRegex("^\\s\\S{3}\\s(?<filter>[\\w|-]+)\\s+.+$", RegexOptions.Multiline)]
|
||||
[GeneratedRegex("^\\s\\S{2,3}\\s(?<filter>[\\w|-]+)\\s+.+$", RegexOptions.Multiline)]
|
||||
private static partial Regex FilterRegex();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -511,7 +511,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
|
||||
? "{0} -i {1} -threads {2} -v warning -print_format json -show_streams -show_chapters -show_format"
|
||||
: "{0} -i {1} -threads {2} -v warning -print_format json -show_streams -show_format";
|
||||
|
||||
if (!isAudio && _proberSupportsFirstVideoFrame)
|
||||
if (protocol == MediaProtocol.File && !isAudio && _proberSupportsFirstVideoFrame)
|
||||
{
|
||||
args += " -show_frames -only_first_vframe";
|
||||
}
|
||||
|
||||
@@ -299,9 +299,12 @@ namespace MediaBrowser.MediaEncoding.Probing
|
||||
// Handle WebM
|
||||
else if (string.Equals(splitFormat[i], "webm", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Limit WebM to supported codecs
|
||||
if (mediaStreams.Any(stream => (stream.Type == MediaStreamType.Video && !_webmVideoCodecs.Contains(stream.Codec, StringComparison.OrdinalIgnoreCase))
|
||||
|| (stream.Type == MediaStreamType.Audio && !_webmAudioCodecs.Contains(stream.Codec, StringComparison.OrdinalIgnoreCase))))
|
||||
// Limit WebM to supported stream types and codecs.
|
||||
// FFprobe can report "matroska,webm" for Matroska-like containers, so only keep "webm" if all streams are WebM-compatible.
|
||||
// Any stream that is not video nor audio is not supported in WebM and should disqualify the webm container probe result.
|
||||
if (mediaStreams.Any(stream => stream.Type is not MediaStreamType.Video and not MediaStreamType.Audio)
|
||||
|| mediaStreams.Any(stream => (stream.Type == MediaStreamType.Video && !_webmVideoCodecs.Contains(stream.Codec, StringComparison.OrdinalIgnoreCase))
|
||||
|| (stream.Type == MediaStreamType.Audio && !_webmAudioCodecs.Contains(stream.Codec, StringComparison.OrdinalIgnoreCase))))
|
||||
{
|
||||
splitFormat[i] = string.Empty;
|
||||
}
|
||||
@@ -853,7 +856,12 @@ namespace MediaBrowser.MediaEncoding.Probing
|
||||
}
|
||||
|
||||
// http://stackoverflow.com/questions/17353387/how-to-detect-anamorphic-video-with-ffprobe
|
||||
if (string.Equals(streamInfo.SampleAspectRatio, "1:1", StringComparison.Ordinal))
|
||||
if (string.IsNullOrEmpty(streamInfo.SampleAspectRatio)
|
||||
&& string.IsNullOrEmpty(streamInfo.DisplayAspectRatio))
|
||||
{
|
||||
stream.IsAnamorphic = false;
|
||||
}
|
||||
else if (string.Equals(streamInfo.SampleAspectRatio, "1:1", StringComparison.Ordinal))
|
||||
{
|
||||
stream.IsAnamorphic = false;
|
||||
}
|
||||
|
||||
@@ -321,7 +321,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
||||
{
|
||||
using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
if (!File.Exists(outputPath))
|
||||
if (!File.Exists(outputPath) || _fileSystem.GetFileInfo(outputPath).Length == 0)
|
||||
{
|
||||
await ConvertTextSubtitleToSrtInternal(subtitleStream, mediaSource, outputPath, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
@@ -423,9 +423,22 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (!File.Exists(outputPath))
|
||||
else if (!File.Exists(outputPath) || _fileSystem.GetFileInfo(outputPath).Length == 0)
|
||||
{
|
||||
failed = true;
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogWarning("Deleting converted subtitle due to failure: {Path}", outputPath);
|
||||
_fileSystem.DeleteFile(outputPath);
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error deleting converted subtitle {Path}", outputPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (failed)
|
||||
@@ -499,7 +512,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
||||
|
||||
var releaser = await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (File.Exists(outputPath))
|
||||
if (File.Exists(outputPath) && _fileSystem.GetFileInfo(outputPath).Length > 0)
|
||||
{
|
||||
releaser.Dispose();
|
||||
continue;
|
||||
@@ -556,7 +569,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
||||
var outputPaths = new List<string>();
|
||||
var args = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"-i {0} -copyts",
|
||||
"-i {0}",
|
||||
inputPath);
|
||||
|
||||
foreach (var subtitleStream in subtitleStreams)
|
||||
@@ -581,7 +594,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
||||
outputPaths.Add(outputPath);
|
||||
args += string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
" -map 0:{0} -an -vn -c:s {1} \"{2}\"",
|
||||
" -map 0:{0} -an -vn -c:s {1} -flush_packets 1 \"{2}\"",
|
||||
streamIndex,
|
||||
outputCodec,
|
||||
outputPath);
|
||||
@@ -600,7 +613,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
||||
var outputPaths = new List<string>();
|
||||
var args = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"-i {0} -copyts",
|
||||
"-i {0}",
|
||||
inputPath);
|
||||
|
||||
foreach (var subtitleStream in subtitleStreams)
|
||||
@@ -626,7 +639,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
||||
outputPaths.Add(outputPath);
|
||||
args += string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
" -map 0:{0} -an -vn -c:s {1} \"{2}\"",
|
||||
" -map 0:{0} -an -vn -c:s {1} -flush_packets 1 \"{2}\"",
|
||||
streamIndex,
|
||||
outputCodec,
|
||||
outputPath);
|
||||
@@ -713,10 +726,24 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
||||
{
|
||||
foreach (var outputPath in outputPaths)
|
||||
{
|
||||
if (!File.Exists(outputPath))
|
||||
if (!File.Exists(outputPath) || _fileSystem.GetFileInfo(outputPath).Length == 0)
|
||||
{
|
||||
_logger.LogError("ffmpeg subtitle extraction failed for {InputPath} to {OutputPath}", inputPath, outputPath);
|
||||
failed = true;
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogWarning("Deleting extracted subtitle due to failure: {Path}", outputPath);
|
||||
_fileSystem.DeleteFile(outputPath);
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error deleting extracted subtitle {Path}", outputPath);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -755,7 +782,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
||||
{
|
||||
using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
if (!File.Exists(outputPath))
|
||||
if (!File.Exists(outputPath) || _fileSystem.GetFileInfo(outputPath).Length == 0)
|
||||
{
|
||||
var subtitleStreamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, subtitleStream);
|
||||
|
||||
@@ -857,9 +884,22 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
||||
_logger.LogError(ex, "Error deleting extracted subtitle {Path}", outputPath);
|
||||
}
|
||||
}
|
||||
else if (!File.Exists(outputPath))
|
||||
else if (!File.Exists(outputPath) || _fileSystem.GetFileInfo(outputPath).Length == 0)
|
||||
{
|
||||
failed = true;
|
||||
|
||||
try
|
||||
{
|
||||
_logger.LogWarning("Deleting extracted subtitle due to failure: {Path}", outputPath);
|
||||
_fileSystem.DeleteFile(outputPath);
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error deleting extracted subtitle {Path}", outputPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (failed)
|
||||
|
||||
@@ -24,6 +24,8 @@ public class EncodingOptions
|
||||
ThrottleDelaySeconds = 180;
|
||||
EnableSegmentDeletion = false;
|
||||
SegmentKeepSeconds = 720;
|
||||
RecoverySegmentLengthSeconds = 2;
|
||||
RecoverySegmentBufferCount = 5;
|
||||
EncodingThreadCount = -1;
|
||||
// This is a DRM device that is almost guaranteed to be there on every intel platform,
|
||||
// plus it's the default one in ffmpeg if you don't specify anything
|
||||
@@ -121,6 +123,20 @@ public class EncodingOptions
|
||||
/// </summary>
|
||||
public int SegmentKeepSeconds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the HLS segment length in seconds to use when HA recovery mode is active.
|
||||
/// Shorter segments allow a takeover pod to resume playback faster after a peer failure.
|
||||
/// Default is <c>2</c>. Valid range is 1–6.
|
||||
/// </summary>
|
||||
public int RecoverySegmentLengthSeconds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the number of HLS segments to keep on disk when HA recovery mode is active.
|
||||
/// This acts as a rolling buffer that a takeover pod can serve while restarting the transcode.
|
||||
/// Default is <c>5</c>. Valid range is 2–10.
|
||||
/// </summary>
|
||||
public int RecoverySegmentBufferCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the hardware acceleration type.
|
||||
/// </summary>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<PropertyGroup>
|
||||
<Authors>Jellyfin Contributors</Authors>
|
||||
<PackageId>Jellyfin.Model</PackageId>
|
||||
<VersionPrefix>10.11.3</VersionPrefix>
|
||||
<VersionPrefix>10.11.7</VersionPrefix>
|
||||
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
||||
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -88,7 +88,15 @@ namespace MediaBrowser.Providers.Manager
|
||||
}
|
||||
}
|
||||
|
||||
singular.AddRange(item.GetImages(ImageType.Backdrop));
|
||||
foreach (var backdrop in item.GetImages(ImageType.Backdrop))
|
||||
{
|
||||
var imageInMetadataFolder = backdrop.Path.StartsWith(itemMetadataPath, StringComparison.OrdinalIgnoreCase);
|
||||
if (imageInMetadataFolder || canDeleteLocal || item.IsSaveLocalMetadataEnabled())
|
||||
{
|
||||
singular.Add(backdrop);
|
||||
}
|
||||
}
|
||||
|
||||
PruneImages(item, singular);
|
||||
|
||||
return singular.Count > 0;
|
||||
@@ -466,10 +474,36 @@ namespace MediaBrowser.Providers.Manager
|
||||
}
|
||||
}
|
||||
|
||||
if (UpdateMultiImages(item, images, ImageType.Backdrop))
|
||||
bool hasBackdrop = false;
|
||||
bool backdropStoredWithMedia = false;
|
||||
|
||||
foreach (var image in images)
|
||||
{
|
||||
changed = true;
|
||||
foundImageTypes.Add(ImageType.Backdrop);
|
||||
if (image.Type != ImageType.Backdrop)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
hasBackdrop = true;
|
||||
|
||||
if (item.ContainingFolderPath is not null && item.ContainingFolderPath.Contains(Path.GetDirectoryName(image.FileInfo.FullName), StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
backdropStoredWithMedia = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasBackdrop)
|
||||
{
|
||||
if (UpdateMultiImages(item, images, ImageType.Backdrop))
|
||||
{
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (backdropStoredWithMedia)
|
||||
{
|
||||
foundImageTypes.Add(ImageType.Backdrop);
|
||||
}
|
||||
}
|
||||
|
||||
if (foundImageTypes.Count > 0)
|
||||
|
||||
@@ -151,9 +151,9 @@ namespace MediaBrowser.Providers.Manager
|
||||
.ConfigureAwait(false);
|
||||
updateType |= beforeSaveResult;
|
||||
|
||||
if (!isFirstRefresh)
|
||||
if (isFirstRefresh)
|
||||
{
|
||||
updateType = await SaveInternal(item, refreshOptions, updateType, isFirstRefresh, requiresRefresh, metadataResult, cancellationToken).ConfigureAwait(false);
|
||||
await SaveItemAsync(metadataResult, ItemUpdateType.MetadataImport, false, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Next run metadata providers
|
||||
@@ -247,7 +247,7 @@ namespace MediaBrowser.Providers.Manager
|
||||
}
|
||||
|
||||
// Save to database
|
||||
await SaveItemAsync(metadataResult, updateType, cancellationToken).ConfigureAwait(false);
|
||||
await SaveItemAsync(metadataResult, updateType, isFirstRefresh, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return updateType;
|
||||
@@ -275,9 +275,14 @@ namespace MediaBrowser.Providers.Manager
|
||||
}
|
||||
}
|
||||
|
||||
protected async Task SaveItemAsync(MetadataResult<TItemType> result, ItemUpdateType reason, CancellationToken cancellationToken)
|
||||
protected async Task SaveItemAsync(MetadataResult<TItemType> result, ItemUpdateType reason, bool reattachUserData, CancellationToken cancellationToken)
|
||||
{
|
||||
await result.Item.UpdateToRepositoryAsync(reason, cancellationToken).ConfigureAwait(false);
|
||||
if (reattachUserData)
|
||||
{
|
||||
await result.Item.ReattachUserDataAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (result.Item.SupportsPeople && result.People is not null)
|
||||
{
|
||||
var baseItem = result.Item;
|
||||
|
||||
@@ -721,8 +721,6 @@ namespace MediaBrowser.Providers.Manager
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_libraryManager.CreateItem(item, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -262,9 +262,28 @@ namespace MediaBrowser.Providers.MediaInfo
|
||||
|
||||
private void FetchShortcutInfo(BaseItem item)
|
||||
{
|
||||
item.ShortcutPath = File.ReadAllLines(item.Path)
|
||||
var shortcutPath = File.ReadAllLines(item.Path)
|
||||
.Select(NormalizeStrmLine)
|
||||
.FirstOrDefault(i => !string.IsNullOrWhiteSpace(i) && !i.StartsWith('#'));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(shortcutPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Only allow remote URLs in .strm files to prevent local file access
|
||||
if (Uri.TryCreate(shortcutPath, UriKind.Absolute, out var uri)
|
||||
&& (string.Equals(uri.Scheme, "http", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(uri.Scheme, "https", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(uri.Scheme, "rtsp", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(uri.Scheme, "rtp", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
item.ShortcutPath = shortcutPath;
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Ignoring invalid or non-remote .strm path in {File}: {Path}", item.Path, shortcutPath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -72,7 +72,7 @@ public class PlaylistMetadataService : MetadataService<Playlist, ItemLookupInfo>
|
||||
}
|
||||
else
|
||||
{
|
||||
targetItem.LinkedChildren = sourceItem.LinkedChildren.Concat(targetItem.LinkedChildren).Distinct().ToArray();
|
||||
targetItem.LinkedChildren = sourceItem.LinkedChildren.Concat(targetItem.LinkedChildren).DistinctBy(i => i.Path).ToArray();
|
||||
}
|
||||
|
||||
if (replaceData || targetItem.Shares.Count == 0)
|
||||
|
||||
@@ -303,9 +303,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
|
||||
CrewMember = crewMember,
|
||||
PersonType = TmdbUtils.MapCrewToPersonType(crewMember)
|
||||
})
|
||||
.Where(entry =>
|
||||
TmdbUtils.WantedCrewKinds.Contains(entry.PersonType) ||
|
||||
TmdbUtils.WantedCrewTypes.Contains(entry.CrewMember.Job ?? string.Empty, StringComparison.OrdinalIgnoreCase));
|
||||
.Where(entry => TmdbUtils.WantedCrewKinds.Contains(entry.PersonType));
|
||||
|
||||
if (config.HideMissingCrewMembers)
|
||||
{
|
||||
|
||||
@@ -275,9 +275,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
|
||||
CrewMember = crewMember,
|
||||
PersonType = TmdbUtils.MapCrewToPersonType(crewMember)
|
||||
})
|
||||
.Where(entry =>
|
||||
TmdbUtils.WantedCrewKinds.Contains(entry.PersonType) ||
|
||||
TmdbUtils.WantedCrewTypes.Contains(entry.CrewMember.Job ?? string.Empty, StringComparison.OrdinalIgnoreCase));
|
||||
.Where(entry => TmdbUtils.WantedCrewKinds.Contains(entry.PersonType));
|
||||
|
||||
if (config.HideMissingCrewMembers)
|
||||
{
|
||||
|
||||
@@ -120,9 +120,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
|
||||
CrewMember = crewMember,
|
||||
PersonType = TmdbUtils.MapCrewToPersonType(crewMember)
|
||||
})
|
||||
.Where(entry =>
|
||||
TmdbUtils.WantedCrewKinds.Contains(entry.PersonType) ||
|
||||
TmdbUtils.WantedCrewTypes.Contains(entry.CrewMember.Job ?? string.Empty, StringComparison.OrdinalIgnoreCase));
|
||||
.Where(entry => TmdbUtils.WantedCrewKinds.Contains(entry.PersonType));
|
||||
|
||||
if (config.HideMissingCrewMembers)
|
||||
{
|
||||
|
||||
@@ -367,9 +367,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
|
||||
CrewMember = crewMember,
|
||||
PersonType = TmdbUtils.MapCrewToPersonType(crewMember)
|
||||
})
|
||||
.Where(entry =>
|
||||
TmdbUtils.WantedCrewKinds.Contains(entry.PersonType) ||
|
||||
TmdbUtils.WantedCrewTypes.Contains(entry.CrewMember.Job ?? string.Empty, StringComparison.OrdinalIgnoreCase));
|
||||
.Where(entry => TmdbUtils.WantedCrewKinds.Contains(entry.PersonType));
|
||||
|
||||
if (config.HideMissingCrewMembers)
|
||||
{
|
||||
|
||||
@@ -518,7 +518,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
|
||||
return null;
|
||||
}
|
||||
|
||||
return _tmDbClient.GetImageUrl(size, path, true).ToString();
|
||||
// Use "original" as default size if size is null or empty to prevent malformed URLs
|
||||
var imageSize = string.IsNullOrEmpty(size) ? "original" : size;
|
||||
|
||||
return _tmDbClient.GetImageUrl(imageSize, path, true).ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -69,19 +69,20 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
|
||||
/// <returns>The Jellyfin person type.</returns>
|
||||
public static PersonKind MapCrewToPersonType(Crew crew)
|
||||
{
|
||||
if (crew.Department.Equals("production", StringComparison.OrdinalIgnoreCase)
|
||||
&& crew.Job.Contains("director", StringComparison.OrdinalIgnoreCase))
|
||||
if (crew.Department.Equals("directing", StringComparison.OrdinalIgnoreCase)
|
||||
&& crew.Job.Equals("director", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return PersonKind.Director;
|
||||
}
|
||||
|
||||
if (crew.Department.Equals("production", StringComparison.OrdinalIgnoreCase)
|
||||
&& crew.Job.Contains("producer", StringComparison.OrdinalIgnoreCase))
|
||||
&& crew.Job.Equals("producer", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return PersonKind.Producer;
|
||||
}
|
||||
|
||||
if (crew.Department.Equals("writing", StringComparison.OrdinalIgnoreCase))
|
||||
if (crew.Department.Equals("writing", StringComparison.OrdinalIgnoreCase)
|
||||
&& (crew.Job.Equals("writer", StringComparison.OrdinalIgnoreCase) || crew.Job.Equals("screenplay", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return PersonKind.Writer;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user