Compare commits
165 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 | |||
| 026f7472cb |
@@ -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.4</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)
|
||||
{
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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.4</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)
|
||||
{
|
||||
@@ -636,19 +669,6 @@ public sealed class BaseItemRepository
|
||||
|
||||
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();
|
||||
@@ -744,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)
|
||||
{
|
||||
@@ -851,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('|');
|
||||
|
||||
@@ -1013,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
|
||||
@@ -1529,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1930,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
|
||||
@@ -2425,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)
|
||||
@@ -2607,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);
|
||||
|
||||
@@ -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.4</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.PremiereDate ?? DateTime.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)
|
||||
{
|
||||
|
||||
@@ -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.4</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))
|
||||
@@ -5949,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}";
|
||||
@@ -6350,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),
|
||||
@@ -7030,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7094,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,
|
||||
@@ -7112,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;
|
||||
@@ -7120,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);
|
||||
@@ -7164,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
|
||||
@@ -7176,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.4</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)
|
||||
|
||||
@@ -153,7 +153,7 @@ namespace MediaBrowser.Providers.Manager
|
||||
|
||||
if (isFirstRefresh)
|
||||
{
|
||||
await SaveItemAsync(metadataResult, ItemUpdateType.MetadataImport, 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;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Emby.Naming.Common;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
@@ -32,6 +33,7 @@ namespace MediaBrowser.Providers.Subtitles
|
||||
private readonly ILibraryMonitor _monitor;
|
||||
private readonly IMediaSourceManager _mediaSourceManager;
|
||||
private readonly ILocalizationManager _localization;
|
||||
private readonly HashSet<string> _allowedSubtitleFormats;
|
||||
|
||||
private readonly ISubtitleProvider[] _subtitleProviders;
|
||||
|
||||
@@ -41,7 +43,8 @@ namespace MediaBrowser.Providers.Subtitles
|
||||
ILibraryMonitor monitor,
|
||||
IMediaSourceManager mediaSourceManager,
|
||||
ILocalizationManager localizationManager,
|
||||
IEnumerable<ISubtitleProvider> subtitleProviders)
|
||||
IEnumerable<ISubtitleProvider> subtitleProviders,
|
||||
NamingOptions namingOptions)
|
||||
{
|
||||
_logger = logger;
|
||||
_fileSystem = fileSystem;
|
||||
@@ -51,6 +54,9 @@ namespace MediaBrowser.Providers.Subtitles
|
||||
_subtitleProviders = subtitleProviders
|
||||
.OrderBy(i => i is IHasOrder hasOrder ? hasOrder.Order : 0)
|
||||
.ToArray();
|
||||
_allowedSubtitleFormats = new HashSet<string>(
|
||||
namingOptions.SubtitleFileExtensions.Select(e => e.TrimStart('.')),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -171,6 +177,12 @@ namespace MediaBrowser.Providers.Subtitles
|
||||
/// <inheritdoc />
|
||||
public Task UploadSubtitle(Video video, SubtitleResponse response)
|
||||
{
|
||||
var format = response.Format;
|
||||
if (string.IsNullOrEmpty(format) || !_allowedSubtitleFormats.Contains(format))
|
||||
{
|
||||
throw new ArgumentException($"Unsupported subtitle format: '{format}'");
|
||||
}
|
||||
|
||||
var libraryOptions = BaseItem.LibraryManager.GetLibraryOptions(video);
|
||||
return TrySaveSubtitle(video, libraryOptions, response);
|
||||
}
|
||||
@@ -193,7 +205,13 @@ namespace MediaBrowser.Providers.Subtitles
|
||||
}
|
||||
|
||||
var savePaths = new List<string>();
|
||||
var saveFileName = Path.GetFileNameWithoutExtension(video.Path) + "." + response.Language.ToLowerInvariant();
|
||||
var language = response.Language.ToLowerInvariant();
|
||||
if (language.AsSpan().IndexOfAny(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) >= 0)
|
||||
{
|
||||
throw new ArgumentException("Language contains invalid characters.");
|
||||
}
|
||||
|
||||
var saveFileName = Path.GetFileNameWithoutExtension(video.Path) + "." + language;
|
||||
|
||||
if (response.IsForced)
|
||||
{
|
||||
@@ -221,15 +239,22 @@ namespace MediaBrowser.Providers.Subtitles
|
||||
|
||||
private async Task TrySaveToFiles(Stream stream, List<string> savePaths, Video video, string extension)
|
||||
{
|
||||
if (!_allowedSubtitleFormats.Contains("." + extension, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new ArgumentException($"Invalid subtitle format: {extension}");
|
||||
}
|
||||
|
||||
List<Exception>? exs = null;
|
||||
|
||||
foreach (var savePath in savePaths)
|
||||
{
|
||||
var path = savePath + "." + extension;
|
||||
var path = Path.GetFullPath(savePath + "." + extension);
|
||||
try
|
||||
{
|
||||
if (path.StartsWith(video.ContainingFolderPath, StringComparison.Ordinal)
|
||||
|| path.StartsWith(video.GetInternalMetadataPath(), StringComparison.Ordinal))
|
||||
var containingFolder = video.ContainingFolderPath + Path.DirectorySeparatorChar;
|
||||
var metadataFolder = video.GetInternalMetadataPath() + Path.DirectorySeparatorChar;
|
||||
if (path.StartsWith(containingFolder, StringComparison.Ordinal)
|
||||
|| path.StartsWith(metadataFolder, StringComparison.Ordinal))
|
||||
{
|
||||
var fileExists = File.Exists(path);
|
||||
var counter = 0;
|
||||
|
||||
@@ -547,7 +547,7 @@ namespace MediaBrowser.XbmcMetadata.Savers
|
||||
writer.WriteElementString("aspectratio", hasAspectRatio.AspectRatio);
|
||||
}
|
||||
|
||||
if (item.TryGetProviderId(MetadataProvider.Tmdb, out var tmdbCollection))
|
||||
if (item.TryGetProviderId(MetadataProvider.TmdbCollection, out var tmdbCollection))
|
||||
{
|
||||
writer.WriteElementString("collectionnumber", tmdbCollection);
|
||||
writtenProviderIds.Add(MetadataProvider.TmdbCollection.ToString());
|
||||
|
||||
@@ -1,82 +1,551 @@
|
||||
<h1 align="center">Jellyfin</h1>
|
||||
<h3 align="center">The Free Software Media System</h3>
|
||||
# jellyfin-ha
|
||||
|
||||
**A fork of [Jellyfin](https://github.com/jellyfin/jellyfin) adding high-availability transcoding support for multi-pod Kubernetes deployments.**
|
||||
|
||||
[](https://www.gnu.org/licenses/old-licenses/gpl-2.0.en.html)
|
||||
[](https://dotnet.microsoft.com/download/dotnet/10.0)
|
||||
[](https://github.com/jellyfin/jellyfin)
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<img alt="Logo Banner" src="https://raw.githubusercontent.com/jellyfin/jellyfin-ux/master/branding/SVG/banner-logo-solid.svg?sanitize=true"/>
|
||||
<br/>
|
||||
<br/>
|
||||
<a href="https://github.com/jellyfin/jellyfin">
|
||||
<img alt="GPL 2.0 License" src="https://img.shields.io/github/license/jellyfin/jellyfin.svg"/>
|
||||
</a>
|
||||
<a href="https://github.com/jellyfin/jellyfin/releases">
|
||||
<img alt="Current Release" src="https://img.shields.io/github/release/jellyfin/jellyfin.svg"/>
|
||||
</a>
|
||||
<a href="https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/?utm_source=widget">
|
||||
<img alt="Translation Status" src="https://translate.jellyfin.org/widgets/jellyfin/-/jellyfin-core/svg-badge.svg"/>
|
||||
</a>
|
||||
<a href="https://hub.docker.com/r/jellyfin/jellyfin">
|
||||
<img alt="Docker Pull Count" src="https://img.shields.io/docker/pulls/jellyfin/jellyfin.svg"/>
|
||||
</a>
|
||||
<br/>
|
||||
<a href="https://opencollective.com/jellyfin">
|
||||
<img alt="Donate" src="https://img.shields.io/opencollective/all/jellyfin.svg?label=backers"/>
|
||||
</a>
|
||||
<a href="https://features.jellyfin.org">
|
||||
<img alt="Submit Feature Requests" src="https://img.shields.io/badge/fider-vote%20on%20features-success.svg"/>
|
||||
</a>
|
||||
<a href="https://matrix.to/#/#jellyfinorg:matrix.org">
|
||||
<img alt="Chat on Matrix" src="https://img.shields.io/matrix/jellyfinorg:matrix.org.svg?logo=matrix"/>
|
||||
</a>
|
||||
<a href="https://github.com/jellyfin/jellyfin/releases.atom">
|
||||
<img alt="Release RSS Feed" src="https://img.shields.io/badge/rss-releases-ffa500?logo=rss" />
|
||||
</a>
|
||||
<a href="https://github.com/jellyfin/jellyfin/commits/master.atom">
|
||||
<img alt="Master Commits RSS Feed" src="https://img.shields.io/badge/rss-commits-ffa500?logo=rss" />
|
||||
</a>
|
||||
</p>
|
||||
## What is this?
|
||||
|
||||
Jellyfin's default assumption is that exactly one server instance is running at a time. Transcode state is held entirely in-memory — when the process dies, so do all active HLS streams. For homelab deployments that want Kubernetes-managed redundancy (rolling restarts, node drain, pod rescheduling), that's a problem.
|
||||
|
||||
This fork adds a thin HA layer on top of unmodified Jellyfin core:
|
||||
|
||||
- **`ITranscodeSessionStore`** — a new interface for durable, distributed transcode session tracking
|
||||
- **`RedisTranscodeSessionStore`** — a Redis-backed implementation using atomic Lua takeover scripts and TTL-based lease expiry
|
||||
- **`NullTranscodeSessionStore`** — a no-op fallback so single-instance deployments work with zero configuration change
|
||||
- **Lease-aware `DeleteTranscodeFileTask`** — coordinates cleanup across replicas so a restarting pod doesn't delete segments another pod is actively streaming
|
||||
- **`SessionManager` HA recovery** — safe takeover of live HLS streams when a pod takes over after lease expiry
|
||||
- **PostgreSQL database provider** — alternative to SQLite for shared-database HA setups (experimental, under `src/Jellyfin.Database/Jellyfin.Database.Providers.PostgreSQL`)
|
||||
|
||||
---
|
||||
|
||||
Jellyfin is a Free Software Media System that puts you in control of managing and streaming your media. It is an alternative to the proprietary Emby and Plex, to provide media from a dedicated server to end-user devices via multiple apps. Jellyfin is descended from Emby's 3.5.2 release and ported to the .NET platform to enable full cross-platform support.
|
||||
## Architecture
|
||||
|
||||
There are no strings attached, no premium licenses or features, and no hidden agendas: just a team that wants to build something better and work together to achieve it. We welcome anyone who is interested in joining us in our quest!
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐
|
||||
│ Jellyfin │ │ Jellyfin │
|
||||
│ Pod A │ │ Pod B │
|
||||
│ │ │ │
|
||||
│ ┌─────────┐ │ │ ┌─────────┐ │
|
||||
│ │Transcode│ │ │ │Transcode│ │
|
||||
│ │Manager │ │ │ │Manager │ │
|
||||
│ └────┬────┘ │ │ └────┬────┘ │
|
||||
└──────┼──────┘ └──────┼──────┘
|
||||
│ │
|
||||
└─────────┬─────────┘
|
||||
│
|
||||
┌───────▼───────┐
|
||||
│ Redis │ ← ITranscodeSessionStore
|
||||
│ (lease store)│ TTL-based ownership
|
||||
└───────────────┘
|
||||
|
||||
For further details, please see [our documentation page](https://jellyfin.org/docs/). To receive the latest updates, get help with Jellyfin, and join the community, please visit [one of our communication channels](https://jellyfin.org/docs/general/getting-help). For more information about the project, please see our [about page](https://jellyfin.org/docs/general/about).
|
||||
┌─────────────────────┐
|
||||
│ Shared NAS / NFS │ ← HLS segments + manifests
|
||||
│ (shared storage) │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
<strong>Want to get started?</strong><br/>
|
||||
Check out our <a href="https://jellyfin.org/downloads">downloads page</a> or our <a href="https://jellyfin.org/docs/general/installation/">installation guide</a>, then see our <a href="https://jellyfin.org/docs/general/quick-start">quick start guide</a>. You can also <a href="https://jellyfin.org/docs/general/installation/source">build from source</a>.<br/>
|
||||
**How takeover works:**
|
||||
|
||||
<strong>Something not working right?</strong><br/>
|
||||
Open an <a href="https://jellyfin.org/docs/general/contributing/issues">Issue</a> on GitHub.<br/>
|
||||
|
||||
<strong>Want to contribute?</strong><br/>
|
||||
Check out our <a href="https://jellyfin.org/contribute">contributing choose-your-own-adventure</a> to see where you can help, then see our <a href="https://jellyfin.org/docs/general/contributing/">contributing guide</a> and our <a href="https://jellyfin.org/docs/general/community-standards">community standards</a>.<br/>
|
||||
|
||||
<strong>New idea or improvement?</strong><br/>
|
||||
Check out our <a href="https://features.jellyfin.org/?view=most-wanted">feature request hub</a>.<br/>
|
||||
|
||||
<strong>Don't see Jellyfin in your language?</strong><br/>
|
||||
Check out our <a href="https://translate.jellyfin.org">Weblate instance</a> to help translate Jellyfin and its subprojects.<br/>
|
||||
|
||||
<a href="https://translate.jellyfin.org/engage/jellyfin/?utm_source=widget">
|
||||
<img src="https://translate.jellyfin.org/widgets/jellyfin/-/jellyfin-web/multi-auto.svg" alt="Detailed Translation Status"/>
|
||||
</a>
|
||||
1. Pod A starts an HLS transcode and writes a `TranscodeSession` to Redis with a 30-second lease.
|
||||
2. Pod A renews the lease every `LeaseDurationSeconds / 2` seconds.
|
||||
3. If Pod A dies, the lease expires in Redis after 30 seconds.
|
||||
4. Pod B receives a client request for the same play session, calls `TryTakeoverAsync`, and atomically claims ownership via a Lua script.
|
||||
5. Pod B resumes FFmpeg from the last durable segment index. The client sees a brief stutter, not an error.
|
||||
|
||||
---
|
||||
|
||||
## Jellyfin Server
|
||||
## Quick Start
|
||||
|
||||
This repository contains the code for Jellyfin's backend server. Note that this is only one of many projects under the Jellyfin GitHub [organization](https://github.com/jellyfin/) on GitHub. If you want to contribute, you can start by checking out our [documentation](https://jellyfin.org/docs/general/contributing/index.html) to see what to work on.
|
||||
### Single instance (no Redis)
|
||||
|
||||
## Server Development
|
||||
No configuration required. `NullTranscodeSessionStore` is used automatically. Behavior is identical to upstream Jellyfin.
|
||||
|
||||
These instructions will help you get set up with a local development environment in order to contribute to this repository. Before you start, please be sure to completely read our [guidelines on development contributions](https://jellyfin.org/docs/general/contributing/development.html). Note that this project is supported on all major operating systems except FreeBSD, which is still incompatible.
|
||||
```bash
|
||||
dotnet run --project Jellyfin.Server/Jellyfin.Server.csproj -- \
|
||||
--datadir /var/lib/jellyfin \
|
||||
--webdir /usr/share/jellyfin/web
|
||||
```
|
||||
|
||||
### HA mode with Redis
|
||||
|
||||
Set the `Jellyfin:TranscodeStore:RedisConnectionString` configuration key. You can pass it as an environment variable, a `DOTNET_` prefixed env var, or in a JSON config file.
|
||||
|
||||
**Environment variable:**
|
||||
|
||||
```bash
|
||||
export Jellyfin__TranscodeStore__RedisConnectionString="redis:6379"
|
||||
export Jellyfin__TranscodeStore__LeaseDurationSeconds="30"
|
||||
|
||||
dotnet run --project Jellyfin.Server/Jellyfin.Server.csproj -- \
|
||||
--datadir /var/lib/jellyfin \
|
||||
--webdir /usr/share/jellyfin/web
|
||||
```
|
||||
|
||||
**`appsettings.json` section:**
|
||||
|
||||
```json
|
||||
{
|
||||
"Jellyfin": {
|
||||
"TranscodeStore": {
|
||||
"RedisConnectionString": "redis:6379,abortConnect=false",
|
||||
"LeaseDurationSeconds": 30
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When `RedisConnectionString` is set, `RedisTranscodeSessionStore` is registered in DI. If the Redis connection fails at startup, the server throws and refuses to start — this is intentional so you don't silently fall back to broken HA behavior.
|
||||
|
||||
---
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
| Key | Default | Description |
|
||||
|-----|---------|-------------|
|
||||
| `Jellyfin:TranscodeStore:RedisConnectionString` | _(empty)_ | StackExchange.Redis connection string. Empty = single-instance mode. |
|
||||
| `Jellyfin:TranscodeStore:LeaseDurationSeconds` | `30` | How long a pod's transcode lease is valid before another pod may take over. |
|
||||
|
||||
### Redis connection string examples
|
||||
|
||||
```
|
||||
# Standalone Redis
|
||||
redis:6379
|
||||
|
||||
# With password
|
||||
redis:6379,password=secret
|
||||
|
||||
# With TLS
|
||||
redis.example.com:6380,ssl=true,abortConnect=false
|
||||
|
||||
# Redis Sentinel
|
||||
sentinel-host:26379,serviceName=mymaster
|
||||
```
|
||||
|
||||
Standard [StackExchange.Redis connection string format](https://stackexchange.github.io/StackExchange.Redis/Configuration) is accepted.
|
||||
|
||||
---
|
||||
|
||||
## Deployment
|
||||
|
||||
> **This project is designed to run as a container.** Running it as a bare `dotnet` process is fine for development and testing, but the HA benefits only materialize when you have multiple replicas managed by a container orchestrator. Docker Compose gets you Redis + Jellyfin wired together locally. Kubernetes (k3s, k8s, or a managed cloud cluster) gets you the actual pod-death-and-recovery story.
|
||||
>
|
||||
> Don't have a Kubernetes cluster yet? [DigitalOcean Kubernetes](https://www.digitalocean.com/?refcode=b9012919f7ff&utm_campaign=Referral_Invite&utm_medium=Referral_Program&utm_source=badge) is the fastest path to a managed cluster if you don't want to run your own nodes.
|
||||
|
||||
---
|
||||
|
||||
### Option 1 — Local HA with Docker Compose
|
||||
|
||||
The simplest way to test the full HA stack locally: two Jellyfin replicas sharing a Redis instance and a local volume for transcode output.
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- "6379:6379"
|
||||
|
||||
jellyfin-1:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.runtime
|
||||
environment:
|
||||
Jellyfin__TranscodeStore__RedisConnectionString: "redis:6379,abortConnect=false"
|
||||
Jellyfin__TranscodeStore__LeaseDurationSeconds: "30"
|
||||
JELLYFIN_HA_POD_NAME: "jellyfin-1"
|
||||
volumes:
|
||||
- ./data/config:/config
|
||||
- ./data/media:/media:ro
|
||||
- transcode-tmp:/transcode
|
||||
ports:
|
||||
- "8096:8096"
|
||||
depends_on:
|
||||
- redis
|
||||
|
||||
jellyfin-2:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.runtime
|
||||
environment:
|
||||
Jellyfin__TranscodeStore__RedisConnectionString: "redis:6379,abortConnect=false"
|
||||
Jellyfin__TranscodeStore__LeaseDurationSeconds: "30"
|
||||
JELLYFIN_HA_POD_NAME: "jellyfin-2"
|
||||
volumes:
|
||||
- ./data/config:/config
|
||||
- ./data/media:/media:ro
|
||||
- transcode-tmp:/transcode
|
||||
ports:
|
||||
- "8097:8096"
|
||||
depends_on:
|
||||
- redis
|
||||
|
||||
volumes:
|
||||
transcode-tmp:
|
||||
```
|
||||
|
||||
Build the image first (the `dotnet publish` step runs outside Docker for I/O performance):
|
||||
|
||||
```bash
|
||||
dotnet publish Jellyfin.Server/Jellyfin.Server.csproj \
|
||||
--configuration Release \
|
||||
--runtime linux-x64 \
|
||||
--self-contained false \
|
||||
--output ./publish-output
|
||||
|
||||
docker compose up
|
||||
```
|
||||
|
||||
Both replicas share the `transcode-tmp` volume and register sessions in Redis. Kill one container mid-stream (`docker kill jellyfin-1`) and the other takes over within `LeaseDurationSeconds`.
|
||||
|
||||
---
|
||||
|
||||
### Option 2 — Kubernetes (k3s / k8s)
|
||||
|
||||
This is the intended production deployment. You need:
|
||||
|
||||
1. A Kubernetes cluster (k3s, kubeadm, EKS, GKE, DigitalOcean Kubernetes, etc.)
|
||||
2. A Redis instance (in-cluster or managed)
|
||||
3. A `ReadWriteMany` storage class for shared transcode scratch space (NFS, Longhorn RWX, Ceph RBD, or a cloud-managed RWX PVC)
|
||||
|
||||
#### Redis (in-cluster, standalone)
|
||||
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: redis
|
||||
namespace: jellyfin
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: redis
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: redis
|
||||
spec:
|
||||
containers:
|
||||
- name: redis
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- containerPort: 6379
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: redis
|
||||
namespace: jellyfin
|
||||
spec:
|
||||
selector:
|
||||
app: redis
|
||||
ports:
|
||||
- port: 6379
|
||||
```
|
||||
|
||||
#### Redis connection secret
|
||||
|
||||
```bash
|
||||
kubectl create secret generic jellyfin-redis \
|
||||
--namespace jellyfin \
|
||||
--from-literal=connection-string="redis.jellyfin.svc.cluster.local:6379,abortConnect=false"
|
||||
```
|
||||
|
||||
#### Shared transcode PVC (RWX)
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: jellyfin-transcode
|
||||
namespace: jellyfin
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteMany
|
||||
storageClassName: longhorn # or nfs-client, csi-driver-nfs, etc.
|
||||
resources:
|
||||
requests:
|
||||
storage: 20Gi
|
||||
```
|
||||
|
||||
#### Jellyfin Deployment
|
||||
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: jellyfin
|
||||
namespace: jellyfin
|
||||
spec:
|
||||
replicas: 2
|
||||
selector:
|
||||
matchLabels:
|
||||
app: jellyfin
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: jellyfin
|
||||
spec:
|
||||
containers:
|
||||
- name: jellyfin
|
||||
image: your-registry/jellyfin-ha:latest
|
||||
ports:
|
||||
- containerPort: 8096
|
||||
env:
|
||||
- name: Jellyfin__TranscodeStore__RedisConnectionString
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: jellyfin-redis
|
||||
key: connection-string
|
||||
- name: Jellyfin__TranscodeStore__LeaseDurationSeconds
|
||||
value: "30"
|
||||
- name: JELLYFIN_HA_POD_NAME
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.name
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /config
|
||||
- name: media
|
||||
mountPath: /media
|
||||
readOnly: true
|
||||
- name: transcode
|
||||
mountPath: /transcode
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8096
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8096
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 5
|
||||
volumes:
|
||||
- name: config
|
||||
persistentVolumeClaim:
|
||||
claimName: jellyfin-config # RWO is fine — config is single-writer
|
||||
- name: media
|
||||
nfs:
|
||||
server: your-nas.local
|
||||
path: /media
|
||||
- name: transcode
|
||||
persistentVolumeClaim:
|
||||
claimName: jellyfin-transcode # Must be RWX
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: jellyfin
|
||||
namespace: jellyfin
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app: jellyfin
|
||||
ports:
|
||||
- port: 8096
|
||||
targetPort: 8096
|
||||
```
|
||||
|
||||
#### Important: storage requirements
|
||||
|
||||
| Volume | Access mode | Why |
|
||||
|--------|-------------|-----|
|
||||
| Config (`/config`) | `ReadWriteOnce` | One writer, SQLite DB lives here |
|
||||
| Media (`/media`) | `ReadOnlyMany` | All pods read the same library |
|
||||
| Transcode (`/transcode`) | **`ReadWriteMany`** | Pods read each other's HLS segments during takeover |
|
||||
|
||||
The transcode volume is the critical one. If it's `ReadWriteOnce`, pod takeover will fail because Pod B cannot read the `.ts` segments Pod A wrote. Use NFS, Longhorn with RWX enabled, or a cloud-managed RWX storage class.
|
||||
|
||||
#### Building the image
|
||||
|
||||
```bash
|
||||
# Publish (run on host, not inside Docker)
|
||||
dotnet publish Jellyfin.Server/Jellyfin.Server.csproj \
|
||||
--configuration Release \
|
||||
--runtime linux-x64 \
|
||||
--self-contained false \
|
||||
--output ./publish-output
|
||||
|
||||
# Build for amd64 (required for most clusters)
|
||||
docker buildx build \
|
||||
--platform linux/amd64 \
|
||||
--provenance=false \
|
||||
-f Dockerfile.runtime \
|
||||
-t your-registry/jellyfin-ha:latest \
|
||||
--push .
|
||||
```
|
||||
|
||||
> Note: `--provenance=false` is required if your cluster runs containerd (k3s, most kubeadm setups). Without it, Docker adds OCI attestation manifests that containerd cannot resolve.
|
||||
|
||||
---
|
||||
|
||||
### Option 3 — Bare dotnet (development only)
|
||||
|
||||
For local development and testing without containers. HA mode still works — you just run two terminal sessions pointing at the same Redis and a shared local directory.
|
||||
|
||||
**Terminal 1:**
|
||||
|
||||
```bash
|
||||
export Jellyfin__TranscodeStore__RedisConnectionString="localhost:6379"
|
||||
export JELLYFIN_HA_POD_NAME="dev-pod-1"
|
||||
|
||||
dotnet run --project Jellyfin.Server/Jellyfin.Server.csproj -- \
|
||||
--datadir /tmp/jellyfin-1/data \
|
||||
--cachedir /tmp/jellyfin-1/cache \
|
||||
--transcodes /tmp/jellyfin-shared/transcode \
|
||||
--webdir /usr/share/jellyfin/web \
|
||||
--port 8096
|
||||
```
|
||||
|
||||
**Terminal 2:**
|
||||
|
||||
```bash
|
||||
export Jellyfin__TranscodeStore__RedisConnectionString="localhost:6379"
|
||||
export JELLYFIN_HA_POD_NAME="dev-pod-2"
|
||||
|
||||
dotnet run --project Jellyfin.Server/Jellyfin.Server.csproj -- \
|
||||
--datadir /tmp/jellyfin-2/data \
|
||||
--cachedir /tmp/jellyfin-2/cache \
|
||||
--transcodes /tmp/jellyfin-shared/transcode \
|
||||
--webdir /usr/share/jellyfin/web \
|
||||
--port 8097
|
||||
```
|
||||
|
||||
Both instances share `/tmp/jellyfin-shared/transcode`. Kill one process mid-stream to test takeover. Start a local Redis with `redis-server` or `docker run -p 6379:6379 redis:7-alpine`.
|
||||
|
||||
---
|
||||
|
||||
## PostgreSQL (experimental)
|
||||
|
||||
This fork includes a PostgreSQL database provider under `src/Jellyfin.Database/Jellyfin.Database.Providers.PostgreSQL`. It is experimental — the SQLite provider remains the default and the recommended choice for most deployments.
|
||||
|
||||
To use PostgreSQL, set the migration provider at startup and run migrations:
|
||||
|
||||
```bash
|
||||
dotnet ef migrations add InitialCreate \
|
||||
--project "src/Jellyfin.Database/Jellyfin.Database.Providers.PostgreSQL" \
|
||||
-- --migration-provider Jellyfin-PostgreSQL
|
||||
```
|
||||
|
||||
See `src/Jellyfin.Database/readme.md` for full migration instructions.
|
||||
|
||||
---
|
||||
|
||||
## Building and Testing
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Before the project can be built, you must first install the [.NET 9.0 SDK](https://dotnet.microsoft.com/download/dotnet) on your system.
|
||||
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
dotnet build Jellyfin.Server/Jellyfin.Server.csproj
|
||||
```
|
||||
|
||||
### Run all tests
|
||||
|
||||
```bash
|
||||
dotnet test Jellyfin.sln \
|
||||
--configuration Release \
|
||||
--filter "Category!=RequiresDocker&FullyQualifiedName!~Integration"
|
||||
```
|
||||
|
||||
### Run HA-specific tests
|
||||
|
||||
The transcode session store and HA recovery tests live in:
|
||||
|
||||
- `tests/Jellyfin.Server.Implementations.Tests/MediaEncoding/RedisTranscodeSessionStoreTests.cs`
|
||||
- `tests/Jellyfin.MediaEncoding.Tests/Fakes/InMemoryTranscodeSessionStore.cs`
|
||||
|
||||
```bash
|
||||
dotnet test tests/Jellyfin.Server.Implementations.Tests \
|
||||
--configuration Release \
|
||||
--filter "FullyQualifiedName~TranscodeSession"
|
||||
```
|
||||
|
||||
### Run with code coverage
|
||||
|
||||
```bash
|
||||
dotnet test Jellyfin.sln \
|
||||
--configuration Release \
|
||||
--collect:"XPlat Code Coverage" \
|
||||
--settings tests/coverletArgs.runsettings
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
MediaBrowser.Controller/MediaEncoding/
|
||||
ITranscodeSessionStore.cs ← Interface (DI contract)
|
||||
TranscodeSession.cs ← Session record model
|
||||
TranscodeStoreOptions.cs ← Configuration options
|
||||
NullTranscodeSessionStore.cs ← No-op, single-instance fallback
|
||||
|
||||
Emby.Server.Implementations/MediaEncoding/
|
||||
RedisTranscodeSessionStore.cs ← Redis-backed HA implementation
|
||||
|
||||
src/Jellyfin.Database/
|
||||
Jellyfin.Database.Providers.PostgreSQL/ ← Experimental PostgreSQL provider
|
||||
|
||||
tests/
|
||||
Jellyfin.Server.Implementations.Tests/MediaEncoding/
|
||||
RedisTranscodeSessionStoreTests.cs
|
||||
Jellyfin.MediaEncoding.Tests/Fakes/
|
||||
InMemoryTranscodeSessionStore.cs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
This is a personal experiment, not an officially maintained fork. Issues and PRs are welcome but response time may vary.
|
||||
|
||||
If you're interested in getting proper HA transcoding into upstream Jellyfin, that conversation belongs in the [upstream repo](https://github.com/jellyfin/jellyfin). The changes here are deliberately narrow and designed to be upstream-friendly if there's maintainer interest.
|
||||
|
||||
**Code conventions** follow the upstream Jellyfin rules:
|
||||
- `async`/`await` everywhere — no `.Result` or `.Wait()`
|
||||
- All public members need XML doc comments
|
||||
- Use `Directory.Packages.props` for NuGet versions — never add `Version=` to a `<PackageReference>`
|
||||
- `.NET 10` required
|
||||
- Warnings are treated as errors
|
||||
|
||||
---
|
||||
|
||||
## Relationship to upstream
|
||||
|
||||
This fork tracks [jellyfin/jellyfin](https://github.com/jellyfin/jellyfin) `master`. The HA additions are intentionally isolated to:
|
||||
|
||||
1. New interfaces and models in `MediaBrowser.Controller`
|
||||
2. New implementations in `Emby.Server.Implementations`
|
||||
3. DI wiring in `Jellyfin.Server/CoreAppHost.cs`
|
||||
4. New test projects
|
||||
|
||||
No core Jellyfin logic was modified — only extended via existing DI extension points.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
GPL-2.0, same as upstream Jellyfin. See [LICENSE](LICENSE).
|
||||
|
||||
---
|
||||
|
||||
*Upstream README preserved below for reference.*
|
||||
|
||||
---
|
||||
|
||||
Instructions to run this project from the command line are included here, but you will also need to install an IDE if you want to debug the server while it is running. Any IDE that supports .NET 6 development will work, but two options are recent versions of [Visual Studio](https://visualstudio.microsoft.com/downloads/) (at least 2022) and [Visual Studio Code](https://code.visualstudio.com/Download).
|
||||
|
||||
@@ -94,13 +563,12 @@ git clone https://github.com/jellyfin/jellyfin.git
|
||||
|
||||
The server is configured to host the static files required for the [web client](https://github.com/jellyfin/jellyfin-web) in addition to serving the backend by default. Before you can run the server, you will need to get a copy of the web client since they are not included in this repository directly.
|
||||
|
||||
Note that it is also possible to [host the web client separately](#hosting-the-web-client-separately) from the web server with some additional configuration, in which case you can skip this step.
|
||||
Note that it is recommended for development to [host the web client separately](#hosting-the-web-client-separately) from the web server with some additional configuration, in which case you can skip this step.
|
||||
|
||||
There are three options to get the files for the web client.
|
||||
There are two options to get the files for the web client.
|
||||
|
||||
1. Download one of the finished builds from the [Azure DevOps pipeline](https://dev.azure.com/jellyfin-project/jellyfin/_build?definitionId=27). You can download the build for a specific release by looking at the [branches tab](https://dev.azure.com/jellyfin-project/jellyfin/_build?definitionId=27&_a=summary&repositoryFilter=6&view=branches) of the pipelines page.
|
||||
2. Build them from source following the instructions on the [jellyfin-web repository](https://github.com/jellyfin/jellyfin-web)
|
||||
3. Get the pre-built files from an existing installation of the server. For example, with a Windows server installation the client files are located at `C:\Program Files\Jellyfin\Server\jellyfin-web`
|
||||
1. Build them from source following the instructions on the [jellyfin-web repository](https://github.com/jellyfin/jellyfin-web)
|
||||
2. Get the pre-built files from an existing installation of the server. For example, with a Windows server installation the client files are located at `C:\Program Files\Jellyfin\Server\jellyfin-web`
|
||||
|
||||
### Running The Server
|
||||
|
||||
@@ -133,7 +601,7 @@ A second option is to build the project and then run the resulting executable fi
|
||||
|
||||
```bash
|
||||
dotnet build # Build the project
|
||||
cd Jellyfin.Server/bin/Debug/net9.0 # Change into the build output directory
|
||||
cd Jellyfin.Server/bin/Debug/net10.0 # Change into the build output directory
|
||||
```
|
||||
|
||||
2. Execute the build output. On Linux, Mac, etc. use `./jellyfin` and on Windows use `jellyfin.exe`.
|
||||
@@ -198,5 +666,5 @@ This project is supported by:
|
||||
<br/>
|
||||
<a href="https://www.digitalocean.com"><img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_blue.svg" height="50px" alt="DigitalOcean"></a>
|
||||
|
||||
<a href="https://www.jetbrains.com"><img src="https://gist.githubusercontent.com/anthonylavado/e8b2403deee9581e0b4cb8cd675af7db/raw/fa104b7d73f759d7262794b94569f1b89df41c0b/jetbrains.svg" height="50px" alt="JetBrains logo"></a>
|
||||
<a href="https://www.jetbrains.com"><img src="https://gist.githubusercontent.com/anthonylavado/e8b2403deee9581e0b4cb8cd675af7db/raw/199ae22980ef5da64882ec2de3e8e5c03fe535b8/jetbrains.svg" height="50px" alt="JetBrains logo"></a>
|
||||
</p>
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
using System.Reflection;
|
||||
|
||||
[assembly: AssemblyVersion("10.11.4")]
|
||||
[assembly: AssemblyFileVersion("10.11.4")]
|
||||
[assembly: AssemblyVersion("10.11.7")]
|
||||
[assembly: AssemblyFileVersion("10.11.7")]
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
apiVersion: v2
|
||||
name: jellyfin-ha
|
||||
description: >
|
||||
High-availability Jellyfin media server with Redis-backed transcode session
|
||||
store, lease-aware segment cleanup, and optional PostgreSQL database provider
|
||||
for multi-pod Kubernetes deployments.
|
||||
type: application
|
||||
version: 0.1.0
|
||||
appVersion: "10.12.0"
|
||||
keywords:
|
||||
- jellyfin
|
||||
- media-server
|
||||
- high-availability
|
||||
- redis
|
||||
- kubernetes
|
||||
home: https://github.com/ZoltyMat/jellyfin-ha
|
||||
sources:
|
||||
- https://github.com/ZoltyMat/jellyfin-ha
|
||||
maintainers:
|
||||
- name: ZoltyMat
|
||||
url: https://github.com/ZoltyMat
|
||||
icon: https://raw.githubusercontent.com/jellyfin/jellyfin/master/Jellyfin.Server/Resources/Images/jellyfin-icon-solid.png
|
||||
@@ -0,0 +1,49 @@
|
||||
1. Jellyfin HA has been deployed.
|
||||
|
||||
{{- if eq (int .Values.replicaCount) 1 }}
|
||||
⚠ replicaCount=1 — running in single-instance mode. Set replicaCount >= 2 and
|
||||
ha.enabled=true to enable HA transcoding.
|
||||
{{- else }}
|
||||
✔ Running {{ .Values.replicaCount }} replicas.
|
||||
{{- if include "jellyfin-ha.haEnabled" . }}
|
||||
✔ HA mode: ACTIVE — transcode sessions replicated via Redis.
|
||||
{{- else }}
|
||||
⚠ HA mode: INACTIVE — NullTranscodeSessionStore in use.
|
||||
Set redis.enabled=true (or ha.transcodeStore.redisConnectionString) to enable HA.
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
2. Get the Jellyfin URL:
|
||||
|
||||
{{- if .Values.ingress.enabled }}
|
||||
{{- range .Values.ingress.hosts }}
|
||||
https://{{ .host }}/
|
||||
{{- end }}
|
||||
{{- else if .Values.traefikIngressRoute.enabled }}
|
||||
https://{{ .Values.traefikIngressRoute.host }}/
|
||||
{{- else }}
|
||||
Access via port-forward:
|
||||
kubectl port-forward -n {{ .Release.Namespace }} svc/{{ include "jellyfin-ha.fullname" . }} 8096:{{ .Values.service.port }}
|
||||
http://localhost:8096/
|
||||
{{- end }}
|
||||
|
||||
3. PostgreSQL:
|
||||
{{- if .Values.postgresql.enabled }}
|
||||
✔ In-cluster PostgreSQL deployed. Ensure the secret "{{ .Values.postgresql.existingSecret }}"
|
||||
exists in namespace {{ .Release.Namespace }} before starting the server.
|
||||
{{- else if eq .Values.config.databaseType "Jellyfin-PostgreSQL" }}
|
||||
⚠ config.databaseType=Jellyfin-PostgreSQL but postgresql.enabled=false.
|
||||
Make sure you have an external PostgreSQL and the correct DATABASE_URL env var set.
|
||||
{{- else }}
|
||||
Using SQLite (default). Enable postgresql.enabled=true for a shared database backend.
|
||||
{{- end }}
|
||||
|
||||
4. Transcode storage:
|
||||
The transcode PVC must be ReadWriteMany when replicaCount > 1.
|
||||
Current accessMode: {{ .Values.persistence.transcode.accessMode }}
|
||||
{{- if and (gt (int .Values.replicaCount) 1) (ne .Values.persistence.transcode.accessMode "ReadWriteMany") }}
|
||||
|
||||
⚠ WARNING: replicaCount > 1 but transcode accessMode is not ReadWriteMany.
|
||||
Pod B cannot read Pod A's HLS segments during session takeover.
|
||||
Set persistence.transcode.accessMode=ReadWriteMany or use an NFS / Longhorn RWX PVC.
|
||||
{{- end }}
|
||||
@@ -0,0 +1,137 @@
|
||||
{{/*
|
||||
Expand the name of the chart.
|
||||
*/}}
|
||||
{{- define "jellyfin-ha.name" -}}
|
||||
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create a default fully qualified app name.
|
||||
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
|
||||
If release name contains chart name it will be used as a full name.
|
||||
*/}}
|
||||
{{- define "jellyfin-ha.fullname" -}}
|
||||
{{- if .Values.fullnameOverride }}
|
||||
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
|
||||
{{- else }}
|
||||
{{- $name := default .Chart.Name .Values.nameOverride }}
|
||||
{{- if contains $name .Release.Name }}
|
||||
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
|
||||
{{- else }}
|
||||
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create chart label.
|
||||
*/}}
|
||||
{{- define "jellyfin-ha.chart" -}}
|
||||
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Common labels.
|
||||
*/}}
|
||||
{{- define "jellyfin-ha.labels" -}}
|
||||
helm.sh/chart: {{ include "jellyfin-ha.chart" . }}
|
||||
{{ include "jellyfin-ha.selectorLabels" . }}
|
||||
{{- if .Chart.AppVersion }}
|
||||
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
||||
{{- end }}
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Selector labels.
|
||||
*/}}
|
||||
{{- define "jellyfin-ha.selectorLabels" -}}
|
||||
app.kubernetes.io/name: {{ include "jellyfin-ha.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: server
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Service account name.
|
||||
*/}}
|
||||
{{- define "jellyfin-ha.serviceAccountName" -}}
|
||||
{{- if .Values.serviceAccount.create }}
|
||||
{{- default (include "jellyfin-ha.fullname" .) .Values.serviceAccount.name }}
|
||||
{{- else }}
|
||||
{{- default "default" .Values.serviceAccount.name }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Fully qualified name of the in-cluster Redis service.
|
||||
*/}}
|
||||
{{- define "jellyfin-ha.redis.fullname" -}}
|
||||
{{- printf "%s-redis" (include "jellyfin-ha.fullname" .) }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Fully qualified name of the in-cluster PostgreSQL service.
|
||||
*/}}
|
||||
{{- define "jellyfin-ha.postgres.fullname" -}}
|
||||
{{- printf "%s-postgres" (include "jellyfin-ha.fullname" .) }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Compute the Redis connection string.
|
||||
Priority:
|
||||
1. existingSecret (mounted as env var in the statefulset template)
|
||||
2. explicit ha.transcodeStore.redisConnectionString value
|
||||
3. auto-compose from the in-cluster Redis service name when redis.enabled=true
|
||||
Returns empty string if none of the above apply (= single-instance / NullStore mode).
|
||||
This helper returns the literal string only for cases 2 and 3; case 1 is handled
|
||||
directly in the container env block via secretKeyRef.
|
||||
*/}}
|
||||
{{- define "jellyfin-ha.redisConnectionString" -}}
|
||||
{{- if .Values.ha.transcodeStore.redisConnectionString }}
|
||||
{{- .Values.ha.transcodeStore.redisConnectionString }}
|
||||
{{- else if .Values.redis.enabled }}
|
||||
{{- printf "%s:6379,abortConnect=false" (include "jellyfin-ha.redis.fullname" .) }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Return true if HA mode is active and Redis should be wired up.
|
||||
*/}}
|
||||
{{- define "jellyfin-ha.haEnabled" -}}
|
||||
{{- if and .Values.ha.enabled (or .Values.redis.enabled .Values.ha.transcodeStore.redisConnectionString .Values.ha.transcodeStore.existingSecret) }}
|
||||
{{- "true" }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Config PVC claim name — either the existing claim or the chart-managed one.
|
||||
*/}}
|
||||
{{- define "jellyfin-ha.configPvcName" -}}
|
||||
{{- if .Values.persistence.config.existingClaim }}
|
||||
{{- .Values.persistence.config.existingClaim }}
|
||||
{{- else }}
|
||||
{{- printf "%s-config" (include "jellyfin-ha.fullname" .) }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Transcode PVC claim name — either the existing claim or the chart-managed one.
|
||||
*/}}
|
||||
{{- define "jellyfin-ha.transcodePvcName" -}}
|
||||
{{- if .Values.persistence.transcode.existingClaim }}
|
||||
{{- .Values.persistence.transcode.existingClaim }}
|
||||
{{- else }}
|
||||
{{- printf "%s-transcode" (include "jellyfin-ha.fullname" .) }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Media PVC claim name — either the existing claim or the chart-managed NFS PVC.
|
||||
*/}}
|
||||
{{- define "jellyfin-ha.mediaPvcName" -}}
|
||||
{{- if .Values.persistence.media.existingClaim }}
|
||||
{{- .Values.persistence.media.existingClaim }}
|
||||
{{- else if .Values.persistence.media.nfs.enabled }}
|
||||
{{- printf "%s-media" (include "jellyfin-ha.fullname" .) }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user