Compare commits
292 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ec581b5e5f | |||
| b77702c851 | |||
| 2370787471 | |||
| a652d718ca | |||
| 0c707c7989 | |||
| 91655fcb0a | |||
| 6e405af1dc | |||
| 95220e2ed6 | |||
| 56919b9565 | |||
| 25269263ce | |||
| b183bec94b | |||
| bc42ff4b28 | |||
| b501ba9620 | |||
| acab85ada3 | |||
| ad834167d5 | |||
| f59ab11c43 | |||
| 10fed9409d | |||
| f2166df463 | |||
| 3b60289502 | |||
| 2f9b8888e9 | |||
| 65af2bcbd7 | |||
| 2e3d24cc05 | |||
| dcb6ae396c | |||
| c438ec17ed | |||
| f54bc76767 | |||
| baa16b6586 | |||
| d825f8ac81 | |||
| 0148f374ee | |||
| d8c7eb5d3d | |||
| 03ac3bbc00 | |||
| e235673f12 | |||
| b6f7732f13 | |||
| 43b4f143eb | |||
| 9b8748c778 | |||
| 673d186de8 | |||
| 14d843897c | |||
| 3b416c7fb2 | |||
| e3571eb2a4 | |||
| 6ccabd4c72 | |||
| c43992630e | |||
| e23d1a23be | |||
| c8a693ae17 | |||
| e68455f0ed | |||
| 2e1e445e47 | |||
| 4920aa871a | |||
| a06b11980e | |||
| 0008bde28e | |||
| d4f9c12c22 | |||
| d46539b718 | |||
| 13f49305da | |||
| 7d4cef51f5 | |||
| a9aa2d53ed | |||
| 08b87504b4 | |||
| df27faaa41 | |||
| fe9ea18918 | |||
| e71efc3f97 | |||
| 9817185fa3 | |||
| c2a11f3e68 | |||
| d60ae43b59 | |||
| a187ab18b8 | |||
| cf5268c1e4 | |||
| f405e17c96 | |||
| 67300ec17f | |||
| 2b4a0a2314 | |||
| d84a37e1d2 | |||
| 465b6c5d9e | |||
| b975195252 | |||
| 1c28df61df | |||
| c69b8ccc53 | |||
| 138081bd8b | |||
| ea732b62d8 | |||
| e7c1451b6c | |||
| a49a766424 | |||
| ddf3b9f609 | |||
| 9c6fab8f12 | |||
| 992e591938 | |||
| 365036f9f2 | |||
| 93af601a5f | |||
| 894d837578 | |||
| e79caccab1 | |||
| 25b137e0ab | |||
| 75ef410437 | |||
| 849f0a06c8 | |||
| 2e5fb57052 | |||
| e72bde9c02 | |||
| 35a1ec8a5d | |||
| a0c38131c8 | |||
| 3bc45ff92d | |||
| 2d4d6d8c38 | |||
| 9a99572354 | |||
| b2aa80ce5c | |||
| ff365dae34 | |||
| 52aebfb7d3 | |||
| 66ea1b50e6 | |||
| 3f656ade7a | |||
| 8bf0d372c6 | |||
| 202d7b5829 | |||
| 352e4f3aba | |||
| c5f6d00c94 | |||
| e8d1d94436 | |||
| 50dc37065b | |||
| 7e88b18192 | |||
| 89e914c7f1 | |||
| 1932ac4765 | |||
| ec33c74ec4 | |||
| 2184ed1b16 | |||
| d3907afde7 | |||
| e12d933531 | |||
| c0ba29d917 | |||
| d1fd81c382 | |||
| e038045494 | |||
| e1691e649e | |||
| 8d28497d29 | |||
| fddd4e7e6b | |||
| 0581cd6610 | |||
| 0f1732e5f5 | |||
| 41c2d51d8c | |||
| 29b2361857 | |||
| ce867f9834 | |||
| 4034bf9d7e | |||
| 3d2658fa43 | |||
| 61b19688ff | |||
| e8d72bf6a3 | |||
| 348b14f7b7 | |||
| fda49a5a49 | |||
| 55c00d76bb | |||
| 519d2113eb | |||
| f34f6b6941 | |||
| 6864e108b8 | |||
| 09ba04662a | |||
| 9cd2418095 | |||
| b6a96513de | |||
| ca57166e95 | |||
| 33496c1693 | |||
| b65daeca0b | |||
| 286cc6d720 | |||
| aa4f09c799 | |||
| afd3c0d9f3 | |||
| 5597d8e1a7 | |||
| 0166362258 | |||
| 58c330b63d | |||
| be71295693 | |||
| 8cd3090cee | |||
| 7bf08daeec | |||
| 290463fe7b | |||
| 1b2d9c100a | |||
| caa05c1bf2 | |||
| a37ead86df | |||
| e65aff8bc6 | |||
| 9734494eb6 | |||
| d41e302418 | |||
| 80ba517294 | |||
| 95d08b264f | |||
| 893a849f28 | |||
| 673f617994 | |||
| 644327eb76 | |||
| 10662e75e4 | |||
| a2b1936e73 | |||
| 2df546af6d | |||
| 338b480217 | |||
| 2943bb6fdd | |||
| 94edcbd2d1 | |||
| a8d1cdefac | |||
| a518160a6f | |||
| b56de6493f | |||
| 093cfc3f3b | |||
| 49775b1f6a | |||
| 22d593b8e9 | |||
| 2cb7fb52d2 | |||
| 8433b6d8a4 | |||
| 32d2414de0 | |||
| 317a3a47c3 | |||
| 845b8cdc8f | |||
| c86f6439c5 | |||
| 559e0088e5 | |||
| adaca95590 | |||
| 09a1c31fa3 | |||
| e4b82025b8 | |||
| 78e3702cb0 | |||
| 01b20d3b75 | |||
| 156761405e | |||
| 1805f2259f | |||
| 4c587776d6 | |||
| 8379b4634a | |||
| 9470439cfa | |||
| 18096e48e0 | |||
| f2d0ac7b28 | |||
| 2ccf08f547 | |||
| 1e27f460fe | |||
| 4cdd8c8233 | |||
| 6e60634c9f | |||
| 12c5d6b636 | |||
| b617c62f8e | |||
| 035b5895b0 | |||
| 22da5187c8 | |||
| 5804d6840c | |||
| b50ce1ad6b | |||
| 481ee03f35 | |||
| d91adb5d54 | |||
| ef7f138a4e | |||
| 2e8d9a311b | |||
| 4c5a3fbff3 | |||
| 636908fc4d | |||
| 997362fc97 | |||
| c5147341e3 | |||
| ca33bcebf0 | |||
| d32f487e8e | |||
| fb65f8f853 | |||
| 2a0b90e385 | |||
| dde70fd8a2 | |||
| 98d1d0cb35 | |||
| ba76a8f3ad | |||
| 8cd5652157 | |||
| 8aff4227d9 | |||
| 026f7472cb | |||
| daca285568 | |||
| fbb9a0b2c7 | |||
| 29b3aa8543 | |||
| 94f3725208 | |||
| 0ee81e87be | |||
| c491a918c2 | |||
| 1e7e46cb82 | |||
| 5ae444d96d | |||
| ee7ad83427 | |||
| 921d7d3364 | |||
| f8e012582a | |||
| def5956cd1 | |||
| abfbaca336 | |||
| 6566188e45 | |||
| 078f9584ed | |||
| ee34c75386 | |||
| e8150428b6 | |||
| 4b38e35bbb | |||
| 435bb14bb2 | |||
| 2e5ced5098 | |||
| f4a846aa4d | |||
| 7c1063177f | |||
| 5878b1ffc5 | |||
| 3c3c2aee0d | |||
| 511223aac4 | |||
| 3b2d64995a | |||
| 13c4517a66 | |||
| 177b6464ca | |||
| 5a9a8363f4 | |||
| 49efd68fc7 | |||
| 90a8a26c6e | |||
| 002c83e6f5 | |||
| 7222910b05 | |||
| 097cb87f6f | |||
| 91c3b1617e | |||
| 8f71922734 | |||
| d140630208 | |||
| 63a3e55297 | |||
| c2e5081d64 | |||
| 4187c6f620 | |||
| e7dbb3afec | |||
| f994dd6211 | |||
| da254ee968 | |||
| 4ad3141875 | |||
| b5f0199a25 | |||
| 6bf88c049e | |||
| 40a33da2a5 | |||
| 3596fc0693 | |||
| 93824dad97 | |||
| e5656af1f2 | |||
| c127c10458 | |||
| 7d1824ea27 | |||
| 2966d27c97 | |||
| 618ec4543e | |||
| 0e4031ae52 | |||
| 442af96ed9 | |||
| a305204cfa | |||
| 75f472e6a7 | |||
| cc32e8f7cb | |||
| 14b3085ff1 | |||
| 5691eee4f1 | |||
| 1520a697ad | |||
| 81b8b0ca4a | |||
| ac3fa3c376 | |||
| 7a1c1cd342 | |||
| 70c32a26fa | |||
| 2b94bb54aa | |||
| 0a6e8146be | |||
| 305b0fdca3 | |||
| d738386fe2 | |||
| ca830d5be7 | |||
| a5bc4524d8 | |||
| 175ee12bbc | |||
| a725220c21 | |||
| a245605152 | |||
| f4a53209f4 | |||
| 877251bcae |
@@ -1,44 +0,0 @@
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
pull_request:
|
||||
branches: [ master ]
|
||||
schedule:
|
||||
- cron: '24 2 * * 4'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ 'csharp' ]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: +security-extended
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
@@ -1,159 +0,0 @@
|
||||
name: ABI Compatibility
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
abi-head:
|
||||
name: ABI - HEAD
|
||||
runs-on: ubuntu-latest
|
||||
permissions: read-all
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.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@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
dotnet build Jellyfin.Server -o ./out
|
||||
|
||||
- name: Upload Head
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: abi-head
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
path: out/
|
||||
|
||||
abi-base:
|
||||
name: ABI - BASE
|
||||
if: ${{ github.base_ref != '' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions: read-all
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.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@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Checkout common ancestor
|
||||
env:
|
||||
HEAD_REF: ${{ github.head_ref }}
|
||||
run: |
|
||||
git remote add upstream https://github.com/${{ github.event.pull_request.base.repo.full_name }}
|
||||
git -c protocol.version=2 fetch --prune --progress --no-recurse-submodules upstream +refs/heads/*:refs/remotes/upstream/* +refs/tags/*:refs/tags/*
|
||||
ANCESTOR_REF=$(git merge-base upstream/${{ github.base_ref }} origin/$HEAD_REF)
|
||||
git checkout --progress --force $ANCESTOR_REF
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
dotnet build Jellyfin.Server -o ./out
|
||||
|
||||
- name: Upload Head
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: abi-base
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
path: out/
|
||||
|
||||
abi-diff:
|
||||
permissions:
|
||||
pull-requests: write # to create or update comment (peter-evans/create-or-update-comment)
|
||||
|
||||
name: ABI - Difference
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- abi-head
|
||||
- abi-base
|
||||
|
||||
steps:
|
||||
- name: Download abi-head
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: abi-head
|
||||
path: abi-head
|
||||
|
||||
- name: Download abi-base
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: abi-base
|
||||
path: abi-base
|
||||
|
||||
- name: Setup ApiCompat
|
||||
run: |
|
||||
dotnet tool install --global Microsoft.DotNet.ApiCompat.Tool
|
||||
|
||||
- name: Run ApiCompat
|
||||
id: diff
|
||||
run: |
|
||||
{
|
||||
echo 'body<<EOF'
|
||||
for file in Jellyfin.Data.dll MediaBrowser.Common.dll MediaBrowser.Controller.dll MediaBrowser.Model.dll Emby.Naming.dll Jellyfin.Extensions.dll Jellyfin.MediaEncoding.Keyframes.dll Jellyfin.Database.Implementations.dll; do
|
||||
COMPAT_OUTPUT="$( { apicompat --left ./abi-base/${file} --right ./abi-head/${file}; } 2>&1 || true )"
|
||||
if [ "APICompat ran successfully without finding any breaking changes." != "${COMPAT_OUTPUT}" ]; then
|
||||
printf "\n${file}\n${COMPAT_OUTPUT}\n"
|
||||
fi
|
||||
done
|
||||
echo EOF
|
||||
} >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Find difference comment
|
||||
uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4.0.0
|
||||
id: find-comment
|
||||
with:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
direction: last
|
||||
body-includes: abi-diff-workflow-comment
|
||||
|
||||
- name: Reply or edit difference comment (changed)
|
||||
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
|
||||
if: ${{ steps.diff.outputs.body != '' }}
|
||||
with:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
comment-id: ${{ steps.find-comment.outputs.comment-id }}
|
||||
edit-mode: replace
|
||||
token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
body: |
|
||||
<!--abi-diff-workflow-comment-->
|
||||
<details>
|
||||
<summary>ABI Difference</summary>
|
||||
|
||||
```
|
||||
${{ steps.diff.outputs.body }}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
- name: Reply or edit difference comment (unchanged)
|
||||
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
|
||||
if: ${{ steps.diff.outputs.body == '' && steps.find-comment.outputs.comment-id != '' }}
|
||||
with:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
comment-id: ${{ steps.find-comment.outputs.comment-id }}
|
||||
edit-mode: replace
|
||||
token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
body: |
|
||||
<!--abi-diff-workflow-comment-->
|
||||
<details>
|
||||
<summary>ABI Difference</summary>
|
||||
|
||||
No changes to the ABI found. See history of this comment for previous changes.
|
||||
|
||||
</details>
|
||||
@@ -1,25 +0,0 @@
|
||||
name: Format
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
# Run formatter against the forked branch, but
|
||||
# do not allow access to secrets
|
||||
# https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#workflows-in-forked-repositories
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
SDK_VERSION: "10.0.x"
|
||||
|
||||
jobs:
|
||||
format-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||
with:
|
||||
dotnet-version: ${{ env.SDK_VERSION }}
|
||||
|
||||
- name: Run DotNet Format
|
||||
run: dotnet format --verify-no-changes --verbosity minimal
|
||||
@@ -1,45 +0,0 @@
|
||||
name: Tests
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
# Run tests against the forked branch, but
|
||||
# do not allow access to secrets
|
||||
# https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#workflows-in-forked-repositories
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
SDK_VERSION: "10.0.x"
|
||||
|
||||
jobs:
|
||||
run-tests:
|
||||
strategy:
|
||||
matrix:
|
||||
os: ["ubuntu-latest", "macos-latest", "windows-latest"]
|
||||
fail-fast: false
|
||||
|
||||
runs-on: "${{ matrix.os }}"
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||
with:
|
||||
dotnet-version: ${{ env.SDK_VERSION }}
|
||||
|
||||
- name: Run DotNet CLI Tests
|
||||
run: >
|
||||
dotnet test Jellyfin.sln
|
||||
--configuration Release
|
||||
--collect:"XPlat Code Coverage"
|
||||
--settings tests/coverletArgs.runsettings
|
||||
--verbosity minimal
|
||||
|
||||
- name: Merge code coverage results
|
||||
uses: danielpalme/ReportGenerator-GitHub-Action@d3ebf1f760f7d8ab92cc44d9bcfee7ad73722a31 # v5.5.11
|
||||
with:
|
||||
reports: "**/coverage.cobertura.xml"
|
||||
targetdir: "merged/"
|
||||
reporttypes: "Cobertura"
|
||||
|
||||
# TODO - which action / tool to use to publish code coverage results?
|
||||
# - name: Publish code coverage results
|
||||
@@ -1,63 +0,0 @@
|
||||
name: Commands
|
||||
on:
|
||||
issue_comment:
|
||||
types:
|
||||
- created
|
||||
- edited
|
||||
pull_request:
|
||||
types:
|
||||
- labeled
|
||||
- synchronize
|
||||
|
||||
permissions: {}
|
||||
jobs:
|
||||
rebase:
|
||||
name: Rebase
|
||||
if: github.event.issue.pull_request != '' && contains(github.event.comment.body, '@jellyfin-bot rebase') && github.event.comment.author_association == 'MEMBER'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Notify as seen
|
||||
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
|
||||
with:
|
||||
token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
comment-id: ${{ github.event.comment.id }}
|
||||
reactions: '+1'
|
||||
|
||||
- name: Checkout the latest code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Automatic Rebase
|
||||
uses: cirrus-actions/rebase@b87d48154a87a85666003575337e27b8cd65f691 # 1.8
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.JF_BOT_TOKEN }}
|
||||
|
||||
rename:
|
||||
name: Rename
|
||||
if: contains(github.event.comment.body, '@jellyfin-bot rename')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: pull in script
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: jellyfin/jellyfin-triage-script
|
||||
|
||||
- name: install python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: '3.14'
|
||||
cache: 'pip'
|
||||
|
||||
- name: install python packages
|
||||
run: pip install -r rename/requirements.txt
|
||||
|
||||
- name: run rename script
|
||||
run: python3 rename.py
|
||||
working-directory: ./rename
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.JF_BOT_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
ISSUE: ${{ github.event.issue.number }}
|
||||
COMMENT_ID: ${{ github.event.comment.id }}
|
||||
@@ -1,35 +0,0 @@
|
||||
name: Stale Issue Labeler
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '30 1 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
actions: write
|
||||
|
||||
jobs:
|
||||
issues:
|
||||
name: Check for stale issues
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ contains(github.repository, 'jellyfin/') }}
|
||||
steps:
|
||||
- uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
|
||||
with:
|
||||
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
ascending: true
|
||||
days-before-stale: 120
|
||||
days-before-pr-stale: -1
|
||||
days-before-close: 21
|
||||
days-before-pr-close: -1
|
||||
operations-per-run: 500
|
||||
exempt-issue-labels: regression,security,roadmap,future,feature,enhancement,confirmed
|
||||
stale-issue-label: stale
|
||||
stale-issue-message: |-
|
||||
This issue has gone 120 days without an update and will be closed within 21 days if there is no new activity. To prevent this issue from being closed, please confirm the issue has not already been fixed by providing updated examples or logs.
|
||||
|
||||
If you have any questions you can use one of several ways to [contact us](https://jellyfin.org/contact).
|
||||
close-issue-message: |-
|
||||
This issue was closed due to inactivity.
|
||||
@@ -1,32 +0,0 @@
|
||||
name: Check Issue Template
|
||||
on:
|
||||
issues:
|
||||
types:
|
||||
- opened
|
||||
jobs:
|
||||
check_issue:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- name: pull in script
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: jellyfin/jellyfin-triage-script
|
||||
|
||||
- name: install python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: '3.14'
|
||||
cache: 'pip'
|
||||
|
||||
- name: install python packages
|
||||
run: pip install -r main-repo-triage/requirements.txt
|
||||
|
||||
- name: check and comment issue
|
||||
working-directory: ./main-repo-triage
|
||||
run: python3 single_issue_gha.py
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.JF_BOT_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
ISSUE: ${{ github.event.issue.number }}
|
||||
@@ -1,44 +0,0 @@
|
||||
name: OpenAPI Generate
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
ref:
|
||||
required: true
|
||||
type: string
|
||||
repository:
|
||||
required: true
|
||||
type: string
|
||||
artifact:
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
main:
|
||||
name: Main
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
repository: ${{ inputs.repository }}
|
||||
|
||||
- name: Configure .NET
|
||||
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Create File
|
||||
run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter Jellyfin.Server.Integration.Tests.OpenApiSpecTests
|
||||
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: ${{ inputs.artifact }}
|
||||
path: tests/Jellyfin.Server.Integration.Tests/bin/Release/net10.0/openapi.json
|
||||
retention-days: 14
|
||||
if-no-files-found: error
|
||||
@@ -1,140 +0,0 @@
|
||||
name: OpenAPI Publish
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
publish-openapi:
|
||||
name: OpenAPI - Publish Artifact
|
||||
uses: ./.github/workflows/openapi-generate.yml
|
||||
permissions:
|
||||
contents: read
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
repository: ${{ github.repository }}
|
||||
artifact: openapi-head
|
||||
|
||||
publish-unstable:
|
||||
name: OpenAPI - Publish Unstable Spec
|
||||
if: ${{ github.event_name != 'pull_request' && !startsWith(github.ref, 'refs/tags/v') && contains(github.repository_owner, 'jellyfin') }}
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- publish-openapi
|
||||
steps:
|
||||
- name: Set unstable dated version
|
||||
id: version
|
||||
run: |-
|
||||
echo "JELLYFIN_VERSION=$(date +'%Y%m%d%H%M%S')" >> $GITHUB_ENV
|
||||
- name: Download openapi-head
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: openapi-head
|
||||
path: openapi-head
|
||||
- name: Upload openapi.json (unstable) to repository server
|
||||
uses: appleboy/scp-action@ff85246acaad7bdce478db94a363cd2bf7c90345 # v1.0.0
|
||||
with:
|
||||
host: "${{ secrets.REPO_HOST }}"
|
||||
username: "${{ secrets.REPO_USER }}"
|
||||
key: "${{ secrets.REPO_KEY }}"
|
||||
source: openapi-head/openapi.json
|
||||
strip_components: 1
|
||||
target: "/srv/incoming/openapi/unstable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}"
|
||||
- name: Move openapi.json (unstable) into place
|
||||
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
|
||||
with:
|
||||
host: "${{ secrets.REPO_HOST }}"
|
||||
username: "${{ secrets.REPO_USER }}"
|
||||
key: "${{ secrets.REPO_KEY }}"
|
||||
debug: false
|
||||
script: |
|
||||
if ! test -d /run/workflows; then
|
||||
sudo mkdir -p /run/workflows
|
||||
sudo chown ${{ secrets.REPO_USER }} /run/workflows
|
||||
fi
|
||||
(
|
||||
flock -x -w 300 200 || exit 1
|
||||
TGT_DIR="/srv/repository/main/openapi"
|
||||
LAST_SPEC="$( ls -lt ${TGT_DIR}/unstable/ | grep 'jellyfin-openapi' | head -1 | awk '{ print $NF }' )"
|
||||
# If new and previous spec don't differ (diff retcode 0), remove incoming and finish
|
||||
if diff /srv/incoming/openapi/unstable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}/openapi.json ${TGT_DIR}/unstable/${LAST_SPEC} &>/dev/null; then
|
||||
rm -r /srv/incoming/openapi/unstable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}
|
||||
exit 0
|
||||
fi
|
||||
# Move new spec into place
|
||||
sudo mv /srv/incoming/openapi/unstable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}/openapi.json ${TGT_DIR}/unstable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}.json
|
||||
# Delete previous jellyfin-openapi-unstable_previous.json
|
||||
sudo rm ${TGT_DIR}/jellyfin-openapi-unstable_previous.json
|
||||
# Move current jellyfin-openapi-unstable.json symlink to jellyfin-openapi-unstable_previous.json
|
||||
sudo mv ${TGT_DIR}/jellyfin-openapi-unstable.json ${TGT_DIR}/jellyfin-openapi-unstable_previous.json
|
||||
# Create new jellyfin-openapi-unstable.json symlink
|
||||
sudo ln -s unstable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}.json ${TGT_DIR}/jellyfin-openapi-unstable.json
|
||||
# Check that the previous openapi unstable spec link is correct
|
||||
if [[ "$( readlink ${TGT_DIR}/jellyfin-openapi-unstable_previous.json )" != "unstable/${LAST_SPEC}" ]]; then
|
||||
sudo rm ${TGT_DIR}/jellyfin-openapi-unstable_previous.json
|
||||
sudo ln -s unstable/${LAST_SPEC} ${TGT_DIR}/jellyfin-openapi-unstable_previous.json
|
||||
fi
|
||||
) 200>/run/workflows/openapi-unstable.lock
|
||||
|
||||
publish-stable:
|
||||
name: OpenAPI - Publish Stable Spec
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/v') && contains(github.repository_owner, 'jellyfin') }}
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- publish-openapi
|
||||
steps:
|
||||
- name: Set version number
|
||||
id: version
|
||||
run: |-
|
||||
echo "JELLYFIN_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
|
||||
- name: Download openapi-head
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: openapi-head
|
||||
path: openapi-head
|
||||
- name: Upload openapi.json (stable) to repository server
|
||||
uses: appleboy/scp-action@ff85246acaad7bdce478db94a363cd2bf7c90345 # v1.0.0
|
||||
with:
|
||||
host: "${{ secrets.REPO_HOST }}"
|
||||
username: "${{ secrets.REPO_USER }}"
|
||||
key: "${{ secrets.REPO_KEY }}"
|
||||
source: openapi-head/openapi.json
|
||||
strip_components: 1
|
||||
target: "/srv/incoming/openapi/stable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}"
|
||||
- name: Move openapi.json (stable) into place
|
||||
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
|
||||
with:
|
||||
host: "${{ secrets.REPO_HOST }}"
|
||||
username: "${{ secrets.REPO_USER }}"
|
||||
key: "${{ secrets.REPO_KEY }}"
|
||||
debug: false
|
||||
script: |
|
||||
if ! test -d /run/workflows; then
|
||||
sudo mkdir -p /run/workflows
|
||||
sudo chown ${{ secrets.REPO_USER }} /run/workflows
|
||||
fi
|
||||
(
|
||||
flock -x -w 300 200 || exit 1
|
||||
TGT_DIR="/srv/repository/main/openapi"
|
||||
LAST_SPEC="$( ls -lt ${TGT_DIR}/stable/ | grep 'jellyfin-openapi' | head -1 | awk '{ print $NF }' )"
|
||||
# If new and previous spec don't differ (diff retcode 0), remove incoming and finish
|
||||
if diff /srv/incoming/openapi/stable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}/openapi.json ${TGT_DIR}/stable/${LAST_SPEC} &>/dev/null; then
|
||||
rm -r /srv/incoming/openapi/stable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}
|
||||
exit 0
|
||||
fi
|
||||
# Move new spec into place
|
||||
sudo mv /srv/incoming/openapi/stable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}/openapi.json ${TGT_DIR}/stable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}.json
|
||||
# Delete previous jellyfin-openapi-stable_previous.json
|
||||
sudo rm ${TGT_DIR}/jellyfin-openapi-stable_previous.json
|
||||
# Move current jellyfin-openapi-stable.json symlink to jellyfin-openapi-stable_previous.json
|
||||
sudo mv ${TGT_DIR}/jellyfin-openapi-stable.json ${TGT_DIR}/jellyfin-openapi-stable_previous.json
|
||||
# Create new jellyfin-openapi-stable.json symlink
|
||||
sudo ln -s stable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}.json ${TGT_DIR}/jellyfin-openapi-stable.json
|
||||
# Check that the previous openapi stable spec link is correct
|
||||
if [[ "$( readlink ${TGT_DIR}/jellyfin-openapi-stable_previous.json )" != "stable/${LAST_SPEC}" ]]; then
|
||||
sudo rm ${TGT_DIR}/jellyfin-openapi-stable_previous.json
|
||||
sudo ln -s stable/${LAST_SPEC} ${TGT_DIR}/jellyfin-openapi-stable_previous.json
|
||||
fi
|
||||
) 200>/run/workflows/openapi-stable.lock
|
||||
@@ -1,80 +0,0 @@
|
||||
name: OpenAPI Check
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
ancestor:
|
||||
name: Common Ancestor
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
base_ref: ${{ steps.ancestor.outputs.base_ref }}
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
fetch-depth: 0
|
||||
- name: Search History
|
||||
id: ancestor
|
||||
run: |
|
||||
git remote add upstream https://github.com/${{ github.event.pull_request.base.repo.full_name }}
|
||||
git fetch --prune --progress --no-recurse-submodules upstream +refs/heads/*:refs/remotes/upstream/* +refs/tags/*:refs/tags/*
|
||||
|
||||
ANCESTOR_REF=$(git merge-base upstream/${{ github.base_ref }} HEAD)
|
||||
|
||||
echo "ref: ${ANCESTOR_REF}"
|
||||
|
||||
echo "base_ref=${ANCESTOR_REF}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
head:
|
||||
name: Head Artifact
|
||||
uses: ./.github/workflows/openapi-generate.yml
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
artifact: openapi-head
|
||||
|
||||
base:
|
||||
name: Base Artifact
|
||||
uses: ./.github/workflows/openapi-generate.yml
|
||||
needs:
|
||||
- ancestor
|
||||
with:
|
||||
ref: ${{ needs.ancestor.outputs.base_ref }}
|
||||
repository: ${{ github.event.pull_request.base.repo.full_name }}
|
||||
artifact: openapi-base
|
||||
|
||||
diff:
|
||||
name: Generate Report
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- head
|
||||
- base
|
||||
steps:
|
||||
- name: Download Head
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: openapi-head
|
||||
path: openapi-head
|
||||
- name: Download Base
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: openapi-base
|
||||
path: openapi-base
|
||||
- name: Detect Changes
|
||||
id: openapi-diff
|
||||
run: |
|
||||
sed -i 's:allOf:oneOf:g' openapi-head/openapi.json
|
||||
sed -i 's:allOf:oneOf:g' openapi-base/openapi.json
|
||||
|
||||
mkdir -p /tmp/openapi-report
|
||||
mv openapi-head/openapi.json /tmp/openapi-report/head.json
|
||||
mv openapi-base/openapi.json /tmp/openapi-report/base.json
|
||||
|
||||
docker run -v /tmp/openapi-report:/data openapitools/openapi-diff:2.1.6 /data/base.json /data/head.json --state -l ERROR --markdown /data/openapi-report.md
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: openapi-report
|
||||
path: /tmp/openapi-report/openapi-report.md
|
||||
@@ -1,59 +0,0 @@
|
||||
name: OpenAPI Report
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- OpenAPI Check
|
||||
types:
|
||||
- completed
|
||||
|
||||
jobs:
|
||||
metadata:
|
||||
name: Generate Metadata
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
outputs:
|
||||
pr_number: ${{ steps.pr_number.outputs.pr_number }}
|
||||
steps:
|
||||
- name: Get Pull Request Number
|
||||
id: pr_number
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
||||
run: |
|
||||
API_RESPONSE=$(gh pr list --repo "${GITHUB_REPOSITORY}" --search "${HEAD_SHA}" --state open --json number)
|
||||
PR_NUMBER=$(echo "${API_RESPONSE}" | jq '.[0].number')
|
||||
|
||||
echo "repository: ${GITHUB_REPOSITORY}"
|
||||
echo "sha: ${HEAD_SHA}"
|
||||
echo "response: ${API_RESPONSE}"
|
||||
echo "pr: ${PR_NUMBER}"
|
||||
|
||||
echo "pr_number=${PR_NUMBER}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
comment:
|
||||
name: Pull Request Comment
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
needs:
|
||||
- metadata
|
||||
permissions:
|
||||
pull-requests: write
|
||||
actions: read
|
||||
contents: read
|
||||
steps:
|
||||
- name: Download OpenAPI Report
|
||||
id: download_report
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: openapi-report
|
||||
path: openapi-report
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Push Comment
|
||||
uses: thollander/actions-comment-pull-request@24bffb9b452ba05a4f3f77933840a6a841d1b32b # v3.0.1
|
||||
with:
|
||||
github-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
file-path: ${{ steps.download_report.outputs.download-path }}/openapi-report.md
|
||||
pr-number: ${{ needs.metadata.outputs.pr_number }}
|
||||
comment-tag: openapi-report
|
||||
@@ -1,66 +0,0 @@
|
||||
name: Project Automation
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
issue_comment:
|
||||
|
||||
permissions: {}
|
||||
jobs:
|
||||
project:
|
||||
name: Project board
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.repository == 'jellyfin/jellyfin' }}
|
||||
steps:
|
||||
- name: Remove from 'Current Release' project
|
||||
uses: alex-page/github-project-automation-plus@303f24a24c67ce7adf565a07e96720faf126fe36 # v0.9.0
|
||||
if: (github.event.pull_request || github.event.issue.pull_request) && !contains(github.event.*.labels.*.name, 'stable backport')
|
||||
continue-on-error: true
|
||||
with:
|
||||
project: Current Release
|
||||
action: delete
|
||||
column: In progress
|
||||
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
|
||||
- name: Add to 'Release Next' project
|
||||
uses: alex-page/github-project-automation-plus@303f24a24c67ce7adf565a07e96720faf126fe36 # v0.9.0
|
||||
if: (github.event.pull_request || github.event.issue.pull_request) && github.event.action == 'opened'
|
||||
continue-on-error: true
|
||||
with:
|
||||
project: Release Next
|
||||
column: In progress
|
||||
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
|
||||
- name: Add to 'Current Release' project
|
||||
uses: alex-page/github-project-automation-plus@303f24a24c67ce7adf565a07e96720faf126fe36 # v0.9.0
|
||||
if: (github.event.pull_request || github.event.issue.pull_request) && !contains(github.event.*.labels.*.name, 'stable backport')
|
||||
continue-on-error: true
|
||||
with:
|
||||
project: Current Release
|
||||
column: In progress
|
||||
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
|
||||
- name: Check number of comments from the team member
|
||||
if: github.event.issue.pull_request == '' && github.event.comment.author_association == 'MEMBER'
|
||||
id: member_comments
|
||||
run: echo "::set-output name=number::$(curl -s ${{ github.event.issue.comments_url }} | jq '.[] | select(.author_association == "MEMBER") | .author_association' | wc -l)"
|
||||
|
||||
- name: Move issue to needs triage
|
||||
uses: alex-page/github-project-automation-plus@303f24a24c67ce7adf565a07e96720faf126fe36 # v0.9.0
|
||||
if: github.event.issue.pull_request == '' && github.event.comment.author_association == 'MEMBER' && steps.member_comments.outputs.number <= 1
|
||||
continue-on-error: true
|
||||
with:
|
||||
project: Issue Triage for Main Repo
|
||||
column: Needs triage
|
||||
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
|
||||
- name: Add issue to triage project
|
||||
uses: alex-page/github-project-automation-plus@303f24a24c67ce7adf565a07e96720faf126fe36 # v0.9.0
|
||||
if: github.event.issue.pull_request == '' && github.event.action == 'opened'
|
||||
continue-on-error: true
|
||||
with:
|
||||
project: Issue Triage for Main Repo
|
||||
column: Pending response
|
||||
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
@@ -1,24 +0,0 @@
|
||||
name: Merge Conflict Labeler
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request_target:
|
||||
types: [synchronize]
|
||||
|
||||
permissions: {}
|
||||
jobs:
|
||||
main:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
if: ${{ github.repository == 'jellyfin/jellyfin' }}
|
||||
steps:
|
||||
- name: Apply label
|
||||
uses: eps1lon/actions-label-merge-conflict@0273be72a0bbd58fcd71d0d6c02c209b50d1e5e1 # v3.1.0
|
||||
with:
|
||||
dirtyLabel: 'merge conflict'
|
||||
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'
|
||||
repoToken: ${{ secrets.JF_BOT_TOKEN }}
|
||||
@@ -1,30 +0,0 @@
|
||||
name: Stale PR Check
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '30 */12 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
actions: write
|
||||
|
||||
jobs:
|
||||
prs-stale-conflicts:
|
||||
name: Check PRs with merge conflicts
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ contains(github.repository, 'jellyfin/') }}
|
||||
steps:
|
||||
- uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
|
||||
with:
|
||||
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
ascending: true
|
||||
operations-per-run: 150
|
||||
# The merge conflict action will remove the label when updated
|
||||
remove-stale-when-updated: false
|
||||
days-before-stale: -1
|
||||
days-before-close: 90
|
||||
days-before-issue-close: -1
|
||||
stale-pr-label: merge conflict
|
||||
close-pr-message: |-
|
||||
This PR has been closed due to having unresolved merge conflicts.
|
||||
@@ -1,82 +0,0 @@
|
||||
name: '🆙 Auto bump_version'
|
||||
|
||||
on:
|
||||
release:
|
||||
types:
|
||||
- published
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
TAG_BRANCH:
|
||||
required: true
|
||||
description: release-x.y.z
|
||||
NEXT_VERSION:
|
||||
required: true
|
||||
description: x.y.z
|
||||
|
||||
jobs:
|
||||
auto_bump_version:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event_name == 'release' && !contains(github.event.release.tag_name, 'rc') }}
|
||||
env:
|
||||
TAG_BRANCH: ${{ github.event.release.target_commitish }}
|
||||
steps:
|
||||
- name: Wait for deploy checks to finish
|
||||
uses: jitterbit/await-check-suites@292a541bb7618078395b2ce711a0d89cfb8a568a # v1
|
||||
with:
|
||||
ref: ${{ env.TAG_BRANCH }}
|
||||
intervalSeconds: 60
|
||||
timeoutSeconds: 3600
|
||||
|
||||
- name: Setup YQ
|
||||
uses: chrisdickinson/setup-yq@fa3192edd79d6eb0e4e12de8dde3a0c26f2b853b # latest
|
||||
with:
|
||||
yq-version: v4.9.8
|
||||
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ env.TAG_BRANCH }}
|
||||
|
||||
- name: Setup EnvVars
|
||||
run: |-
|
||||
CURRENT_VERSION=$(yq e '.version' build.yaml)
|
||||
CURRENT_MAJOR_MINOR=${CURRENT_VERSION%.*}
|
||||
CURRENT_PATCH=${CURRENT_VERSION##*.}
|
||||
echo "CURRENT_VERSION=${CURRENT_VERSION}" >> $GITHUB_ENV
|
||||
echo "CURRENT_MAJOR_MINOR=${CURRENT_MAJOR_MINOR}" >> $GITHUB_ENV
|
||||
echo "CURRENT_PATCH=${CURRENT_PATCH}" >> $GITHUB_ENV
|
||||
echo "NEXT_VERSION=${CURRENT_MAJOR_MINOR}.$(($CURRENT_PATCH + 1))" >> $GITHUB_ENV
|
||||
|
||||
- name: Run bump_version
|
||||
run: ./bump_version ${{ env.NEXT_VERSION }}
|
||||
|
||||
- name: Commit Changes
|
||||
run: |-
|
||||
git config user.name "jellyfin-bot"
|
||||
git config user.email "team@jellyfin.org"
|
||||
git checkout ${{ env.TAG_BRANCH }}
|
||||
git commit -am "Bump version to ${{ env.NEXT_VERSION }}"
|
||||
git push origin ${{ env.TAG_BRANCH }}
|
||||
|
||||
manual_bump_version:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
env:
|
||||
TAG_BRANCH: ${{ github.event.inputs.TAG_BRANCH }}
|
||||
NEXT_VERSION: ${{ github.event.inputs.NEXT_VERSION }}
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ env.TAG_BRANCH }}
|
||||
|
||||
- name: Run bump_version
|
||||
run: ./bump_version ${{ env.NEXT_VERSION }}
|
||||
|
||||
- name: Commit Changes
|
||||
run: |-
|
||||
git config user.name "jellyfin-bot"
|
||||
git config user.email "team@jellyfin.org"
|
||||
git checkout ${{ env.TAG_BRANCH }}
|
||||
git commit -am "Bump version to ${{ env.NEXT_VERSION }}"
|
||||
git push origin ${{ env.TAG_BRANCH }}
|
||||
@@ -0,0 +1,79 @@
|
||||
when:
|
||||
- event: pull_request
|
||||
- event: push
|
||||
|
||||
steps:
|
||||
# Restore, build and test in one step so the NuGet cache stays on the step's
|
||||
# own ephemeral storage instead of the 10Gi workspace volume.
|
||||
# global.json pins the .NET 10 SDK (10.0.0, rollForward latestMinor).
|
||||
- name: build-test
|
||||
image: mcr.microsoft.com/dotnet/sdk:10.0
|
||||
environment:
|
||||
DOTNET_CLI_TELEMETRY_OPTOUT: "1"
|
||||
DOTNET_NOLOGO: "1"
|
||||
commands:
|
||||
- dotnet --info
|
||||
- dotnet restore Jellyfin.sln
|
||||
- dotnet build Jellyfin.sln -c Release --no-restore
|
||||
# libSkiaSharp needs fontconfig to load and the SDK image does not ship it.
|
||||
# The mirror occasionally serves a half-synced index, so update is retried.
|
||||
- apt-get -o Acquire::Retries=3 update || apt-get -o Acquire::Retries=3 update
|
||||
- apt-get install -y --no-install-recommends libfontconfig1
|
||||
- ldconfig -p | grep -q libfontconfig
|
||||
# BackupServiceTests is excluded because BackupService refuses to write a
|
||||
# backup with less than 5GiB free on its target path. The clone and build
|
||||
# output fill 5.7G of the 9.8G workspace volume, leaving 4.1G. Raising the
|
||||
# agent's workspace volume size is the only way to run it here.
|
||||
- df -h .
|
||||
- dotnet test Jellyfin.sln -c Release --no-build --verbosity minimal --filter "Category!=RequiresDocker&FullyQualifiedName!~Integration&FullyQualifiedName!~BackupServiceTests"
|
||||
backend_options:
|
||||
kubernetes:
|
||||
serviceAccountName: jellyfin-ha-src
|
||||
resources:
|
||||
requests:
|
||||
memory: 2Gi
|
||||
cpu: 2
|
||||
ephemeral-storage: 10Gi
|
||||
limits:
|
||||
memory: 8Gi
|
||||
cpu: 4
|
||||
ephemeral-storage: 20Gi
|
||||
|
||||
# The PostgreSQL migration tests are the only ones that run the startup migration chain against
|
||||
# the provider production uses, and the filter above has always skipped them.
|
||||
# The server runs inside this step: the kubernetes backend has no docker daemon for
|
||||
# testcontainers, and a postgres service container deadlocks the step because the backend mounts
|
||||
# the ReadWriteOnce workspace volume into service pods and schedules them on another node.
|
||||
# Its data directory lives on the step's ephemeral storage, not on the workspace volume.
|
||||
# Scoped to Jellyfin.Server.Tests: the three classes in Jellyfin.Database.Tests.PostgreSQL still
|
||||
# start their own container, and PostgreSqlProviderTests already fails on main - on an EF 10
|
||||
# scalar query and on its own data - which a third test in the class then inherits.
|
||||
- name: postgres-migration-chain
|
||||
image: mcr.microsoft.com/dotnet/sdk:10.0
|
||||
depends_on:
|
||||
- build-test
|
||||
environment:
|
||||
DOTNET_CLI_TELEMETRY_OPTOUT: "1"
|
||||
DOTNET_NOLOGO: "1"
|
||||
JELLYFIN_TEST_POSTGRES: "Host=127.0.0.1;Port=5432;Database=postgres;Username=postgres"
|
||||
commands:
|
||||
- apt-get -o Acquire::Retries=3 update || apt-get -o Acquire::Retries=3 update
|
||||
- DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends postgresql
|
||||
- install -d -o postgres -g postgres /tmp/pgdata /tmp/pgrun
|
||||
- PGBIN=$(ls -d /usr/lib/postgresql/*/bin | tail -1)
|
||||
- su postgres -c "$PGBIN/initdb -D /tmp/pgdata -A trust -U postgres"
|
||||
- su postgres -c "$PGBIN/pg_ctl -D /tmp/pgdata -o \"-c listen_addresses=127.0.0.1 -k /tmp/pgrun\" -l /tmp/pg.log -w start"
|
||||
- dotnet build tests/Jellyfin.Server.Tests/Jellyfin.Server.Tests.csproj -c Release
|
||||
- dotnet test tests/Jellyfin.Server.Tests/Jellyfin.Server.Tests.csproj -c Release --no-build --verbosity minimal --filter "Category=RequiresDocker"
|
||||
backend_options:
|
||||
kubernetes:
|
||||
serviceAccountName: jellyfin-ha-src
|
||||
resources:
|
||||
requests:
|
||||
memory: 2Gi
|
||||
cpu: 2
|
||||
ephemeral-storage: 6Gi
|
||||
limits:
|
||||
memory: 6Gi
|
||||
cpu: 4
|
||||
ephemeral-storage: 12Gi
|
||||
@@ -8,6 +8,7 @@
|
||||
<PackageVersion Include="AutoFixture.AutoMoq" Version="4.18.1" />
|
||||
<PackageVersion Include="AutoFixture.Xunit3" Version="4.19.0" />
|
||||
<PackageVersion Include="AutoFixture" Version="4.18.1" />
|
||||
<PackageVersion Include="AWSSDK.S3" Version="4.0.103.1" />
|
||||
<PackageVersion Include="BDInfo" Version="0.8.0" />
|
||||
<PackageVersion Include="BitFaster.Caching" Version="2.6.1" />
|
||||
<PackageVersion Include="BlurHashSharp.SkiaSharp" Version="1.4.0-pre.1" />
|
||||
@@ -46,6 +47,7 @@
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.11" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.9.0" />
|
||||
<PackageVersion Include="MimeTypes" Version="2.5.2" />
|
||||
@@ -53,6 +55,8 @@
|
||||
<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="10.0.3" />
|
||||
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
|
||||
<PackageVersion Include="PDFtoImage" Version="5.2.1" />
|
||||
<PackageVersion Include="PlaylistsNET" Version="1.4.1" />
|
||||
<PackageVersion Include="prometheus-net.AspNetCore" Version="8.2.1" />
|
||||
@@ -74,12 +78,15 @@
|
||||
<PackageVersion Include="SkiaSharp.HarfBuzz" Version="3.119.4" />
|
||||
<PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="3.119.4" />
|
||||
<PackageVersion Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" />
|
||||
<PackageVersion Include="StackExchange.Redis" Version="2.13.17" />
|
||||
<PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" />
|
||||
<PackageVersion Include="Svg.Skia" Version="3.7.0" />
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="10.2.3" />
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.3" />
|
||||
<PackageVersion Include="System.Text.Json" Version="10.0.11" />
|
||||
<PackageVersion Include="TagLibSharp" Version="2.3.0" />
|
||||
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.15.0" />
|
||||
<PackageVersion Include="Testcontainers.Redis" Version="4.15.0" />
|
||||
<PackageVersion Include="z440.atl.core" Version="7.16.0" />
|
||||
<PackageVersion Include="TMDbLib" Version="3.0.0" />
|
||||
<PackageVersion Include="UTF.Unknown" Version="2.7.0" />
|
||||
|
||||
+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. 12.0+deb12).
|
||||
# Web assets land at /usr/share/jellyfin/web/ — stable, prebuilt, no npm required.
|
||||
# The web client version must match the server version this fork is based on.
|
||||
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=12.0+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. 12.0+deb12).
|
||||
# Web assets land at /usr/share/jellyfin/web/ — stable, prebuilt, no npm required.
|
||||
# The web client version must match the server version this fork is based on.
|
||||
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=12.0+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"]
|
||||
@@ -65,6 +65,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Ignore" />
|
||||
<PackageReference Include="StackExchange.Redis" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
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:";
|
||||
|
||||
/// <summary>
|
||||
/// Lua script for atomic takeover: reads the stored session, checks whether the lease has
|
||||
/// expired (comparing the stored expiry against the caller-supplied current time) and, if it
|
||||
/// has, claims it for the calling pod 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)
|
||||
if tonumber(session['LeaseExpiresUtc']) > tonumber(ARGV[1]) then return 0 end
|
||||
session['OwnerPod'] = ARGV[2]
|
||||
session['LeaseExpiresUtc'] = tonumber(ARGV[1]) + tonumber(ARGV[3])
|
||||
redis.call('SET', KEYS[1], cjson.encode(session), 'PX', tonumber(ARGV[4]))
|
||||
return 1";
|
||||
|
||||
/// <summary>
|
||||
/// Lua script for atomic, ownership-checked renewal: extends the lease only while the calling
|
||||
/// pod still owns it, so a renewal racing a successful takeover cannot revert the new owner.
|
||||
/// </summary>
|
||||
private const string RenewScript = @"
|
||||
local raw = redis.call('GET', KEYS[1])
|
||||
if not raw then return 0 end
|
||||
local session = cjson.decode(raw)
|
||||
if session['OwnerPod'] ~= ARGV[2] then return 0 end
|
||||
session['LeaseExpiresUtc'] = tonumber(ARGV[1]) + tonumber(ARGV[3])
|
||||
redis.call('SET', KEYS[1], cjson.encode(session), 'PX', tonumber(ARGV[4]))
|
||||
return 1";
|
||||
|
||||
// The lease expiry is serialized as unix milliseconds because the Lua scripts compare it
|
||||
// numerically; an ISO-8601 string cannot be compared against a number in Lua.
|
||||
private static readonly JsonSerializerOptions _jsonOptions = new JsonSerializerOptions
|
||||
{
|
||||
Converters = { new UnixMillisecondsDateTimeConverter() }
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
private long LeaseDurationMs => (long)_options.LeaseDurationSeconds * 1000;
|
||||
|
||||
private long RetentionMs => Math.Max((long)_options.SessionRetentionSeconds * 1000, LeaseDurationMs);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SetAsync(TranscodeSession session, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var key = GetKey(session.PlaySessionId);
|
||||
var json = JsonSerializer.Serialize(session, _jsonOptions);
|
||||
await _db.StringSetAsync(key, json, TimeSpan.FromMilliseconds(RetentionMs)).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(), _jsonOptions);
|
||||
|
||||
// The record outlives the lease so that an orphaned session can still be taken over,
|
||||
// so the lease has to be checked explicitly here.
|
||||
if (session is null || session.LeaseExpiresUtc <= DateTime.UtcNow)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> RenewLeaseAsync(string playSessionId, string ownerPod, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = (long?)await _db.ScriptEvaluateAsync(
|
||||
RenewScript,
|
||||
keys: new RedisKey[] { GetKey(playSessionId) },
|
||||
values: new RedisValue[] { UnixMillisecondsNow(), ownerPod, LeaseDurationMs, RetentionMs }).ConfigureAwait(false);
|
||||
|
||||
if (result != 1)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Pod {OwnerPod} no longer owns transcode session {PlaySessionId}; lease not renewed.",
|
||||
ownerPod,
|
||||
playSessionId);
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Renewed lease for transcode session {PlaySessionId}.", playSessionId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <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 result = (long?)await _db.ScriptEvaluateAsync(
|
||||
TakeoverScript,
|
||||
keys: new RedisKey[] { GetKey(playSessionId) },
|
||||
values: new RedisValue[] { UnixMillisecondsNow(), claimingPod, LeaseDurationMs, RetentionMs }).ConfigureAwait(false);
|
||||
|
||||
var succeeded = result == 1;
|
||||
if (succeeded)
|
||||
{
|
||||
_logger.LogInformation("Pod {ClaimingPod} successfully took over transcode session {PlaySessionId}.", claimingPod, playSessionId);
|
||||
}
|
||||
|
||||
return succeeded;
|
||||
}
|
||||
|
||||
/// <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(), _jsonOptions);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to deserialize transcode session from Redis.");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (session is not null && session.LeaseExpiresUtc > DateTime.UtcNow)
|
||||
{
|
||||
sessions.Add(session);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sessions;
|
||||
}
|
||||
|
||||
private static string GetKey(string playSessionId) => KeyPrefix + playSessionId;
|
||||
|
||||
private static long UnixMillisecondsNow() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
private sealed class UnixMillisecondsDateTimeConverter : JsonConverter<DateTime>
|
||||
{
|
||||
public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
var milliseconds = reader.TryGetInt64(out var value) ? value : (long)reader.GetDouble();
|
||||
return DateTimeOffset.FromUnixTimeMilliseconds(milliseconds).UtcDateTime;
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options)
|
||||
{
|
||||
var utc = value.Kind switch
|
||||
{
|
||||
DateTimeKind.Utc => value,
|
||||
DateTimeKind.Local => value.ToUniversalTime(),
|
||||
_ => DateTime.SpecifyKind(value, DateTimeKind.Utc)
|
||||
};
|
||||
|
||||
writer.WriteNumberValue(new DateTimeOffset(utc, TimeSpan.Zero).ToUnixTimeMilliseconds());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace Emby.Server.Implementations.MediaEncoding;
|
||||
|
||||
/// <summary>
|
||||
/// Pings the configured Redis transcode session store once at startup so an unreachable store is
|
||||
/// reported there instead of being discovered as a silent loss of cross-pod takeover.
|
||||
/// </summary>
|
||||
public sealed class TranscodeStoreConnectivityProbe : IHostedService
|
||||
{
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly ILogger<TranscodeStoreConnectivityProbe> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TranscodeStoreConnectivityProbe"/> class.
|
||||
/// </summary>
|
||||
/// <param name="serviceProvider">The service provider used to resolve the Redis connection.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public TranscodeStoreConnectivityProbe(IServiceProvider serviceProvider, ILogger<TranscodeStoreConnectivityProbe> logger)
|
||||
{
|
||||
_serviceProvider = serviceProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Resolved here rather than injected: connecting must not be able to abort startup.
|
||||
var redis = _serviceProvider.GetRequiredService<IConnectionMultiplexer>();
|
||||
var roundTrip = await redis.GetDatabase().PingAsync().ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Redis transcode session store is reachable ({RoundTripMs}ms round trip). HA transcode takeover is active.",
|
||||
(long)roundTrip.TotalMilliseconds);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Redis transcode session store is configured but UNREACHABLE. HA transcode takeover is not working: sessions stay local to this instance and are lost when it restarts. Check {Key}.",
|
||||
TranscodeStoreOptions.RedisConnectionStringKey);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Controller.ScheduledTasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace Emby.Server.Implementations.ScheduledTasks;
|
||||
|
||||
/// <summary>
|
||||
/// A Redis-backed <see cref="IScanLeaderLease"/> that elects a single scan-leader instance using a
|
||||
/// TTL lease on a shared key. The lease value is this instance's pod identity; a leader that keeps
|
||||
/// renewing retains the lease, and any instance can claim it once the previous leader's lease expires.
|
||||
/// </summary>
|
||||
public sealed class RedisScanLeaderLease : IScanLeaderLease
|
||||
{
|
||||
private const string LeaderKey = "jellyfin:scanleader";
|
||||
|
||||
/// <summary>
|
||||
/// Lua script for atomic acquire-or-renew: if the key is unset (missing or already expired) it is
|
||||
/// set to this pod for the lease duration and 1 is returned; if it already holds this pod the TTL is
|
||||
/// extended and 1 is returned; otherwise another pod owns a live lease and 0 is returned.
|
||||
/// </summary>
|
||||
private const string AcquireOrRenewScript = @"
|
||||
local current = redis.call('GET', KEYS[1])
|
||||
if not current then
|
||||
redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[2])
|
||||
return 1
|
||||
elseif current == ARGV[1] then
|
||||
redis.call('PEXPIRE', KEYS[1], ARGV[2])
|
||||
return 1
|
||||
else
|
||||
return 0
|
||||
end";
|
||||
|
||||
private readonly IDatabase _db;
|
||||
private readonly ScanLeaderOptions _options;
|
||||
private readonly ILogger<RedisScanLeaderLease> _logger;
|
||||
private readonly string _podId;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RedisScanLeaderLease"/> class.
|
||||
/// </summary>
|
||||
/// <param name="redis">The Redis connection multiplexer.</param>
|
||||
/// <param name="options">The scan-leader configuration options.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public RedisScanLeaderLease(
|
||||
IConnectionMultiplexer redis,
|
||||
IOptions<ScanLeaderOptions> options,
|
||||
ILogger<RedisScanLeaderLease> logger)
|
||||
{
|
||||
_db = redis.GetDatabase();
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
_podId = Environment.GetEnvironmentVariable("JELLYFIN_INSTANCE_ID") ?? Environment.MachineName;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> TryAcquireOrRenewAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var leaseDurationMs = (long)_options.LeaseDurationSeconds * 1000;
|
||||
|
||||
try
|
||||
{
|
||||
var result = (long?)await _db.ScriptEvaluateAsync(
|
||||
AcquireOrRenewScript,
|
||||
keys: new RedisKey[] { LeaderKey },
|
||||
values: new RedisValue[] { _podId, leaseDurationMs }).ConfigureAwait(false);
|
||||
|
||||
return result == 1;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Fail-safe: if Redis is unreachable, treat this instance as the leader so scheduled scans
|
||||
// keep running. Every instance scanning is preferable to no instance scanning.
|
||||
_logger.LogWarning(ex, "Scan-leader lease evaluation failed; treating {PodId} as leader.", _podId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ using Jellyfin.Data.Events;
|
||||
using Jellyfin.Extensions.Json;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Extensions;
|
||||
using MediaBrowser.Controller.ScheduledTasks;
|
||||
using MediaBrowser.Model.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -27,6 +28,8 @@ public class ScheduledTaskWorker : IScheduledTaskWorker
|
||||
private readonly IApplicationPaths _applicationPaths;
|
||||
private readonly ILogger _logger;
|
||||
private readonly ITaskManager _taskManager;
|
||||
private readonly IScanLeaderLease _scanLeaderLease;
|
||||
private readonly ScanLeaderOptions _scanLeaderOptions;
|
||||
private readonly Lock _lastExecutionResultSyncLock = new();
|
||||
private bool _readFromFile;
|
||||
private TaskResult _lastExecutionResult;
|
||||
@@ -41,6 +44,8 @@ public class ScheduledTaskWorker : IScheduledTaskWorker
|
||||
/// <param name="applicationPaths">The application paths.</param>
|
||||
/// <param name="taskManager">The task manager.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
/// <param name="scanLeaderLease">The scan-leader lease used to gate periodic library-mutating tasks, or <c>null</c> to disable gating.</param>
|
||||
/// <param name="scanLeaderOptions">The scan-leader options, or <c>null</c> to disable gating.</param>
|
||||
/// <exception cref="ArgumentNullException">
|
||||
/// scheduledTask
|
||||
/// or
|
||||
@@ -52,7 +57,13 @@ public class ScheduledTaskWorker : IScheduledTaskWorker
|
||||
/// or
|
||||
/// logger.
|
||||
/// </exception>
|
||||
public ScheduledTaskWorker(IScheduledTask scheduledTask, IApplicationPaths applicationPaths, ITaskManager taskManager, ILogger logger)
|
||||
public ScheduledTaskWorker(
|
||||
IScheduledTask scheduledTask,
|
||||
IApplicationPaths applicationPaths,
|
||||
ITaskManager taskManager,
|
||||
ILogger logger,
|
||||
IScanLeaderLease scanLeaderLease = null,
|
||||
ScanLeaderOptions scanLeaderOptions = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(scheduledTask);
|
||||
ArgumentNullException.ThrowIfNull(applicationPaths);
|
||||
@@ -63,6 +74,8 @@ public class ScheduledTaskWorker : IScheduledTaskWorker
|
||||
_applicationPaths = applicationPaths;
|
||||
_taskManager = taskManager;
|
||||
_logger = logger;
|
||||
_scanLeaderLease = scanLeaderLease;
|
||||
_scanLeaderOptions = scanLeaderOptions;
|
||||
|
||||
InitTriggerEvents();
|
||||
}
|
||||
@@ -268,6 +281,20 @@ public class ScheduledTaskWorker : IScheduledTaskWorker
|
||||
|
||||
trigger.Stop();
|
||||
|
||||
if (_scanLeaderLease is not null
|
||||
&& _scanLeaderOptions is not null
|
||||
&& _scanLeaderOptions.Enabled
|
||||
&& _scanLeaderOptions.GatedTaskKeys is not null
|
||||
&& _scanLeaderOptions.GatedTaskKeys.Contains(ScheduledTask.Key, StringComparer.Ordinal)
|
||||
&& !await _scanLeaderLease.TryAcquireOrRenewAsync().ConfigureAwait(false))
|
||||
{
|
||||
_logger.LogDebug("Skipping gated task {Task}: this instance does not hold the scan-leader lease.", Name);
|
||||
|
||||
// Re-arm the trigger for the next interval without enqueueing on this instance.
|
||||
trigger.Start(LastExecutionResult, _logger, Name, false);
|
||||
return;
|
||||
}
|
||||
|
||||
_taskManager.QueueScheduledTask(ScheduledTask, trigger.TaskOptions);
|
||||
|
||||
await Task.Delay(1000).ConfigureAwait(false);
|
||||
|
||||
@@ -5,8 +5,10 @@ using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data.Events;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Controller.ScheduledTasks;
|
||||
using MediaBrowser.Model.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Emby.Server.Implementations.ScheduledTasks;
|
||||
|
||||
@@ -23,18 +25,26 @@ public class TaskManager : ITaskManager
|
||||
|
||||
private readonly IApplicationPaths _applicationPaths;
|
||||
private readonly ILogger<TaskManager> _logger;
|
||||
private readonly IScanLeaderLease? _scanLeaderLease;
|
||||
private readonly ScanLeaderOptions? _scanLeaderOptions;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TaskManager" /> class.
|
||||
/// </summary>
|
||||
/// <param name="applicationPaths">The application paths.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
/// <param name="scanLeaderLease">The scan-leader lease used to gate periodic library-mutating tasks, or <c>null</c> to disable gating.</param>
|
||||
/// <param name="scanLeaderOptions">The scan-leader options, or <c>null</c> to disable gating.</param>
|
||||
public TaskManager(
|
||||
IApplicationPaths applicationPaths,
|
||||
ILogger<TaskManager> logger)
|
||||
ILogger<TaskManager> logger,
|
||||
IScanLeaderLease? scanLeaderLease = null,
|
||||
IOptions<ScanLeaderOptions>? scanLeaderOptions = null)
|
||||
{
|
||||
_applicationPaths = applicationPaths;
|
||||
_logger = logger;
|
||||
_scanLeaderLease = scanLeaderLease;
|
||||
_scanLeaderOptions = scanLeaderOptions?.Value;
|
||||
|
||||
ScheduledTasks = [];
|
||||
}
|
||||
@@ -175,7 +185,7 @@ public class TaskManager : ITaskManager
|
||||
/// <inheritdoc />
|
||||
public void AddTasks(IEnumerable<IScheduledTask> tasks)
|
||||
{
|
||||
var list = tasks.Select(t => new ScheduledTaskWorker(t, _applicationPaths, this, _logger));
|
||||
var list = tasks.Select(t => new ScheduledTaskWorker(t, _applicationPaths, this, _logger, _scanLeaderLease, _scanLeaderOptions));
|
||||
|
||||
ScheduledTasks = ScheduledTasks.Concat(list).ToArray();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,7 +256,7 @@ namespace Emby.Server.Implementations.Session
|
||||
ArgumentException.ThrowIfNullOrEmpty(deviceId);
|
||||
|
||||
var activityDate = DateTime.UtcNow;
|
||||
var session = GetSessionInfo(appName, appVersion, deviceId, deviceName, remoteEndPoint, user);
|
||||
var session = await GetSessionInfo(appName, appVersion, deviceId, deviceName, remoteEndPoint, user).ConfigureAwait(false);
|
||||
var lastActivityDate = session.LastActivityDate;
|
||||
session.LastActivityDate = activityDate;
|
||||
|
||||
@@ -491,7 +491,7 @@ namespace Emby.Server.Implementations.Session
|
||||
/// <param name="remoteEndPoint">The remote end point.</param>
|
||||
/// <param name="user">The user.</param>
|
||||
/// <returns>SessionInfo.</returns>
|
||||
private SessionInfo GetSessionInfo(
|
||||
private async Task<SessionInfo> GetSessionInfo(
|
||||
string appName,
|
||||
string appVersion,
|
||||
string deviceId,
|
||||
@@ -504,7 +504,7 @@ namespace Emby.Server.Implementations.Session
|
||||
ArgumentException.ThrowIfNullOrEmpty(deviceId);
|
||||
|
||||
var key = GetSessionKey(appName, deviceId, user?.Id ?? Guid.Empty);
|
||||
SessionInfo newSession = CreateSessionInfo(key, appName, appVersion, deviceId, deviceName, remoteEndPoint, user);
|
||||
SessionInfo newSession = await CreateSessionInfo(key, appName, appVersion, deviceId, deviceName, remoteEndPoint, user).ConfigureAwait(false);
|
||||
SessionInfo sessionInfo = _activeConnections.GetOrAdd(key, newSession);
|
||||
if (ReferenceEquals(newSession, sessionInfo))
|
||||
{
|
||||
@@ -532,7 +532,7 @@ namespace Emby.Server.Implementations.Session
|
||||
return sessionInfo;
|
||||
}
|
||||
|
||||
private SessionInfo CreateSessionInfo(
|
||||
private async Task<SessionInfo> CreateSessionInfo(
|
||||
string key,
|
||||
string appName,
|
||||
string appVersion,
|
||||
@@ -562,7 +562,7 @@ namespace Emby.Server.Implementations.Session
|
||||
deviceName = "Network Device";
|
||||
}
|
||||
|
||||
var deviceOptions = _deviceManager.GetDeviceOptions(deviceId) ?? new()
|
||||
var deviceOptions = await _deviceManager.GetDeviceOptions(deviceId).ConfigureAwait(false) ?? new()
|
||||
{
|
||||
DeviceId = deviceId
|
||||
};
|
||||
@@ -1768,12 +1768,12 @@ namespace Emby.Server.Implementations.Session
|
||||
// This should be validated above, but if it isn't don't delete all tokens.
|
||||
ArgumentException.ThrowIfNullOrEmpty(deviceId);
|
||||
|
||||
var existing = _deviceManager.GetDevices(
|
||||
var existing = (await _deviceManager.GetDevices(
|
||||
new DeviceQuery
|
||||
{
|
||||
DeviceId = deviceId,
|
||||
UserId = user.Id
|
||||
}).Items;
|
||||
}).ConfigureAwait(false)).Items;
|
||||
|
||||
foreach (var auth in existing)
|
||||
{
|
||||
@@ -1801,12 +1801,12 @@ namespace Emby.Server.Implementations.Session
|
||||
|
||||
ArgumentException.ThrowIfNullOrEmpty(accessToken);
|
||||
|
||||
var existing = _deviceManager.GetDevices(
|
||||
var existing = (await _deviceManager.GetDevices(
|
||||
new DeviceQuery
|
||||
{
|
||||
Limit = 1,
|
||||
AccessToken = accessToken
|
||||
}).Items;
|
||||
}).ConfigureAwait(false)).Items;
|
||||
|
||||
if (existing.Count > 0)
|
||||
{
|
||||
@@ -1845,10 +1845,10 @@ namespace Emby.Server.Implementations.Session
|
||||
{
|
||||
CheckDisposed();
|
||||
|
||||
var existing = _deviceManager.GetDevices(new DeviceQuery
|
||||
var existing = await _deviceManager.GetDevices(new DeviceQuery
|
||||
{
|
||||
UserId = userId
|
||||
});
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
foreach (var info in existing.Items)
|
||||
{
|
||||
@@ -2045,11 +2045,11 @@ namespace Emby.Server.Implementations.Session
|
||||
/// <inheritdoc />
|
||||
public async Task<SessionInfo> GetSessionByAuthenticationToken(string token, string deviceId, string remoteEndpoint)
|
||||
{
|
||||
var items = _deviceManager.GetDevices(new DeviceQuery
|
||||
var items = (await _deviceManager.GetDevices(new DeviceQuery
|
||||
{
|
||||
AccessToken = token,
|
||||
Limit = 1
|
||||
}).Items;
|
||||
}).ConfigureAwait(false)).Items;
|
||||
|
||||
if (items.Count == 0)
|
||||
{
|
||||
|
||||
@@ -50,10 +50,10 @@ public class DevicesController : BaseJellyfinApiController
|
||||
/// <returns>An <see cref="OkResult"/> containing the list of devices.</returns>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public ActionResult<QueryResult<DeviceInfoDto>> GetDevices([FromQuery] Guid? userId)
|
||||
public async Task<ActionResult<QueryResult<DeviceInfoDto>>> GetDevices([FromQuery] Guid? userId)
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
return _deviceManager.GetDevicesForUser(userId);
|
||||
return await _deviceManager.GetDevicesForUser(userId).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -66,9 +66,9 @@ public class DevicesController : BaseJellyfinApiController
|
||||
[HttpGet("Info")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public ActionResult<DeviceInfoDto> GetDeviceInfo([FromQuery, Required] string id)
|
||||
public async Task<ActionResult<DeviceInfoDto>> GetDeviceInfo([FromQuery, Required] string id)
|
||||
{
|
||||
var deviceInfo = _deviceManager.GetDevice(id);
|
||||
var deviceInfo = await _deviceManager.GetDevice(id).ConfigureAwait(false);
|
||||
if (deviceInfo is null)
|
||||
{
|
||||
return NotFound();
|
||||
@@ -87,9 +87,9 @@ public class DevicesController : BaseJellyfinApiController
|
||||
[HttpGet("Options")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public ActionResult<DeviceOptionsDto> GetDeviceOptions([FromQuery, Required] string id)
|
||||
public async Task<ActionResult<DeviceOptionsDto>> GetDeviceOptions([FromQuery, Required] string id)
|
||||
{
|
||||
var deviceInfo = _deviceManager.GetDeviceOptions(id);
|
||||
var deviceInfo = await _deviceManager.GetDeviceOptions(id).ConfigureAwait(false);
|
||||
if (deviceInfo is null)
|
||||
{
|
||||
return NotFound();
|
||||
@@ -127,7 +127,12 @@ public class DevicesController : BaseJellyfinApiController
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult> DeleteDevice([FromQuery] string[] id)
|
||||
{
|
||||
var devices = id.Select(_deviceManager.GetDevice).ToArray();
|
||||
var devices = new List<DeviceInfoDto?>(id.Length);
|
||||
foreach (var deviceId in id)
|
||||
{
|
||||
devices.Add(await _deviceManager.GetDevice(deviceId).ConfigureAwait(false));
|
||||
}
|
||||
|
||||
if (devices.Any(f => f is null))
|
||||
{
|
||||
return BadRequest();
|
||||
@@ -135,7 +140,7 @@ public class DevicesController : BaseJellyfinApiController
|
||||
|
||||
foreach (var device in devices)
|
||||
{
|
||||
var sessions = _deviceManager.GetDevices(new DeviceQuery { DeviceId = device!.Id });
|
||||
var sessions = await _deviceManager.GetDevices(new DeviceQuery { DeviceId = device!.Id }).ConfigureAwait(false);
|
||||
|
||||
foreach (var session in sessions.Items)
|
||||
{
|
||||
|
||||
@@ -29,6 +29,7 @@ using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Jellyfin.Api.Controllers;
|
||||
|
||||
@@ -44,6 +45,8 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
private const EncoderPreset DefaultEventEncoderPreset = EncoderPreset.superfast;
|
||||
private const TranscodingJobType TranscodingJobType = MediaBrowser.Controller.MediaEncoding.TranscodingJobType.Hls;
|
||||
|
||||
private static readonly string _podIdentity = Environment.GetEnvironmentVariable("JELLYFIN_INSTANCE_ID") ?? Environment.MachineName;
|
||||
|
||||
private readonly Version _minFFmpegFlacInMp4 = new Version(6, 0);
|
||||
private readonly Version _minFFmpegX265BframeInFmp4 = new Version(7, 0, 1);
|
||||
private readonly Version _minFFmpegHlsSegmentOptions = new Version(5, 0);
|
||||
@@ -60,6 +63,8 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
private readonly IDynamicHlsPlaylistGenerator _dynamicHlsPlaylistGenerator;
|
||||
private readonly DynamicHlsHelper _dynamicHlsHelper;
|
||||
private readonly EncodingOptions _encodingOptions;
|
||||
private readonly ITranscodeSessionStore _transcodeSessionStore;
|
||||
private readonly TranscodeStoreOptions _transcodeStoreOptions;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DynamicHlsController"/> class.
|
||||
@@ -75,6 +80,8 @@ 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>
|
||||
/// <param name="transcodeStoreOptions">The <see cref="TranscodeStoreOptions"/> holding the session lease duration.</param>
|
||||
public DynamicHlsController(
|
||||
ILibraryManager libraryManager,
|
||||
IUserManager userManager,
|
||||
@@ -86,7 +93,9 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
ILogger<DynamicHlsController> logger,
|
||||
DynamicHlsHelper dynamicHlsHelper,
|
||||
EncodingHelper encodingHelper,
|
||||
IDynamicHlsPlaylistGenerator dynamicHlsPlaylistGenerator)
|
||||
IDynamicHlsPlaylistGenerator dynamicHlsPlaylistGenerator,
|
||||
ITranscodeSessionStore transcodeSessionStore,
|
||||
IOptions<TranscodeStoreOptions> transcodeStoreOptions)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
_userManager = userManager;
|
||||
@@ -99,6 +108,8 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
_dynamicHlsHelper = dynamicHlsHelper;
|
||||
_encodingHelper = encodingHelper;
|
||||
_dynamicHlsPlaylistGenerator = dynamicHlsPlaylistGenerator;
|
||||
_transcodeSessionStore = transcodeSessionStore;
|
||||
_transcodeStoreOptions = transcodeStoreOptions.Value;
|
||||
|
||||
_encodingOptions = serverConfigurationManager.GetEncodingOptions();
|
||||
}
|
||||
@@ -306,15 +317,24 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
// If the playlist doesn't already exist, startup ffmpeg
|
||||
try
|
||||
{
|
||||
var isHaMode = await IsHaTakeoverAsync(playSessionId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
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,
|
||||
playlistPath,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
StartLeaseRenewal(playSessionId ?? string.Empty, cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -1511,14 +1531,23 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
|
||||
streamingRequest.StartTimeTicks = streamingRequest.CurrentRuntimeTicks;
|
||||
|
||||
var isHaMode = await IsHaTakeoverAsync(streamingRequest.PlaySessionId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
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,
|
||||
playlistPath,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
StartLeaseRenewal(streamingRequest.PlaySessionId ?? string.Empty, cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -1543,6 +1572,31 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the segment length ffmpeg is told to use. HA mode shortens segments so a takeover pod
|
||||
/// has to re-encode less; the configured value is user-editable so it is clamped.
|
||||
/// </summary>
|
||||
/// <param name="isHaMode">Whether the session is being resumed from the durable store.</param>
|
||||
/// <param name="requestedSegmentLength">The segment length requested by the streaming pipeline.</param>
|
||||
/// <param name="encodingOptions">The encoding options holding the recovery segment length.</param>
|
||||
/// <returns>The segment length in seconds.</returns>
|
||||
internal static int GetEffectiveSegmentLength(bool isHaMode, int requestedSegmentLength, EncodingOptions encodingOptions)
|
||||
=> isHaMode
|
||||
? Math.Clamp(encodingOptions.RecoverySegmentLengthSeconds, 1, 6)
|
||||
: requestedSegmentLength;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the ffmpeg <c>hls_list_size</c>. HA mode keeps a bounded rolling buffer; 0 (unbounded)
|
||||
/// otherwise.
|
||||
/// </summary>
|
||||
/// <param name="isHaMode">Whether the session is being resumed from the durable store.</param>
|
||||
/// <param name="encodingOptions">The encoding options holding the recovery buffer count.</param>
|
||||
/// <returns>The number of segments to keep in the playlist, or 0 for all of them.</returns>
|
||||
internal static int GetHlsListSize(bool isHaMode, EncodingOptions encodingOptions)
|
||||
=> isHaMode
|
||||
? Math.Clamp(encodingOptions.RecoverySegmentBufferCount, 2, 10)
|
||||
: 0;
|
||||
|
||||
internal static async Task WaitForActiveTranscodingRequests(TranscodingJob? job, CancellationToken cancellationToken)
|
||||
{
|
||||
while (job?.ActiveRequestCount > 0)
|
||||
@@ -1554,6 +1608,116 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
private static double[] GetSegmentLengths(StreamState state)
|
||||
=> GetSegmentLengthsInternal(state.RunTimeTicks ?? 0, state.SegmentLength);
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the play session is already present in the durable store, which means
|
||||
/// another pod owned it and this request is resuming it after a failover.
|
||||
/// </summary>
|
||||
private async Task<bool> IsHaTakeoverAsync(string? playSessionId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrEmpty(playSessionId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await _transcodeSessionStore.TryGetAsync(playSessionId, cancellationToken).ConfigureAwait(false) is not null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to check HA mode for session {PlaySessionId}.", playSessionId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the durable record for an HLS output owned by this instance. The manifest and segment
|
||||
/// paths have to be real, otherwise <c>DeleteTranscodeFileTask</c> cannot tell which files
|
||||
/// belong to a live session.
|
||||
/// </summary>
|
||||
/// <param name="playSessionId">The play session identifier.</param>
|
||||
/// <param name="mediaSourceId">The media source identifier.</param>
|
||||
/// <param name="playlistPath">The absolute path of the HLS playlist this instance is writing.</param>
|
||||
/// <param name="leaseDuration">The initial lease duration.</param>
|
||||
/// <returns>The session record to store.</returns>
|
||||
internal static TranscodeSession CreateSessionRecord(string playSessionId, string mediaSourceId, string playlistPath, TimeSpan leaseDuration)
|
||||
=> TranscodeSession.CreateForPlaylist(playSessionId, mediaSourceId, _podIdentity, playlistPath, leaseDuration);
|
||||
|
||||
private async Task RegisterTranscodeSessionAsync(string playSessionId, string mediaSourceId, string playlistPath, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var session = CreateSessionRecord(
|
||||
playSessionId,
|
||||
mediaSourceId,
|
||||
playlistPath,
|
||||
TimeSpan.FromSeconds(_transcodeStoreOptions.LeaseDurationSeconds));
|
||||
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)
|
||||
{
|
||||
var renewalInterval = TimeSpan.FromSeconds(Math.Max(1, _transcodeStoreOptions.LeaseDurationSeconds / 3));
|
||||
|
||||
_ = Task.Run(
|
||||
async () =>
|
||||
{
|
||||
var ownsLease = true;
|
||||
try
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(renewalInterval, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!await _transcodeSessionStore.RenewLeaseAsync(playSessionId, _podIdentity, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
// Another pod owns the session now; its record must not be touched.
|
||||
ownsLease = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
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
|
||||
{
|
||||
if (ownsLease)
|
||||
{
|
||||
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;
|
||||
@@ -1575,7 +1739,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);
|
||||
@@ -1588,10 +1752,13 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
var outputExtension = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer);
|
||||
var outputTsArg = outputPrefix + "%d" + outputExtension;
|
||||
|
||||
var effectiveSegmentLength = GetEffectiveSegmentLength(isHaMode, state.SegmentLength, _encodingOptions);
|
||||
var hlsListSize = GetHlsListSize(isHaMode, _encodingOptions);
|
||||
|
||||
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))
|
||||
{
|
||||
@@ -1644,10 +1811,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,
|
||||
@@ -1784,8 +1951,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)
|
||||
{
|
||||
@@ -1860,7 +2028,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)
|
||||
|
||||
@@ -31,8 +31,6 @@ namespace Jellyfin.Server.Implementations.Devices
|
||||
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly ConcurrentDictionary<string, ClientCapabilities> _capabilitiesMap = new();
|
||||
private readonly ConcurrentDictionary<int, Device> _devices;
|
||||
private readonly ConcurrentDictionary<string, DeviceOptions> _deviceOptions;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DeviceManager"/> class.
|
||||
@@ -43,23 +41,6 @@ namespace Jellyfin.Server.Implementations.Devices
|
||||
{
|
||||
_dbProvider = dbProvider;
|
||||
_userManager = userManager;
|
||||
_devices = new ConcurrentDictionary<int, Device>();
|
||||
_deviceOptions = new ConcurrentDictionary<string, DeviceOptions>();
|
||||
|
||||
using var dbContext = _dbProvider.CreateDbContext();
|
||||
foreach (var device in dbContext.Devices
|
||||
.OrderBy(d => d.Id)
|
||||
.AsEnumerable())
|
||||
{
|
||||
_devices.TryAdd(device.Id, device);
|
||||
}
|
||||
|
||||
foreach (var deviceOption in dbContext.DeviceOptions
|
||||
.OrderBy(d => d.Id)
|
||||
.AsEnumerable())
|
||||
{
|
||||
_deviceOptions.TryAdd(deviceOption.DeviceId, deviceOption);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -89,8 +70,6 @@ namespace Jellyfin.Server.Implementations.Devices
|
||||
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_deviceOptions[deviceId] = deviceOptions;
|
||||
|
||||
DeviceOptionsUpdated?.Invoke(this, new GenericEventArgs<Tuple<string, DeviceOptions>>(new Tuple<string, DeviceOptions>(deviceId, deviceOptions)));
|
||||
}
|
||||
|
||||
@@ -102,21 +81,24 @@ namespace Jellyfin.Server.Implementations.Devices
|
||||
{
|
||||
dbContext.Devices.Add(device);
|
||||
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
||||
_devices.TryAdd(device.Id, device);
|
||||
}
|
||||
|
||||
return device;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public DeviceOptionsDto? GetDeviceOptions(string deviceId)
|
||||
public async Task<DeviceOptionsDto?> GetDeviceOptions(string deviceId)
|
||||
{
|
||||
if (_deviceOptions.TryGetValue(deviceId, out var deviceOptions))
|
||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
return ToDeviceOptionsDto(deviceOptions);
|
||||
}
|
||||
var deviceOptions = await dbContext.DeviceOptions
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(dev => dev.DeviceId == deviceId)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return null;
|
||||
return deviceOptions is null ? null : ToDeviceOptionsDto(deviceOptions);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -133,43 +115,79 @@ namespace Jellyfin.Server.Implementations.Devices
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public DeviceInfoDto? GetDevice(string id)
|
||||
public async Task<DeviceInfoDto?> GetDevice(string id)
|
||||
{
|
||||
var device = _devices.Values.Where(d => d.DeviceId == id).OrderByDescending(d => d.DateLastActivity).FirstOrDefault();
|
||||
_deviceOptions.TryGetValue(id, out var deviceOption);
|
||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
var device = await dbContext.Devices
|
||||
.AsNoTracking()
|
||||
.Where(d => d.DeviceId == id)
|
||||
.OrderByDescending(d => d.DateLastActivity)
|
||||
.FirstOrDefaultAsync()
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var deviceInfo = device is null ? null : ToDeviceInfo(device, deviceOption);
|
||||
return deviceInfo is null ? null : ToDeviceInfoDto(deviceInfo);
|
||||
if (device is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var deviceOption = await dbContext.DeviceOptions
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(dev => dev.DeviceId == id)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return ToDeviceInfoDto(ToDeviceInfo(device, deviceOption));
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public QueryResult<Device> GetDevices(DeviceQuery query)
|
||||
public async Task<QueryResult<Device>> GetDevices(DeviceQuery query)
|
||||
{
|
||||
IEnumerable<Device> devices = _devices.Values
|
||||
.Where(device => !query.UserId.HasValue || device.UserId.Equals(query.UserId.Value))
|
||||
.Where(device => query.DeviceId is null || device.DeviceId == query.DeviceId)
|
||||
.Where(device => query.AccessToken is null || device.AccessToken == query.AccessToken)
|
||||
.OrderBy(d => d.Id)
|
||||
.ToList();
|
||||
var count = devices.Count();
|
||||
|
||||
if (query.Skip.HasValue)
|
||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
devices = devices.Skip(query.Skip.Value);
|
||||
}
|
||||
IQueryable<Device> filtered = dbContext.Devices.AsNoTracking();
|
||||
|
||||
if (query.Limit.HasValue && query.Limit.Value > 0)
|
||||
{
|
||||
devices = devices.Take(query.Limit.Value);
|
||||
}
|
||||
if (query.UserId.HasValue)
|
||||
{
|
||||
filtered = filtered.Where(device => device.UserId.Equals(query.UserId.Value));
|
||||
}
|
||||
|
||||
return new QueryResult<Device>(query.Skip, count, devices.ToList());
|
||||
if (query.DeviceId is not null)
|
||||
{
|
||||
filtered = filtered.Where(device => device.DeviceId == query.DeviceId);
|
||||
}
|
||||
|
||||
if (query.AccessToken is not null)
|
||||
{
|
||||
filtered = filtered.Where(device => device.AccessToken == query.AccessToken);
|
||||
}
|
||||
|
||||
// Every filter is an exact match on an indexed column and the table holds one row per
|
||||
// user and device, so paging the materialised set costs less than a second round trip.
|
||||
var matched = await filtered.OrderBy(d => d.Id).ToListAsync().ConfigureAwait(false);
|
||||
|
||||
IEnumerable<Device> devices = matched;
|
||||
|
||||
if (query.Skip.HasValue)
|
||||
{
|
||||
devices = devices.Skip(query.Skip.Value);
|
||||
}
|
||||
|
||||
if (query.Limit.HasValue && query.Limit.Value > 0)
|
||||
{
|
||||
devices = devices.Take(query.Limit.Value);
|
||||
}
|
||||
|
||||
return new QueryResult<Device>(query.Skip, matched.Count, devices.ToList());
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public QueryResult<DeviceInfo> GetDeviceInfos(DeviceQuery query)
|
||||
public async Task<QueryResult<DeviceInfo>> GetDeviceInfos(DeviceQuery query)
|
||||
{
|
||||
var devices = GetDevices(query);
|
||||
var devices = await GetDevices(query).ConfigureAwait(false);
|
||||
|
||||
return new QueryResult<DeviceInfo>(
|
||||
devices.StartIndex,
|
||||
@@ -178,38 +196,49 @@ namespace Jellyfin.Server.Implementations.Devices
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public QueryResult<DeviceInfoDto> GetDevicesForUser(Guid? userId)
|
||||
public async Task<QueryResult<DeviceInfoDto>> GetDevicesForUser(Guid? userId)
|
||||
{
|
||||
IEnumerable<Device> devices = _devices.Values
|
||||
.OrderByDescending(d => d.DateLastActivity)
|
||||
.ThenBy(d => d.DeviceId);
|
||||
|
||||
if (!userId.IsNullOrEmpty())
|
||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
var user = _userManager.GetUserById(userId.Value);
|
||||
if (user is null)
|
||||
IEnumerable<Device> devices = await dbContext.Devices
|
||||
.AsNoTracking()
|
||||
.OrderByDescending(d => d.DateLastActivity)
|
||||
.ThenBy(d => d.DeviceId)
|
||||
.ToListAsync()
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!userId.IsNullOrEmpty())
|
||||
{
|
||||
throw new ResourceNotFoundException();
|
||||
var user = _userManager.GetUserById(userId.Value);
|
||||
if (user is null)
|
||||
{
|
||||
throw new ResourceNotFoundException();
|
||||
}
|
||||
|
||||
devices = devices.Where(i => CanAccessDevice(user, i.DeviceId));
|
||||
}
|
||||
|
||||
devices = devices.Where(i => CanAccessDevice(user, i.DeviceId));
|
||||
var options = await dbContext.DeviceOptions
|
||||
.AsNoTracking()
|
||||
.ToDictionaryAsync(dev => dev.DeviceId)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var array = devices.Select(device =>
|
||||
{
|
||||
options.TryGetValue(device.DeviceId, out var option);
|
||||
return ToDeviceInfo(device, option);
|
||||
})
|
||||
.Select(ToDeviceInfoDto)
|
||||
.ToArray();
|
||||
|
||||
return new QueryResult<DeviceInfoDto>(array);
|
||||
}
|
||||
|
||||
var array = devices.Select(device =>
|
||||
{
|
||||
_deviceOptions.TryGetValue(device.DeviceId, out var option);
|
||||
return ToDeviceInfo(device, option);
|
||||
})
|
||||
.Select(ToDeviceInfoDto)
|
||||
.ToArray();
|
||||
|
||||
return new QueryResult<DeviceInfoDto>(array);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteDevice(Device device)
|
||||
{
|
||||
_devices.TryRemove(device.Id, out _);
|
||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
@@ -229,8 +258,6 @@ namespace Jellyfin.Server.Implementations.Devices
|
||||
dbContext.Devices.Update(device);
|
||||
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_devices[device.Id] = device;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -219,9 +219,11 @@ public sealed partial class BaseItemRepository
|
||||
}
|
||||
else
|
||||
{
|
||||
// The representative is the row no other row in its group sorts before, not MIN(Id):
|
||||
// PostgreSQL has no min(uuid) aggregate, while comparing two uuids is supported everywhere.
|
||||
representativeIds = masterQuery
|
||||
.GroupBy(e => e.PresentationUniqueKey)
|
||||
.Select(g => g.Min(e => e.Id))
|
||||
.Where(e => !masterQuery.Any(o => o.PresentationUniqueKey == e.PresentationUniqueKey && o.Id.CompareTo(e.Id) < 0))
|
||||
.Select(e => e.Id)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
|
||||
@@ -96,22 +96,36 @@ public sealed partial class BaseItemRepository
|
||||
// primary version (PrimaryVersionId is null) so detail pages and actions target it instead
|
||||
// of an arbitrary alternate. Keep the grouped ids as an IQueryable sub-select; materializing
|
||||
// to a List would inline one bound parameter per id and hit SQLite's variable cap.
|
||||
// The representative is the row no other row in its group sorts before, not MIN(Id):
|
||||
// PostgreSQL has no min(uuid) aggregate, while comparing two uuids is supported everywhere.
|
||||
// The anti-join reads the filtered set twice, so it has to close over a local that the
|
||||
// reassignment below cannot reach - capturing dbQuery itself makes the tree self-referential.
|
||||
var candidates = dbQuery;
|
||||
var enableGroupByPresentationUniqueKey = EnableGroupByPresentationUniqueKey(filter);
|
||||
if (enableGroupByPresentationUniqueKey && filter.GroupBySeriesPresentationUniqueKey)
|
||||
{
|
||||
var groupedIds = dbQuery.GroupBy(e => new { e.PresentationUniqueKey, e.SeriesPresentationUniqueKey })
|
||||
.Select(g => g.Where(e => e.PrimaryVersionId == null).Min(e => (Guid?)e.Id) ?? g.Min(e => (Guid?)e.Id));
|
||||
var groupedIds = candidates
|
||||
.Where(e => !candidates.Any(o => o.PresentationUniqueKey == e.PresentationUniqueKey
|
||||
&& o.SeriesPresentationUniqueKey == e.SeriesPresentationUniqueKey
|
||||
&& ((o.PrimaryVersionId == null && e.PrimaryVersionId != null)
|
||||
|| ((o.PrimaryVersionId == null) == (e.PrimaryVersionId == null) && o.Id.CompareTo(e.Id) < 0))))
|
||||
.Select(e => e.Id);
|
||||
dbQuery = context.BaseItems.AsNoTracking().Where(e => groupedIds.Contains(e.Id));
|
||||
}
|
||||
else if (enableGroupByPresentationUniqueKey)
|
||||
{
|
||||
var groupedIds = dbQuery.GroupBy(e => e.PresentationUniqueKey)
|
||||
.Select(g => g.Where(e => e.PrimaryVersionId == null).Min(e => (Guid?)e.Id) ?? g.Min(e => (Guid?)e.Id));
|
||||
var groupedIds = candidates
|
||||
.Where(e => !candidates.Any(o => o.PresentationUniqueKey == e.PresentationUniqueKey
|
||||
&& ((o.PrimaryVersionId == null && e.PrimaryVersionId != null)
|
||||
|| ((o.PrimaryVersionId == null) == (e.PrimaryVersionId == null) && o.Id.CompareTo(e.Id) < 0))))
|
||||
.Select(e => e.Id);
|
||||
dbQuery = context.BaseItems.AsNoTracking().Where(e => groupedIds.Contains(e.Id));
|
||||
}
|
||||
else if (filter.GroupBySeriesPresentationUniqueKey)
|
||||
{
|
||||
var groupedIds = dbQuery.GroupBy(e => e.SeriesPresentationUniqueKey).Select(e => e.Min(x => x.Id));
|
||||
var groupedIds = candidates
|
||||
.Where(e => !candidates.Any(o => o.SeriesPresentationUniqueKey == e.SeriesPresentationUniqueKey && o.Id.CompareTo(e.Id) < 0))
|
||||
.Select(e => e.Id);
|
||||
dbQuery = context.BaseItems.AsNoTracking().Where(e => groupedIds.Contains(e.Id));
|
||||
}
|
||||
else
|
||||
|
||||
@@ -35,6 +35,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>
|
||||
|
||||
|
||||
@@ -126,95 +126,95 @@ namespace Jellyfin.Server.Implementations.Security
|
||||
return authInfo;
|
||||
}
|
||||
|
||||
var device = (await _deviceManager.GetDevices(
|
||||
new DeviceQuery { AccessToken = token }).ConfigureAwait(false)).Items.FirstOrDefault();
|
||||
|
||||
if (device is not null)
|
||||
{
|
||||
authInfo.IsAuthenticated = true;
|
||||
var updateToken = false;
|
||||
|
||||
// TODO: Remove these checks for IsNullOrWhiteSpace
|
||||
if (string.IsNullOrWhiteSpace(authInfo.Client))
|
||||
{
|
||||
authInfo.Client = device.AppName;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(authInfo.DeviceId))
|
||||
{
|
||||
authInfo.DeviceId = device.DeviceId;
|
||||
}
|
||||
|
||||
// Temporary. TODO - allow clients to specify that the token has been shared with a casting device
|
||||
var allowTokenInfoUpdate = !authInfo.Client.Contains("chromecast", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(authInfo.Device))
|
||||
{
|
||||
authInfo.Device = device.DeviceName;
|
||||
}
|
||||
else if (!string.Equals(authInfo.Device, device.DeviceName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (allowTokenInfoUpdate)
|
||||
{
|
||||
updateToken = true;
|
||||
device.DeviceName = authInfo.Device;
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(authInfo.Version))
|
||||
{
|
||||
authInfo.Version = device.AppVersion;
|
||||
}
|
||||
else if (!string.Equals(authInfo.Version, device.AppVersion, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (allowTokenInfoUpdate)
|
||||
{
|
||||
updateToken = true;
|
||||
device.AppVersion = authInfo.Version;
|
||||
}
|
||||
}
|
||||
|
||||
if ((DateTime.UtcNow - device.DateLastActivity).TotalMinutes > 3)
|
||||
{
|
||||
device.DateLastActivity = DateTime.UtcNow;
|
||||
updateToken = true;
|
||||
}
|
||||
|
||||
authInfo.User = _userManager.GetUserById(device.UserId);
|
||||
|
||||
if (updateToken)
|
||||
{
|
||||
await _deviceManager.UpdateDevice(device).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return authInfo;
|
||||
}
|
||||
|
||||
var dbContext = await _jellyfinDbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
var device = _deviceManager.GetDevices(
|
||||
new DeviceQuery { AccessToken = token }).Items.FirstOrDefault();
|
||||
|
||||
if (device is not null)
|
||||
var key = await dbContext.ApiKeys.FirstOrDefaultAsync(apiKey => apiKey.AccessToken == token).ConfigureAwait(false);
|
||||
if (key is not null)
|
||||
{
|
||||
authInfo.IsAuthenticated = true;
|
||||
var updateToken = false;
|
||||
|
||||
// TODO: Remove these checks for IsNullOrWhiteSpace
|
||||
if (string.IsNullOrWhiteSpace(authInfo.Client))
|
||||
{
|
||||
authInfo.Client = device.AppName;
|
||||
}
|
||||
|
||||
authInfo.Client = key.Name;
|
||||
authInfo.Token = key.AccessToken;
|
||||
if (string.IsNullOrWhiteSpace(authInfo.DeviceId))
|
||||
{
|
||||
authInfo.DeviceId = device.DeviceId;
|
||||
authInfo.DeviceId = _serverApplicationHost.SystemId;
|
||||
}
|
||||
|
||||
// Temporary. TODO - allow clients to specify that the token has been shared with a casting device
|
||||
var allowTokenInfoUpdate = !authInfo.Client.Contains("chromecast", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(authInfo.Device))
|
||||
{
|
||||
authInfo.Device = device.DeviceName;
|
||||
}
|
||||
else if (!string.Equals(authInfo.Device, device.DeviceName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (allowTokenInfoUpdate)
|
||||
{
|
||||
updateToken = true;
|
||||
device.DeviceName = authInfo.Device;
|
||||
}
|
||||
authInfo.Device = _serverApplicationHost.Name;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(authInfo.Version))
|
||||
{
|
||||
authInfo.Version = device.AppVersion;
|
||||
}
|
||||
else if (!string.Equals(authInfo.Version, device.AppVersion, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (allowTokenInfoUpdate)
|
||||
{
|
||||
updateToken = true;
|
||||
device.AppVersion = authInfo.Version;
|
||||
}
|
||||
authInfo.Version = _serverApplicationHost.ApplicationVersionString;
|
||||
}
|
||||
|
||||
if ((DateTime.UtcNow - device.DateLastActivity).TotalMinutes > 3)
|
||||
{
|
||||
device.DateLastActivity = DateTime.UtcNow;
|
||||
updateToken = true;
|
||||
}
|
||||
|
||||
authInfo.User = _userManager.GetUserById(device.UserId);
|
||||
|
||||
if (updateToken)
|
||||
{
|
||||
await _deviceManager.UpdateDevice(device).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var key = await dbContext.ApiKeys.FirstOrDefaultAsync(apiKey => apiKey.AccessToken == token).ConfigureAwait(false);
|
||||
if (key is not null)
|
||||
{
|
||||
authInfo.IsAuthenticated = true;
|
||||
authInfo.Client = key.Name;
|
||||
authInfo.Token = key.AccessToken;
|
||||
if (string.IsNullOrWhiteSpace(authInfo.DeviceId))
|
||||
{
|
||||
authInfo.DeviceId = _serverApplicationHost.SystemId;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(authInfo.Device))
|
||||
{
|
||||
authInfo.Device = _serverApplicationHost.Name;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(authInfo.Version))
|
||||
{
|
||||
authInfo.Version = _serverApplicationHost.ApplicationVersionString;
|
||||
}
|
||||
|
||||
authInfo.IsApiKey = true;
|
||||
}
|
||||
authInfo.IsApiKey = true;
|
||||
}
|
||||
|
||||
return authInfo;
|
||||
|
||||
@@ -61,10 +61,10 @@ public sealed class DeviceAccessHost : IHostedService
|
||||
|
||||
private async Task UpdateDeviceAccess(User user)
|
||||
{
|
||||
var existing = _deviceManager.GetDevices(new DeviceQuery
|
||||
var existing = (await _deviceManager.GetDevices(new DeviceQuery
|
||||
{
|
||||
UserId = user.Id
|
||||
}).Items;
|
||||
}).ConfigureAwait(false)).Items;
|
||||
|
||||
foreach (var device in existing)
|
||||
{
|
||||
|
||||
@@ -2,12 +2,15 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using Emby.Server.Implementations;
|
||||
using Emby.Server.Implementations.MediaEncoding;
|
||||
using Emby.Server.Implementations.ScheduledTasks;
|
||||
using Emby.Server.Implementations.Session;
|
||||
using Jellyfin.Api.WebSocketListeners;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Drawing;
|
||||
using Jellyfin.Drawing.Skia;
|
||||
using Jellyfin.LiveTv;
|
||||
using Jellyfin.Server.Extensions;
|
||||
using Jellyfin.Server.Implementations.Activity;
|
||||
using Jellyfin.Server.Implementations.Devices;
|
||||
using Jellyfin.Server.Implementations.Events;
|
||||
@@ -23,6 +26,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;
|
||||
@@ -39,6 +43,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 +63,7 @@ namespace Jellyfin.Server
|
||||
options,
|
||||
startupConfig)
|
||||
{
|
||||
_startupConfig = startupConfig;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -98,6 +105,13 @@ namespace Jellyfin.Server
|
||||
|
||||
serviceCollection.AddScoped<IAuthenticationManager, AuthenticationManager>();
|
||||
|
||||
// Transcode session store: Redis-backed when configured, no-op otherwise.
|
||||
serviceCollection.AddTranscodeSessionStore(_startupConfig, Logger);
|
||||
|
||||
// Scan-leader lease: gates periodic library-mutating scheduled tasks to a single leader
|
||||
// instance. Active by default once a Redis connection is configured, no-op otherwise.
|
||||
serviceCollection.AddScanLeaderLease(_startupConfig, Logger);
|
||||
|
||||
foreach (var type in GetExportTypes<ILyricProvider>())
|
||||
{
|
||||
serviceCollection.AddSingleton(typeof(ILyricProvider), type);
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace Jellyfin.Server.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Extensions for building the application configuration.
|
||||
/// </summary>
|
||||
public static class ConfigurationBuilderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// The environment variable prefix that maps onto this fork's own <c>Jellyfin:*</c> configuration
|
||||
/// keys, for example <c>Jellyfin__TranscodeStore__RedisConnectionString</c>.
|
||||
/// </summary>
|
||||
public const string JellyfinSectionEnvironmentPrefix = "Jellyfin__";
|
||||
|
||||
/// <summary>
|
||||
/// The configuration section root this fork keeps its own settings under.
|
||||
/// </summary>
|
||||
public const string JellyfinSectionRoot = "Jellyfin";
|
||||
|
||||
/// <summary>
|
||||
/// Adds environment variables named <c>Jellyfin__Section__Key</c> as the configuration keys
|
||||
/// <c>Jellyfin:Section:Key</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The base configuration only reads <c>JELLYFIN_</c> prefixed environment variables, so the
|
||||
/// unprefixed form every manifest, chart and document uses would otherwise be dropped and the
|
||||
/// feature it configures would stay off with no error.
|
||||
/// </remarks>
|
||||
/// <param name="builder">The configuration builder.</param>
|
||||
/// <returns>The updated configuration builder.</returns>
|
||||
public static IConfigurationBuilder AddJellyfinSectionEnvironmentVariables(this IConfigurationBuilder builder)
|
||||
{
|
||||
// Read through the framework provider so "__" to ":" normalisation and case handling match the
|
||||
// prefixed form exactly; the prefix it strips is then put back as the section root.
|
||||
var scoped = new ConfigurationBuilder()
|
||||
.AddEnvironmentVariables(JellyfinSectionEnvironmentPrefix)
|
||||
.Build();
|
||||
|
||||
var entries = scoped.AsEnumerable()
|
||||
.Where(entry => entry.Value is not null)
|
||||
.Select(entry => new KeyValuePair<string, string?>(
|
||||
ConfigurationPath.Combine(JellyfinSectionRoot, entry.Key),
|
||||
entry.Value))
|
||||
.ToList();
|
||||
|
||||
return builder.AddInMemoryCollection(entries);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using System;
|
||||
using Emby.Server.Implementations.ScheduledTasks;
|
||||
using MediaBrowser.Controller.ScheduledTasks;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Server.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Extensions for registering the scan-leader lease.
|
||||
/// </summary>
|
||||
public static class ScanLeaderServiceCollectionExtensions
|
||||
{
|
||||
private const string RedisConnectionStringKey = "Jellyfin:TranscodeStore:RedisConnectionString";
|
||||
|
||||
/// <summary>
|
||||
/// Registers the scan-leader lease and reports at <see cref="LogLevel.Information"/> whether
|
||||
/// timer-driven library tasks are gated to a single instance.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Gating is on by default once a Redis connection string is configured: that is only set for a
|
||||
/// multi-instance deployment, which is the only shape where running library scans on every
|
||||
/// instance is wrong. Single-instance installs have no Redis and keep running every task locally.
|
||||
/// </remarks>
|
||||
/// <param name="serviceCollection">The service collection.</param>
|
||||
/// <param name="configuration">The configuration to read <c>Jellyfin:ScanLeader</c> from.</param>
|
||||
/// <param name="logger">The logger to report the gating decision on.</param>
|
||||
/// <returns>The updated service collection.</returns>
|
||||
public static IServiceCollection AddScanLeaderLease(
|
||||
this IServiceCollection serviceCollection,
|
||||
IConfiguration configuration,
|
||||
ILogger logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
serviceCollection.Configure<ScanLeaderOptions>(configuration.GetSection(ScanLeaderOptions.ConfigurationSection));
|
||||
|
||||
var redisConfigured = !string.IsNullOrEmpty(configuration[RedisConnectionStringKey]);
|
||||
var requested = bool.TryParse(configuration[ScanLeaderOptions.EnabledKey], out var explicitChoice)
|
||||
? explicitChoice
|
||||
: redisConfigured;
|
||||
var active = requested && redisConfigured;
|
||||
|
||||
// The task worker reads Enabled from the bound options, so the effective decision has to land
|
||||
// there as well as on the lease registration below.
|
||||
serviceCollection.PostConfigure<ScanLeaderOptions>(options => options.Enabled = active);
|
||||
|
||||
if (active)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Scan-leader gating is active: timer-driven library tasks run only on the instance holding the Redis scan-leader lease.");
|
||||
|
||||
return serviceCollection.AddSingleton<IScanLeaderLease, RedisScanLeaderLease>();
|
||||
}
|
||||
|
||||
if (requested)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Scan-leader gating is enabled but no Redis connection string is configured ({Key}), so it cannot run: timer-driven library tasks run on every instance.",
|
||||
RedisConnectionStringKey);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogInformation("Scan-leader gating is off: timer-driven library tasks run on every instance.");
|
||||
}
|
||||
|
||||
return serviceCollection.AddSingleton<IScanLeaderLease, NullScanLeaderLease>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Emby.Server.Implementations.MediaEncoding;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace Jellyfin.Server.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Extensions for registering the transcode session store.
|
||||
/// </summary>
|
||||
public static class TranscodeStoreServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers the transcode session store, Redis-backed when a connection string is configured and
|
||||
/// no-op otherwise, and reports the selected store at <see cref="LogLevel.Information"/>.
|
||||
/// </summary>
|
||||
/// <param name="serviceCollection">The service collection.</param>
|
||||
/// <param name="configuration">The configuration to read <c>Jellyfin:TranscodeStore</c> from.</param>
|
||||
/// <param name="logger">The logger to report the selected store on.</param>
|
||||
/// <returns>The updated service collection.</returns>
|
||||
public static IServiceCollection AddTranscodeSessionStore(
|
||||
this IServiceCollection serviceCollection,
|
||||
IConfiguration configuration,
|
||||
ILogger logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(configuration);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
serviceCollection.Configure<TranscodeStoreOptions>(configuration.GetSection(TranscodeStoreOptions.ConfigurationSection));
|
||||
|
||||
var redisConnectionString = configuration[TranscodeStoreOptions.RedisConnectionStringKey];
|
||||
if (string.IsNullOrEmpty(redisConnectionString))
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Transcode session store: {Store}. Cross-pod transcode takeover is off; set {Key} to enable it.",
|
||||
nameof(NullTranscodeSessionStore),
|
||||
TranscodeStoreOptions.RedisConnectionStringKey);
|
||||
|
||||
return serviceCollection.AddSingleton<ITranscodeSessionStore, NullTranscodeSessionStore>();
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"Transcode session store: {Store} on {Endpoints}.",
|
||||
nameof(RedisTranscodeSessionStore),
|
||||
DescribeEndpoints(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 {Key} configuration.", TranscodeStoreOptions.RedisConnectionStringKey);
|
||||
throw;
|
||||
}
|
||||
});
|
||||
serviceCollection.AddSingleton<ITranscodeSessionStore, RedisTranscodeSessionStore>();
|
||||
serviceCollection.AddHostedService<TranscodeStoreConnectivityProbe>();
|
||||
|
||||
return serviceCollection;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders the endpoints of a connection string for logging. The connection string itself is never
|
||||
/// logged because it can carry a password.
|
||||
/// </summary>
|
||||
private static string DescribeEndpoints(string redisConnectionString)
|
||||
{
|
||||
try
|
||||
{
|
||||
return string.Join(
|
||||
',',
|
||||
ConfigurationOptions.Parse(redisConnectionString).EndPoints.Select(endpoint => endpoint.ToString()));
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
return "(unparsable connection string)";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,7 @@
|
||||
<PackageReference Include="Serilog.Sinks.Console" />
|
||||
<PackageReference Include="Serilog.Sinks.File" />
|
||||
<PackageReference Include="Serilog.Sinks.Graylog" />
|
||||
<PackageReference Include="StackExchange.Redis" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -36,7 +36,9 @@ internal class MigrateRatingLevels : IDatabaseMigrationRoutine
|
||||
_logger.LogInformation("Recalculating parental rating levels based on rating string.");
|
||||
using var context = _provider.CreateDbContext();
|
||||
using var transaction = context.Database.BeginTransaction();
|
||||
var ratings = context.BaseItems.AsNoTracking().Select(e => e.OfficialRating).Distinct();
|
||||
// Read the whole list up front: the updates below run on the same connection, and a provider
|
||||
// that cannot multiplex commands rejects them while the reader is still open.
|
||||
var ratings = context.BaseItems.AsNoTracking().Select(e => e.OfficialRating).Distinct().ToList();
|
||||
foreach (var rating in ratings)
|
||||
{
|
||||
if (string.IsNullOrEmpty(rating))
|
||||
|
||||
@@ -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;
|
||||
@@ -304,6 +305,10 @@ namespace Jellyfin.Server
|
||||
.AddJellyfinDbContext(startupConfigurationManager, startupConfig)
|
||||
.AddSingleton<IApplicationPaths>(appPaths)
|
||||
.AddSingleton<ServerApplicationPaths>(appPaths)
|
||||
// Required by the NpgsqlDataSource factory in AddJellyfinDbContext when
|
||||
// DatabaseType=Jellyfin-PostgreSQL — the factory resolves this from DI
|
||||
// to read CustomProviderOptions and pool settings.
|
||||
.AddSingleton<IServerConfigurationManager>(startupConfigurationManager)
|
||||
.RegisterStartupLogger();
|
||||
|
||||
var startupService = migrationStartupServiceProvider.BuildServiceProvider();
|
||||
@@ -386,6 +391,8 @@ namespace Jellyfin.Server
|
||||
.AddInMemoryCollection(inMemoryDefaultConfig)
|
||||
.AddJsonFile(LoggingConfigFileDefault, optional: false, reloadOnChange: true)
|
||||
.AddJsonFile(LoggingConfigFileSystem, optional: true, reloadOnChange: true)
|
||||
// Added before the prefixed source so an explicit JELLYFIN_ variable still wins.
|
||||
.AddJellyfinSectionEnvironmentVariables()
|
||||
.AddEnvironmentVariables("JELLYFIN_")
|
||||
.AddInMemoryCollection(commandLineOpts.ConvertToConfig());
|
||||
}
|
||||
|
||||
@@ -68,6 +68,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}"
|
||||
@@ -95,10 +97,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
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Drawing.Skia.Tests", "tests\Jellyfin.Drawing.Skia.Tests\Jellyfin.Drawing.Skia.Tests.csproj", "{E24A279C-9A37-419A-8F9C-853C11FBE753}"
|
||||
EndProject
|
||||
Global
|
||||
@@ -219,6 +227,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
|
||||
@@ -259,6 +271,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
|
||||
@@ -267,6 +283,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
|
||||
{E24A279C-9A37-419A-8F9C-853C11FBE753}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{E24A279C-9A37-419A-8F9C-853C11FBE753}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{E24A279C-9A37-419A-8F9C-853C11FBE753}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
@@ -290,6 +310,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}
|
||||
@@ -301,8 +322,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}
|
||||
{E24A279C-9A37-419A-8F9C-853C11FBE753} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
|
||||
@@ -47,29 +47,29 @@ public interface IDeviceManager
|
||||
/// Gets the device information.
|
||||
/// </summary>
|
||||
/// <param name="id">The identifier.</param>
|
||||
/// <returns>DeviceInfoDto.</returns>
|
||||
DeviceInfoDto? GetDevice(string id);
|
||||
/// <returns>A <see cref="Task"/> representing the retrieval of the device information.</returns>
|
||||
Task<DeviceInfoDto?> GetDevice(string id);
|
||||
|
||||
/// <summary>
|
||||
/// Gets devices based on the provided query.
|
||||
/// </summary>
|
||||
/// <param name="query">The device query.</param>
|
||||
/// <returns>A <see cref="Task{QueryResult}"/> representing the retrieval of the devices.</returns>
|
||||
QueryResult<Device> GetDevices(DeviceQuery query);
|
||||
Task<QueryResult<Device>> GetDevices(DeviceQuery query);
|
||||
|
||||
/// <summary>
|
||||
/// Gets device information based on the provided query.
|
||||
/// </summary>
|
||||
/// <param name="query">The device query.</param>
|
||||
/// <returns>A <see cref="Task{QueryResult}"/> representing the retrieval of the device information.</returns>
|
||||
QueryResult<DeviceInfo> GetDeviceInfos(DeviceQuery query);
|
||||
Task<QueryResult<DeviceInfo>> GetDeviceInfos(DeviceQuery query);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the device information.
|
||||
/// </summary>
|
||||
/// <param name="userId">The user's id, or <c>null</c>.</param>
|
||||
/// <returns>IEnumerable<DeviceInfoDto>.</returns>
|
||||
QueryResult<DeviceInfoDto> GetDevicesForUser(Guid? userId);
|
||||
/// <returns>A <see cref="Task{QueryResult}"/> representing the retrieval of the device information.</returns>
|
||||
Task<QueryResult<DeviceInfoDto>> GetDevicesForUser(Guid? userId);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a device.
|
||||
@@ -105,8 +105,8 @@ public interface IDeviceManager
|
||||
/// Gets the options of a device.
|
||||
/// </summary>
|
||||
/// <param name="deviceId">The device id.</param>
|
||||
/// <returns><see cref="DeviceOptions"/> of the device.</returns>
|
||||
DeviceOptionsDto? GetDeviceOptions(string deviceId);
|
||||
/// <returns>A <see cref="Task"/> representing the retrieval of the <see cref="DeviceOptions"/> of the device.</returns>
|
||||
Task<DeviceOptionsDto?> GetDeviceOptions(string deviceId);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the dto for client capabilities.
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
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.
|
||||
/// The renewal is rejected when <paramref name="ownerPod"/> no longer owns the lease, so a
|
||||
/// renewal in flight while another pod wins <see cref="TryTakeoverAsync"/> cannot revert the takeover.
|
||||
/// </summary>
|
||||
/// <param name="playSessionId">The play session identifier.</param>
|
||||
/// <param name="ownerPod">The name of the pod that believes it owns the lease.</param>
|
||||
/// <param name="cancellationToken">A cancellation token.</param>
|
||||
/// <returns>
|
||||
/// <c>true</c> if the lease was renewed; <c>false</c> if the session no longer exists or is
|
||||
/// owned by another pod.
|
||||
/// </returns>
|
||||
Task<bool> RenewLeaseAsync(string playSessionId, string ownerPod, 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);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
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<bool> RenewLeaseAsync(string playSessionId, string ownerPod, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(true);
|
||||
|
||||
/// <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>());
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
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; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a session record for an HLS output, deriving <see cref="ManifestPath"/> and
|
||||
/// <see cref="SegmentPathPrefix"/> from the playlist path so that cleanup can recognise
|
||||
/// every file the session owns.
|
||||
/// </summary>
|
||||
/// <param name="playSessionId">The play session identifier.</param>
|
||||
/// <param name="mediaSourceId">The media source identifier.</param>
|
||||
/// <param name="ownerPod">The name of the pod that owns the session.</param>
|
||||
/// <param name="playlistPath">The absolute path of the HLS playlist (.m3u8) file.</param>
|
||||
/// <param name="leaseDuration">The initial lease duration.</param>
|
||||
/// <returns>The new <see cref="TranscodeSession"/>.</returns>
|
||||
public static TranscodeSession CreateForPlaylist(
|
||||
string playSessionId,
|
||||
string mediaSourceId,
|
||||
string ownerPod,
|
||||
string playlistPath,
|
||||
TimeSpan leaseDuration)
|
||||
=> new TranscodeSession
|
||||
{
|
||||
PlaySessionId = playSessionId,
|
||||
OwnerPod = ownerPod,
|
||||
LeaseExpiresUtc = DateTime.UtcNow.Add(leaseDuration),
|
||||
ManifestPath = playlistPath,
|
||||
SegmentPathPrefix = GetSegmentPathPrefix(playlistPath),
|
||||
MediaSourceId = mediaSourceId,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Gets the prefix every segment file of the HLS output at <paramref name="playlistPath"/> starts with.
|
||||
/// Segments are written as <c><playlist path without extension><index><segment extension></c>.
|
||||
/// </summary>
|
||||
/// <param name="playlistPath">The absolute path of the HLS playlist (.m3u8) file.</param>
|
||||
/// <returns>The segment path prefix.</returns>
|
||||
public static string GetSegmentPathPrefix(string playlistPath)
|
||||
=> Path.ChangeExtension(playlistPath, null) ?? playlistPath;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
namespace MediaBrowser.Controller.MediaEncoding;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the transcode session store.
|
||||
/// </summary>
|
||||
public sealed class TranscodeStoreOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// The configuration section these options bind from.
|
||||
/// </summary>
|
||||
public const string ConfigurationSection = "Jellyfin:TranscodeStore";
|
||||
|
||||
/// <summary>
|
||||
/// The configuration key holding the Redis connection string.
|
||||
/// </summary>
|
||||
public const string RedisConnectionStringKey = ConfigurationSection + ":RedisConnectionString";
|
||||
|
||||
/// <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;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets how long in seconds a session record is retained after its lease was last renewed.
|
||||
/// The record must outlive the lease, otherwise an orphaned session is gone before another pod
|
||||
/// can take it over.
|
||||
/// </summary>
|
||||
public int SessionRetentionSeconds { get; set; } = 300;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace MediaBrowser.Controller.ScheduledTasks;
|
||||
|
||||
/// <summary>
|
||||
/// Provides a distributed leader lease that gates periodic, library-mutating scheduled tasks
|
||||
/// to a single instance across a multi-pod deployment.
|
||||
/// </summary>
|
||||
public interface IScanLeaderLease
|
||||
{
|
||||
/// <summary>
|
||||
/// Attempts to acquire the scan-leader lease, or renews it when this instance already holds it.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A cancellation token.</param>
|
||||
/// <returns>
|
||||
/// <c>true</c> if this instance holds the leader lease and gated periodic tasks may run here;
|
||||
/// otherwise <c>false</c>.
|
||||
/// </returns>
|
||||
Task<bool> TryAcquireOrRenewAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace MediaBrowser.Controller.ScheduledTasks;
|
||||
|
||||
/// <summary>
|
||||
/// A no-op <see cref="IScanLeaderLease"/> used when scan-leader election is disabled or no Redis
|
||||
/// connection is configured. Every instance is treated as the leader, preserving the default
|
||||
/// single-instance behavior where all periodic tasks run locally.
|
||||
/// </summary>
|
||||
public sealed class NullScanLeaderLease : IScanLeaderLease
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task<bool> TryAcquireOrRenewAsync(CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(true);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
namespace MediaBrowser.Controller.ScheduledTasks;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for scan-leader election, which gates periodic library-mutating
|
||||
/// scheduled tasks to a single leader instance in a multi-pod deployment.
|
||||
/// </summary>
|
||||
public sealed class ScanLeaderOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// The configuration section these options bind from.
|
||||
/// </summary>
|
||||
public const string ConfigurationSection = "Jellyfin:ScanLeader";
|
||||
|
||||
/// <summary>
|
||||
/// The configuration key that overrides the default enablement.
|
||||
/// </summary>
|
||||
public const string EnabledKey = ConfigurationSection + ":Enabled";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether scan-leader election is enabled. When disabled,
|
||||
/// every instance runs its periodic tasks as before. Left unset, election is enabled whenever a
|
||||
/// Redis connection string is configured, because that is the only deployment shape that needs it.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the duration in seconds for which the scan-leader lease is held before it must
|
||||
/// be renewed. A leader that stops renewing loses the lease after this duration.
|
||||
/// </summary>
|
||||
public int LeaseDurationSeconds { get; set; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the set of scheduled task keys whose periodic (timer-driven) execution is gated
|
||||
/// to the scan leader. Tasks not listed here run on every instance, and manual or API-triggered
|
||||
/// runs are never gated.
|
||||
/// </summary>
|
||||
public string[] GatedTaskKeys { get; set; } =
|
||||
{
|
||||
"RefreshLibrary",
|
||||
"RefreshPeople",
|
||||
"RefreshChapterImages",
|
||||
"AudioNormalization",
|
||||
"TaskExtractMediaSegments",
|
||||
"KeyframeExtraction",
|
||||
"CleanupUserDataTask",
|
||||
"OptimizeDatabaseTask"
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
#pragma warning disable CA1819 // XML serialization handles collections improperly, so we need to use arrays
|
||||
|
||||
#nullable disable
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Xml.Serialization;
|
||||
using MediaBrowser.Model.Entities;
|
||||
|
||||
namespace MediaBrowser.Model.Configuration;
|
||||
@@ -25,6 +28,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
|
||||
@@ -125,6 +130,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>
|
||||
@@ -218,8 +237,27 @@ public class EncodingOptions
|
||||
/// <summary>
|
||||
/// Gets or sets the encoder preset.
|
||||
/// </summary>
|
||||
[XmlIgnore]
|
||||
public EncoderPreset EncoderPreset { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the encoder preset as it is stored in the configuration file. Files written before the preset
|
||||
/// list was validated can carry an empty element, which has to read back as the default rather than throw and
|
||||
/// take the rest of the configuration down with it.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
[XmlElement(nameof(EncoderPreset))]
|
||||
public string EncoderPresetXml
|
||||
{
|
||||
get => EncoderPreset.ToString();
|
||||
|
||||
// TryParse also accepts a number, which would yield a preset value nothing maps to, so the result has to be
|
||||
// a declared one.
|
||||
set => EncoderPreset = Enum.TryParse<EncoderPreset>(value, true, out var encoderPreset) && Enum.IsDefined(encoderPreset)
|
||||
? encoderPreset
|
||||
: EncoderPreset.auto;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the framerate is doubled when deinterlacing.
|
||||
/// </summary>
|
||||
|
||||
@@ -1,64 +1,571 @@
|
||||
<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
|
||||
- **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 / 3` seconds; the renewal is rejected if Pod A no longer owns it.
|
||||
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 a `Jellyfin__TranscodeStore__RedisConnectionString` environment variable, as the equivalent `JELLYFIN_` prefixed variable, 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
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The selected store is logged at startup, so HA transcoding is never on or off without a signal:
|
||||
|
||||
```
|
||||
Transcode session store: RedisTranscodeSessionStore on valkey:6379.
|
||||
Redis transcode session store is reachable (2ms round trip). HA transcode takeover is active.
|
||||
```
|
||||
|
||||
Without a connection string the line reads `Transcode session store: NullTranscodeSessionStore`. A configured but unreachable store is logged at `Error`; the server keeps serving with per-instance sessions rather than refusing to start.
|
||||
|
||||
---
|
||||
|
||||
## 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. |
|
||||
| `Jellyfin:TranscodeStore:SessionRetentionSeconds` | `300` | How long an unrenewed session record is kept so another pod can still take it 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 10 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 the PostgreSQL migration tests
|
||||
|
||||
They run the startup migration chain against a real server. Set `JELLYFIN_TEST_POSTGRES` to a
|
||||
connection string for an already running server and they use it; without it they start a container
|
||||
through testcontainers.
|
||||
|
||||
```bash
|
||||
dotnet test tests/Jellyfin.Server.Tests \
|
||||
--configuration Release \
|
||||
--filter "Category=RequiresDocker"
|
||||
```
|
||||
|
||||
### Run HA-specific tests
|
||||
|
||||
The transcode session store and HA recovery tests live in:
|
||||
|
||||
- `tests/Jellyfin.Server.Implementations.Tests/MediaEncoding/RedisTranscodeSessionStoreTests.cs` (real Redis, `Category=RequiresDocker`)
|
||||
- `tests/Jellyfin.MediaEncoding.Tests/Transcoding/InMemoryTranscodeSessionStoreTests.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 ← real Redis via Testcontainers
|
||||
Jellyfin.MediaEncoding.Tests/
|
||||
Fakes/InMemoryTranscodeSessionStore.cs
|
||||
Transcoding/InMemoryTranscodeSessionStoreTests.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) release tags and is currently based on `v12.0`. 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).
|
||||
|
||||
@@ -177,5 +684,7 @@ Since this is a common scenario, there is also a separate launch profile defined
|
||||
This project is supported by:
|
||||
<br/>
|
||||
<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/199ae22980ef5da64882ec2de3e8e5c03fe535b8/jetbrains.svg" height="50px" alt="JetBrains logo"></a>
|
||||
</p>
|
||||
|
||||
@@ -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: "12.0.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 }}
|
||||
@@ -0,0 +1,15 @@
|
||||
{{- if .Values.runtimeConfig.enabled }}
|
||||
# jellyfin.runtimeconfig.json ConfigMap.
|
||||
# Mount path: /jellyfin/jellyfin.runtimeconfig.json
|
||||
# Use this to set .NET runtime configuration switches (e.g. Intel QSV codec flags).
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: {{ include "jellyfin-ha.fullname" . }}-runtimeconfig
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "jellyfin-ha.labels" . | nindent 4 }}
|
||||
data:
|
||||
jellyfin.runtimeconfig.json: |
|
||||
{{- .Values.runtimeConfig.json | nindent 4 }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,97 @@
|
||||
{{- if .Values.ingress.enabled }}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ include "jellyfin-ha.fullname" . }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "jellyfin-ha.labels" . | nindent 4 }}
|
||||
{{- with .Values.ingress.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if .Values.ingress.className }}
|
||||
ingressClassName: {{ .Values.ingress.className }}
|
||||
{{- end }}
|
||||
{{- if .Values.ingress.tls }}
|
||||
tls:
|
||||
{{- toYaml .Values.ingress.tls | nindent 4 }}
|
||||
{{- end }}
|
||||
rules:
|
||||
{{- range .Values.ingress.hosts }}
|
||||
- host: {{ .host | quote }}
|
||||
http:
|
||||
paths:
|
||||
{{- range .paths }}
|
||||
- path: {{ .path }}
|
||||
pathType: {{ .pathType }}
|
||||
backend:
|
||||
service:
|
||||
name: {{ include "jellyfin-ha.fullname" $ }}
|
||||
port:
|
||||
name: http
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
---
|
||||
{{- if .Values.traefikIngressRoute.enabled }}
|
||||
# Traefik v3 IngressRoute (used by k3s default ingress controller).
|
||||
# Enables sticky session cookies — required for multi-replica Jellyfin so that
|
||||
# a client always lands on the same pod (session affinity).
|
||||
apiVersion: traefik.io/v1alpha1
|
||||
kind: IngressRoute
|
||||
metadata:
|
||||
name: {{ include "jellyfin-ha.fullname" . }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "jellyfin-ha.labels" . | nindent 4 }}
|
||||
{{- if .Values.traefikIngressRoute.tls.enabled }}
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: {{ .Values.traefikIngressRoute.tls.clusterIssuer }}
|
||||
{{- end }}
|
||||
spec:
|
||||
entryPoints:
|
||||
{{- toYaml .Values.traefikIngressRoute.entryPoints | nindent 4 }}
|
||||
routes:
|
||||
- match: Host(`{{ .Values.traefikIngressRoute.host }}`)
|
||||
kind: Rule
|
||||
services:
|
||||
- name: {{ include "jellyfin-ha.fullname" . }}
|
||||
port: {{ .Values.service.port }}
|
||||
{{- if .Values.traefikIngressRoute.sticky.enabled }}
|
||||
sticky:
|
||||
cookie:
|
||||
name: {{ .Values.traefikIngressRoute.sticky.cookieName }}
|
||||
httpOnly: {{ .Values.traefikIngressRoute.sticky.httpOnly }}
|
||||
secure: {{ .Values.traefikIngressRoute.sticky.secure }}
|
||||
{{- end }}
|
||||
{{- if .Values.traefikIngressRoute.tls.enabled }}
|
||||
tls:
|
||||
secretName: {{ .Values.traefikIngressRoute.tls.secretName }}
|
||||
{{- end }}
|
||||
|
||||
---
|
||||
{{- if .Values.traefikIngressRoute.tls.enabled }}
|
||||
# cert-manager Certificate for Traefik TLS termination.
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: Certificate
|
||||
metadata:
|
||||
name: {{ include "jellyfin-ha.fullname" . }}-tls
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "jellyfin-ha.labels" . | nindent 4 }}
|
||||
spec:
|
||||
secretName: {{ .Values.traefikIngressRoute.tls.secretName }}
|
||||
issuerRef:
|
||||
name: {{ .Values.traefikIngressRoute.tls.clusterIssuer }}
|
||||
kind: ClusterIssuer
|
||||
dnsNames:
|
||||
{{- if .Values.traefikIngressRoute.tls.dnsNames }}
|
||||
{{- toYaml .Values.traefikIngressRoute.tls.dnsNames | nindent 4 }}
|
||||
{{- else }}
|
||||
- {{ .Values.traefikIngressRoute.host }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,14 @@
|
||||
{{- if .Values.podDisruptionBudget.enabled }}
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: {{ include "jellyfin-ha.fullname" . }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "jellyfin-ha.labels" . | nindent 4 }}
|
||||
spec:
|
||||
minAvailable: {{ .Values.podDisruptionBudget.minAvailable }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "jellyfin-ha.selectorLabels" . | nindent 6 }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,20 @@
|
||||
{{- if .Values.postgresql.enabled }}
|
||||
# PersistentVolumeClaim for the in-cluster PostgreSQL data directory.
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ include "jellyfin-ha.postgres.fullname" . }}-data
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "jellyfin-ha.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: database
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
{{- if .Values.postgresql.persistence.storageClass }}
|
||||
storageClassName: {{ .Values.postgresql.persistence.storageClass | quote }}
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.postgresql.persistence.size }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,21 @@
|
||||
{{- if .Values.postgresql.enabled }}
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "jellyfin-ha.postgres.fullname" . }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "jellyfin-ha.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: database
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app.kubernetes.io/name: {{ include "jellyfin-ha.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: database
|
||||
ports:
|
||||
- name: postgres
|
||||
port: {{ .Values.postgresql.service.port }}
|
||||
targetPort: postgres
|
||||
protocol: TCP
|
||||
{{- end }}
|
||||
@@ -0,0 +1,84 @@
|
||||
{{- if .Values.postgresql.enabled }}
|
||||
# In-cluster PostgreSQL StatefulSet — experimental.
|
||||
# The credentials secret must be created manually before first deploy:
|
||||
#
|
||||
# kubectl create secret generic {{ .Values.postgresql.existingSecret }} \
|
||||
# --namespace {{ .Release.Namespace }} \
|
||||
# --from-literal=POSTGRES_USER=jellyfin \
|
||||
# --from-literal=POSTGRES_PASSWORD=<strong-password> \
|
||||
# --from-literal=POSTGRES_DB=jellyfin \
|
||||
# --from-literal=DATABASE_URL="postgresql://jellyfin:<password>@{{ include "jellyfin-ha.postgres.fullname" . }}:5432/jellyfin"
|
||||
#
|
||||
# SECURITY: Do NOT add a Secret resource here. Applying this file must not
|
||||
# overwrite a live secret.
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: {{ include "jellyfin-ha.postgres.fullname" . }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "jellyfin-ha.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: database
|
||||
spec:
|
||||
replicas: 1
|
||||
serviceName: {{ include "jellyfin-ha.postgres.fullname" . }}
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: {{ include "jellyfin-ha.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: database
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: {{ include "jellyfin-ha.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: database
|
||||
spec:
|
||||
containers:
|
||||
- name: postgres
|
||||
image: "{{ .Values.postgresql.image.repository }}:{{ .Values.postgresql.image.tag }}"
|
||||
imagePullPolicy: {{ .Values.postgresql.image.pullPolicy }}
|
||||
ports:
|
||||
- name: postgres
|
||||
containerPort: 5432
|
||||
protocol: TCP
|
||||
env:
|
||||
- name: POSTGRES_USER
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Values.postgresql.existingSecret }}
|
||||
key: POSTGRES_USER
|
||||
- name: POSTGRES_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Values.postgresql.existingSecret }}
|
||||
key: POSTGRES_PASSWORD
|
||||
- name: POSTGRES_DB
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Values.postgresql.existingSecret }}
|
||||
key: POSTGRES_DB
|
||||
- name: PGDATA
|
||||
value: /var/lib/postgresql/data/pgdata
|
||||
resources:
|
||||
{{- toYaml .Values.postgresql.resources | nindent 12 }}
|
||||
livenessProbe:
|
||||
exec:
|
||||
command: ["pg_isready", "-U", "$(POSTGRES_USER)"]
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 20
|
||||
timeoutSeconds: 5
|
||||
readinessProbe:
|
||||
exec:
|
||||
command: ["pg_isready", "-U", "$(POSTGRES_USER)"]
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 3
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /var/lib/postgresql/data
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ include "jellyfin-ha.postgres.fullname" . }}-data
|
||||
{{- end }}
|
||||
@@ -0,0 +1,90 @@
|
||||
{{- if not .Values.persistence.config.existingClaim }}
|
||||
# Shared config PVC — used by all Jellyfin replicas.
|
||||
# Must be ReadWriteMany when replicaCount > 1.
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ include "jellyfin-ha.fullname" . }}-config
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "jellyfin-ha.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: storage
|
||||
spec:
|
||||
accessModes:
|
||||
- {{ .Values.persistence.config.accessMode }}
|
||||
{{- if .Values.persistence.config.storageClass }}
|
||||
storageClassName: {{ .Values.persistence.config.storageClass | quote }}
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.persistence.config.size }}
|
||||
{{- end }}
|
||||
|
||||
---
|
||||
{{- if not .Values.persistence.transcode.existingClaim }}
|
||||
# Shared transcode PVC — must be ReadWriteMany so pod takeover can read
|
||||
# HLS segments written by the previous owner pod.
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ include "jellyfin-ha.fullname" . }}-transcode
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "jellyfin-ha.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: storage
|
||||
spec:
|
||||
accessModes:
|
||||
- {{ .Values.persistence.transcode.accessMode }}
|
||||
{{- if .Values.persistence.transcode.storageClass }}
|
||||
storageClassName: {{ .Values.persistence.transcode.storageClass | quote }}
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.persistence.transcode.size }}
|
||||
{{- end }}
|
||||
|
||||
---
|
||||
{{- if and .Values.persistence.media.nfs.enabled (not .Values.persistence.media.existingClaim) }}
|
||||
# NFS PersistentVolume and PersistentVolumeClaim for the media library.
|
||||
# Enable persistence.media.nfs.enabled and provide server/path to use this.
|
||||
# Alternatively, set persistence.media.existingClaim to reuse an existing PVC.
|
||||
apiVersion: v1
|
||||
kind: PersistentVolume
|
||||
metadata:
|
||||
name: {{ include "jellyfin-ha.fullname" . }}-media-nfs
|
||||
labels:
|
||||
{{- include "jellyfin-ha.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: storage
|
||||
spec:
|
||||
capacity:
|
||||
storage: {{ .Values.persistence.media.nfs.size }}
|
||||
accessModes:
|
||||
- ReadOnlyMany
|
||||
persistentVolumeReclaimPolicy: Retain
|
||||
{{- if .Values.persistence.media.nfs.storageClass }}
|
||||
storageClassName: {{ .Values.persistence.media.nfs.storageClass | quote }}
|
||||
{{- end }}
|
||||
nfs:
|
||||
server: {{ .Values.persistence.media.nfs.server | quote }}
|
||||
path: {{ .Values.persistence.media.nfs.path | quote }}
|
||||
readOnly: true
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ include "jellyfin-ha.fullname" . }}-media
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "jellyfin-ha.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: storage
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadOnlyMany
|
||||
{{- if .Values.persistence.media.nfs.storageClass }}
|
||||
storageClassName: {{ .Values.persistence.media.nfs.storageClass | quote }}
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.persistence.media.nfs.size }}
|
||||
volumeName: {{ include "jellyfin-ha.fullname" . }}-media-nfs
|
||||
{{- end }}
|
||||
@@ -0,0 +1,15 @@
|
||||
{{- if .Values.redis.enabled }}
|
||||
# ConfigMap holding the Redis configuration file.
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: {{ include "jellyfin-ha.redis.fullname" . }}-config
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "jellyfin-ha.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: redis
|
||||
data:
|
||||
redis.conf: |
|
||||
maxmemory {{ .Values.redis.maxmemory }}
|
||||
maxmemory-policy {{ .Values.redis.maxmemoryPolicy }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,58 @@
|
||||
{{- if .Values.redis.enabled }}
|
||||
# In-cluster Redis Deployment for jellyifn-ha transcode session store.
|
||||
# No persistence — lease data is small and reconstructable on restart.
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "jellyfin-ha.redis.fullname" . }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "jellyfin-ha.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: redis
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: {{ include "jellyfin-ha.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: redis
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: {{ include "jellyfin-ha.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: redis
|
||||
spec:
|
||||
containers:
|
||||
- name: redis
|
||||
image: "{{ .Values.redis.image.repository }}:{{ .Values.redis.image.tag }}"
|
||||
imagePullPolicy: {{ .Values.redis.image.pullPolicy }}
|
||||
args: ["redis-server", "/etc/redis/redis.conf"]
|
||||
ports:
|
||||
- name: redis
|
||||
containerPort: 6379
|
||||
protocol: TCP
|
||||
resources:
|
||||
{{- toYaml .Values.redis.resources | nindent 12 }}
|
||||
livenessProbe:
|
||||
exec:
|
||||
command: ["redis-cli", "ping"]
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 20
|
||||
timeoutSeconds: 5
|
||||
readinessProbe:
|
||||
exec:
|
||||
command: ["redis-cli", "ping"]
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 3
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /etc/redis
|
||||
volumes:
|
||||
- name: config
|
||||
configMap:
|
||||
name: {{ include "jellyfin-ha.redis.fullname" . }}-config
|
||||
{{- end }}
|
||||
@@ -0,0 +1,21 @@
|
||||
{{- if .Values.redis.enabled }}
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "jellyfin-ha.redis.fullname" . }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "jellyfin-ha.labels" . | nindent 4 }}
|
||||
app.kubernetes.io/component: redis
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app.kubernetes.io/name: {{ include "jellyfin-ha.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: redis
|
||||
ports:
|
||||
- name: redis
|
||||
port: 6379
|
||||
targetPort: redis
|
||||
protocol: TCP
|
||||
{{- end }}
|
||||
@@ -0,0 +1,20 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "jellyfin-ha.fullname" . }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "jellyfin-ha.labels" . | nindent 4 }}
|
||||
{{- with .Values.service.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
type: {{ .Values.service.type }}
|
||||
selector:
|
||||
{{- include "jellyfin-ha.selectorLabels" . | nindent 4 }}
|
||||
ports:
|
||||
- name: http
|
||||
port: {{ .Values.service.port }}
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
@@ -0,0 +1,27 @@
|
||||
{{- if .Values.serviceMonitor.enabled }}
|
||||
# Prometheus ServiceMonitor.
|
||||
# Jellyfin does not expose a native /metrics endpoint. Enable this if you have
|
||||
# a Prometheus sidecar or plan to add one. The kube-state-metrics replica count
|
||||
# alert is the primary health signal for Jellyfin without a native exporter.
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
kind: ServiceMonitor
|
||||
metadata:
|
||||
name: {{ include "jellyfin-ha.fullname" . }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "jellyfin-ha.labels" . | nindent 4 }}
|
||||
{{- with .Values.serviceMonitor.additionalLabels }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "jellyfin-ha.selectorLabels" . | nindent 6 }}
|
||||
endpoints:
|
||||
- port: http
|
||||
path: {{ .Values.serviceMonitor.path }}
|
||||
interval: {{ .Values.serviceMonitor.interval }}
|
||||
namespaceSelector:
|
||||
matchNames:
|
||||
- {{ .Release.Namespace }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,279 @@
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: {{ include "jellyfin-ha.fullname" . }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "jellyfin-ha.labels" . | nindent 4 }}
|
||||
{{- with .Values.labels }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
{{- with .Values.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
replicas: {{ .Values.replicaCount }}
|
||||
serviceName: {{ include "jellyfin-ha.fullname" . }}
|
||||
updateStrategy:
|
||||
{{- toYaml .Values.updateStrategy | nindent 4 }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "jellyfin-ha.selectorLabels" . | nindent 6 }}
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "jellyfin-ha.selectorLabels" . | nindent 8 }}
|
||||
{{- with .Values.podLabels }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.podAnnotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
serviceAccountName: {{ include "jellyfin-ha.serviceAccountName" . }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.podSecurityContext | nindent 8 }}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Affinity / anti-affinity
|
||||
# ---------------------------------------------------------------------------
|
||||
affinity:
|
||||
{{- if and .Values.gpu.enabled .Values.gpu.intel.nodeLabel.key }}
|
||||
nodeAffinity:
|
||||
preferredDuringSchedulingIgnoredDuringExecution:
|
||||
- weight: 100
|
||||
preference:
|
||||
matchExpressions:
|
||||
- key: {{ .Values.gpu.intel.nodeLabel.key }}
|
||||
operator: In
|
||||
values:
|
||||
- {{ .Values.gpu.intel.nodeLabel.value }}
|
||||
{{- end }}
|
||||
{{- if .Values.podAntiAffinity.enabled }}
|
||||
podAntiAffinity:
|
||||
{{- if eq .Values.podAntiAffinity.type "required" }}
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
- labelSelector:
|
||||
matchLabels:
|
||||
{{- include "jellyfin-ha.selectorLabels" . | nindent 18 }}
|
||||
topologyKey: kubernetes.io/hostname
|
||||
{{- else }}
|
||||
preferredDuringSchedulingIgnoredDuringExecution:
|
||||
- weight: {{ .Values.podAntiAffinity.weight }}
|
||||
podAffinityTerm:
|
||||
labelSelector:
|
||||
matchLabels:
|
||||
{{- include "jellyfin-ha.selectorLabels" . | nindent 20 }}
|
||||
topologyKey: kubernetes.io/hostname
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
# GPU node toleration
|
||||
{{- if .Values.gpu.enabled }}
|
||||
tolerations:
|
||||
- key: {{ .Values.gpu.intel.toleration.key }}
|
||||
operator: Equal
|
||||
value: {{ .Values.gpu.intel.toleration.value | quote }}
|
||||
effect: {{ .Values.gpu.intel.toleration.effect }}
|
||||
{{- end }}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Init containers
|
||||
# ---------------------------------------------------------------------------
|
||||
initContainers:
|
||||
{{- if eq .Values.config.databaseType "Jellyfin-PostgreSQL" }}
|
||||
# Inject database.xml to select the PostgreSQL provider at startup.
|
||||
- name: inject-db-config
|
||||
image: busybox:1.37.0
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
mkdir -p /config/config
|
||||
chown {{ .Values.securityContext.runAsUser }}:{{ .Values.securityContext.runAsGroup }} /config/config
|
||||
chmod 775 /config/config
|
||||
cat > /config/config/database.xml << 'DBEOF'
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<DatabaseConfigurationOptions>
|
||||
<DatabaseType>Jellyfin-PostgreSQL</DatabaseType>
|
||||
<LockingBehavior>NoLock</LockingBehavior>
|
||||
</DatabaseConfigurationOptions>
|
||||
DBEOF
|
||||
chown {{ .Values.securityContext.runAsUser }}:{{ .Values.securityContext.runAsGroup }} /config/config/database.xml
|
||||
chmod 664 /config/config/database.xml
|
||||
echo "database.xml injected."
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /config
|
||||
{{- end }}
|
||||
{{- with .Values.extraInitContainers }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main container
|
||||
# ---------------------------------------------------------------------------
|
||||
containers:
|
||||
- name: jellyfin
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8096
|
||||
protocol: TCP
|
||||
env:
|
||||
# Pod identity — used by the Redis transcode lease store to identify this replica.
|
||||
- name: JELLYFIN_HA_POD_NAME
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.name
|
||||
- name: JELLYFIN_INSTANCE_ID
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.name
|
||||
|
||||
# Disable UDP auto-discovery when running multiple replicas.
|
||||
- name: JELLYFIN_Network__AutoDiscovery
|
||||
value: {{ .Values.config.autoDiscovery | quote }}
|
||||
|
||||
# Config directory (must differ from data root; see Jellyfin sanity check).
|
||||
- name: JELLYFIN_CONFIG_DIR
|
||||
value: {{ .Values.config.configDir | quote }}
|
||||
|
||||
{{- if .Values.config.publishedServerUrl }}
|
||||
- name: JELLYFIN_PublishedServerUrl
|
||||
value: {{ .Values.config.publishedServerUrl | quote }}
|
||||
{{- end }}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Redis (HA transcode session store)
|
||||
# ---------------------------------------------------------------------------
|
||||
{{- if include "jellyfin-ha.haEnabled" . }}
|
||||
{{- if .Values.ha.transcodeStore.existingSecret }}
|
||||
# Connection string sourced from an existing secret.
|
||||
- name: Jellyfin__TranscodeStore__RedisConnectionString
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Values.ha.transcodeStore.existingSecret }}
|
||||
key: {{ .Values.ha.transcodeStore.existingSecretKey }}
|
||||
{{- else }}
|
||||
- name: Jellyfin__TranscodeStore__RedisConnectionString
|
||||
value: {{ include "jellyfin-ha.redisConnectionString" . | quote }}
|
||||
{{- end }}
|
||||
- name: Jellyfin__TranscodeStore__LeaseDurationSeconds
|
||||
value: {{ .Values.ha.transcodeStore.leaseDurationSeconds | quote }}
|
||||
- name: Jellyfin__TranscodeStore__SessionRetentionSeconds
|
||||
value: {{ .Values.ha.transcodeStore.sessionRetentionSeconds | quote }}
|
||||
{{- end }}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PostgreSQL (experimental)
|
||||
# ---------------------------------------------------------------------------
|
||||
{{- if and .Values.postgresql.enabled (eq .Values.config.databaseType "Jellyfin-PostgreSQL") }}
|
||||
- name: POSTGRES_CONNECTION_STRING
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Values.postgresql.existingSecret }}
|
||||
key: DATABASE_URL
|
||||
{{- end }}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Extra environment variables
|
||||
# ---------------------------------------------------------------------------
|
||||
{{- with .Values.config.extraEnv }}
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
|
||||
resources:
|
||||
{{- toYaml .Values.resources | nindent 12 }}
|
||||
|
||||
securityContext:
|
||||
privileged: {{ if and .Values.gpu.enabled .Values.gpu.mountDri }}true{{ else }}{{ .Values.securityContext.privileged }}{{ end }}
|
||||
runAsUser: {{ .Values.securityContext.runAsUser }}
|
||||
runAsGroup: {{ .Values.securityContext.runAsGroup }}
|
||||
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /config
|
||||
{{- if or .Values.persistence.media.existingClaim (and .Values.persistence.media.nfs.enabled) }}
|
||||
- name: media
|
||||
mountPath: /media
|
||||
readOnly: true
|
||||
{{- end }}
|
||||
- name: transcode
|
||||
mountPath: /config/transcodes
|
||||
- name: cache
|
||||
mountPath: /cache
|
||||
{{- if and .Values.gpu.enabled .Values.gpu.mountDri }}
|
||||
- name: dri
|
||||
mountPath: /dev/dri
|
||||
{{- end }}
|
||||
{{- if .Values.runtimeConfig.enabled }}
|
||||
- name: runtimeconfig
|
||||
mountPath: /jellyfin/jellyfin.runtimeconfig.json
|
||||
subPath: jellyfin.runtimeconfig.json
|
||||
readOnly: true
|
||||
{{- end }}
|
||||
{{- with .Values.extraVolumeMounts }}
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
|
||||
livenessProbe:
|
||||
{{- toYaml .Values.livenessProbe | nindent 12 }}
|
||||
readinessProbe:
|
||||
{{- toYaml .Values.readinessProbe | nindent 12 }}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Volumes (static — shared across all pods)
|
||||
# ---------------------------------------------------------------------------
|
||||
volumes:
|
||||
- name: config
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ include "jellyfin-ha.configPvcName" . }}
|
||||
- name: transcode
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ include "jellyfin-ha.transcodePvcName" . }}
|
||||
{{- if or .Values.persistence.media.existingClaim .Values.persistence.media.nfs.enabled }}
|
||||
- name: media
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ include "jellyfin-ha.mediaPvcName" . }}
|
||||
{{- end }}
|
||||
{{- if and .Values.gpu.enabled .Values.gpu.mountDri }}
|
||||
- name: dri
|
||||
hostPath:
|
||||
path: /dev/dri
|
||||
{{- end }}
|
||||
{{- if .Values.runtimeConfig.enabled }}
|
||||
- name: runtimeconfig
|
||||
configMap:
|
||||
name: {{ include "jellyfin-ha.fullname" . }}-runtimeconfig
|
||||
{{- end }}
|
||||
{{- with .Values.extraVolumes }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-pod volumes via volumeClaimTemplates
|
||||
# Cache is per-pod (RWO) — each replica has an independent transcoding cache,
|
||||
# which avoids lock contention and is safe to lose on pod termination.
|
||||
# ---------------------------------------------------------------------------
|
||||
volumeClaimTemplates:
|
||||
- metadata:
|
||||
name: cache
|
||||
labels:
|
||||
{{- include "jellyfin-ha.labels" . | nindent 10 }}
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
{{- if .Values.persistence.cache.storageClass }}
|
||||
storageClassName: {{ .Values.persistence.cache.storageClass | quote }}
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.persistence.cache.size }}
|
||||
@@ -0,0 +1,408 @@
|
||||
# Default values for jellyfin-ha.
|
||||
# This is a YAML-formatted file.
|
||||
|
||||
# -- Override the chart name.
|
||||
nameOverride: ""
|
||||
# -- Override the full resource name prefix.
|
||||
fullnameOverride: ""
|
||||
|
||||
# -- Number of Jellyfin replicas.
|
||||
# Set >= 2 to use HA mode. When replicaCount > 1, ha.enabled should be true
|
||||
# and a Redis connection must be configured (via redis.enabled or ha.transcodeStore.redisConnectionString).
|
||||
replicaCount: 2
|
||||
|
||||
# -- Container image configuration.
|
||||
image:
|
||||
repository: "your-registry/jellyfin-ha"
|
||||
tag: "latest"
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
# -- Image pull secrets (e.g. for private ECR registries).
|
||||
# Example:
|
||||
# - name: ecr-pull-secret
|
||||
imagePullSecrets: []
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HA (High-Availability) configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
ha:
|
||||
# -- Enable HA mode. When true, a Redis connection string is required
|
||||
# (either via redis.enabled or ha.transcodeStore.redisConnectionString).
|
||||
# When false, NullTranscodeSessionStore is used and behavior is identical
|
||||
# to upstream Jellyfin.
|
||||
enabled: true
|
||||
|
||||
transcodeStore:
|
||||
# -- StackExchange.Redis connection string.
|
||||
# Leave empty to auto-compose from the in-cluster Redis service when redis.enabled=true.
|
||||
# Explicit examples:
|
||||
# redis:6379
|
||||
# redis:6379,password=secret
|
||||
# redis.example.com:6380,ssl=true,abortConnect=false
|
||||
# sentinel-host:26379,serviceName=mymaster
|
||||
redisConnectionString: ""
|
||||
|
||||
# -- How long (seconds) a pod's transcode lease is valid before another pod may take over.
|
||||
leaseDurationSeconds: 30
|
||||
|
||||
# -- How long (seconds) an unrenewed session record is kept so another pod can still take it over.
|
||||
sessionRetentionSeconds: 300
|
||||
|
||||
# -- Secret containing the Redis connection string.
|
||||
# If set, the connection string is read from this secret instead of the value above.
|
||||
# The secret must have a key named by existingSecret.key.
|
||||
existingSecret: ""
|
||||
existingSecretKey: "connection-string"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# In-cluster Redis (for transcode session store)
|
||||
# ---------------------------------------------------------------------------
|
||||
redis:
|
||||
# -- Deploy an in-cluster Redis instance.
|
||||
# Disable and set ha.transcodeStore.redisConnectionString to use an external Redis.
|
||||
enabled: true
|
||||
|
||||
image:
|
||||
repository: redis
|
||||
tag: "7.4.2-alpine3.21"
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
# -- Maximum memory for Redis to use.
|
||||
maxmemory: "256mb"
|
||||
# -- LRU eviction policy when maxmemory is reached.
|
||||
maxmemoryPolicy: "allkeys-lru"
|
||||
|
||||
resources:
|
||||
requests:
|
||||
cpu: 25m
|
||||
memory: 64Mi
|
||||
limits:
|
||||
cpu: 200m
|
||||
memory: 256Mi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Jellyfin application configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
config:
|
||||
# -- The externally-reachable URL Jellyfin reports to clients.
|
||||
publishedServerUrl: ""
|
||||
|
||||
# -- Disable UDP auto-discovery (port 7359).
|
||||
# Recommended when running multiple replicas to prevent duplicate discovery responses.
|
||||
autoDiscovery: false
|
||||
|
||||
# -- Jellyfin config directory inside the container.
|
||||
# Must differ from the data/root directory to pass Jellyfin's sanity check.
|
||||
configDir: "/config/config"
|
||||
|
||||
# -- Database provider: "SQLite" (default) or "Jellyfin-PostgreSQL" (experimental).
|
||||
# When set to "Jellyfin-PostgreSQL", an init container will inject database.xml
|
||||
# and the postgresql.enabled section (or an external connection string) must be configured.
|
||||
databaseType: "SQLite"
|
||||
|
||||
# -- Extra environment variables to set on the Jellyfin container.
|
||||
# Example:
|
||||
# - name: JELLYFIN_Network__BaseUrl
|
||||
# value: "/jellyfin"
|
||||
extraEnv: []
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PostgreSQL (experimental — only needed when config.databaseType = Jellyfin-PostgreSQL)
|
||||
# ---------------------------------------------------------------------------
|
||||
postgresql:
|
||||
# -- Deploy an in-cluster PostgreSQL instance.
|
||||
enabled: false
|
||||
|
||||
image:
|
||||
repository: postgres
|
||||
tag: "16.6-alpine3.21"
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
cpu: "1"
|
||||
memory: 1Gi
|
||||
|
||||
persistence:
|
||||
storageClass: ""
|
||||
size: 5Gi
|
||||
|
||||
# -- Name of an existing secret with PostgreSQL credentials.
|
||||
# Required when postgresql.enabled=true. The secret must contain:
|
||||
# POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB, DATABASE_URL
|
||||
# Create it with:
|
||||
# kubectl create secret generic jellyfin-postgres-credentials \
|
||||
# --from-literal=POSTGRES_USER=jellyfin \
|
||||
# --from-literal=POSTGRES_PASSWORD=<password> \
|
||||
# --from-literal=POSTGRES_DB=jellyfin \
|
||||
# --from-literal=DATABASE_URL="postgresql://jellyfin:<password>@<host>:5432/jellyfin"
|
||||
existingSecret: "jellyfin-postgres-credentials"
|
||||
|
||||
service:
|
||||
port: 5432
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GPU / hardware transcoding
|
||||
# ---------------------------------------------------------------------------
|
||||
gpu:
|
||||
# -- Enable Intel QSV / VA-API hardware transcoding.
|
||||
# Mounts /dev/dri from the host and sets the required security context.
|
||||
enabled: false
|
||||
|
||||
intel:
|
||||
# -- Node affinity label to prefer GPU-capable nodes.
|
||||
nodeLabel:
|
||||
key: gpu
|
||||
value: intel-uhd-630
|
||||
|
||||
# -- Toleration for the GPU node taint.
|
||||
toleration:
|
||||
key: gpu
|
||||
value: "true"
|
||||
effect: NoSchedule
|
||||
|
||||
# -- Mount /dev/dri from the host (required for VA-API; implies privileged=true).
|
||||
mountDri: true
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Persistence
|
||||
# ---------------------------------------------------------------------------
|
||||
persistence:
|
||||
# Config volume — single-writer; RWO is fine for single-replica deployments.
|
||||
# For multi-replica: use an RWX storage class (e.g. Longhorn RWX, NFS) or
|
||||
# point all pods at an existing shared PVC via existingClaim.
|
||||
config:
|
||||
# -- Size of the config PVC.
|
||||
size: 5Gi
|
||||
# -- Storage class. Leave empty to use the cluster default.
|
||||
storageClass: ""
|
||||
# -- Access mode. Use ReadWriteMany when replicaCount > 1 and sharing one PVC.
|
||||
accessMode: ReadWriteMany
|
||||
# -- Reuse an existing PVC. When set, no new PVC is created.
|
||||
existingClaim: ""
|
||||
|
||||
# Media volume — read-only mount shared by all pods.
|
||||
# Configure one of: existingClaim (for an existing PVC), nfs (to create an NFS PV+PVC),
|
||||
# or existingClaim pointing at a pre-created PVC.
|
||||
media:
|
||||
# -- Reuse an existing media PVC (most common for homelab NFS/Longhorn setups).
|
||||
existingClaim: ""
|
||||
# -- Create an NFS-backed PV and PVC for the media library.
|
||||
nfs:
|
||||
enabled: false
|
||||
server: "your-nas.local"
|
||||
path: "/media"
|
||||
size: 1Ti
|
||||
storageClass: ""
|
||||
|
||||
# Transcode volume — MUST be ReadWriteMany when replicaCount > 1 so that
|
||||
# a recovering pod can read HLS segments written by the pod it is replacing.
|
||||
# When replicaCount=1, ReadWriteOnce is acceptable.
|
||||
transcode:
|
||||
size: 30Gi
|
||||
storageClass: ""
|
||||
accessMode: ReadWriteMany
|
||||
existingClaim: ""
|
||||
|
||||
# Per-pod cache volume — local to each pod; always RWO.
|
||||
# Created via StatefulSet volumeClaimTemplates (one PVC per pod).
|
||||
cache:
|
||||
size: 30Gi
|
||||
storageClass: ""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Service
|
||||
# ---------------------------------------------------------------------------
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 8096
|
||||
# -- Annotations for the Service resource.
|
||||
annotations: {}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ingress (standard Kubernetes Ingress)
|
||||
# ---------------------------------------------------------------------------
|
||||
ingress:
|
||||
enabled: false
|
||||
# -- Ingress class name (e.g. "nginx", "traefik").
|
||||
className: ""
|
||||
annotations: {}
|
||||
# cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
# nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
|
||||
hosts:
|
||||
- host: jellyfin.example.com
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
tls: []
|
||||
# - secretName: jellyfin-tls
|
||||
# hosts:
|
||||
# - jellyfin.example.com
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Traefik IngressRoute (Traefik v3 CRD — used by k3s default ingress)
|
||||
# ---------------------------------------------------------------------------
|
||||
traefikIngressRoute:
|
||||
enabled: false
|
||||
entryPoints:
|
||||
- websecure
|
||||
# -- Hostname for the Traefik routing rule.
|
||||
host: "jellyfin.example.com"
|
||||
# -- Enable sticky session cookie (recommended for multi-replica Jellyfin).
|
||||
sticky:
|
||||
enabled: true
|
||||
cookieName: "jellyfin-server-id"
|
||||
httpOnly: true
|
||||
secure: true
|
||||
# -- cert-manager Certificate resource for TLS.
|
||||
tls:
|
||||
enabled: false
|
||||
secretName: "jellyfin-tls"
|
||||
clusterIssuer: "letsencrypt-prod"
|
||||
dnsNames: []
|
||||
# - jellyfin.example.com
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Resource requests and limits for the Jellyfin container
|
||||
# ---------------------------------------------------------------------------
|
||||
resources:
|
||||
requests:
|
||||
cpu: 500m
|
||||
memory: 1Gi
|
||||
limits:
|
||||
cpu: "4"
|
||||
memory: 4Gi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Liveness and readiness probes
|
||||
# ---------------------------------------------------------------------------
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: http
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: http
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Security context
|
||||
# ---------------------------------------------------------------------------
|
||||
# Container-level security context.
|
||||
securityContext:
|
||||
# -- Set to true only when GPU passthrough via /dev/dri is required.
|
||||
# privileged=true is required for DRM ioctls (VA-API). Omit (false) for
|
||||
# software-only transcoding.
|
||||
privileged: false
|
||||
# -- UID for the Jellyfin process. Use 10010 to match the svc-jellyfin NAS account
|
||||
# when NFS root_squash is enabled.
|
||||
runAsUser: 1000
|
||||
runAsGroup: 1000
|
||||
|
||||
# Pod-level security context.
|
||||
podSecurityContext:
|
||||
# -- fsGroup ensures mounted volumes are group-writable.
|
||||
fsGroup: 1000
|
||||
# -- Additional groups for /dev/dri access (video=44, render=109 or 991).
|
||||
supplementalGroups: []
|
||||
# - 44 # video
|
||||
# - 109 # render (legacy)
|
||||
# - 991 # render (Debian 13 trixie)
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Service account
|
||||
# ---------------------------------------------------------------------------
|
||||
serviceAccount:
|
||||
create: false
|
||||
name: ""
|
||||
annotations: {}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pod Disruption Budget
|
||||
# ---------------------------------------------------------------------------
|
||||
podDisruptionBudget:
|
||||
enabled: true
|
||||
minAvailable: 1
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pod anti-affinity (spread replicas across nodes for node-level HA)
|
||||
# ---------------------------------------------------------------------------
|
||||
podAntiAffinity:
|
||||
enabled: true
|
||||
# -- "preferred" won't block scheduling if nodes are insufficient.
|
||||
# Use "required" to enforce strict cross-node placement.
|
||||
type: preferred
|
||||
weight: 100
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Prometheus ServiceMonitor
|
||||
# Note: Jellyfin has no native /metrics endpoint. This ServiceMonitor is
|
||||
# included for future use (e.g. if you add a sidecar exporter) or for
|
||||
# blackbox-style readiness monitoring. Disable if not using kube-prometheus-stack.
|
||||
# ---------------------------------------------------------------------------
|
||||
serviceMonitor:
|
||||
enabled: false
|
||||
# -- Scrape interval.
|
||||
interval: "30s"
|
||||
# -- Scrape path (Jellyfin does not expose Prometheus metrics natively).
|
||||
path: /metrics
|
||||
# -- Additional labels to add to the ServiceMonitor (e.g. to match a Prometheus release label).
|
||||
additionalLabels: {}
|
||||
# release: kube-prometheus-stack
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Runtime config (jellyfin.runtimeconfig.json)
|
||||
# Set dotnet runtime switches here if needed. Leave empty for defaults.
|
||||
# ---------------------------------------------------------------------------
|
||||
runtimeConfig:
|
||||
enabled: false
|
||||
# -- Raw JSON content for jellyfin.runtimeconfig.json.
|
||||
# See jellyfin-runtimeconfig ConfigMap in the existing manifests for an example.
|
||||
json: |
|
||||
{
|
||||
"configProperties": {}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Extra Kubernetes resources
|
||||
# ---------------------------------------------------------------------------
|
||||
# -- Additional volumes to attach to the Jellyfin pod.
|
||||
extraVolumes: []
|
||||
# - name: my-extra-config
|
||||
# configMap:
|
||||
# name: my-configmap
|
||||
|
||||
# -- Additional volume mounts for the Jellyfin container.
|
||||
extraVolumeMounts: []
|
||||
# - name: my-extra-config
|
||||
# mountPath: /etc/my-config
|
||||
|
||||
# -- Additional init containers.
|
||||
extraInitContainers: []
|
||||
|
||||
# -- Annotations to add to the StatefulSet.
|
||||
annotations: {}
|
||||
# -- Annotations to add to individual pods.
|
||||
podAnnotations: {}
|
||||
# -- Labels to add to the StatefulSet.
|
||||
labels: {}
|
||||
# -- Labels to add to individual pods.
|
||||
podLabels: {}
|
||||
|
||||
# -- Update strategy for the StatefulSet.
|
||||
updateStrategy:
|
||||
type: RollingUpdate
|
||||
@@ -0,0 +1,202 @@
|
||||
> **Last updated: 2026-03-04**
|
||||
|
||||
# Jellyfin Server Architecture
|
||||
|
||||
High-level overview of the Jellyfin server structure, layer responsibilities, and key subsystems.
|
||||
|
||||
## Runtime
|
||||
|
||||
| Component | Value |
|
||||
|---|---|
|
||||
| Framework | .NET 10 / ASP.NET Core 10 |
|
||||
| Target | `net10.0` |
|
||||
| Entry point | `Jellyfin.Server` |
|
||||
| Version | `12.0.0` (see `SharedVersion.cs`) |
|
||||
|
||||
---
|
||||
|
||||
## Layer Diagram
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────────────┐
|
||||
│ HTTP Clients │
|
||||
│ (Jellyfin Web, mobile apps, 3rd-party) │
|
||||
└────────────────────────┬──────────────────────────────────┘
|
||||
│ REST / WebSocket
|
||||
┌────────────────────────▼──────────────────────────────────┐
|
||||
│ Jellyfin.Api │
|
||||
│ ASP.NET Core controllers, middleware, auth, Swashbuckle │
|
||||
└────────────────────────┬──────────────────────────────────┘
|
||||
│ Interfaces (ILibraryManager, etc.)
|
||||
┌────────────────────────▼──────────────────────────────────┐
|
||||
│ MediaBrowser.Controller │
|
||||
│ Core domain interfaces — no implementation here │
|
||||
└────────────────────────┬──────────────────────────────────┘
|
||||
│ Implementations
|
||||
┌────────────────────────▼──────────────────────────────────┐
|
||||
│ Emby.Server.Implementations / Jellyfin.Server.Impl │
|
||||
│ Library manager, item repos, scheduled tasks, HTTP server│
|
||||
└────────┬───────────────────────────────┬──────────────────┘
|
||||
│ │
|
||||
┌────────▼────────┐ ┌────────▼────────┐
|
||||
│ Jellyfin.Data │ │ MediaBrowser │
|
||||
│ EF Core DbCtx │ │ MediaEncoding │
|
||||
│ SQLite via │ │ FFmpeg, HLS, │
|
||||
│ Microsoft.Data │ │ Trickplay │
|
||||
│ .Sqlite │ └─────────────────┘
|
||||
└─────────────────┘
|
||||
│
|
||||
┌────────▼─────────────────────────────────────────────────┐
|
||||
│ MediaBrowser.Model │
|
||||
│ Pure DTOs, enums, no logic (shared by all layers) │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Project Responsibilities
|
||||
|
||||
### `Jellyfin.Server`
|
||||
|
||||
Entry point. Handles:
|
||||
- CLI argument parsing (`CommandLineParser`)
|
||||
- Serilog configuration (console, file, Graylog sinks)
|
||||
- DI container wiring (`ApplicationHost`)
|
||||
- ASP.NET Core host startup
|
||||
|
||||
### `Jellyfin.Api`
|
||||
|
||||
All HTTP surface. Handles:
|
||||
- ASP.NET Core controllers (`Controllers/`)
|
||||
- Authentication middleware (`Auth/`)
|
||||
- Swashbuckle/OpenAPI configuration
|
||||
- Request/response formatting (camelCase + PascalCase JSON)
|
||||
- WebSocket listeners (`WebSocketListeners/`)
|
||||
|
||||
Controllers inherit from `BaseJellyfinApiController` which sets default route, produces JSON, and provides typed `Ok<T>()` helpers.
|
||||
|
||||
### `MediaBrowser.Controller`
|
||||
|
||||
Core domain interfaces. Key examples:
|
||||
- `ILibraryManager` — media library operations
|
||||
- `IMediaEncoder` — FFmpeg wrapper
|
||||
- `IProviderManager` — metadata provider coordination
|
||||
- `IUserManager` — user management
|
||||
- `IPlaybackManager` — playback session tracking
|
||||
|
||||
**No implementations live here.** This keeps the domain decoupled from infrastructure.
|
||||
|
||||
### `Emby.Server.Implementations`
|
||||
|
||||
Primary implementation assembly. Contains:
|
||||
- `ApplicationHost.cs` — DI wiring and startup
|
||||
- `Data/` — SQLite queries and EF Core repositories
|
||||
- `Library/` — `LibraryManager`, `LibraryMonitor`
|
||||
- `Images/` — image processing pipeline (SkiaSharp)
|
||||
- `HttpServer/` — HTTP server wiring
|
||||
|
||||
### `Jellyfin.Server.Implementations`
|
||||
|
||||
Secondary implementation assembly split from `Emby.Server.Implementations`. Contains newer implementations using EF Core patterns.
|
||||
|
||||
### `Jellyfin.Data`
|
||||
|
||||
EF Core data models and `DbContext`. Migrations managed here.
|
||||
|
||||
### `MediaBrowser.Model`
|
||||
|
||||
Pure data-transfer objects (DTOs) and enums. No logic. Consumed by all layers and by external clients. Changes here are API-breaking.
|
||||
|
||||
### `MediaBrowser.Providers`
|
||||
|
||||
Online metadata providers:
|
||||
- TMDB (movies, TV)
|
||||
- MusicBrainz (audio)
|
||||
- OMDB
|
||||
- TV Maze, TheTVDB
|
||||
|
||||
Uses `IMetadataProvider<T>` interface from `MediaBrowser.Controller`.
|
||||
|
||||
### `MediaBrowser.MediaEncoding`
|
||||
|
||||
FFmpeg process management, HLS streaming, keyframe extraction, subtitle transcoding, trickplay image generation.
|
||||
|
||||
### `Emby.Naming`
|
||||
|
||||
Media file path parsing — resolves series/season/episode structure, detects extras, parses video codecs from filenames.
|
||||
|
||||
### `MediaBrowser.LocalMetadata` / `MediaBrowser.XbmcMetadata`
|
||||
|
||||
Local NFO/XML metadata providers (Kodi-compatible `.nfo` sidecar files).
|
||||
|
||||
### `src/Jellyfin.CodeAnalysis`
|
||||
|
||||
Custom Roslyn analyzer. Runs only in Debug builds. Enforces project-specific rules.
|
||||
|
||||
---
|
||||
|
||||
## Key Subsystems
|
||||
|
||||
### Authentication
|
||||
|
||||
- Session-based API keys (stored in SQLite)
|
||||
- Quick Connect (pairing flow)
|
||||
- Auth middleware in `Jellyfin.Api/Auth/`
|
||||
- Policies defined in `Jellyfin.Api/Constants/Policies.cs`
|
||||
|
||||
### Library Scanning
|
||||
|
||||
1. `LibraryMonitor` watches filesystem for changes
|
||||
2. `LibraryManager` resolves paths → `BaseItem` subclasses
|
||||
3. `Emby.Naming` parses filenames → metadata hints
|
||||
4. `IProviderManager` fetches remote metadata and saves locally
|
||||
5. Results persisted to SQLite via EF Core
|
||||
|
||||
### Transcoding
|
||||
|
||||
1. Client requests a stream via `MediaInfoController` or `DynamicHlsController`
|
||||
2. `MediaInfoHelper` determines if transcoding is needed (codec matrix)
|
||||
3. `MediaEncoder` spawns an FFmpeg subprocess with computed arguments
|
||||
4. HLS segments or direct stream served via `AudioController` / `VideosController`
|
||||
|
||||
### Metrics
|
||||
|
||||
prometheus-net serves metrics at `/metrics`. Key meters:
|
||||
- `prometheus-net.AspNetCore` — HTTP request duration/count
|
||||
- `prometheus-net.DotNetRuntime` — GC, thread pool, JIT metrics
|
||||
- Custom counters can be added via `Metrics.CreateCounter(...)` in any service
|
||||
|
||||
### Logging
|
||||
|
||||
Serilog pipeline:
|
||||
- Console sink (structured)
|
||||
- File sink (rolling, default `%APPDATA%/jellyfin/logs/`)
|
||||
- Graylog GELF sink (optional, configured via `logging.json`)
|
||||
|
||||
---
|
||||
|
||||
## Database
|
||||
|
||||
SQLite database at `{DataDir}/data/jellyfin.db`. Accessed via:
|
||||
- EF Core (`Jellyfin.Data.JellyfinDbContext`) for new data access
|
||||
- `Microsoft.Data.Sqlite` direct queries for legacy paths
|
||||
|
||||
**All EF Core operations must use async methods** (`ToListAsync`, `FirstOrDefaultAsync`, etc.).
|
||||
|
||||
---
|
||||
|
||||
## Test Layout
|
||||
|
||||
```
|
||||
tests/
|
||||
Jellyfin.Api.Tests/ Controller + middleware unit tests
|
||||
Jellyfin.Common.Tests/ MediaBrowser.Common utilities
|
||||
Jellyfin.Controller.Tests/ Interface contracts and helpers
|
||||
Jellyfin.MediaEncoding.Tests/ FFmpeg argument building
|
||||
Jellyfin.Naming.Tests/ File path parsing
|
||||
Jellyfin.Providers.Tests/ Provider logic
|
||||
Jellyfin.Server.Integration.Tests/ Full-stack HTTP tests + OpenAPI spec gen
|
||||
Jellyfin.Server.Tests/ Server startup and DI tests
|
||||
```
|
||||
|
||||
Test stack: xUnit + AutoFixture + Moq + FsCheck. See `.github/instructions/testing.instructions.md`.
|
||||
@@ -0,0 +1,164 @@
|
||||
> **Last updated: 2026-03-04**
|
||||
|
||||
# Contributing to Jellyfin Server
|
||||
|
||||
This guide covers everything you need to develop, build, test, and submit changes to the Jellyfin server.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Tool | Version | Notes |
|
||||
|---|---|---|
|
||||
| .NET SDK | 10.0.x | See `global.json` — `rollForward: latestMinor` |
|
||||
| Git | any recent | `git clone` with submodules not required |
|
||||
| FFmpeg | 7.x | Required for transcoding tests; install via devcontainer or manually |
|
||||
| Docker | optional | For devcontainer workflow |
|
||||
|
||||
### macOS (Homebrew)
|
||||
|
||||
```bash
|
||||
brew install dotnet
|
||||
```
|
||||
|
||||
### Linux (Debian/Ubuntu)
|
||||
|
||||
```bash
|
||||
wget https://dot.net/v1/dotnet-install.sh && bash dotnet-install.sh --channel 10.0
|
||||
```
|
||||
|
||||
### Windows
|
||||
|
||||
Download the [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10) installer.
|
||||
|
||||
### DevContainer (recommended for new contributors)
|
||||
|
||||
Open the repo in VS Code and accept the "Reopen in Container" prompt. The devcontainer installs:
|
||||
- .NET 10
|
||||
- FFmpeg
|
||||
- All recommended VS Code extensions
|
||||
|
||||
---
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
# Build the server entry point
|
||||
dotnet build Jellyfin.Server/Jellyfin.Server.csproj
|
||||
|
||||
# Build the entire solution (all projects)
|
||||
dotnet build Jellyfin.sln
|
||||
```
|
||||
|
||||
Debug builds activate all code analyzers (StyleCop, BannedApiAnalyzers, IDisposableAnalyzers, MultithreadingAnalyzer). **Expect build failures if your code has missing XML docs or uses banned APIs.**
|
||||
|
||||
---
|
||||
|
||||
## Run Locally
|
||||
|
||||
```bash
|
||||
dotnet run --project Jellyfin.Server/Jellyfin.Server.csproj \
|
||||
-- --datadir /tmp/jellyfin-data --webdir /tmp/jellyfin-web --nowebclient
|
||||
```
|
||||
|
||||
The server starts on `http://localhost:8096` by default.
|
||||
|
||||
---
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
# Run all tests (cross-platform matrix: Linux, macOS, Windows)
|
||||
dotnet test Jellyfin.sln --configuration Release --verbosity minimal
|
||||
|
||||
# Run a single test project
|
||||
dotnet test tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj
|
||||
|
||||
# Run tests matching a name filter
|
||||
dotnet test Jellyfin.sln --filter "ClassName=MyServiceTests"
|
||||
|
||||
# Run with code coverage
|
||||
dotnet test Jellyfin.sln \
|
||||
--configuration Release \
|
||||
--collect:"XPlat Code Coverage" \
|
||||
--settings tests/coverletArgs.runsettings
|
||||
```
|
||||
|
||||
Coverage output: `merged/Cobertura.xml` (merged by ReportGenerator in CI).
|
||||
|
||||
### Regenerate OpenAPI Spec
|
||||
|
||||
After adding or changing any API endpoint:
|
||||
|
||||
```bash
|
||||
dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj \
|
||||
-c Release \
|
||||
--filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests"
|
||||
```
|
||||
|
||||
Commit the updated `openapi.json` — the CI diff job will flag unintentional breaking changes.
|
||||
|
||||
---
|
||||
|
||||
## Code Style
|
||||
|
||||
All style rules are enforced by the compiler in Debug builds. Key rules:
|
||||
|
||||
- **Nullable enabled** — mark nullable types with `?`, never silence with `null!` without a comment
|
||||
- **Warnings as errors** — fix every warning; do not suppress with `#pragma warning disable`
|
||||
- **XML docs** — every `public` type and member must have `/// <summary>`
|
||||
- **No `Task.Result`** — always `await` instead
|
||||
- **Central NuGet versions** — versions in `Directory.Packages.props` only, never in `.csproj`
|
||||
- **File-scoped namespaces** — use `namespace Jellyfin.Example;` (not block-scoped)
|
||||
|
||||
See `.github/instructions/csharp.instructions.md` for the full ruleset.
|
||||
|
||||
---
|
||||
|
||||
## Pull Request Process
|
||||
|
||||
1. Fork the repo and create a feature branch from `main`
|
||||
2. Make your changes; ensure `dotnet build` and `dotnet test` pass locally
|
||||
3. Fill out the PR template (`.github/pull_request_template.md`):
|
||||
- **Changes**: 1–5 sentence summary
|
||||
- **Issues**: tag with `Fixes #NNN`
|
||||
4. CI runs automatically:
|
||||
- `ci-tests.yml` — tests on Linux, macOS, Windows
|
||||
- `ci-openapi.yml` — OpenAPI diff
|
||||
- `ci-codeql-analysis.yml` — security scan
|
||||
5. A maintainer will review and merge
|
||||
|
||||
### Title format
|
||||
|
||||
Use the imperative mood:
|
||||
- ✅ `Add lyrics endpoint for audio items`
|
||||
- ✅ `Fix null reference in LibraryController`
|
||||
- ❌ `Added lyrics endpoint`
|
||||
- ❌ `Fixed null reference`
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Package Dependency
|
||||
|
||||
1. Add the version to `Directory.Packages.props`:
|
||||
```xml
|
||||
<PackageVersion Include="SomePackage" Version="1.2.3" />
|
||||
```
|
||||
2. Add the reference to the relevant `.csproj` (no `Version=` attribute):
|
||||
```xml
|
||||
<PackageReference Include="SomePackage" />
|
||||
```
|
||||
|
||||
**Never** specify both a version in `Directory.Packages.props` AND in the `.csproj` — that causes `NU1008`.
|
||||
|
||||
---
|
||||
|
||||
## Project Conventions
|
||||
|
||||
See `.github/instructions/` for detailed instructions per concern:
|
||||
|
||||
| Topic | File |
|
||||
|---|---|
|
||||
| C# style | `csharp.instructions.md` |
|
||||
| API controllers | `api.instructions.md` |
|
||||
| Tests | `testing.instructions.md` |
|
||||
| CI/CD workflows | `ci-cd.instructions.md` |
|
||||
| Documentation | `docs.instructions.md` |
|
||||
@@ -0,0 +1,140 @@
|
||||
# Fork Diff: `unkin/jellyfin-ha-src` vs `jellyfin/jellyfin`
|
||||
|
||||
> **Base:** `v12.0` (`6c073e19ddf604b2369c638716164fdab4c952dc`)
|
||||
> **Head:** `origin/main`
|
||||
> **Summary:** 56 files added · 16 upstream files modified · 15 upstream files deleted
|
||||
|
||||
---
|
||||
|
||||
## What changed and why
|
||||
|
||||
This fork adds a **high-availability layer** so Jellyfin can run as more than one
|
||||
replica against shared storage. The design principle: extend through dependency
|
||||
injection, touch as little upstream code as possible. No core media, library,
|
||||
auth or plugin logic is rewritten.
|
||||
|
||||
| Bucket | Files | Lines added |
|
||||
|--------|-------|-------------|
|
||||
| HA contracts and models (`MediaBrowser.Controller`) | 8 | ~330 |
|
||||
| Redis implementations (`Emby.Server.Implementations`) | 2 | ~350 |
|
||||
| PostgreSQL database provider | 6 | ~4,980 |
|
||||
| SQLite to PostgreSQL migration tool | 6 | ~720 |
|
||||
| Modified upstream files | 16 | ~420 |
|
||||
| Tests | 14 | ~2,790 |
|
||||
| Helm chart | 17 | ~1,370 |
|
||||
| CI and container build | 3 | ~175 |
|
||||
|
||||
---
|
||||
|
||||
## New components
|
||||
|
||||
### Transcode session store
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `MediaBrowser.Controller/MediaEncoding/ITranscodeSessionStore.cs` | DI contract for durable transcode session tracking |
|
||||
| `MediaBrowser.Controller/MediaEncoding/TranscodeSession.cs` | Session record: owning pod, lease expiry, manifest and segment paths, last durable segment |
|
||||
| `MediaBrowser.Controller/MediaEncoding/TranscodeStoreOptions.cs` | `RedisConnectionString`, `LeaseDurationSeconds` and `SessionRetentionSeconds` |
|
||||
| `MediaBrowser.Controller/MediaEncoding/NullTranscodeSessionStore.cs` | No-op store used when no Redis connection is configured |
|
||||
| `Emby.Server.Implementations/MediaEncoding/RedisTranscodeSessionStore.cs` | Redis store; sessions under `jellyfin:transcode:{playSessionId}`, key TTL is the retention window so an orphaned session outlives its lease |
|
||||
| `Emby.Server.Implementations/MediaEncoding/TranscodeStoreConnectivityProbe.cs` | Startup ping; an unreachable configured store is logged at `Error` instead of failing open silently |
|
||||
| `Jellyfin.Server/Extensions/TranscodeStoreServiceCollectionExtensions.cs` | Store selection, logged at `Information` so the active store is visible at startup |
|
||||
| `Jellyfin.Server/Extensions/ConfigurationBuilderExtensions.cs` | Reads bare `Jellyfin__*` environment variables into the `Jellyfin:*` configuration root |
|
||||
|
||||
Lease takeover and renewal each run as a single Lua script, so concurrent pods cannot both
|
||||
claim an expired lease and a renewal cannot revert a takeover. The expiry is stored as unix
|
||||
milliseconds so the script can compare it:
|
||||
|
||||
```lua
|
||||
local raw = redis.call('GET', KEYS[1])
|
||||
if not raw then return 0 end
|
||||
local session = cjson.decode(raw)
|
||||
-- takeover: only an expired lease may be claimed
|
||||
if tonumber(session['LeaseExpiresUtc']) > tonumber(ARGV[1]) then return 0 end
|
||||
-- renewal: only the pod that still owns the lease may extend it
|
||||
-- if session['OwnerPod'] ~= ARGV[2] then return 0 end
|
||||
session['OwnerPod'] = ARGV[2]
|
||||
-- update expiry and SET with PX in the same script
|
||||
return 1
|
||||
```
|
||||
|
||||
### Scan-leader lease
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `MediaBrowser.Controller/ScheduledTasks/IScanLeaderLease.cs` | DI contract for the leader lease |
|
||||
| `MediaBrowser.Controller/ScheduledTasks/ScanLeaderOptions.cs` | `Enabled` (defaults to on when Redis is configured), `LeaseDurationSeconds`, `GatedTaskKeys` |
|
||||
| `MediaBrowser.Controller/ScheduledTasks/NullScanLeaderLease.cs` | Always-leader default, preserving single-instance behaviour |
|
||||
| `Emby.Server.Implementations/ScheduledTasks/RedisScanLeaderLease.cs` | Redis TTL lease; an unreachable Redis is treated as holding the lease |
|
||||
|
||||
Gated by default: `RefreshLibrary`, `RefreshPeople`, `RefreshChapterImages`,
|
||||
`AudioNormalization`, `TaskExtractMediaSegments`, `KeyframeExtraction`,
|
||||
`CleanupUserDataTask`, `OptimizeDatabaseTask`. Only timer-driven runs are gated;
|
||||
manual and API-triggered runs always execute locally.
|
||||
|
||||
Gating is on whenever `Jellyfin:TranscodeStore:RedisConnectionString` is set, because that is only
|
||||
set for a multi-instance deployment. Set `Jellyfin:ScanLeader:Enabled=false` to opt out.
|
||||
Startup logs which way it went:
|
||||
|
||||
```
|
||||
Scan-leader gating is active: timer-driven library tasks run only on the instance holding the Redis scan-leader lease.
|
||||
Scan-leader gating is off: timer-driven library tasks run on every instance.
|
||||
```
|
||||
|
||||
`Enabled=true` with no Redis connection string logs a warning, because gating cannot run.
|
||||
|
||||
### PostgreSQL provider
|
||||
|
||||
`src/Jellyfin.Database/Jellyfin.Database.Providers.PostgreSQL/` is an EF Core
|
||||
provider parallel to the SQLite one, selected with
|
||||
`DatabaseType: Jellyfin-PostgreSQL`. It carries a single initial migration
|
||||
generated against the `v12.0` model. SQLite remains the default, so existing
|
||||
deployments are unaffected.
|
||||
|
||||
`tools/Jellyfin.DbMigrator/` moves an existing `jellyfin.db` into PostgreSQL,
|
||||
optionally uploading a pre-migration copy of the SQLite file to S3.
|
||||
|
||||
---
|
||||
|
||||
## Modified upstream files
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `Jellyfin.Server/CoreAppHost.cs` (+46) | Registers the Redis or null transcode store and scan-leader lease from startup config |
|
||||
| `Jellyfin.Api/Controllers/DynamicHlsController.cs` | Registers the play session with its manifest and segment paths, renews the lease under this pod's identity, and shortens segments when resuming a stored session |
|
||||
| `Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteTranscodeFileTask.cs` (+52/-6) | Keeps files belonging to an active session in the store |
|
||||
| `Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs` (+28/-1) | Skips timer-driven gated tasks without the leader lease |
|
||||
| `Emby.Server.Implementations/ScheduledTasks/TaskManager.cs` (+12/-2) | Passes the lease and options to each worker |
|
||||
| `Jellyfin.Server.Implementations/Extensions/ServiceCollectionExtensions.cs` (+53) | Registers the PostgreSQL provider and a pooled `NpgsqlDataSource` |
|
||||
| `Jellyfin.Server/Program.cs` (+5) | Exposes `IServerConfigurationManager` to the startup migration container |
|
||||
| `MediaBrowser.Model/Configuration/EncodingOptions.cs` (+16) | Adds `RecoverySegmentLengthSeconds` and `RecoverySegmentBufferCount` |
|
||||
| `Directory.Packages.props`, `Jellyfin.sln`, four `.csproj` files | New packages, projects and references |
|
||||
| `tests/.../SessionManagerTests.cs`, `tests/.../IdlePlaybackTests.cs` | Pass the new constructor argument |
|
||||
|
||||
---
|
||||
|
||||
## Deleted upstream files
|
||||
|
||||
`.github/workflows/*` (15 files). The fork is hosted on Gitea and builds on
|
||||
Woodpecker via `.woodpecker/ci.yaml`.
|
||||
|
||||
---
|
||||
|
||||
## Rebasing onto a newer upstream release
|
||||
|
||||
```bash
|
||||
git remote add upstream https://github.com/jellyfin/jellyfin.git
|
||||
git fetch upstream --tags
|
||||
|
||||
# files this fork owns outright
|
||||
git diff v12.0...HEAD --name-only --diff-filter=A
|
||||
|
||||
# files that need re-porting onto the new release
|
||||
git diff v12.0...HEAD --name-only --diff-filter=M
|
||||
|
||||
# upstream churn on a touched file since this base
|
||||
git log v12.0..<new-tag> --oneline -- <path>
|
||||
```
|
||||
|
||||
Re-apply the modified-file changes onto the new upstream code rather than
|
||||
reverting upstream's changes to make a patch apply.
|
||||
@@ -0,0 +1,83 @@
|
||||
# Jellyfin HA transcoding fork: Redis-backed session failover + experimental PostgreSQL provider
|
||||
|
||||
I've been working on a fork of Jellyfin focused on one specific problem: making HLS transcoding survive pod restarts in a multi-replica Kubernetes deployment.
|
||||
|
||||
## What it does
|
||||
|
||||
Right now, Jellyfin assumes transcode state lives in one server process. If that pod dies, active transcodes die with it. This fork adds a small HA layer so transcode ownership can survive a pod restart:
|
||||
|
||||
- A new `ITranscodeSessionStore` abstraction for durable transcode session tracking
|
||||
- A `RedisTranscodeSessionStore` implementation with lease-based ownership
|
||||
- Atomic pod takeover using a Redis Lua script when a lease expires
|
||||
- Lease-aware cleanup so one pod does not delete segments another pod still needs
|
||||
- A `NullTranscodeSessionStore` fallback, so single-instance deployments behave exactly like upstream with no config changes
|
||||
|
||||
I also added an experimental PostgreSQL provider for shared-database deployments, since SQLite is not a good fit once multiple replicas are involved.
|
||||
|
||||
## What the HA flow looks like
|
||||
|
||||
- Pod A starts an HLS transcode and registers the session in Redis
|
||||
- Pod A renews the lease while it owns the session
|
||||
- If Pod A dies, the lease expires
|
||||
- Pod B receives the next request, atomically claims the expired lease, and resumes from the last completed segment on shared storage
|
||||
- The client sees a short buffer pause instead of a hard failure
|
||||
|
||||
## How to run it
|
||||
|
||||
There are three practical modes:
|
||||
|
||||
### 1. Single instance
|
||||
|
||||
No config needed. It falls back to the no-op store automatically.
|
||||
|
||||
### 2. Local HA test
|
||||
|
||||
Run two Jellyfin instances against:
|
||||
|
||||
- the same Redis
|
||||
- the same shared transcode directory
|
||||
|
||||
That is enough to test failover behavior locally.
|
||||
|
||||
### 3. Kubernetes / k3s
|
||||
|
||||
This is the intended deployment model. You need:
|
||||
|
||||
- 2+ Jellyfin replicas
|
||||
- Redis
|
||||
- shared RWX storage for transcode output
|
||||
- shared media storage
|
||||
- ideally PostgreSQL if you want a proper shared DB setup
|
||||
|
||||
The key config is:
|
||||
|
||||
```text
|
||||
Jellyfin:TranscodeStore:RedisConnectionString
|
||||
Jellyfin:TranscodeStore:LeaseDurationSeconds
|
||||
```
|
||||
|
||||
Repo and write-up:
|
||||
|
||||
- Source: https://github.com/ZoltyMat/jellyfin-ha
|
||||
- Full change summary vs upstream: https://github.com/ZoltyMat/jellyfin-ha/blob/main/docs/FORK-DIFF.md
|
||||
- Write-up with diagrams and k8s manifests: https://blog.zolty.systems/posts/jellyfin-ha-kubernetes
|
||||
|
||||
## What would be required to merge upstream
|
||||
|
||||
I do not expect this to be merged as-is without discussion. If there is interest, I think the realistic path is to split it into small pieces:
|
||||
|
||||
1. Introduce `ITranscodeSessionStore`, `TranscodeSession`, and `NullTranscodeSessionStore` only
|
||||
2. Add the DI wiring with no behavior change unless configured
|
||||
3. Add HLS session registration and lease renewal hooks
|
||||
4. Add lease-aware cleanup in `DeleteTranscodeFileTask`
|
||||
5. Add takeover logic in the HLS/session path
|
||||
6. Discuss whether Redis should be the first supported distributed store, or whether the interface should land before any concrete implementation
|
||||
7. Treat PostgreSQL as a separate discussion entirely
|
||||
|
||||
I think the HA transcode work has a better chance of review if it is separated from the PostgreSQL provider and migration tooling.
|
||||
|
||||
## Why I'm posting it
|
||||
|
||||
I'm not trying to maintain a permanent hard fork. I built this to see whether Jellyfin could be made to behave well in a replicated environment without rewriting major subsystems. The answer seems to be yes, but it needs maintainers to decide whether this kind of deployment is something upstream wants to support.
|
||||
|
||||
If there's interest, I'm happy to break the work into smaller PRs, clean up anything that does not match project direction, and rework the design around maintainer feedback.
|
||||
@@ -0,0 +1,473 @@
|
||||
# HA Transcoding Design — Phase 5.1.1 Audit
|
||||
|
||||
> **Status**: Design audit only. No functional code changes in this document.
|
||||
> **Purpose**: Map the exact transcode lifecycle before Phase 5.2 code changes begin.
|
||||
> **Last updated**: 2026-03-07
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Sequence Diagram: Full Transcode Lifecycle](#sequence-diagram-full-transcode-lifecycle)
|
||||
2. [Key In-Memory State Fields](#key-in-memory-state-fields)
|
||||
3. [Why `playSessionId` Is Insufficient](#why-playsessionid-is-insufficient)
|
||||
4. [Why `DeleteTranscodeFileTask` Is Unsafe for Shared Storage](#why-deletetranscodfiletask-is-unsafe-for-shared-storage)
|
||||
5. [How `SessionManager._activeLiveStreamSessions` Works](#how-sessionmanager_activelivestreamsessions-works)
|
||||
6. [NFSv3 Lock Recovery on Pod Death](#nfsv3-lock-recovery-on-pod-death)
|
||||
7. [Minimum Recovery State](#minimum-recovery-state)
|
||||
8. [HA Failure Scenario Walk-Through](#ha-failure-scenario-walk-through)
|
||||
9. [Open Questions Before Phase 5.2](#open-questions-before-phase-52)
|
||||
10. [Cross-References](#cross-references)
|
||||
|
||||
---
|
||||
|
||||
## Sequence Diagram: Full Transcode Lifecycle
|
||||
|
||||
The following describes the path from a client HLS manifest request through
|
||||
FFmpeg startup to segment delivery and session cleanup.
|
||||
|
||||
```
|
||||
Client DynamicHlsController StreamingHelpers TranscodeManager
|
||||
| | | |
|
||||
| GET /Videos/{id}/live.m3u8 | | |
|
||||
|------------------------------->| | |
|
||||
| | GetStreamingState() | |
|
||||
| |-------------------------->| |
|
||||
| | StreamState | |
|
||||
| |<--------------------------| |
|
||||
| | | |
|
||||
| | File.Exists(playlistPath)?| |
|
||||
| |---------- NO ----------> | |
|
||||
| | | |
|
||||
| | LockAsync(playlistPath) | |
|
||||
| |--------------------------------------------->| |
|
||||
| | (async keyed lock held) | | |
|
||||
| | | | |
|
||||
| | StartFfMpeg(state, ...) | |
|
||||
| |------------------------------------------>| |
|
||||
| | | OnTranscodeBeginning()
|
||||
| | | _activeTranscodingJobs.Add(job)
|
||||
| | | Process.Start(ffmpeg)
|
||||
| | TranscodingJob | |
|
||||
| |<------------------------------------------| |
|
||||
| | | |
|
||||
| | WaitForMinimumSegmentCount() (if minSegments > 0) |
|
||||
| |------------------------------------------ ... ---|
|
||||
| | | |
|
||||
| 200 OK (m3u8 playlist text) | | |
|
||||
|<-------------------------------| | |
|
||||
| | | |
|
||||
| GET /Videos/{id}/hls/segment0.ts | |
|
||||
|------------------------------->| | |
|
||||
| | GetStreamingState() | |
|
||||
| |-------------------------->| |
|
||||
| | | |
|
||||
| | File.Exists(playlistPath)?| |
|
||||
| |---------- YES ----------> | |
|
||||
| | | |
|
||||
| | OnTranscodeBeginRequest(playlistPath, type) |
|
||||
| |------------------------------------------>| |
|
||||
| | job (from _activeTranscodingJobs by path) |
|
||||
| |<------------------------------------------| |
|
||||
| | | |
|
||||
| | PingTranscodingJob(playSessionId) |
|
||||
| | (resets kill timer, marks active) |
|
||||
| | | |
|
||||
| 200 OK (segment data) | | |
|
||||
|<-------------------------------| | |
|
||||
| | | |
|
||||
| (client stops requesting) | | |
|
||||
| | | |
|
||||
| [kill timer fires after inactivity timeout] | |
|
||||
| | | |
|
||||
| | OnTranscodeKillTimerStopped() |
|
||||
| |------------------------------------------>| |
|
||||
| | KillTranscodingJob(job, ...) |
|
||||
| | Process.Kill(ffmpeg) |
|
||||
| | DeletePartialStreamFiles(path) |
|
||||
| | _activeTranscodingJobs.Remove(job) |
|
||||
```
|
||||
|
||||
### `GetStreamingState()` — What It Does
|
||||
|
||||
`StreamingHelpers.GetStreamingState()` (in `Jellyfin.Api/Helpers/StreamingHelpers.cs`)
|
||||
constructs a `StreamState` object from the inbound `StreamingRequestDto`. It:
|
||||
|
||||
- Resolves the `MediaSourceInfo` for the request
|
||||
- Computes `OutputFilePath` from `IApplicationPaths.TranscodePath` + a hash-derived subdirectory
|
||||
- Applies encoding parameters from the request and the device profile
|
||||
- Does **not** consult any durable store — state is recomputed from scratch on every request
|
||||
|
||||
### `StartFfMpeg()` — What It Does
|
||||
|
||||
`TranscodeManager.StartFfMpeg()` (line ~371, `MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs`):
|
||||
|
||||
1. Calls `OnTranscodeBeginning()` → creates a `TranscodingJob`, adds it to `_activeTranscodingJobs`
|
||||
2. Calls `AcquireResources()` (waits `MediaSource.BufferMs` if set)
|
||||
3. Starts FFmpeg process with the generated command line
|
||||
4. Calls `StartThrottler()` and `StartSegmentCleaner()` if applicable
|
||||
5. Returns the `TranscodingJob` to the caller
|
||||
|
||||
### `OnTranscodeBeginRequest()` — What It Does
|
||||
|
||||
Called when the playlist already exists on disk. Looks up a job in `_activeTranscodingJobs`
|
||||
by filesystem path and `TranscodingJobType`. Returns `null` if no matching in-memory job
|
||||
exists (which is exactly the pod-takeover failure scenario).
|
||||
|
||||
---
|
||||
|
||||
## Key In-Memory State Fields
|
||||
|
||||
### `TranscodeManager._activeTranscodingJobs`
|
||||
|
||||
**Location**: `MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs`, line 48
|
||||
|
||||
```csharp
|
||||
private readonly List<TranscodingJob> _activeTranscodingJobs = new();
|
||||
```
|
||||
|
||||
- Protected by `lock(_activeTranscodingJobs)` (monitor lock)
|
||||
- **Process-local**: not shared between pods, not persisted to any durable store
|
||||
- Contains one `TranscodingJob` per active FFmpeg process
|
||||
- Looked up by `PlaySessionId` (string) or by path + type pair
|
||||
|
||||
Key `TranscodingJob` fields relevant to recovery:
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `PlaySessionId` | `string?` | Caller-supplied; can be null |
|
||||
| `Path` | `string` | Absolute path to the m3u8 playlist file |
|
||||
| `Type` | `TranscodingJobType` | `HLS`, `Progressive`, etc. |
|
||||
| `DeviceId` | `string` | Client device identifier |
|
||||
| `Process` | `Process?` | The live FFmpeg process handle |
|
||||
| `IsLiveOutput` | `bool` | Set to `true` for live HLS streams |
|
||||
| `Id` | `string` | `Guid.NewGuid().ToString("N")` — per-job, not durable |
|
||||
|
||||
### `SessionManager._activeLiveStreamSessions`
|
||||
|
||||
**Location**: `Emby.Server.Implementations/Session/SessionManager.cs`, line ~67
|
||||
|
||||
```csharp
|
||||
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, string>> _activeLiveStreamSessions
|
||||
```
|
||||
|
||||
- Maps `liveStreamId → (sessionId → playSessionId)`
|
||||
- Updated by `UpdateLiveStreamActiveSessionMappings()` (line ~849)
|
||||
- Queried in media-open paths to prevent double-opening a live stream
|
||||
- **Process-local**: cleared on pod shutdown (`_activeLiveStreamSessions.Clear()` on line ~2151)
|
||||
- A takeover pod **cannot** inherit these mappings without explicit rehydration from a durable store
|
||||
|
||||
---
|
||||
|
||||
## Why `playSessionId` Is Insufficient
|
||||
|
||||
`playSessionId` is an **optional, caller-supplied** query parameter:
|
||||
|
||||
```csharp
|
||||
// DynamicHlsController.cs, GetLiveHlsStream():
|
||||
[FromQuery] string? playSessionId,
|
||||
```
|
||||
|
||||
It is passed directly to `StreamingRequestDto.PlaySessionId` and from there into
|
||||
`TranscodingJob.PlaySessionId`. This creates three failure modes for HA:
|
||||
|
||||
### Failure Mode 1: Two clients collide on the same ID
|
||||
|
||||
If two clients supply the same `playSessionId` string, `GetTranscodingJob(playSessionId)`
|
||||
returns the first matching job regardless of which device owns it. The second client's
|
||||
segment requests will ping the first client's kill timer, potentially extending an
|
||||
unrelated session indefinitely.
|
||||
|
||||
### Failure Mode 2: `null` PlaySessionId is common
|
||||
|
||||
When the Jellyfin web client does not supply a `playSessionId`, the field is `null`.
|
||||
`GetTranscodingJob(string playSessionId)` does an `OrdinalIgnoreCase` compare:
|
||||
|
||||
```csharp
|
||||
return _activeTranscodingJobs.FirstOrDefault(j =>
|
||||
string.Equals(j.PlaySessionId, playSessionId, StringComparison.OrdinalIgnoreCase));
|
||||
```
|
||||
|
||||
If `playSessionId` is null, `string.Equals(null, null)` returns `true`, so the lookup
|
||||
returns the **first job in the list with a null PlaySessionId**, regardless of path,
|
||||
device, or item. On a shared filesystem with two pods, this creates an ambiguity
|
||||
between jobs running on different pods.
|
||||
|
||||
### Failure Mode 3: Insufficient as a durable recovery key
|
||||
|
||||
`playSessionId` is not generated by the server — it is client-supplied. There is no
|
||||
guarantee it is present, globally unique, or stable across client reconnects. A durable
|
||||
recovery store (Issue 5.2.1) must use a server-generated, correlation-stable key that
|
||||
includes at minimum: server-assigned UUID, item ID, media source ID, and owner pod name.
|
||||
|
||||
---
|
||||
|
||||
## Why `DeleteTranscodeFileTask` Is Unsafe for Shared Storage
|
||||
|
||||
**Location**: `Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteTranscodeFileTask.cs`
|
||||
|
||||
```csharp
|
||||
public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
||||
{
|
||||
var minDateModified = DateTime.UtcNow.AddDays(-1);
|
||||
// ...
|
||||
DeleteTempFilesFromDirectory(_configurationManager.GetTranscodePath(), minDateModified, ...);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void DeleteTempFilesFromDirectory(string directory, DateTime minDateModified, ...)
|
||||
{
|
||||
var filesToDelete = _fileSystem.GetFiles(directory, true)
|
||||
.Where(f => _fileSystem.GetLastWriteTimeUtc(f) < minDateModified) // ← age only
|
||||
.ToList();
|
||||
// deletes without any lease check
|
||||
}
|
||||
```
|
||||
|
||||
**Triggers**: startup + every 24h.
|
||||
|
||||
**Problem for shared NFS storage**: The task deletes *any* file not written to in the
|
||||
last 24 hours. When a pod dies and a takeover pod attempts recovery, it needs to:
|
||||
|
||||
1. Read the existing `.m3u8` manifest to find segment path prefix
|
||||
2. Determine the last fully-written `.ts` segment
|
||||
3. Restart FFmpeg from one segment before that point
|
||||
|
||||
If those files have an `mtime` older than 24 hours (e.g., the original pod started an
|
||||
overnight transcode), the cleanup task running on any pod that boots after 24h will
|
||||
delete them before the recovery pod can read them. There is **no lease or ownership check**.
|
||||
|
||||
**Required fix (Phase 5.2.2b)**: Before deleting a file, check whether a valid recovery
|
||||
lease exists in the durable store (`ITranscodeSessionStore`). Skip deletion for any path
|
||||
covered by an active or recently-expired lease.
|
||||
|
||||
---
|
||||
|
||||
## How `SessionManager._activeLiveStreamSessions` Works
|
||||
|
||||
When a Jellyfin client opens a live stream, `OpenMediaSource()` calls
|
||||
`UpdateLiveStreamActiveSessionMappings(liveStreamId, sessionId, playSessionId)`:
|
||||
|
||||
```csharp
|
||||
// SessionManager.cs, line ~849
|
||||
private void UpdateLiveStreamActiveSessionMappings(string liveStreamId, string sessionId, string playSessionId)
|
||||
{
|
||||
var activeSessionMappings = _activeLiveStreamSessions.GetOrAdd(
|
||||
liveStreamId, _ => new ConcurrentDictionary<string, string>());
|
||||
activeSessionMappings[sessionId] = playSessionId;
|
||||
}
|
||||
```
|
||||
|
||||
This prevents two sessions from opening the same live stream without coordination. It is
|
||||
consulted when another `OpenMediaSource` call arrives for the same `liveStreamId`.
|
||||
|
||||
**Why this breaks in HA**:
|
||||
|
||||
- The mapping lives only in the pod that originally opened the stream
|
||||
- When the owning pod dies, active session mappings are gone
|
||||
- A takeover pod has no record that liveStreamId `X` is in use
|
||||
- `CloseLiveStream()` on pod B will never be called for a stream opened on pod A
|
||||
- The live stream source (e.g., a TV tuner) may stay locked open indefinitely
|
||||
|
||||
**Recovery approach (Phase 5.2.1/5.3.1)**: The durable `ITranscodeSessionStore` must
|
||||
persist `(liveStreamId → sessionId, playSessionId, ownerPod, openedAt)` and allow
|
||||
takeover pods to query and claim abandoned streams.
|
||||
|
||||
---
|
||||
|
||||
## NFSv3 Lock Recovery on Pod Death
|
||||
|
||||
**NFS version confirmed**: `nfsvers=3` — from `kubernetes/apps/media/nfs-pv.yaml` mount
|
||||
options used for all existing media NFS PersistentVolumes.
|
||||
|
||||
### NFSv3 Lock (`lockd`) Behavior on Pod Death
|
||||
|
||||
NFSv3 uses the Network Lock Manager (`lockd`) for advisory file locks. When a client
|
||||
(pod) terminates:
|
||||
|
||||
1. The NFS client kernel module sends an `NSM` (Network Status Monitor) notification
|
||||
to the NFS server
|
||||
2. The NFS server's `lockd` releases all locks held by that client after a grace period
|
||||
(typically the `sm-notify` retry window, default ~15s)
|
||||
3. **Not guaranteed**: If the pod is killed abruptly (OOM/SIGKILL) and cannot send NSM
|
||||
notification, the NFS server detects the client has disappeared via TCP keep-alive
|
||||
timeout (typically 20–120s depending on server configuration)
|
||||
|
||||
### Implications for Segment Files
|
||||
|
||||
FFmpeg writes `.ts` files sequentially. A typical write pattern:
|
||||
|
||||
1. Open `segment_N.ts` for write
|
||||
2. Write video/audio data (2–4 MB for a 2–4s segment)
|
||||
3. Close and rename/flush
|
||||
|
||||
If the pod dies **mid-write** of `segment_N.ts`:
|
||||
|
||||
- The file may be 0 bytes, partially filled, or have a corrupted end
|
||||
- NFSv3 does **not** guarantee close-to-open consistency for concurrent readers
|
||||
— another pod may see a stale cached version or a partial file
|
||||
- The NFS server releases the lock within seconds to minutes, but the file
|
||||
content is not rolled back
|
||||
|
||||
**Recovery rule (must implement in Phase 5.2)**:
|
||||
|
||||
> When resuming from a manifest on shared storage, identify the last `.ts` segment
|
||||
> that appears in the `.m3u8` `#EXTINF` entries AND is non-zero in size AND has a
|
||||
> stable mtime (not being written). Restart FFmpeg from **one segment before** that
|
||||
> point to ensure the last segment is re-written cleanly.
|
||||
|
||||
This is analogous to the WAL recovery principle: never trust the last write from a
|
||||
crashed writer.
|
||||
|
||||
### NFS Lock Hold-Up on Active Pod
|
||||
|
||||
When a Jellyfin pod has an open file handle on the NFS mount and the NAS becomes
|
||||
unreachable, NFSv3 with `hard` mount option (confirmed in existing PVs) will block
|
||||
I/O indefinitely — the pod will not crash, but it will stall. This is the correct
|
||||
behavior for transcode recovery: FFmpeg stalls rather than emitting corrupt segments.
|
||||
Test this in Issue 5.1.2 NAS outage test.
|
||||
|
||||
---
|
||||
|
||||
## Minimum Recovery State
|
||||
|
||||
For a takeover pod to resume an orphaned transcode session, the following minimum
|
||||
state must be durably stored (Phase 5.2.1):
|
||||
|
||||
| Field | Source | Why Needed |
|
||||
|---|---|---|
|
||||
| `sessionId` | server-generated UUID | Stable correlation key; not client-supplied |
|
||||
| `playSessionId` | client-supplied (may be null) | Needed to match kill-timer pings |
|
||||
| `ownerPod` | k8s `POD_NAME` env var | Identify which pod is current owner |
|
||||
| `manifestPath` | `OutputFilePath` with `.m3u8` extension | Entry point for takeover pod |
|
||||
| `segmentPathPrefix` | derived from `manifestPath` directory | Find `.ts` files |
|
||||
| `mediaSourceId` | `StreamState.MediaSource.Id` | Re-open the same stream |
|
||||
| `itemId` | `StreamState.Request.ItemId` | Re-construct `StreamingRequestDto` |
|
||||
| `encodingParams` | serialized subset of `StreamState` | Restart FFmpeg with identical params |
|
||||
| `lastHeartbeatUtc` | updated by owner pod on segment write | Orphan detection: > 120s = orphaned |
|
||||
| `lastCompletedSegmentIndex` | updated on each segment flush | Recovery knows where to seek |
|
||||
| `deviceId` | `StreamState.Request.DeviceId` | Kill-job scope on cleanup |
|
||||
|
||||
---
|
||||
|
||||
## HA Failure Scenario Walk-Through
|
||||
|
||||
### Scenario: Pod A dies mid-transcode, Pod B receives next segment request
|
||||
|
||||
```
|
||||
Pod A (owner) Redis (durable store) Pod B (takeover)
|
||||
| | |
|
||||
| write sessionKey → Redis | |
|
||||
|-------------------------------->| |
|
||||
| | |
|
||||
| heartbeat every 30s | |
|
||||
|-------------------------------->| |
|
||||
| | |
|
||||
DIES (OOMKill / node drain) | |
|
||||
| GET segment_N+1.ts
|
||||
|<------------------------|
|
||||
| session key exists |
|
||||
| lastHeartbeat > 120s ago
|
||||
| ownerPod != me |
|
||||
| |
|
||||
[today, WITHOUT Phase 5.2]: |
|
||||
| |
|
||||
| _activeTranscodingJobs is empty on Pod B
|
||||
| OnTranscodeBeginRequest() → null
|
||||
| No ffmpeg started
|
||||
| Client receives stale m3u8, then 404s on segment
|
||||
| Playback stalls indefinitely
|
||||
| |
|
||||
[with Phase 5.2]: |
|
||||
| |
|
||||
| CAS: set ownerPod = pod-B |
|
||||
|<------------------------|
|
||||
| |
|
||||
| recover from segment_N-1 |
|
||||
| StartFfMpeg(resumeFrom=N-1)
|
||||
|<------------------------|
|
||||
| |
|
||||
| client resumes from segment N-1 (~4s rewind)
|
||||
```
|
||||
|
||||
### Current State (Without Phase 5.2)
|
||||
|
||||
1. Client sends `GET .../segment_100.ts` to pod B (Traefik sticky session cookie
|
||||
`jellyfin-server-id` routes to pod B because pod A is gone)
|
||||
2. Pod B calls `GetStreamingState()` → computes same `OutputFilePath` (deterministic hash)
|
||||
3. Pod B calls `File.Exists(playlistPath)` → **true** (file exists on NFS from pod A)
|
||||
4. Pod B calls `OnTranscodeBeginRequest(playlistPath, HLS)` → **null** (no job in pod B's `_activeTranscodingJobs`)
|
||||
5. `job is null` → `OnTranscodeEndRequest` not called, no ping, no FFmpeg restart
|
||||
6. Pod B reads and returns the existing `.m3u8` from disk
|
||||
7. Client requests segment 100 → pod B tries to serve `segment_100.ts`
|
||||
- If the file exists and is complete: **success** (but no new segments will be produced)
|
||||
- If the file does not exist yet (pod A was mid-write): **404**, client stalls
|
||||
|
||||
Without Phase 5.2, the transcode stream terminates on pod death. No recovery happens
|
||||
automatically. The client must re-initiate playback from the beginning or from a
|
||||
seek point.
|
||||
|
||||
---
|
||||
|
||||
## Open Questions Before Phase 5.2
|
||||
|
||||
| # | Question | Who Answers | When |
|
||||
|---|---|---|---|
|
||||
| Q1 | What is the actual `leasetime` configured on the Ugreen DXP4800 NFS server? (default 90s, but UGOS Pro may differ) | Issue 5.1.2 benchmark pod | 5.1.2 |
|
||||
| Q2 | Does the NFS mount use `nfsvers=3` exclusively, or does UGOS Pro negotiate v4 when requested? | `nfsstat -m` in test pod | 5.1.2 |
|
||||
| Q3 | What is the minimum HLS segment duration in practice? (affects recovery seek distance) | FFmpeg log inspection | 5.1.1 follow-on |
|
||||
| Q4 | Does the Jellyfin web client re-supply a stable `playSessionId` on reconnect, or generate a new one? | Client code inspection | 5.2.2a |
|
||||
| Q5 | Does `StackExchange.Redis` in the fork use connection multiplexing that survives pod address changes? | 5.2.1a implementation | 5.2.1a |
|
||||
|
||||
---
|
||||
|
||||
## Cross-References
|
||||
|
||||
- [jellyfin-ha-plan.md](../home_k3s_cluster/docs/jellyfin-ha/jellyfin-ha-plan.md) — overall HA plan and phase structure
|
||||
- [jellyfin-ha-phase5-transcoding.md](../home_k3s_cluster/docs/jellyfin-ha/jellyfin-ha-phase5-transcoding.md) — Phase 5 issue list, rollback matrix, Go/No-Go preconditions
|
||||
- [jellyfin-ha-failover-test.md](../home_k3s_cluster/docs/jellyfin-ha/jellyfin-ha-failover-test.md) — SLO baselines, failover test procedures
|
||||
- [ci-cd.md](../home_k3s_cluster/docs/ci-cd.md) — Phase 5 CI/CD paths
|
||||
- `MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs` — `_activeTranscodingJobs`, `StartFfMpeg()`, `KillTranscodingJob()`
|
||||
- `Jellyfin.Api/Controllers/DynamicHlsController.cs` — `GetLiveHlsStream()`, segment lookup
|
||||
- `Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteTranscodeFileTask.cs` — age-only cleanup
|
||||
- `Emby.Server.Implementations/Session/SessionManager.cs` — `_activeLiveStreamSessions`
|
||||
- `kubernetes/apps/media/nfs-pv.yaml` — `nfsvers=3` confirmed
|
||||
|
||||
---
|
||||
|
||||
## Bitrate/Segment Tradeoffs
|
||||
|
||||
### Why shorter segments trade throughput for faster failover
|
||||
|
||||
HLS streaming works by dividing a media stream into a series of short, independently decodable
|
||||
segments. The segment length is a fundamental trade-off: longer segments reduce per-segment HTTP
|
||||
overhead and allow FFmpeg to apply more aggressive compression across each chunk, improving overall
|
||||
bitrate efficiency. Shorter segments, however, mean that when a pod fails mid-transcode, a takeover
|
||||
pod only needs to rewind to the previous segment boundary — not the start of a much longer one.
|
||||
With the default 6-second segment length, a client could stall for up to 6 seconds before the
|
||||
takeover pod produces a new segment for it to consume. With the HA recovery default of 2 seconds
|
||||
(`RecoverySegmentLengthSeconds = 2`), that stall window is reduced to at most 2 seconds of rewind,
|
||||
dramatically improving the perceived continuity of playback during a pod failover.
|
||||
|
||||
### The rolling segment buffer and disk usage
|
||||
|
||||
In HA mode, `RecoverySegmentBufferCount` (default `5`) controls how many segments are retained in
|
||||
the HLS playlist at any one time. This creates a rolling on-disk buffer of `5 × 2 s = 10 seconds`
|
||||
of media that a takeover pod can serve immediately while it restarts FFmpeg from the last known
|
||||
position. Keeping fewer segments wastes less NFS storage but shrinks the window in which a newly
|
||||
promoted pod can respond to in-flight client requests without waiting for new segments to be
|
||||
produced. Keeping more segments lengthens the recovery window but increases NFS write pressure and
|
||||
disk usage proportionally. The valid range (2–10) was chosen so that the minimum buffer is always
|
||||
at least 4 seconds (2 × 2 s) and the maximum stays under 20 seconds (10 × 2 s), balancing storage
|
||||
cost against recovery robustness.
|
||||
|
||||
### Tuning guidance and rollback
|
||||
|
||||
The two knobs, `RecoverySegmentLengthSeconds` and `RecoverySegmentBufferCount`, can be adjusted in
|
||||
the Jellyfin server's encoding options without restarting the service; the new values take effect on
|
||||
the next transcode session that enters HA mode. To reduce disk I/O at the cost of a slightly longer
|
||||
stall window, increase `RecoverySegmentLengthSeconds` toward its maximum of 6 (matching the
|
||||
throughput-optimized default). To shrink the NFS footprint at the cost of a narrower recovery
|
||||
window, lower `RecoverySegmentBufferCount` toward its minimum of 2. To roll back to the
|
||||
pre-HA-mode behavior entirely, set `RecoverySegmentLengthSeconds = 6` and ensure that no active
|
||||
session is registered in the `ITranscodeSessionStore` (which disables HA mode detection in
|
||||
`DynamicHlsController`). All changes are backwards-compatible: in single-pod deployments where the
|
||||
store is a no-op, these settings have no effect on the FFmpeg command generated.
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="..\..\..\SharedVersion.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\MediaBrowser.Common\MediaBrowser.Common.csproj" />
|
||||
<ProjectReference Include="..\Jellyfin.Database.Implementations\Jellyfin.Database.Implementations.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+1690
File diff suppressed because it is too large
Load Diff
+1146
File diff suppressed because it is too large
Load Diff
+1771
File diff suppressed because it is too large
Load Diff
+585
@@ -0,0 +1,585 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jellyfin.Database.Providers.PostgreSQL.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class UpgradeToServer12Schema : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// UserId becomes non-nullable below, so the rows that never had an owner have to go first.
|
||||
migrationBuilder.Sql(@"DELETE FROM ""Permissions"" WHERE ""UserId"" IS NULL;");
|
||||
migrationBuilder.Sql(@"DELETE FROM ""Preferences"" WHERE ""UserId"" IS NULL;");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_UserData_UserId",
|
||||
table: "UserData");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Preferences_UserId_Kind",
|
||||
table: "Preferences");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Permissions_UserId_Kind",
|
||||
table: "Permissions");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_PeopleBaseItemMap_PeopleId",
|
||||
table: "PeopleBaseItemMap");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_MediaStreamInfos_StreamIndex",
|
||||
table: "MediaStreamInfos");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_MediaStreamInfos_StreamIndex_StreamType",
|
||||
table: "MediaStreamInfos");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_MediaStreamInfos_StreamIndex_StreamType_Language",
|
||||
table: "MediaStreamInfos");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_MediaStreamInfos_StreamType",
|
||||
table: "MediaStreamInfos");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Devices_DeviceId",
|
||||
table: "Devices");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_BaseItems_Id_Type_IsFolder_IsVirtualItem",
|
||||
table: "BaseItems");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_BaseItemProviders_ProviderId_ProviderValue_ItemId",
|
||||
table: "BaseItemProviders");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_BaseItemImageInfos_ItemId",
|
||||
table: "BaseItemImageInfos");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Preference_Preferences_Guid",
|
||||
table: "Preferences");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Permission_Permissions_Guid",
|
||||
table: "Permissions");
|
||||
|
||||
// ExtraIds and OriginalLanguage are unrelated columns that EF pairs up as a rename because both are
|
||||
// nullable text; the extra ids are superseded by the OwnerId relation and their content must not survive.
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ExtraIds",
|
||||
table: "BaseItems");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "OriginalLanguage",
|
||||
table: "BaseItems",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "NormalizedUsername",
|
||||
table: "Users",
|
||||
type: "character varying(255)",
|
||||
maxLength: 255,
|
||||
nullable: false,
|
||||
defaultValue: string.Empty);
|
||||
|
||||
migrationBuilder.AlterColumn<Guid>(
|
||||
name: "UserId",
|
||||
table: "Preferences",
|
||||
type: "uuid",
|
||||
nullable: false,
|
||||
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
|
||||
oldClrType: typeof(Guid),
|
||||
oldType: "uuid",
|
||||
oldNullable: true);
|
||||
|
||||
migrationBuilder.AlterColumn<Guid>(
|
||||
name: "UserId",
|
||||
table: "Permissions",
|
||||
type: "uuid",
|
||||
nullable: false,
|
||||
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
|
||||
oldClrType: typeof(Guid),
|
||||
oldType: "uuid",
|
||||
oldNullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsOriginal",
|
||||
table: "MediaStreamInfos",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
// PostgreSQL refuses an implicit text to uuid conversion, so both columns are converted with an explicit
|
||||
// USING cast. Only the two shapes the cast accepts survive: 32 hexadecimal digits, or the hyphenated
|
||||
// 8-4-4-4-12 form. A digit count alone is not enough, because misplaced hyphens keep the count and still
|
||||
// fail the cast, which would abort the whole migration.
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
UPDATE "BaseItems" SET "PrimaryVersionId" = NULL
|
||||
WHERE "PrimaryVersionId" IS NOT NULL
|
||||
AND ("PrimaryVersionId" !~ '^([0-9a-fA-F]{32}|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$'
|
||||
OR replace("PrimaryVersionId", '-', '') = '00000000000000000000000000000000');
|
||||
|
||||
ALTER TABLE "BaseItems"
|
||||
ALTER COLUMN "PrimaryVersionId" TYPE uuid USING "PrimaryVersionId"::uuid;
|
||||
""");
|
||||
|
||||
// The detached-item placeholder is cleared as well, matching the SQLite chain: leaving it in place would
|
||||
// make CleanupOrphanedExtras delete an item that has a real owner relation only by coincidence.
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
UPDATE "BaseItems" SET "OwnerId" = NULL
|
||||
WHERE "OwnerId" IS NOT NULL
|
||||
AND ("OwnerId" !~ '^([0-9a-fA-F]{32}|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$'
|
||||
OR replace("OwnerId", '-', '') IN ('00000000000000000000000000000000', '00000000000000000000000000000001'));
|
||||
|
||||
ALTER TABLE "BaseItems"
|
||||
ALTER COLUMN "OwnerId" TYPE uuid USING "OwnerId"::uuid;
|
||||
""");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "LinkedChildren",
|
||||
columns: table => new
|
||||
{
|
||||
ParentId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
SortOrder = table.Column<int>(type: "integer", nullable: false),
|
||||
ChildId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
ChildType = table.Column<int>(type: "integer", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_LinkedChildren", x => new { x.ParentId, x.SortOrder });
|
||||
table.ForeignKey(
|
||||
name: "FK_LinkedChildren_BaseItems_ChildId",
|
||||
column: x => x.ChildId,
|
||||
principalTable: "BaseItems",
|
||||
principalColumn: "Id");
|
||||
table.ForeignKey(
|
||||
name: "FK_LinkedChildren_BaseItems_ParentId",
|
||||
column: x => x.ParentId,
|
||||
principalTable: "BaseItems",
|
||||
principalColumn: "Id");
|
||||
});
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "BaseItems",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000001"),
|
||||
columns: new[] { "Name", "OwnerId", "PrimaryVersionId" },
|
||||
values: new object[] { "This is a placeholder item for UserData that has been detached from its original item", null, null });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_UserData_UserId_IsFavorite_ItemId",
|
||||
table: "UserData",
|
||||
columns: new[] { "UserId", "IsFavorite", "ItemId" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_UserData_UserId_ItemId_LastPlayedDate",
|
||||
table: "UserData",
|
||||
columns: new[] { "UserId", "ItemId", "LastPlayedDate" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_UserData_UserId_Played_ItemId",
|
||||
table: "UserData",
|
||||
columns: new[] { "UserId", "Played", "ItemId" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Preferences_UserId_Kind",
|
||||
table: "Preferences",
|
||||
columns: new[] { "UserId", "Kind" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Permissions_UserId_Kind",
|
||||
table: "Permissions",
|
||||
columns: new[] { "UserId", "Kind" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PeopleBaseItemMap_PeopleId_ItemId",
|
||||
table: "PeopleBaseItemMap",
|
||||
columns: new[] { "PeopleId", "ItemId" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_MediaStreamInfos_StreamType_ItemId_Language_IsExternal",
|
||||
table: "MediaStreamInfos",
|
||||
columns: new[] { "StreamType", "ItemId", "Language", "IsExternal" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BaseItems_ExtraType_OwnerId",
|
||||
table: "BaseItems",
|
||||
columns: new[] { "ExtraType", "OwnerId" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BaseItems_Name",
|
||||
table: "BaseItems",
|
||||
column: "Name");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BaseItems_OwnerId",
|
||||
table: "BaseItems",
|
||||
column: "OwnerId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BaseItems_PrimaryVersionId",
|
||||
table: "BaseItems",
|
||||
column: "PrimaryVersionId",
|
||||
filter: "\"PrimaryVersionId\" IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BaseItems_SeasonId",
|
||||
table: "BaseItems",
|
||||
column: "SeasonId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BaseItems_SeriesId",
|
||||
table: "BaseItems",
|
||||
column: "SeriesId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BaseItems_SeriesName",
|
||||
table: "BaseItems",
|
||||
column: "SeriesName");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BaseItems_TopParentId_IsFolder_IsVirtualItem_DateCreated",
|
||||
table: "BaseItems",
|
||||
columns: new[] { "TopParentId", "IsFolder", "IsVirtualItem", "DateCreated" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BaseItems_TopParentId_MediaType_IsVirtualItem_DateCreated",
|
||||
table: "BaseItems",
|
||||
columns: new[] { "TopParentId", "MediaType", "IsVirtualItem", "DateCreated" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BaseItems_TopParentId_Type_IsVirtualItem",
|
||||
table: "BaseItems",
|
||||
columns: new[] { "TopParentId", "Type", "IsVirtualItem" },
|
||||
filter: "\"PrimaryVersionId\" IS NULL AND (\"OwnerId\" IS NULL OR \"ExtraType\" IS NOT NULL)");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BaseItems_TopParentId_Type_IsVirtualItem_DateCreated",
|
||||
table: "BaseItems",
|
||||
columns: new[] { "TopParentId", "Type", "IsVirtualItem", "DateCreated" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BaseItems_Type_CleanName",
|
||||
table: "BaseItems",
|
||||
columns: new[] { "Type", "CleanName" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BaseItems_Type_SeriesPresentationUniqueKey_ParentIndexNumbe~",
|
||||
table: "BaseItems",
|
||||
columns: new[] { "Type", "SeriesPresentationUniqueKey", "ParentIndexNumber", "IndexNumber" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BaseItems_Type_TopParentId_SortName",
|
||||
table: "BaseItems",
|
||||
columns: new[] { "Type", "TopParentId", "SortName" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BaseItemProviders_ProviderId_ItemId_ProviderValue",
|
||||
table: "BaseItemProviders",
|
||||
columns: new[] { "ProviderId", "ItemId", "ProviderValue" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BaseItemImageInfos_ItemId_ImageType",
|
||||
table: "BaseItemImageInfos",
|
||||
columns: new[] { "ItemId", "ImageType" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_LinkedChildren_ChildId_ChildType",
|
||||
table: "LinkedChildren",
|
||||
columns: new[] { "ChildId", "ChildType" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_LinkedChildren_ParentId_ChildType",
|
||||
table: "LinkedChildren",
|
||||
columns: new[] { "ParentId", "ChildType" });
|
||||
|
||||
// The baseline seeds the placeholder, but the repoint below and the foreign key both depend on it, so it is
|
||||
// recreated if anything removed it rather than letting the migration abort.
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
INSERT INTO "BaseItems" ("Id", "Type", "Name", "IsMovie", "IsLocked", "IsSeries", "IsRepeat",
|
||||
"IsInMixedFolder", "IsFolder", "IsVirtualItem")
|
||||
VALUES ('00000000-0000-0000-0000-000000000001', 'PLACEHOLDER',
|
||||
'This is a placeholder item for UserData that has been detached from its original item',
|
||||
false, false, false, false, false, false, false)
|
||||
ON CONFLICT ("Id") DO NOTHING;
|
||||
""");
|
||||
|
||||
// Owners that no longer exist are repointed at the detached-item placeholder so the new self referencing
|
||||
// foreign key holds. The CleanupOrphanedExtras routine removes the items afterwards.
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
UPDATE "BaseItems"
|
||||
SET "OwnerId" = '00000000-0000-0000-0000-000000000001'
|
||||
WHERE "OwnerId" IS NOT NULL
|
||||
AND "OwnerId" NOT IN (SELECT "Id" FROM "BaseItems");
|
||||
""");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_BaseItems_BaseItems_OwnerId",
|
||||
table: "BaseItems",
|
||||
column: "OwnerId",
|
||||
principalTable: "BaseItems",
|
||||
principalColumn: "Id");
|
||||
|
||||
// Those defaults only existed to fill the rows that predate the columns. The model declares none, so they
|
||||
// are dropped again to leave exactly the schema a model generated migration would create.
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
ALTER TABLE "Users" ALTER COLUMN "NormalizedUsername" DROP DEFAULT;
|
||||
ALTER TABLE "Permissions" ALTER COLUMN "UserId" DROP DEFAULT;
|
||||
ALTER TABLE "Preferences" ALTER COLUMN "UserId" DROP DEFAULT;
|
||||
ALTER TABLE "MediaStreamInfos" ALTER COLUMN "IsOriginal" DROP DEFAULT;
|
||||
""");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_BaseItems_BaseItems_OwnerId",
|
||||
table: "BaseItems");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "LinkedChildren");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_UserData_UserId_IsFavorite_ItemId",
|
||||
table: "UserData");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_UserData_UserId_ItemId_LastPlayedDate",
|
||||
table: "UserData");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_UserData_UserId_Played_ItemId",
|
||||
table: "UserData");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Preferences_UserId_Kind",
|
||||
table: "Preferences");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Permissions_UserId_Kind",
|
||||
table: "Permissions");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_PeopleBaseItemMap_PeopleId_ItemId",
|
||||
table: "PeopleBaseItemMap");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_MediaStreamInfos_StreamType_ItemId_Language_IsExternal",
|
||||
table: "MediaStreamInfos");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_BaseItems_ExtraType_OwnerId",
|
||||
table: "BaseItems");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_BaseItems_Name",
|
||||
table: "BaseItems");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_BaseItems_OwnerId",
|
||||
table: "BaseItems");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_BaseItems_PrimaryVersionId",
|
||||
table: "BaseItems");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_BaseItems_SeasonId",
|
||||
table: "BaseItems");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_BaseItems_SeriesId",
|
||||
table: "BaseItems");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_BaseItems_SeriesName",
|
||||
table: "BaseItems");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_BaseItems_TopParentId_IsFolder_IsVirtualItem_DateCreated",
|
||||
table: "BaseItems");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_BaseItems_TopParentId_MediaType_IsVirtualItem_DateCreated",
|
||||
table: "BaseItems");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_BaseItems_TopParentId_Type_IsVirtualItem",
|
||||
table: "BaseItems");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_BaseItems_TopParentId_Type_IsVirtualItem_DateCreated",
|
||||
table: "BaseItems");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_BaseItems_Type_CleanName",
|
||||
table: "BaseItems");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_BaseItems_Type_SeriesPresentationUniqueKey_ParentIndexNumbe~",
|
||||
table: "BaseItems");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_BaseItems_Type_TopParentId_SortName",
|
||||
table: "BaseItems");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_BaseItemProviders_ProviderId_ItemId_ProviderValue",
|
||||
table: "BaseItemProviders");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_BaseItemImageInfos_ItemId_ImageType",
|
||||
table: "BaseItemImageInfos");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "NormalizedUsername",
|
||||
table: "Users");
|
||||
|
||||
// Reverting the column takes the code migration that populates it with it.
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
DELETE FROM "__EFMigrationsHistory"
|
||||
WHERE "MigrationId" = '20260522092304_UpdateNormalizedUsername';
|
||||
""");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsOriginal",
|
||||
table: "MediaStreamInfos");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "OriginalLanguage",
|
||||
table: "BaseItems");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ExtraIds",
|
||||
table: "BaseItems",
|
||||
type: "text",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AlterColumn<Guid>(
|
||||
name: "UserId",
|
||||
table: "Preferences",
|
||||
type: "uuid",
|
||||
nullable: true,
|
||||
oldClrType: typeof(Guid),
|
||||
oldType: "uuid");
|
||||
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "Preference_Preferences_Guid",
|
||||
table: "Preferences",
|
||||
type: "uuid",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AlterColumn<Guid>(
|
||||
name: "UserId",
|
||||
table: "Permissions",
|
||||
type: "uuid",
|
||||
nullable: true,
|
||||
oldClrType: typeof(Guid),
|
||||
oldType: "uuid");
|
||||
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "Permission_Permissions_Guid",
|
||||
table: "Permissions",
|
||||
type: "uuid",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
ALTER TABLE "BaseItems"
|
||||
ALTER COLUMN "PrimaryVersionId" TYPE text USING "PrimaryVersionId"::text;
|
||||
|
||||
ALTER TABLE "BaseItems"
|
||||
ALTER COLUMN "OwnerId" TYPE text USING "OwnerId"::text;
|
||||
""");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "BaseItems",
|
||||
keyColumn: "Id",
|
||||
keyValue: new Guid("00000000-0000-0000-0000-000000000001"),
|
||||
columns: new[] { "Name", "OwnerId", "PrimaryVersionId" },
|
||||
values: new object[] { "This is a placeholder item for UserData that has been detacted from its original item", null, null });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_UserData_UserId",
|
||||
table: "UserData",
|
||||
column: "UserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Preferences_UserId_Kind",
|
||||
table: "Preferences",
|
||||
columns: new[] { "UserId", "Kind" },
|
||||
unique: true,
|
||||
filter: "\"UserId\" IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Permissions_UserId_Kind",
|
||||
table: "Permissions",
|
||||
columns: new[] { "UserId", "Kind" },
|
||||
unique: true,
|
||||
filter: "\"UserId\" IS NOT NULL");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PeopleBaseItemMap_PeopleId",
|
||||
table: "PeopleBaseItemMap",
|
||||
column: "PeopleId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_MediaStreamInfos_StreamIndex",
|
||||
table: "MediaStreamInfos",
|
||||
column: "StreamIndex");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_MediaStreamInfos_StreamIndex_StreamType",
|
||||
table: "MediaStreamInfos",
|
||||
columns: new[] { "StreamIndex", "StreamType" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_MediaStreamInfos_StreamIndex_StreamType_Language",
|
||||
table: "MediaStreamInfos",
|
||||
columns: new[] { "StreamIndex", "StreamType", "Language" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_MediaStreamInfos_StreamType",
|
||||
table: "MediaStreamInfos",
|
||||
column: "StreamType");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Devices_DeviceId",
|
||||
table: "Devices",
|
||||
column: "DeviceId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BaseItems_Id_Type_IsFolder_IsVirtualItem",
|
||||
table: "BaseItems",
|
||||
columns: new[] { "Id", "Type", "IsFolder", "IsVirtualItem" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BaseItemProviders_ProviderId_ProviderValue_ItemId",
|
||||
table: "BaseItemProviders",
|
||||
columns: new[] { "ProviderId", "ProviderValue", "ItemId" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BaseItemImageInfos_ItemId",
|
||||
table: "BaseItemImageInfos",
|
||||
column: "ItemId");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1774
File diff suppressed because it is too large
Load Diff
+28
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Jellyfin.Database.Providers.PostgreSQL.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddUniqueNormalizedUsernameIndex : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Users_NormalizedUsername",
|
||||
table: "Users",
|
||||
column: "NormalizedUsername",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Users_NormalizedUsername",
|
||||
table: "Users");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1771
File diff suppressed because it is too large
Load Diff
+112
@@ -0,0 +1,112 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.DbConfiguration;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
|
||||
namespace Jellyfin.Database.Providers.PostgreSQL;
|
||||
|
||||
/// <summary>
|
||||
/// Configures Jellyfin to use a PostgreSQL database.
|
||||
/// </summary>
|
||||
[JellyfinDatabaseProviderKey("Jellyfin-PostgreSQL")]
|
||||
public sealed class PostgreSqlDatabaseProvider : IJellyfinDatabaseProvider
|
||||
{
|
||||
// Sentinel returned by MigrationBackupFast to signal that no file backup was
|
||||
// created (PostgreSQL backups are handled externally by jellyfin-pg-backup CronJob).
|
||||
private const string NoAutomatedBackupKey = "postgresql-no-automated-backup";
|
||||
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PostgreSqlDatabaseProvider"/> class.
|
||||
/// </summary>
|
||||
/// <param name="dataSource">The <see cref="NpgsqlDataSource"/> used for PostgreSQL connections.</param>
|
||||
public PostgreSqlDatabaseProvider(NpgsqlDataSource dataSource)
|
||||
{
|
||||
_dataSource = dataSource;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IDbContextFactory<JellyfinDbContext>? DbContextFactory { get; set; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Initialise(DbContextOptionsBuilder options, DatabaseConfigurationOptions databaseConfiguration)
|
||||
{
|
||||
options.UseNpgsql(
|
||||
_dataSource,
|
||||
o => o.MigrationsAssembly(GetType().Assembly.FullName));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task RunScheduledOptimisation(CancellationToken cancellationToken)
|
||||
{
|
||||
var context = await DbContextFactory!.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using (context.ConfigureAwait(false))
|
||||
{
|
||||
await context.Database.ExecuteSqlRawAsync("ANALYZE", cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task RunShutdownTask(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<string> MigrationBackupFast(CancellationToken cancellationToken)
|
||||
{
|
||||
// PostgreSQL pre-migration backups are handled externally by the
|
||||
// jellyfin-pg-backup CronJob. Return a sentinel so callers know no
|
||||
// file backup was created and the migration can proceed safely.
|
||||
return Task.FromResult(NoAutomatedBackupKey);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task RestoreBackupFast(string key, CancellationToken cancellationToken)
|
||||
{
|
||||
// No automated backup was taken; nothing to restore.
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task DeleteBackup(string key)
|
||||
{
|
||||
// No automated backup was taken; nothing to delete.
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task PurgeDatabase(JellyfinDbContext dbContext, IEnumerable<string>? tableNames)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(tableNames);
|
||||
|
||||
await dbContext.Database.ExecuteSqlRawAsync("SET session_replication_role = 'replica'").ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
foreach (var tableName in tableNames)
|
||||
{
|
||||
var truncateSql = "TRUNCATE TABLE \"" + tableName + "\" CASCADE";
|
||||
await dbContext.Database.ExecuteSqlRawAsync(truncateSql).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await dbContext.Database.ExecuteSqlRawAsync("SET session_replication_role = 'origin'").ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
using System;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.Locking;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Npgsql;
|
||||
|
||||
namespace Jellyfin.Database.Providers.PostgreSQL;
|
||||
|
||||
/// <summary>
|
||||
/// The design time factory for <see cref="JellyfinDbContext"/> using PostgreSQL.
|
||||
/// This is only used for the creation of migrations and not during runtime.
|
||||
/// </summary>
|
||||
internal sealed class PostgreSqlDesignTimeJellyfinDbFactory : IDesignTimeDbContextFactory<JellyfinDbContext>
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public JellyfinDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var connectionString =
|
||||
Environment.GetEnvironmentVariable("POSTGRES_CONNECTION_STRING")
|
||||
?? "Host=localhost;Database=jellyfin;Username=postgres;Password=postgres";
|
||||
|
||||
var optionsBuilder = new DbContextOptionsBuilder<JellyfinDbContext>();
|
||||
|
||||
// Build a NpgsqlDataSource for EF Core configuration. The DI-owned singleton data source
|
||||
// is not available in design-time context; this instance is intentionally not disposed here
|
||||
// because EF Core holds a reference to it for the lifetime of the returned context.
|
||||
// As a design-time-only factory (used only for dotnet-ef CLI operations), the process
|
||||
// exits after the migration is applied, which releases all resources.
|
||||
#pragma warning disable CA2000 // Dispose objects before losing scope
|
||||
var dataSource = new NpgsqlDataSourceBuilder(connectionString).Build();
|
||||
#pragma warning restore CA2000 // Dispose objects before losing scope
|
||||
optionsBuilder.UseNpgsql(dataSource, o => o.MigrationsAssembly(GetType().Assembly));
|
||||
|
||||
return new JellyfinDbContext(
|
||||
optionsBuilder.Options,
|
||||
NullLogger<JellyfinDbContext>.Instance,
|
||||
new PostgreSqlDatabaseProvider(dataSource),
|
||||
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using Jellyfin.Api.Controllers;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
using MediaBrowser.Model.Configuration;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Api.Tests.Controllers
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests for the HA behaviour of <see cref="DynamicHlsController"/>: the session record it
|
||||
/// registers in <see cref="ITranscodeSessionStore"/> and the ffmpeg segmenting options it
|
||||
/// picks once a session is resumed on another pod.
|
||||
/// </summary>
|
||||
public class DynamicHlsHaModeTests
|
||||
{
|
||||
/// <summary>
|
||||
/// The registered session has to name the files it owns, otherwise
|
||||
/// <c>DeleteTranscodeFileTask</c> cannot recognise and protect them.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
[Trait("Category", "UnitTest")]
|
||||
public void CreateSessionRecord_PopulatesPathsFromPlaylistPath()
|
||||
{
|
||||
const string PlaylistPath = "/transcodes/9e1c6f.m3u8";
|
||||
|
||||
var session = DynamicHlsController.CreateSessionRecord("play-1", "media-1", PlaylistPath, TimeSpan.FromSeconds(30));
|
||||
|
||||
Assert.Equal(PlaylistPath, session.ManifestPath);
|
||||
Assert.Equal("/transcodes/9e1c6f", session.SegmentPathPrefix);
|
||||
Assert.Equal("play-1", session.PlaySessionId);
|
||||
Assert.Equal("media-1", session.MediaSourceId);
|
||||
Assert.NotEmpty(session.OwnerPod);
|
||||
Assert.True(session.LeaseExpiresUtc > DateTime.UtcNow);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every segment ffmpeg writes for the playlist has to start with the recorded prefix.
|
||||
/// Segment paths are built as <c><playlist without extension><index><extension></c>.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(".ts")]
|
||||
[InlineData(".mp4")]
|
||||
[Trait("Category", "UnitTest")]
|
||||
public void GetSegmentPathPrefix_CoversEverySegmentPath(string segmentExtension)
|
||||
{
|
||||
const string PlaylistPath = "/transcodes/9e1c6f.m3u8";
|
||||
var prefix = TranscodeSession.GetSegmentPathPrefix(PlaylistPath);
|
||||
|
||||
for (var index = 0; index < 5; index++)
|
||||
{
|
||||
var segmentPath = Path.Combine(
|
||||
Path.GetDirectoryName(PlaylistPath)!,
|
||||
Path.GetFileNameWithoutExtension(PlaylistPath) + index.ToString(CultureInfo.InvariantCulture) + segmentExtension);
|
||||
|
||||
Assert.StartsWith(prefix, segmentPath, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HA mode shortens segments and bounds the rolling buffer; the configured values are
|
||||
/// user-editable so they are clamped.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(2, 2)]
|
||||
[InlineData(0, 1)]
|
||||
[InlineData(60, 6)]
|
||||
[Trait("Category", "UnitTest")]
|
||||
public void GetEffectiveSegmentLength_InHaMode_ClampsConfiguredRecoveryLength(int configured, int expected)
|
||||
{
|
||||
var options = new EncodingOptions { RecoverySegmentLengthSeconds = configured };
|
||||
|
||||
Assert.Equal(expected, DynamicHlsController.GetEffectiveSegmentLength(true, 6, options));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Outside HA mode the requested segment length is used unchanged and the playlist is unbounded.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
[Trait("Category", "UnitTest")]
|
||||
public void GetSegmentingOptions_OutsideHaMode_UsesRequestedValues()
|
||||
{
|
||||
var options = new EncodingOptions { RecoverySegmentLengthSeconds = 2, RecoverySegmentBufferCount = 5 };
|
||||
|
||||
Assert.Equal(6, DynamicHlsController.GetEffectiveSegmentLength(false, 6, options));
|
||||
Assert.Equal(0, DynamicHlsController.GetHlsListSize(false, options));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HA mode keeps a bounded rolling buffer of segments for a takeover pod to serve.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(5, 5)]
|
||||
[InlineData(0, 2)]
|
||||
[InlineData(100, 10)]
|
||||
[Trait("Category", "UnitTest")]
|
||||
public void GetHlsListSize_InHaMode_ClampsConfiguredBufferCount(int configured, int expected)
|
||||
{
|
||||
var options = new EncodingOptions { RecoverySegmentBufferCount = configured };
|
||||
|
||||
Assert.Equal(expected, DynamicHlsController.GetHlsListSize(true, options));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="Npgsql" />
|
||||
<PackageReference Include="Testcontainers.PostgreSql" />
|
||||
<PackageReference Include="xunit.v3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Jellyfin.Database\Jellyfin.Database.Providers.PostgreSQL\Jellyfin.Database.Providers.PostgreSQL.csproj" />
|
||||
<ProjectReference Include="..\..\src\Jellyfin.Database\Jellyfin.Database.Implementations\Jellyfin.Database.Implementations.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,129 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using DotNet.Testcontainers.Builders;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.DbConfiguration;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Database.Implementations.Locking;
|
||||
using Jellyfin.Database.Providers.PostgreSQL;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Npgsql;
|
||||
using Testcontainers.PostgreSql;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Database.Tests.PostgreSQL;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests that verify concurrent access patterns against a real PostgreSQL 16 container.
|
||||
/// </summary>
|
||||
[Xunit.Trait("Category", "RequiresDocker")]
|
||||
public sealed class PostgreSqlConcurrencyTests : IAsyncLifetime
|
||||
{
|
||||
private readonly PostgreSqlContainer _container;
|
||||
private NpgsqlDataSource? _dataSource;
|
||||
private PostgreSqlDatabaseProvider? _provider;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PostgreSqlConcurrencyTests"/> class.
|
||||
/// </summary>
|
||||
public PostgreSqlConcurrencyTests()
|
||||
{
|
||||
_container = new PostgreSqlBuilder("postgres:16-alpine")
|
||||
.WithWaitStrategy(Wait.ForUnixContainer().UntilCommandIsCompleted("pg_isready"))
|
||||
.Build();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts the PostgreSQL container and applies migrations before any tests in the class run.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
await _container.StartAsync().ConfigureAwait(false);
|
||||
|
||||
_dataSource = new NpgsqlDataSourceBuilder(_container.GetConnectionString()).Build();
|
||||
_provider = new PostgreSqlDatabaseProvider(_dataSource);
|
||||
|
||||
// Apply migrations once for the whole test class.
|
||||
var context = CreateContext();
|
||||
await using (context.ConfigureAwait(false))
|
||||
{
|
||||
await context.Database.MigrateAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops and removes the PostgreSQL container after all tests in the class have run.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_dataSource is not null)
|
||||
{
|
||||
await _dataSource.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await _container.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that concurrent inserts on <see cref="ActivityLog"/> from four parallel tasks succeed without deadlock.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ConcurrentInserts_ActivityLogs_SucceedWithoutDeadlock()
|
||||
{
|
||||
const int parallelTasks = 4;
|
||||
const int insertsPerTask = 10;
|
||||
|
||||
var tasks = new List<Task>(parallelTasks);
|
||||
for (var i = 0; i < parallelTasks; i++)
|
||||
{
|
||||
var taskIndex = i;
|
||||
tasks.Add(Task.Run(
|
||||
() => InsertBatchAsync(taskIndex, insertsPerTask),
|
||||
TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
// Verify all rows were inserted
|
||||
var verifyCtx = CreateContext();
|
||||
await using (verifyCtx)
|
||||
{
|
||||
var count = await verifyCtx.ActivityLogs
|
||||
.CountAsync(l => l.Type == "ConcurrencyTest", TestContext.Current.CancellationToken);
|
||||
Assert.Equal(parallelTasks * insertsPerTask, count);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task InsertBatchAsync(int taskIndex, int insertsPerTask)
|
||||
{
|
||||
var ctx = CreateContext();
|
||||
await using (ctx.ConfigureAwait(false))
|
||||
{
|
||||
for (var j = 0; j < insertsPerTask; j++)
|
||||
{
|
||||
ctx.ActivityLogs.Add(new ActivityLog(
|
||||
$"Task {taskIndex} Insert {j}",
|
||||
"ConcurrencyTest",
|
||||
Guid.Empty));
|
||||
}
|
||||
|
||||
await ctx.SaveChangesAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private JellyfinDbContext CreateContext()
|
||||
{
|
||||
var optionsBuilder = new DbContextOptionsBuilder<JellyfinDbContext>();
|
||||
_provider!.Initialise(optionsBuilder, new DatabaseConfigurationOptions { DatabaseType = "PostgreSQL" });
|
||||
return new JellyfinDbContext(
|
||||
optionsBuilder.Options,
|
||||
NullLogger<JellyfinDbContext>.Instance,
|
||||
_provider,
|
||||
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using System.Threading.Tasks;
|
||||
using DotNet.Testcontainers.Builders;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.DbConfiguration;
|
||||
using Jellyfin.Database.Implementations.Locking;
|
||||
using Jellyfin.Database.Providers.PostgreSQL;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Npgsql;
|
||||
using Testcontainers.PostgreSql;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Database.Tests.PostgreSQL;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests that validate PostgreSQL migrations against a real container.
|
||||
/// </summary>
|
||||
[Xunit.Trait("Category", "RequiresDocker")]
|
||||
public sealed class PostgreSqlMigrationTests : IAsyncLifetime
|
||||
{
|
||||
private readonly PostgreSqlContainer _container;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PostgreSqlMigrationTests"/> class.
|
||||
/// </summary>
|
||||
public PostgreSqlMigrationTests()
|
||||
{
|
||||
_container = new PostgreSqlBuilder("postgres:16-alpine")
|
||||
.WithWaitStrategy(Wait.ForUnixContainer().UntilCommandIsCompleted("pg_isready"))
|
||||
.Build();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts the PostgreSQL container before any tests in the class run.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
await _container.StartAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops and removes the PostgreSQL container after all tests in the class have run.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _container.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the <c>InitialPostgreSql</c> migration applies cleanly to a fresh PostgreSQL 16 container.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task MigrateAsync_AppliesInitialMigrationCleanly()
|
||||
{
|
||||
await using var dataSource = new NpgsqlDataSourceBuilder(_container.GetConnectionString()).Build();
|
||||
var context = CreateContext(dataSource);
|
||||
await using (context)
|
||||
{
|
||||
await context.Database.MigrateAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var pendingMigrations = await context.Database.GetPendingMigrationsAsync(TestContext.Current.CancellationToken);
|
||||
Assert.Empty(pendingMigrations);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that no pending model changes exist for the PostgreSQL provider,
|
||||
/// acting as a CI gate that fails when model changes are added without a corresponding migration.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CheckForUnappliedMigrations_PostgreSql()
|
||||
{
|
||||
// Use a dummy connection string; HasPendingModelChanges() is a purely in-memory check
|
||||
// that compares the current compiled model with the migration snapshots — no real DB needed.
|
||||
const string dummyConnectionString = "Host=localhost;Database=jellyfin;Username=postgres;Password=postgres";
|
||||
using var dataSource = new NpgsqlDataSourceBuilder(dummyConnectionString).Build();
|
||||
using var context = CreateContext(dataSource);
|
||||
|
||||
Assert.False(
|
||||
context.Database.HasPendingModelChanges(),
|
||||
"There are unapplied changes to the EFCore model for PostgreSQL. Please create a Migration.");
|
||||
}
|
||||
|
||||
private static JellyfinDbContext CreateContext(NpgsqlDataSource dataSource)
|
||||
{
|
||||
var optionsBuilder = new DbContextOptionsBuilder<JellyfinDbContext>();
|
||||
var provider = new PostgreSqlDatabaseProvider(dataSource);
|
||||
provider.Initialise(optionsBuilder, new DatabaseConfigurationOptions { DatabaseType = "PostgreSQL" });
|
||||
return new JellyfinDbContext(
|
||||
optionsBuilder.Options,
|
||||
NullLogger<JellyfinDbContext>.Instance,
|
||||
provider,
|
||||
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using DotNet.Testcontainers.Builders;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.DbConfiguration;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Database.Implementations.Locking;
|
||||
using Jellyfin.Database.Providers.PostgreSQL;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Npgsql;
|
||||
using Testcontainers.PostgreSql;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Database.Tests.PostgreSQL;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for CRUD operations, optimisation, and purge against a real PostgreSQL 16 container.
|
||||
/// </summary>
|
||||
[Xunit.Trait("Category", "RequiresDocker")]
|
||||
public sealed class PostgreSqlProviderTests : IAsyncLifetime
|
||||
{
|
||||
private readonly PostgreSqlContainer _container;
|
||||
private NpgsqlDataSource? _dataSource;
|
||||
private PostgreSqlDatabaseProvider? _provider;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PostgreSqlProviderTests"/> class.
|
||||
/// </summary>
|
||||
public PostgreSqlProviderTests()
|
||||
{
|
||||
_container = new PostgreSqlBuilder("postgres:16-alpine")
|
||||
.WithWaitStrategy(Wait.ForUnixContainer().UntilCommandIsCompleted("pg_isready"))
|
||||
.Build();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts the PostgreSQL container and applies migrations before any tests in the class run.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
await _container.StartAsync().ConfigureAwait(false);
|
||||
|
||||
_dataSource = new NpgsqlDataSourceBuilder(_container.GetConnectionString()).Build();
|
||||
_provider = new PostgreSqlDatabaseProvider(_dataSource);
|
||||
|
||||
// Apply migrations once for the whole test class.
|
||||
var context = CreateContext();
|
||||
await using (context.ConfigureAwait(false))
|
||||
{
|
||||
await context.Database.MigrateAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops and removes the PostgreSQL container after all tests in the class have run.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_dataSource is not null)
|
||||
{
|
||||
await _dataSource.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await _container.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies Create/Read/Update/Delete operations on <see cref="User"/>.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Crud_User()
|
||||
{
|
||||
var ctx = CreateContext();
|
||||
await using (ctx)
|
||||
{
|
||||
// Create
|
||||
var user = new User("testuser", "Jellyfin.Server.Implementations.Users.DefaultAuthenticationProvider", "Jellyfin.Server.Implementations.Users.DefaultPasswordResetProvider");
|
||||
ctx.Users.Add(user);
|
||||
await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var userId = user.Id;
|
||||
|
||||
// Read
|
||||
var read = await ctx.Users.FindAsync([userId], TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(read);
|
||||
Assert.Equal("testuser", read.Username);
|
||||
|
||||
// Update
|
||||
read.Username = "updateduser";
|
||||
await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var updated = await ctx.Users.FindAsync([userId], TestContext.Current.CancellationToken);
|
||||
Assert.Equal("updateduser", updated!.Username);
|
||||
|
||||
// Delete
|
||||
ctx.Users.Remove(updated);
|
||||
await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var deleted = await ctx.Users.FindAsync([userId], TestContext.Current.CancellationToken);
|
||||
Assert.Null(deleted);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies Create/Read/Update/Delete operations on <see cref="ActivityLog"/>.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Crud_ActivityLog()
|
||||
{
|
||||
var ctx = CreateContext();
|
||||
await using (ctx)
|
||||
{
|
||||
// Create
|
||||
var log = new ActivityLog("Test activity", "TestType", Guid.Empty);
|
||||
ctx.ActivityLogs.Add(log);
|
||||
await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var logId = log.Id;
|
||||
|
||||
// Read
|
||||
var read = await ctx.ActivityLogs.FindAsync([logId], TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(read);
|
||||
Assert.Equal("Test activity", read.Name);
|
||||
|
||||
// Update
|
||||
read.Overview = "Updated overview";
|
||||
await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var updated = await ctx.ActivityLogs.FindAsync([logId], TestContext.Current.CancellationToken);
|
||||
Assert.Equal("Updated overview", updated!.Overview);
|
||||
|
||||
// Delete
|
||||
ctx.ActivityLogs.Remove(updated);
|
||||
await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var deleted = await ctx.ActivityLogs.FindAsync([logId], TestContext.Current.CancellationToken);
|
||||
Assert.Null(deleted);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies Create/Read/Update/Delete operations on <see cref="DisplayPreferences"/>.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Crud_DisplayPreferences()
|
||||
{
|
||||
var ctx = CreateContext();
|
||||
await using (ctx)
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
var itemId = Guid.NewGuid();
|
||||
|
||||
// Create
|
||||
var prefs = new DisplayPreferences(userId, itemId, "TestClient");
|
||||
ctx.DisplayPreferences.Add(prefs);
|
||||
await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var prefsId = prefs.Id;
|
||||
|
||||
// Read
|
||||
var read = await ctx.DisplayPreferences.FindAsync([prefsId], TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(read);
|
||||
Assert.Equal("TestClient", read.Client);
|
||||
|
||||
// Update
|
||||
read.ShowSidebar = true;
|
||||
await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var updated = await ctx.DisplayPreferences.FindAsync([prefsId], TestContext.Current.CancellationToken);
|
||||
Assert.True(updated!.ShowSidebar);
|
||||
|
||||
// Delete
|
||||
ctx.DisplayPreferences.Remove(updated);
|
||||
await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var deleted = await ctx.DisplayPreferences.FindAsync([prefsId], TestContext.Current.CancellationToken);
|
||||
Assert.Null(deleted);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies Create/Read/Update/Delete operations on <see cref="BaseItemEntity"/>, <see cref="Chapter"/>, and <see cref="MediaStreamInfo"/>.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task Crud_BaseItem_Chapter_MediaStream()
|
||||
{
|
||||
var ctx = CreateContext();
|
||||
await using (ctx)
|
||||
{
|
||||
var itemId = Guid.NewGuid();
|
||||
|
||||
// Create BaseItem
|
||||
var item = new BaseItemEntity { Id = itemId, Type = "Movie", Name = "Test Movie" };
|
||||
ctx.BaseItems.Add(item);
|
||||
await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
// Create Chapter linked to BaseItem
|
||||
var chapter = new Chapter { ItemId = itemId, Item = item, ChapterIndex = 0, StartPositionTicks = 0, Name = "Intro" };
|
||||
ctx.Chapters.Add(chapter);
|
||||
|
||||
// Create MediaStreamInfo linked to BaseItem
|
||||
var stream = new MediaStreamInfo { ItemId = itemId, Item = item, StreamIndex = 0, StreamType = MediaStreamTypeEntity.Video };
|
||||
ctx.MediaStreamInfos.Add(stream);
|
||||
|
||||
await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
// Read
|
||||
var readItem = await ctx.BaseItems
|
||||
.Include(i => i.Chapters)
|
||||
.Include(i => i.MediaStreams)
|
||||
.FirstOrDefaultAsync(i => i.Id.Equals(itemId), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(readItem);
|
||||
Assert.Equal("Test Movie", readItem.Name);
|
||||
Assert.Single(readItem.Chapters!);
|
||||
Assert.Single(readItem.MediaStreams!);
|
||||
|
||||
// Update
|
||||
readItem.Name = "Updated Movie";
|
||||
await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var updated = await ctx.BaseItems.FindAsync([itemId], TestContext.Current.CancellationToken);
|
||||
Assert.Equal("Updated Movie", updated!.Name);
|
||||
|
||||
// Delete (cascades to Chapter and MediaStreamInfo)
|
||||
ctx.BaseItems.Remove(updated);
|
||||
await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var deleted = await ctx.BaseItems.FindAsync([itemId], TestContext.Current.CancellationToken);
|
||||
Assert.Null(deleted);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that <see cref="PostgreSqlDatabaseProvider.RunScheduledOptimisation"/> executes ANALYZE without error.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunScheduledOptimisation_ExecutesWithoutError()
|
||||
{
|
||||
var ctx = CreateContext();
|
||||
await using (ctx)
|
||||
{
|
||||
var factory = new TestDbContextFactory(ctx);
|
||||
_provider!.DbContextFactory = factory;
|
||||
|
||||
await _provider.RunScheduledOptimisation(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that <see cref="PostgreSqlDatabaseProvider.PurgeDatabase"/> empties tables and resets <c>session_replication_role</c>.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task PurgeDatabase_EmptiesTablesAndResetsFkRole()
|
||||
{
|
||||
var ctx = CreateContext();
|
||||
await using (ctx)
|
||||
{
|
||||
// Seed a row
|
||||
ctx.ActivityLogs.Add(new ActivityLog("Purge test", "TestType", Guid.Empty));
|
||||
await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(await ctx.ActivityLogs.AnyAsync(TestContext.Current.CancellationToken));
|
||||
|
||||
// Purge
|
||||
await _provider!.PurgeDatabase(ctx, ["ActivityLogs"]);
|
||||
|
||||
// session_replication_role should be reset to 'origin' (default)
|
||||
var role = await ctx.Database
|
||||
.SqlQueryRaw<string>("SELECT current_setting('session_replication_role')")
|
||||
.FirstAsync(TestContext.Current.CancellationToken);
|
||||
Assert.Equal("origin", role);
|
||||
}
|
||||
|
||||
// Verify table is empty via a fresh context
|
||||
var freshCtx = CreateContext();
|
||||
await using (freshCtx)
|
||||
{
|
||||
Assert.False(await freshCtx.ActivityLogs.AnyAsync(TestContext.Current.CancellationToken));
|
||||
}
|
||||
}
|
||||
|
||||
private JellyfinDbContext CreateContext()
|
||||
{
|
||||
var optionsBuilder = new DbContextOptionsBuilder<JellyfinDbContext>();
|
||||
_provider!.Initialise(optionsBuilder, new DatabaseConfigurationOptions { DatabaseType = "PostgreSQL" });
|
||||
return new JellyfinDbContext(
|
||||
optionsBuilder.Options,
|
||||
NullLogger<JellyfinDbContext>.Instance,
|
||||
_provider,
|
||||
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A minimal <see cref="IDbContextFactory{TContext}"/> wrapper that returns a pre-existing context.
|
||||
/// </summary>
|
||||
private sealed class TestDbContextFactory : IDbContextFactory<JellyfinDbContext>
|
||||
{
|
||||
private readonly JellyfinDbContext _context;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TestDbContextFactory"/> class.
|
||||
/// </summary>
|
||||
/// <param name="context">The context to return from <see cref="CreateDbContext"/>.</param>
|
||||
public TestDbContextFactory(JellyfinDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the pre-existing <see cref="JellyfinDbContext"/> instance.
|
||||
/// </summary>
|
||||
/// <returns>The pre-existing <see cref="JellyfinDbContext"/> instance.</returns>
|
||||
public JellyfinDbContext CreateDbContext() => _context;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the pre-existing <see cref="JellyfinDbContext"/> instance as a completed task.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A cancellation token (unused).</param>
|
||||
/// <returns>A <see cref="Task{TResult}"/> containing the pre-existing <see cref="JellyfinDbContext"/> instance.</returns>
|
||||
public Task<JellyfinDbContext> CreateDbContextAsync(CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult(_context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
|
||||
namespace Jellyfin.MediaEncoding.Tests.Fakes;
|
||||
|
||||
/// <summary>
|
||||
/// Thread-safe, in-memory implementation of <see cref="ITranscodeSessionStore"/> for use in unit tests.
|
||||
/// </summary>
|
||||
public sealed class InMemoryTranscodeSessionStore : ITranscodeSessionStore
|
||||
{
|
||||
/// <summary>
|
||||
/// The duration added to <see cref="DateTime.UtcNow"/> when a lease is renewed or first claimed.
|
||||
/// </summary>
|
||||
public static readonly TimeSpan DefaultLeaseDuration = TimeSpan.FromSeconds(30);
|
||||
|
||||
private readonly Dictionary<string, TranscodeSession> _sessions = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Lock _lock = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TranscodeSession?> TryGetAsync(string playSessionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_sessions.TryGetValue(playSessionId, out var session) && session.LeaseExpiresUtc > DateTime.UtcNow)
|
||||
{
|
||||
return Task.FromResult<TranscodeSession?>(Clone(session));
|
||||
}
|
||||
|
||||
return Task.FromResult<TranscodeSession?>(null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> TryTakeoverAsync(string playSessionId, string claimingPod, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_sessions.TryGetValue(playSessionId, out var session))
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
if (session.LeaseExpiresUtc > DateTime.UtcNow)
|
||||
{
|
||||
// Another pod's lease is still valid – takeover not permitted.
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
// Lease has expired – claim it atomically.
|
||||
session.OwnerPod = claimingPod;
|
||||
session.LeaseExpiresUtc = DateTime.UtcNow.Add(DefaultLeaseDuration);
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SetAsync(TranscodeSession session, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_sessions[session.PlaySessionId] = session;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> RenewLeaseAsync(string playSessionId, string ownerPod, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_sessions.TryGetValue(playSessionId, out var session)
|
||||
|| !string.Equals(session.OwnerPod, ownerPod, StringComparison.Ordinal))
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
session.LeaseExpiresUtc = DateTime.UtcNow.Add(DefaultLeaseDuration);
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task DeleteAsync(string playSessionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_sessions.Remove(playSessionId);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IEnumerable<TranscodeSession>> GetActiveSessionsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var sessions = _sessions.Values.Select(Clone).ToList();
|
||||
return Task.FromResult<IEnumerable<TranscodeSession>>(sessions);
|
||||
}
|
||||
}
|
||||
|
||||
private static TranscodeSession Clone(TranscodeSession source)
|
||||
=> new TranscodeSession
|
||||
{
|
||||
PlaySessionId = source.PlaySessionId,
|
||||
OwnerPod = source.OwnerPod,
|
||||
LeaseExpiresUtc = source.LeaseExpiresUtc,
|
||||
ManifestPath = source.ManifestPath,
|
||||
SegmentPathPrefix = source.SegmentPathPrefix,
|
||||
MediaSourceId = source.MediaSourceId,
|
||||
LastCompletedSegmentIndex = source.LastCompletedSegmentIndex,
|
||||
LastDurablePlaybackOffset = source.LastDurablePlaybackOffset,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.MediaEncoding.Tests.Fakes;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.MediaEncoding.Tests.Transcoding;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the <see cref="ITranscodeSessionStore"/> contract — lease expiry, double-claim
|
||||
/// prevention, ownership-checked renewal and stale-session cleanup — against
|
||||
/// <see cref="InMemoryTranscodeSessionStore"/>, the reference implementation. The Redis-backed
|
||||
/// implementation is covered by <c>RedisTranscodeSessionStoreTests</c> against a real Redis.
|
||||
/// </summary>
|
||||
public class InMemoryTranscodeSessionStoreTests
|
||||
{
|
||||
private static TranscodeSession CreateSession(
|
||||
string id,
|
||||
string pod,
|
||||
DateTime leaseExpiry,
|
||||
int lastSegmentIndex = 0,
|
||||
long lastOffset = 0L)
|
||||
=> new TranscodeSession
|
||||
{
|
||||
PlaySessionId = id,
|
||||
OwnerPod = pod,
|
||||
LeaseExpiresUtc = leaseExpiry,
|
||||
ManifestPath = $"/transcode/{id}/manifest.m3u8",
|
||||
SegmentPathPrefix = $"/transcode/{id}/segment",
|
||||
MediaSourceId = $"media-source-{id}",
|
||||
LastCompletedSegmentIndex = lastSegmentIndex,
|
||||
LastDurablePlaybackOffset = lastOffset,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Lease expiry: <see cref="ITranscodeSessionStore.TryGetAsync"/> returns <c>null</c>
|
||||
/// once <see cref="TranscodeSession.LeaseExpiresUtc"/> has passed.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
[Trait("Category", "UnitTest")]
|
||||
public async Task TryGetAsync_AfterLeaseExpires_ReturnsNull()
|
||||
{
|
||||
var store = new InMemoryTranscodeSessionStore();
|
||||
var session = CreateSession("session-expired", "pod-a", DateTime.UtcNow.AddMilliseconds(-1));
|
||||
await store.SetAsync(session, TestContext.Current.CancellationToken);
|
||||
|
||||
var result = await store.TryGetAsync("session-expired", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A session whose lease has not yet expired is returned correctly.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
[Trait("Category", "UnitTest")]
|
||||
public async Task TryGetAsync_WithinLease_ReturnsSession()
|
||||
{
|
||||
var store = new InMemoryTranscodeSessionStore();
|
||||
var session = CreateSession("session-live", "pod-a", DateTime.UtcNow.AddMinutes(5), lastSegmentIndex: 3, lastOffset: 18_000_000L);
|
||||
await store.SetAsync(session, TestContext.Current.CancellationToken);
|
||||
|
||||
var result = await store.TryGetAsync("session-live", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("session-live", result.PlaySessionId);
|
||||
Assert.Equal("pod-a", result.OwnerPod);
|
||||
Assert.Equal(3, result.LastCompletedSegmentIndex);
|
||||
Assert.Equal(18_000_000L, result.LastDurablePlaybackOffset);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Double-claim prevention: <see cref="ITranscodeSessionStore.TryTakeoverAsync"/> returns
|
||||
/// <c>false</c> while the first pod's lease is still valid.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
[Trait("Category", "UnitTest")]
|
||||
public async Task TryTakeoverAsync_WhileLeaseValid_ReturnsFalse()
|
||||
{
|
||||
var store = new InMemoryTranscodeSessionStore();
|
||||
var session = CreateSession("session-valid", "pod-a", DateTime.UtcNow.AddMinutes(5));
|
||||
await store.SetAsync(session, TestContext.Current.CancellationToken);
|
||||
|
||||
var firstAttempt = await store.TryTakeoverAsync("session-valid", "pod-b", TestContext.Current.CancellationToken);
|
||||
var secondAttempt = await store.TryTakeoverAsync("session-valid", "pod-c", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(firstAttempt);
|
||||
Assert.False(secondAttempt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// After a lease expires, the first concurrent caller that invokes
|
||||
/// <see cref="ITranscodeSessionStore.TryTakeoverAsync"/> wins; the second caller returns <c>false</c>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
[Trait("Category", "UnitTest")]
|
||||
public async Task TryTakeoverAsync_AfterLeaseExpires_OnlyFirstClaimerSucceeds()
|
||||
{
|
||||
var store = new InMemoryTranscodeSessionStore();
|
||||
var session = CreateSession("session-stale", "pod-a", DateTime.UtcNow.AddMilliseconds(-1));
|
||||
await store.SetAsync(session, TestContext.Current.CancellationToken);
|
||||
|
||||
// First pod wins; its takeover renews the lease atomically.
|
||||
var firstTakeover = await store.TryTakeoverAsync("session-stale", "pod-b", TestContext.Current.CancellationToken);
|
||||
|
||||
// Second pod is too late – pod-b already holds a fresh lease.
|
||||
var secondTakeover = await store.TryTakeoverAsync("session-stale", "pod-c", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(firstTakeover);
|
||||
Assert.False(secondTakeover);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Heartbeat renewal: <see cref="ITranscodeSessionStore.RenewLeaseAsync"/> extends
|
||||
/// <see cref="TranscodeSession.LeaseExpiresUtc"/> beyond its original value.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
[Trait("Category", "UnitTest")]
|
||||
public async Task RenewLeaseAsync_ExtendsLeaseExpiry()
|
||||
{
|
||||
var store = new InMemoryTranscodeSessionStore();
|
||||
var originalExpiry = DateTime.UtcNow.AddSeconds(5);
|
||||
var session = CreateSession("session-renew", "pod-a", originalExpiry, lastSegmentIndex: 2, lastOffset: 10_000_000L);
|
||||
await store.SetAsync(session, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(await store.RenewLeaseAsync("session-renew", "pod-a", TestContext.Current.CancellationToken));
|
||||
|
||||
var renewed = await store.TryGetAsync("session-renew", TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(renewed);
|
||||
Assert.True(
|
||||
renewed.LeaseExpiresUtc > originalExpiry,
|
||||
"Renewed lease expiry should be later than the original expiry.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A renewal from a pod that no longer owns the lease must fail and must not revert ownership.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
[Trait("Category", "UnitTest")]
|
||||
public async Task RenewLeaseAsync_ByNonOwner_ReturnsFalse()
|
||||
{
|
||||
var store = new InMemoryTranscodeSessionStore();
|
||||
var session = CreateSession("session-renew-other", "pod-a", DateTime.UtcNow.AddMilliseconds(-1));
|
||||
await store.SetAsync(session, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(await store.TryTakeoverAsync("session-renew-other", "pod-b", TestContext.Current.CancellationToken));
|
||||
Assert.False(await store.RenewLeaseAsync("session-renew-other", "pod-a", TestContext.Current.CancellationToken));
|
||||
|
||||
var current = await store.TryGetAsync("session-renew-other", TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(current);
|
||||
Assert.Equal("pod-b", current.OwnerPod);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stale-session cleanup: an expired session can be deleted without error, and a
|
||||
/// subsequent <see cref="ITranscodeSessionStore.TryGetAsync"/> returns <c>null</c>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
[Trait("Category", "UnitTest")]
|
||||
public async Task DeleteAsync_ExpiredSession_CompletesWithoutError()
|
||||
{
|
||||
var store = new InMemoryTranscodeSessionStore();
|
||||
var session = CreateSession("session-delete", "pod-a", DateTime.UtcNow.AddMilliseconds(-1));
|
||||
await store.SetAsync(session, TestContext.Current.CancellationToken);
|
||||
|
||||
var ex = await Record.ExceptionAsync(() => store.DeleteAsync("session-delete", TestContext.Current.CancellationToken));
|
||||
Assert.Null(ex);
|
||||
|
||||
var result = await store.TryGetAsync("session-delete", TestContext.Current.CancellationToken);
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deleting a session that was never stored must complete without error.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
[Trait("Category", "UnitTest")]
|
||||
public async Task DeleteAsync_NonExistentSession_CompletesWithoutError()
|
||||
{
|
||||
var store = new InMemoryTranscodeSessionStore();
|
||||
|
||||
var ex = await Record.ExceptionAsync(() => store.DeleteAsync("nonexistent-session", TestContext.Current.CancellationToken));
|
||||
|
||||
Assert.Null(ex);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.IO;
|
||||
using System.Xml.Serialization;
|
||||
using MediaBrowser.Model.Configuration;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Model.Tests.Configuration;
|
||||
|
||||
public class EncodingOptionsTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("<EncoderPreset></EncoderPreset>")]
|
||||
[InlineData("<EncoderPreset />")]
|
||||
[InlineData("<EncoderPreset> </EncoderPreset>")]
|
||||
[InlineData("<EncoderPreset>notapreset</EncoderPreset>")]
|
||||
[InlineData("<EncoderPreset>42</EncoderPreset>")]
|
||||
[InlineData("<EncoderPreset>-1</EncoderPreset>")]
|
||||
public void Deserialize_UnreadableEncoderPreset_FallsBackToDefault(string encoderPresetElement)
|
||||
{
|
||||
var options = Deserialize($"<EncodingOptions>{encoderPresetElement}<H264Crf>21</H264Crf></EncodingOptions>");
|
||||
|
||||
Assert.Equal(EncoderPreset.auto, options.EncoderPreset);
|
||||
|
||||
// The rest of the file has to survive: a throwing preset used to discard every other encoding setting.
|
||||
Assert.Equal(21, options.H264Crf);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_KnownEncoderPreset_IsKept()
|
||||
{
|
||||
var options = Deserialize("<EncodingOptions><EncoderPreset>veryfast</EncoderPreset></EncodingOptions>");
|
||||
|
||||
Assert.Equal(EncoderPreset.veryfast, options.EncoderPreset);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Serialize_WritesTheEncoderPresetElement()
|
||||
{
|
||||
var serializer = new XmlSerializer(typeof(EncodingOptions));
|
||||
using var writer = new StringWriter();
|
||||
serializer.Serialize(writer, new EncodingOptions { EncoderPreset = EncoderPreset.slow });
|
||||
|
||||
Assert.Contains("<EncoderPreset>slow</EncoderPreset>", writer.ToString(), System.StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static EncodingOptions Deserialize(string xml)
|
||||
{
|
||||
var serializer = new XmlSerializer(typeof(EncodingOptions));
|
||||
using var reader = new StringReader(xml);
|
||||
return (EncodingOptions)serializer.Deserialize(reader)!;
|
||||
}
|
||||
}
|
||||
+2
@@ -18,6 +18,8 @@
|
||||
<PackageReference Include="AutoFixture.AutoMoq" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="StackExchange.Redis" />
|
||||
<PackageReference Include="Testcontainers.Redis" />
|
||||
<PackageReference Include="xunit.v3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
|
||||
+285
@@ -0,0 +1,285 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Emby.Server.Implementations.MediaEncoding;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StackExchange.Redis;
|
||||
using Testcontainers.Redis;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Server.Implementations.Tests.MediaEncoding;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests for <see cref="RedisTranscodeSessionStore"/> and its Lua scripts against a
|
||||
/// real Redis container.
|
||||
/// </summary>
|
||||
[Trait("Category", "RequiresDocker")]
|
||||
public sealed class RedisTranscodeSessionStoreTests : IAsyncLifetime
|
||||
{
|
||||
private readonly RedisContainer _container;
|
||||
private IConnectionMultiplexer? _redis;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="RedisTranscodeSessionStoreTests"/> class.
|
||||
/// </summary>
|
||||
public RedisTranscodeSessionStoreTests()
|
||||
{
|
||||
_container = new RedisBuilder("redis:7-alpine").Build();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts the Redis container before any tests in the class run.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
await _container.StartAsync().ConfigureAwait(false);
|
||||
_redis = await ConnectionMultiplexer.ConnectAsync(_container.GetConnectionString()).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops and removes the Redis container after all tests in the class have run.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_redis is not null)
|
||||
{
|
||||
await _redis.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await _container.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A stored session round-trips through Redis with the paths cleanup relies on intact.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task SetAsync_RoundTripsSession()
|
||||
{
|
||||
var store = CreateStore();
|
||||
var id = NewSessionId();
|
||||
var session = TranscodeSession.CreateForPlaylist(id, "media-1", "pod-a", "/transcodes/abc.m3u8", TimeSpan.FromSeconds(30));
|
||||
|
||||
await store.SetAsync(session, TestContext.Current.CancellationToken);
|
||||
|
||||
var stored = await store.TryGetAsync(id, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(stored);
|
||||
Assert.Equal("pod-a", stored.OwnerPod);
|
||||
Assert.Equal("media-1", stored.MediaSourceId);
|
||||
Assert.Equal("/transcodes/abc.m3u8", stored.ManifestPath);
|
||||
Assert.Equal("/transcodes/abc", stored.SegmentPathPrefix);
|
||||
Assert.True(stored.LeaseExpiresUtc > DateTime.UtcNow);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The owning pod can extend its own lease.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RenewLeaseAsync_ByOwner_ExtendsLease()
|
||||
{
|
||||
var store = CreateStore(leaseSeconds: 4);
|
||||
var id = NewSessionId();
|
||||
await store.SetAsync(NewSession(id, "pod-a", TimeSpan.FromSeconds(4)), TestContext.Current.CancellationToken);
|
||||
|
||||
var before = await store.TryGetAsync(id, TestContext.Current.CancellationToken);
|
||||
await Task.Delay(TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(await store.RenewLeaseAsync(id, "pod-a", TestContext.Current.CancellationToken));
|
||||
|
||||
var after = await store.TryGetAsync(id, TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(before);
|
||||
Assert.NotNull(after);
|
||||
Assert.True(after.LeaseExpiresUtc > before.LeaseExpiresUtc);
|
||||
Assert.Equal("pod-a", after.OwnerPod);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A pod that does not own the lease cannot renew it, and its attempt leaves the owner alone.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RenewLeaseAsync_ByNonOwner_ReturnsFalse()
|
||||
{
|
||||
var store = CreateStore();
|
||||
var id = NewSessionId();
|
||||
await store.SetAsync(NewSession(id, "pod-a", TimeSpan.FromSeconds(30)), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(await store.RenewLeaseAsync(id, "pod-b", TestContext.Current.CancellationToken));
|
||||
|
||||
var stored = await store.TryGetAsync(id, TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(stored);
|
||||
Assert.Equal("pod-a", stored.OwnerPod);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renewal of a session that is gone fails instead of recreating it.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RenewLeaseAsync_AfterDelete_ReturnsFalse()
|
||||
{
|
||||
var store = CreateStore();
|
||||
var id = NewSessionId();
|
||||
await store.SetAsync(NewSession(id, "pod-a", TimeSpan.FromSeconds(30)), TestContext.Current.CancellationToken);
|
||||
await store.DeleteAsync(id, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(await store.RenewLeaseAsync(id, "pod-a", TestContext.Current.CancellationToken));
|
||||
Assert.Null(await store.TryGetAsync(id, TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A valid lease blocks takeover.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task TryTakeoverAsync_WhileLeaseValid_ReturnsFalse()
|
||||
{
|
||||
var store = CreateStore();
|
||||
var id = NewSessionId();
|
||||
await store.SetAsync(NewSession(id, "pod-a", TimeSpan.FromSeconds(30)), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(await store.TryTakeoverAsync(id, "pod-b", TestContext.Current.CancellationToken));
|
||||
|
||||
var stored = await store.TryGetAsync(id, TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(stored);
|
||||
Assert.Equal("pod-a", stored.OwnerPod);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Once the lease expires the session record is still retained, so another pod can claim it.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task TryTakeoverAsync_AfterLeaseExpires_TransfersOwnership()
|
||||
{
|
||||
var store = CreateStore(leaseSeconds: 1);
|
||||
var id = NewSessionId();
|
||||
await store.SetAsync(NewSession(id, "pod-a", TimeSpan.FromSeconds(1)), TestContext.Current.CancellationToken);
|
||||
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(1200), TestContext.Current.CancellationToken);
|
||||
Assert.Null(await store.TryGetAsync(id, TestContext.Current.CancellationToken));
|
||||
|
||||
Assert.True(await store.TryTakeoverAsync(id, "pod-b", TestContext.Current.CancellationToken));
|
||||
|
||||
var stored = await store.TryGetAsync(id, TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(stored);
|
||||
Assert.Equal("pod-b", stored.OwnerPod);
|
||||
Assert.Equal("/transcodes/" + id + ".m3u8", stored.ManifestPath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Only one of several pods racing for an expired lease wins it.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task TryTakeoverAsync_ConcurrentClaims_OnlyOneWins()
|
||||
{
|
||||
var store = CreateStore(leaseSeconds: 1);
|
||||
var id = NewSessionId();
|
||||
await store.SetAsync(NewSession(id, "pod-a", TimeSpan.FromSeconds(1)), TestContext.Current.CancellationToken);
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(1200), TestContext.Current.CancellationToken);
|
||||
|
||||
var claims = Enumerable.Range(0, 10)
|
||||
.Select(i => store.TryTakeoverAsync(id, "pod-" + i.ToString(CultureInfo.InvariantCulture), TestContext.Current.CancellationToken))
|
||||
.ToList();
|
||||
|
||||
var results = await Task.WhenAll(claims);
|
||||
|
||||
Assert.Equal(1, results.Count(won => won));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The race behind the non-atomic renewal: after another pod wins the takeover, a renewal from
|
||||
/// the previous owner must fail rather than restore its own ownership and lease.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RenewLeaseAsync_AfterLosingTakeover_DoesNotRevertOwner()
|
||||
{
|
||||
var store = CreateStore(leaseSeconds: 1);
|
||||
var id = NewSessionId();
|
||||
await store.SetAsync(NewSession(id, "pod-a", TimeSpan.FromSeconds(1)), TestContext.Current.CancellationToken);
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(1200), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(await store.TryTakeoverAsync(id, "pod-b", TestContext.Current.CancellationToken));
|
||||
Assert.False(await store.RenewLeaseAsync(id, "pod-a", TestContext.Current.CancellationToken));
|
||||
|
||||
var stored = await store.TryGetAsync(id, TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(stored);
|
||||
Assert.Equal("pod-b", stored.OwnerPod);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renewal and takeover racing at the moment the lease expires always leave exactly one owner:
|
||||
/// the claiming pod when the takeover wins, the original pod when the renewal got in first.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RenewLeaseAsync_RacingTakeover_LeavesSingleConsistentOwner()
|
||||
{
|
||||
var store = CreateStore(leaseSeconds: 1);
|
||||
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
var id = NewSessionId();
|
||||
await store.SetAsync(NewSession(id, "pod-a", TimeSpan.FromSeconds(1)), TestContext.Current.CancellationToken);
|
||||
|
||||
// Both calls are issued at the expiry boundary so their order is genuinely undefined.
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(1000), TestContext.Current.CancellationToken);
|
||||
var renewal = store.RenewLeaseAsync(id, "pod-a", TestContext.Current.CancellationToken);
|
||||
var takeover = store.TryTakeoverAsync(id, "pod-b", TestContext.Current.CancellationToken);
|
||||
|
||||
var renewed = await renewal;
|
||||
var tookOver = await takeover;
|
||||
|
||||
var stored = await store.TryGetAsync(id, TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(stored);
|
||||
Assert.False(renewed && tookOver, "A renewal and a takeover must not both succeed for the same lease.");
|
||||
Assert.Equal(tookOver ? "pod-b" : "pod-a", stored.OwnerPod);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cleanup reads the active sessions, so a session whose lease has lapsed must not be reported
|
||||
/// as active even while its record is retained for takeover.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task GetActiveSessionsAsync_ExcludesExpiredLeases()
|
||||
{
|
||||
var store = CreateStore(leaseSeconds: 1);
|
||||
var expiredId = NewSessionId();
|
||||
await store.SetAsync(NewSession(expiredId, "pod-a", TimeSpan.FromSeconds(1)), TestContext.Current.CancellationToken);
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(1200), TestContext.Current.CancellationToken);
|
||||
|
||||
var liveId = NewSessionId();
|
||||
await store.SetAsync(NewSession(liveId, "pod-a", TimeSpan.FromSeconds(30)), TestContext.Current.CancellationToken);
|
||||
|
||||
var active = (await store.GetActiveSessionsAsync(TestContext.Current.CancellationToken)).ToList();
|
||||
|
||||
Assert.Contains(active, s => string.Equals(s.PlaySessionId, liveId, StringComparison.Ordinal));
|
||||
Assert.DoesNotContain(active, s => string.Equals(s.PlaySessionId, expiredId, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private static string NewSessionId() => "play-" + Guid.NewGuid().ToString("N");
|
||||
|
||||
private static TranscodeSession NewSession(string id, string pod, TimeSpan leaseDuration)
|
||||
=> TranscodeSession.CreateForPlaylist(id, "media-" + id, pod, "/transcodes/" + id + ".m3u8", leaseDuration);
|
||||
|
||||
private RedisTranscodeSessionStore CreateStore(int leaseSeconds = 30, int retentionSeconds = 300)
|
||||
=> new RedisTranscodeSessionStore(
|
||||
_redis!,
|
||||
Options.Create(new TranscodeStoreOptions
|
||||
{
|
||||
LeaseDurationSeconds = leaseSeconds,
|
||||
SessionRetentionSeconds = retentionSeconds
|
||||
}),
|
||||
NullLogger<RedisTranscodeSessionStore>.Instance);
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using Emby.Server.Implementations.MediaEncoding;
|
||||
using Jellyfin.Server;
|
||||
using Jellyfin.Server.Extensions;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using StackExchange.Redis;
|
||||
using Testcontainers.Redis;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Server.Implementations.Tests.MediaEncoding;
|
||||
|
||||
/// <summary>
|
||||
/// Drives the whole configuration path a deployment uses: a bare <c>Jellyfin__TranscodeStore__*</c>
|
||||
/// environment variable, the server's own configuration builder, the store registration, and a
|
||||
/// session round-trip against a real Valkey server.
|
||||
/// </summary>
|
||||
[Trait("Category", "RequiresDocker")]
|
||||
public sealed class TranscodeStoreWiringTests : IAsyncLifetime
|
||||
{
|
||||
private const string RedisConnectionStringVariable = "Jellyfin__TranscodeStore__RedisConnectionString";
|
||||
|
||||
private readonly RedisContainer _container;
|
||||
private string _configDirectory = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TranscodeStoreWiringTests"/> class.
|
||||
/// </summary>
|
||||
public TranscodeStoreWiringTests()
|
||||
{
|
||||
_container = new RedisBuilder("valkey/valkey:8-alpine").Build();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts Valkey and lays out the configuration directory the server reads at startup.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
await _container.StartAsync().ConfigureAwait(false);
|
||||
_configDirectory = Directory.CreateTempSubdirectory("jellyfin-wiring-test").FullName;
|
||||
await File.WriteAllTextAsync(Path.Combine(_configDirectory, "logging.default.json"), "{}").ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes the environment variable, configuration directory and container.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
Environment.SetEnvironmentVariable(RedisConnectionStringVariable, null);
|
||||
|
||||
if (_configDirectory.Length > 0)
|
||||
{
|
||||
Directory.Delete(_configDirectory, true);
|
||||
}
|
||||
|
||||
await _container.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The variable form deployments set selects the Redis store and that store really talks to Valkey.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ManifestStyleEnvironmentVariable_Should_Reach_Valkey()
|
||||
{
|
||||
Environment.SetEnvironmentVariable(
|
||||
RedisConnectionStringVariable,
|
||||
_container.GetConnectionString() + ",abortConnect=false");
|
||||
|
||||
var appPaths = new Mock<IApplicationPaths>();
|
||||
appPaths.Setup(paths => paths.ConfigurationDirectoryPath).Returns(_configDirectory);
|
||||
var configuration = Program.CreateAppConfiguration(new StartupOptions(), appPaths.Object);
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddTranscodeSessionStore(configuration, NullLogger.Instance);
|
||||
|
||||
await using var provider = services.BuildServiceProvider();
|
||||
|
||||
var store = provider.GetRequiredService<ITranscodeSessionStore>();
|
||||
Assert.IsType<RedisTranscodeSessionStore>(store);
|
||||
|
||||
var playSessionId = Guid.NewGuid().ToString("N");
|
||||
await store.SetAsync(
|
||||
TranscodeSession.CreateForPlaylist(playSessionId, "media-1", "pod-a", "/transcodes/abc.m3u8", TimeSpan.FromSeconds(30)),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
var stored = await store.TryGetAsync(playSessionId, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(stored);
|
||||
Assert.Equal("pod-a", stored.OwnerPod);
|
||||
|
||||
var redis = provider.GetRequiredService<IConnectionMultiplexer>();
|
||||
Assert.True(await redis.GetDatabase().KeyExistsAsync("jellyfin:transcode:" + playSessionId));
|
||||
}
|
||||
}
|
||||
+477
@@ -0,0 +1,477 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Controller.MediaEncoding;
|
||||
using MediaBrowser.Model.Configuration;
|
||||
using MediaBrowser.Model.IO;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Server.Implementations.Tests.ScheduledTasks;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the lease-aware cleanup behaviour of <c>DeleteTranscodeFileTask</c>: files belonging
|
||||
/// to a session whose lease is still live must survive a cleanup pass, and a store failure must
|
||||
/// abort the pass rather than risk deleting files in use.
|
||||
/// </summary>
|
||||
public class DeleteTranscodeFileTaskTests
|
||||
{
|
||||
private static TranscodeSession CreateSession(string id, string pod, DateTime leaseExpiry)
|
||||
=> new TranscodeSession
|
||||
{
|
||||
PlaySessionId = id,
|
||||
OwnerPod = pod,
|
||||
LeaseExpiresUtc = leaseExpiry,
|
||||
ManifestPath = $"/transcode/{id}/manifest.m3u8",
|
||||
SegmentPathPrefix = $"/transcode/{id}/segment",
|
||||
MediaSourceId = $"media-source-{id}",
|
||||
LastCompletedSegmentIndex = 2,
|
||||
LastDurablePlaybackOffset = 12_000_000L,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a mock <see cref="IConfigurationManager"/> that returns <paramref name="transcodePath"/>
|
||||
/// as the configured transcode path, used by the <c>GetTranscodePath</c> extension method.
|
||||
/// </summary>
|
||||
private static Mock<IConfigurationManager> CreateConfigMock(string transcodePath)
|
||||
{
|
||||
var appPathsMock = new Mock<IApplicationPaths>();
|
||||
appPathsMock
|
||||
.Setup(p => p.CreateAndCheckMarker(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<bool>()));
|
||||
|
||||
var configMock = new Mock<IConfigurationManager>();
|
||||
configMock
|
||||
.Setup(c => c.GetConfiguration("encoding"))
|
||||
.Returns(new EncodingOptions { TranscodingTempPath = transcodePath });
|
||||
configMock
|
||||
.Setup(c => c.CommonApplicationPaths)
|
||||
.Returns(appPathsMock.Object);
|
||||
|
||||
return configMock;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A directory that belongs to a session with a live lease must NOT be deleted.
|
||||
/// The store returns non-null, signalling to the cleanup task that the session is active.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
[Trait("Category", "UnitTest")]
|
||||
public async Task LiveLease_StoreReturnsSession_DirectoryShouldNotBeDeleted()
|
||||
{
|
||||
var store = new CleanupTestSessionStore();
|
||||
var session = CreateSession("cleanup-session-1", "pod-a", DateTime.UtcNow.AddMinutes(5));
|
||||
await store.SetAsync(session, TestContext.Current.CancellationToken);
|
||||
|
||||
// The cleanup task should query the store before deleting.
|
||||
var liveSession = await store.TryGetAsync("cleanup-session-1", TestContext.Current.CancellationToken);
|
||||
|
||||
// Non-null result → lease is active → directory must be retained.
|
||||
Assert.NotNull(liveSession);
|
||||
Assert.Equal("pod-a", liveSession.OwnerPod);
|
||||
Assert.True(liveSession.LeaseExpiresUtc > DateTime.UtcNow);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A directory whose session lease has expired beyond the recovery window MAY be deleted.
|
||||
/// The store returns <c>null</c>, signalling to the cleanup task that deletion is safe.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
[Trait("Category", "UnitTest")]
|
||||
public async Task ExpiredBeyondRecoveryWindow_StoreReturnsNull_DirectoryMayBeDeleted()
|
||||
{
|
||||
var store = new CleanupTestSessionStore();
|
||||
|
||||
// Lease expired two hours ago – beyond any reasonable recovery window.
|
||||
var session = CreateSession("cleanup-session-2", "pod-a", DateTime.UtcNow.AddHours(-2));
|
||||
await store.SetAsync(session, TestContext.Current.CancellationToken);
|
||||
|
||||
var liveSession = await store.TryGetAsync("cleanup-session-2", TestContext.Current.CancellationToken);
|
||||
|
||||
// Null result → lease is expired → cleanup task may delete the directory.
|
||||
Assert.Null(liveSession);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When no session record exists in the store for a given directory, the cleanup task
|
||||
/// should treat the directory as deletable (store returns <c>null</c>).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
[Trait("Category", "UnitTest")]
|
||||
public async Task NoSessionRecord_StoreReturnsNull_DirectoryMayBeDeleted()
|
||||
{
|
||||
var store = new CleanupTestSessionStore();
|
||||
|
||||
var liveSession = await store.TryGetAsync("unknown-session", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Null(liveSession);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Files that belong to an active session (manifest or segments) must NOT be deleted
|
||||
/// even when their modification time is older than <c>minDateModified</c>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
[Trait("Category", "UnitTest")]
|
||||
public async Task ExecuteAsync_WithActiveSession_DoesNotDeleteActiveFiles()
|
||||
{
|
||||
// Arrange
|
||||
const string TranscodePath = "/transcode";
|
||||
const string SessionId = "active-session-1";
|
||||
const string ManifestPath = "/transcode/active-session-1/manifest.m3u8";
|
||||
const string SegmentPath = "/transcode/active-session-1/segment0.ts";
|
||||
|
||||
var store = new CleanupTestSessionStore();
|
||||
var session = new TranscodeSession
|
||||
{
|
||||
PlaySessionId = SessionId,
|
||||
OwnerPod = "pod-a",
|
||||
LeaseExpiresUtc = DateTime.UtcNow.AddMinutes(5),
|
||||
ManifestPath = ManifestPath,
|
||||
SegmentPathPrefix = "/transcode/active-session-1/segment",
|
||||
MediaSourceId = "media-source-1",
|
||||
};
|
||||
await store.SetAsync(session, TestContext.Current.CancellationToken);
|
||||
|
||||
var deletedFiles = new List<string>();
|
||||
var oldModifyTime = DateTime.UtcNow.AddDays(-2);
|
||||
|
||||
var fileSystemMock = new Mock<IFileSystem>();
|
||||
fileSystemMock
|
||||
.Setup(fs => fs.GetFiles(TranscodePath, true))
|
||||
.Returns(new[]
|
||||
{
|
||||
new FileSystemMetadata { FullName = ManifestPath, IsDirectory = false },
|
||||
new FileSystemMetadata { FullName = SegmentPath, IsDirectory = false },
|
||||
});
|
||||
fileSystemMock
|
||||
.Setup(fs => fs.GetLastWriteTimeUtc(It.IsAny<FileSystemMetadata>()))
|
||||
.Returns(oldModifyTime);
|
||||
fileSystemMock
|
||||
.Setup(fs => fs.DeleteFile(It.IsAny<string>()))
|
||||
.Callback<string>(path => deletedFiles.Add(path));
|
||||
fileSystemMock
|
||||
.Setup(fs => fs.GetFiles(TranscodePath, false))
|
||||
.Returns(Enumerable.Empty<FileSystemMetadata>());
|
||||
fileSystemMock
|
||||
.Setup(fs => fs.GetDirectories(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.Returns(Enumerable.Empty<FileSystemMetadata>());
|
||||
|
||||
var configMock = CreateConfigMock(TranscodePath);
|
||||
|
||||
var localizationMock = new Mock<MediaBrowser.Model.Globalization.ILocalizationManager>();
|
||||
localizationMock
|
||||
.Setup(l => l.GetLocalizedString(It.IsAny<string>()))
|
||||
.Returns<string>(s => s);
|
||||
|
||||
var loggerMock = new Mock<Microsoft.Extensions.Logging.ILogger<Emby.Server.Implementations.ScheduledTasks.Tasks.DeleteTranscodeFileTask>>();
|
||||
|
||||
var task = new Emby.Server.Implementations.ScheduledTasks.Tasks.DeleteTranscodeFileTask(
|
||||
loggerMock.Object,
|
||||
fileSystemMock.Object,
|
||||
configMock.Object,
|
||||
localizationMock.Object,
|
||||
store);
|
||||
|
||||
// Act
|
||||
await task.ExecuteAsync(new Progress<double>(), CancellationToken.None);
|
||||
|
||||
// Assert – neither the manifest nor the segment should have been deleted
|
||||
Assert.DoesNotContain(ManifestPath, deletedFiles);
|
||||
Assert.DoesNotContain(SegmentPath, deletedFiles);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Files whose session lease has expired are NOT returned by <see cref="ITranscodeSessionStore.GetActiveSessionsAsync"/>
|
||||
/// and therefore should be eligible for time-based deletion.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
[Trait("Category", "UnitTest")]
|
||||
public async Task ExecuteAsync_WithExpiredSession_DeletesFiles()
|
||||
{
|
||||
// Arrange
|
||||
const string TranscodePath = "/transcode";
|
||||
const string SessionId = "expired-session-1";
|
||||
const string ManifestPath = "/transcode/expired-session-1/manifest.m3u8";
|
||||
|
||||
var store = new CleanupTestSessionStore();
|
||||
// Lease expired two hours ago
|
||||
var session = new TranscodeSession
|
||||
{
|
||||
PlaySessionId = SessionId,
|
||||
OwnerPod = "pod-a",
|
||||
LeaseExpiresUtc = DateTime.UtcNow.AddHours(-2),
|
||||
ManifestPath = ManifestPath,
|
||||
SegmentPathPrefix = "/transcode/expired-session-1/segment",
|
||||
MediaSourceId = "media-source-1",
|
||||
};
|
||||
await store.SetAsync(session, TestContext.Current.CancellationToken);
|
||||
|
||||
var deletedFiles = new List<string>();
|
||||
var oldModifyTime = DateTime.UtcNow.AddDays(-2);
|
||||
|
||||
var fileSystemMock = new Mock<IFileSystem>();
|
||||
fileSystemMock
|
||||
.Setup(fs => fs.GetFiles(TranscodePath, true))
|
||||
.Returns(new[]
|
||||
{
|
||||
new FileSystemMetadata { FullName = ManifestPath, IsDirectory = false },
|
||||
});
|
||||
fileSystemMock
|
||||
.Setup(fs => fs.GetLastWriteTimeUtc(It.IsAny<FileSystemMetadata>()))
|
||||
.Returns(oldModifyTime);
|
||||
fileSystemMock
|
||||
.Setup(fs => fs.DeleteFile(It.IsAny<string>()))
|
||||
.Callback<string>(path => deletedFiles.Add(path));
|
||||
fileSystemMock
|
||||
.Setup(fs => fs.GetDirectories(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.Returns(Enumerable.Empty<FileSystemMetadata>());
|
||||
|
||||
var configMock = CreateConfigMock(TranscodePath);
|
||||
|
||||
var localizationMock = new Mock<MediaBrowser.Model.Globalization.ILocalizationManager>();
|
||||
localizationMock
|
||||
.Setup(l => l.GetLocalizedString(It.IsAny<string>()))
|
||||
.Returns<string>(s => s);
|
||||
|
||||
var loggerMock = new Mock<Microsoft.Extensions.Logging.ILogger<Emby.Server.Implementations.ScheduledTasks.Tasks.DeleteTranscodeFileTask>>();
|
||||
|
||||
var task = new Emby.Server.Implementations.ScheduledTasks.Tasks.DeleteTranscodeFileTask(
|
||||
loggerMock.Object,
|
||||
fileSystemMock.Object,
|
||||
configMock.Object,
|
||||
localizationMock.Object,
|
||||
store);
|
||||
|
||||
// Act
|
||||
await task.ExecuteAsync(new Progress<double>(), CancellationToken.None);
|
||||
|
||||
// Assert – expired session files are eligible for time-based deletion
|
||||
Assert.Contains(ManifestPath, deletedFiles);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When <see cref="ITranscodeSessionStore.GetActiveSessionsAsync"/> throws an exception,
|
||||
/// the task should abort deletion safely rather than risk removing files in use.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
[Trait("Category", "UnitTest")]
|
||||
public async Task ExecuteAsync_WhenStoreFails_AbortsDeletion()
|
||||
{
|
||||
// Arrange
|
||||
const string TranscodePath = "/transcode";
|
||||
const string ManifestPath = "/transcode/session-1/manifest.m3u8";
|
||||
|
||||
var deletedFiles = new List<string>();
|
||||
var oldModifyTime = DateTime.UtcNow.AddDays(-2);
|
||||
|
||||
var fileSystemMock = new Mock<IFileSystem>();
|
||||
fileSystemMock
|
||||
.Setup(fs => fs.GetFiles(TranscodePath, true))
|
||||
.Returns(new[]
|
||||
{
|
||||
new FileSystemMetadata { FullName = ManifestPath, IsDirectory = false },
|
||||
});
|
||||
fileSystemMock
|
||||
.Setup(fs => fs.GetLastWriteTimeUtc(It.IsAny<FileSystemMetadata>()))
|
||||
.Returns(oldModifyTime);
|
||||
fileSystemMock
|
||||
.Setup(fs => fs.DeleteFile(It.IsAny<string>()))
|
||||
.Callback<string>(path => deletedFiles.Add(path));
|
||||
|
||||
var configMock = CreateConfigMock(TranscodePath);
|
||||
|
||||
var localizationMock = new Mock<MediaBrowser.Model.Globalization.ILocalizationManager>();
|
||||
localizationMock
|
||||
.Setup(l => l.GetLocalizedString(It.IsAny<string>()))
|
||||
.Returns<string>(s => s);
|
||||
|
||||
var loggerMock = new Mock<Microsoft.Extensions.Logging.ILogger<Emby.Server.Implementations.ScheduledTasks.Tasks.DeleteTranscodeFileTask>>();
|
||||
|
||||
var failingStoreMock = new Mock<ITranscodeSessionStore>();
|
||||
failingStoreMock
|
||||
.Setup(s => s.GetActiveSessionsAsync(It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("Redis unavailable"));
|
||||
|
||||
var task = new Emby.Server.Implementations.ScheduledTasks.Tasks.DeleteTranscodeFileTask(
|
||||
loggerMock.Object,
|
||||
fileSystemMock.Object,
|
||||
configMock.Object,
|
||||
localizationMock.Object,
|
||||
failingStoreMock.Object);
|
||||
|
||||
// Act
|
||||
await task.ExecuteAsync(new Progress<double>(), CancellationToken.None);
|
||||
|
||||
// Assert – when the store fails, no files should be deleted (safe abort)
|
||||
Assert.Empty(deletedFiles);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The files of a live session, named exactly as the HLS pipeline writes them and recorded by
|
||||
/// the same production factory the controller uses, survive a cleanup pass while an unrelated
|
||||
/// stale file from a finished session is deleted.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
[Trait("Category", "UnitTest")]
|
||||
public async Task ExecuteAsync_WithLiveSessionRegisteredByProduction_KeepsItsFilesAndDeletesTheRest()
|
||||
{
|
||||
const string TranscodePath = "/transcode";
|
||||
var playlistPath = Path.Combine(TranscodePath, "9e1c6f.m3u8");
|
||||
var session = TranscodeSession.CreateForPlaylist("play-1", "media-1", "pod-a", playlistPath, TimeSpan.FromMinutes(5));
|
||||
|
||||
var sessionFiles = new[]
|
||||
{
|
||||
playlistPath,
|
||||
TranscodeSession.GetSegmentPathPrefix(playlistPath) + "0.ts",
|
||||
TranscodeSession.GetSegmentPathPrefix(playlistPath) + "1.ts",
|
||||
TranscodeSession.GetSegmentPathPrefix(playlistPath) + "-1.mp4",
|
||||
};
|
||||
|
||||
var orphanedFile = Path.Combine(TranscodePath, "abandoned.m3u8");
|
||||
|
||||
var store = new CleanupTestSessionStore();
|
||||
await store.SetAsync(session, TestContext.Current.CancellationToken);
|
||||
|
||||
var deletedFiles = new List<string>();
|
||||
var fileSystemMock = new Mock<IFileSystem>();
|
||||
fileSystemMock
|
||||
.Setup(fs => fs.GetFiles(TranscodePath, true))
|
||||
.Returns(sessionFiles.Append(orphanedFile).Select(path => new FileSystemMetadata { FullName = path, IsDirectory = false }));
|
||||
fileSystemMock
|
||||
.Setup(fs => fs.GetLastWriteTimeUtc(It.IsAny<FileSystemMetadata>()))
|
||||
.Returns(DateTime.UtcNow.AddDays(-2));
|
||||
fileSystemMock
|
||||
.Setup(fs => fs.DeleteFile(It.IsAny<string>()))
|
||||
.Callback<string>(deletedFiles.Add);
|
||||
fileSystemMock
|
||||
.Setup(fs => fs.GetDirectories(It.IsAny<string>(), It.IsAny<bool>()))
|
||||
.Returns(Enumerable.Empty<FileSystemMetadata>());
|
||||
|
||||
var localizationMock = new Mock<MediaBrowser.Model.Globalization.ILocalizationManager>();
|
||||
localizationMock
|
||||
.Setup(l => l.GetLocalizedString(It.IsAny<string>()))
|
||||
.Returns<string>(key => key);
|
||||
|
||||
var task = new Emby.Server.Implementations.ScheduledTasks.Tasks.DeleteTranscodeFileTask(
|
||||
new Mock<Microsoft.Extensions.Logging.ILogger<Emby.Server.Implementations.ScheduledTasks.Tasks.DeleteTranscodeFileTask>>().Object,
|
||||
fileSystemMock.Object,
|
||||
CreateConfigMock(TranscodePath).Object,
|
||||
localizationMock.Object,
|
||||
store);
|
||||
|
||||
await task.ExecuteAsync(new Progress<double>(), CancellationToken.None);
|
||||
|
||||
Assert.Equal(new[] { orphanedFile }, deletedFiles);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal in-memory <see cref="ITranscodeSessionStore"/> used within this test class
|
||||
/// to avoid a cross-project reference to Jellyfin.MediaEncoding.Tests.
|
||||
/// </summary>
|
||||
private sealed class CleanupTestSessionStore : ITranscodeSessionStore
|
||||
{
|
||||
private static readonly TimeSpan LeaseDuration = TimeSpan.FromSeconds(30);
|
||||
|
||||
private readonly Dictionary<string, TranscodeSession> _sessions =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private readonly Lock _lock = new();
|
||||
|
||||
public Task<TranscodeSession?> TryGetAsync(string playSessionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_sessions.TryGetValue(playSessionId, out var s) && s.LeaseExpiresUtc > DateTime.UtcNow)
|
||||
{
|
||||
return Task.FromResult<TranscodeSession?>(Clone(s));
|
||||
}
|
||||
|
||||
return Task.FromResult<TranscodeSession?>(null);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<bool> TryTakeoverAsync(string playSessionId, string claimingPod, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_sessions.TryGetValue(playSessionId, out var s))
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
if (s.LeaseExpiresUtc > DateTime.UtcNow)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
s.OwnerPod = claimingPod;
|
||||
s.LeaseExpiresUtc = DateTime.UtcNow.Add(LeaseDuration);
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
public Task SetAsync(TranscodeSession session, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_sessions[session.PlaySessionId] = session;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<bool> RenewLeaseAsync(string playSessionId, string ownerPod, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_sessions.TryGetValue(playSessionId, out var s)
|
||||
|| !string.Equals(s.OwnerPod, ownerPod, StringComparison.Ordinal))
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
s.LeaseExpiresUtc = DateTime.UtcNow.Add(LeaseDuration);
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
public Task DeleteAsync(string playSessionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_sessions.Remove(playSessionId);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IEnumerable<TranscodeSession>> GetActiveSessionsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var sessions = _sessions.Values
|
||||
.Where(s => s.LeaseExpiresUtc > DateTime.UtcNow)
|
||||
.Select(Clone)
|
||||
.ToList();
|
||||
return Task.FromResult<IEnumerable<TranscodeSession>>(sessions);
|
||||
}
|
||||
}
|
||||
|
||||
private static TranscodeSession Clone(TranscodeSession source)
|
||||
=> new TranscodeSession
|
||||
{
|
||||
PlaySessionId = source.PlaySessionId,
|
||||
OwnerPod = source.OwnerPod,
|
||||
LeaseExpiresUtc = source.LeaseExpiresUtc,
|
||||
ManifestPath = source.ManifestPath,
|
||||
SegmentPathPrefix = source.SegmentPathPrefix,
|
||||
MediaSourceId = source.MediaSourceId,
|
||||
LastCompletedSegmentIndex = source.LastCompletedSegmentIndex,
|
||||
LastDurablePlaybackOffset = source.LastDurablePlaybackOffset,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Emby.Server.Implementations.ScheduledTasks;
|
||||
using MediaBrowser.Controller.ScheduledTasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StackExchange.Redis;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Server.Implementations.Tests.ScheduledTasks;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for scan-leader lease behavior. The acquire/renew/takeover state machine is exercised
|
||||
/// through an in-memory reference implementation that mirrors the Redis Lua contract (no real Redis
|
||||
/// required), while the fail-safe and success paths of <see cref="RedisScanLeaderLease"/> are
|
||||
/// exercised against a mocked <see cref="IConnectionMultiplexer"/>.
|
||||
/// </summary>
|
||||
public class ScanLeaderLeaseTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies that the first instance to call the lease becomes the leader.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
[Trait("Category", "UnitTest")]
|
||||
public async Task Acquire_WhenUnheld_ReturnsTrue()
|
||||
{
|
||||
var store = new FakeLeaderStore();
|
||||
var clock = new TestClock(DateTime.UtcNow);
|
||||
var podA = new ReferenceScanLeaderLease(store, "pod-a", clock, TimeSpan.FromSeconds(60));
|
||||
|
||||
Assert.True(await podA.TryAcquireOrRenewAsync(TestContext.Current.CancellationToken));
|
||||
Assert.Equal("pod-a", store.Owner);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the current leader renewing its own lease succeeds.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
[Trait("Category", "UnitTest")]
|
||||
public async Task Renew_BySameInstance_ReturnsTrue()
|
||||
{
|
||||
var store = new FakeLeaderStore();
|
||||
var clock = new TestClock(DateTime.UtcNow);
|
||||
var podA = new ReferenceScanLeaderLease(store, "pod-a", clock, TimeSpan.FromSeconds(60));
|
||||
|
||||
Assert.True(await podA.TryAcquireOrRenewAsync(TestContext.Current.CancellationToken));
|
||||
|
||||
clock.Advance(TimeSpan.FromSeconds(10));
|
||||
|
||||
Assert.True(await podA.TryAcquireOrRenewAsync(TestContext.Current.CancellationToken));
|
||||
Assert.Equal("pod-a", store.Owner);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a second instance cannot acquire the lease while the leader's lease is still valid.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
[Trait("Category", "UnitTest")]
|
||||
public async Task Acquire_BySecondInstance_WhileLeaseValid_ReturnsFalse()
|
||||
{
|
||||
var store = new FakeLeaderStore();
|
||||
var clock = new TestClock(DateTime.UtcNow);
|
||||
var podA = new ReferenceScanLeaderLease(store, "pod-a", clock, TimeSpan.FromSeconds(60));
|
||||
var podB = new ReferenceScanLeaderLease(store, "pod-b", clock, TimeSpan.FromSeconds(60));
|
||||
|
||||
Assert.True(await podA.TryAcquireOrRenewAsync(TestContext.Current.CancellationToken));
|
||||
|
||||
clock.Advance(TimeSpan.FromSeconds(30));
|
||||
|
||||
Assert.False(await podB.TryAcquireOrRenewAsync(TestContext.Current.CancellationToken));
|
||||
Assert.Equal("pod-a", store.Owner);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a second instance takes over the lease once the previous leader's lease has expired.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
[Trait("Category", "UnitTest")]
|
||||
public async Task Acquire_BySecondInstance_AfterLeaseExpires_ReturnsTrue()
|
||||
{
|
||||
var store = new FakeLeaderStore();
|
||||
var clock = new TestClock(DateTime.UtcNow);
|
||||
var podA = new ReferenceScanLeaderLease(store, "pod-a", clock, TimeSpan.FromSeconds(60));
|
||||
var podB = new ReferenceScanLeaderLease(store, "pod-b", clock, TimeSpan.FromSeconds(60));
|
||||
|
||||
Assert.True(await podA.TryAcquireOrRenewAsync(TestContext.Current.CancellationToken));
|
||||
|
||||
// Advance past pod-a's lease expiry without pod-a renewing.
|
||||
clock.Advance(TimeSpan.FromSeconds(61));
|
||||
|
||||
Assert.True(await podB.TryAcquireOrRenewAsync(TestContext.Current.CancellationToken));
|
||||
Assert.Equal("pod-b", store.Owner);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that <see cref="NullScanLeaderLease"/> always reports the caller as the leader.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
[Trait("Category", "UnitTest")]
|
||||
public async Task NullScanLeaderLease_AlwaysReturnsTrue()
|
||||
{
|
||||
var lease = new NullScanLeaderLease();
|
||||
|
||||
Assert.True(await lease.TryAcquireOrRenewAsync(TestContext.Current.CancellationToken));
|
||||
Assert.True(await lease.TryAcquireOrRenewAsync(TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that <see cref="RedisScanLeaderLease"/> returns <c>true</c> (fail-safe) when the Redis
|
||||
/// evaluation throws, so that scheduled scans keep running when Redis is unreachable.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
[Trait("Category", "UnitTest")]
|
||||
public async Task RedisScanLeaderLease_WhenRedisThrows_ReturnsTrue()
|
||||
{
|
||||
var dbMock = new Mock<IDatabase>();
|
||||
dbMock
|
||||
.Setup(d => d.ScriptEvaluateAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<RedisKey[]>(),
|
||||
It.IsAny<RedisValue[]>(),
|
||||
It.IsAny<CommandFlags>()))
|
||||
.ThrowsAsync(new InvalidOperationException("Redis unavailable"));
|
||||
|
||||
var lease = new RedisScanLeaderLease(
|
||||
CreateMultiplexer(dbMock.Object),
|
||||
Options.Create(new ScanLeaderOptions { Enabled = true, LeaseDurationSeconds = 60 }),
|
||||
new Mock<ILogger<RedisScanLeaderLease>>().Object);
|
||||
|
||||
Assert.True(await lease.TryAcquireOrRenewAsync(TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that <see cref="RedisScanLeaderLease"/> reports leadership when the Redis script
|
||||
/// returns 1 (lease acquired or renewed).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
[Trait("Category", "UnitTest")]
|
||||
public async Task RedisScanLeaderLease_WhenScriptReturnsOne_ReturnsTrue()
|
||||
{
|
||||
var dbMock = new Mock<IDatabase>();
|
||||
dbMock
|
||||
.Setup(d => d.ScriptEvaluateAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<RedisKey[]>(),
|
||||
It.IsAny<RedisValue[]>(),
|
||||
It.IsAny<CommandFlags>()))
|
||||
.ReturnsAsync(RedisResult.Create((RedisValue)1L));
|
||||
|
||||
var lease = new RedisScanLeaderLease(
|
||||
CreateMultiplexer(dbMock.Object),
|
||||
Options.Create(new ScanLeaderOptions { Enabled = true, LeaseDurationSeconds = 60 }),
|
||||
new Mock<ILogger<RedisScanLeaderLease>>().Object);
|
||||
|
||||
Assert.True(await lease.TryAcquireOrRenewAsync(TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that <see cref="RedisScanLeaderLease"/> reports non-leadership when the Redis script
|
||||
/// returns 0 (another instance holds a live lease).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
[Trait("Category", "UnitTest")]
|
||||
public async Task RedisScanLeaderLease_WhenScriptReturnsZero_ReturnsFalse()
|
||||
{
|
||||
var dbMock = new Mock<IDatabase>();
|
||||
dbMock
|
||||
.Setup(d => d.ScriptEvaluateAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<RedisKey[]>(),
|
||||
It.IsAny<RedisValue[]>(),
|
||||
It.IsAny<CommandFlags>()))
|
||||
.ReturnsAsync(RedisResult.Create((RedisValue)0L));
|
||||
|
||||
var lease = new RedisScanLeaderLease(
|
||||
CreateMultiplexer(dbMock.Object),
|
||||
Options.Create(new ScanLeaderOptions { Enabled = true, LeaseDurationSeconds = 60 }),
|
||||
new Mock<ILogger<RedisScanLeaderLease>>().Object);
|
||||
|
||||
Assert.False(await lease.TryAcquireOrRenewAsync(TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
private static IConnectionMultiplexer CreateMultiplexer(IDatabase database)
|
||||
{
|
||||
var muxMock = new Mock<IConnectionMultiplexer>();
|
||||
muxMock
|
||||
.Setup(m => m.GetDatabase(It.IsAny<int>(), It.IsAny<object>()))
|
||||
.Returns(database);
|
||||
return muxMock.Object;
|
||||
}
|
||||
|
||||
private sealed class FakeLeaderStore
|
||||
{
|
||||
public string? Owner { get; set; }
|
||||
|
||||
public DateTime ExpiresUtc { get; set; }
|
||||
}
|
||||
|
||||
private sealed class TestClock
|
||||
{
|
||||
private DateTime _now;
|
||||
|
||||
public TestClock(DateTime now)
|
||||
{
|
||||
_now = now;
|
||||
}
|
||||
|
||||
public DateTime UtcNow => _now;
|
||||
|
||||
public void Advance(TimeSpan by) => _now += by;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory reference lease that mirrors the Redis Lua acquire-or-renew contract: a key that is
|
||||
/// unset or expired is claimed by the caller; a key already owned by the caller is renewed; a key
|
||||
/// owned by a different, still-valid holder is refused.
|
||||
/// </summary>
|
||||
private sealed class ReferenceScanLeaderLease : IScanLeaderLease
|
||||
{
|
||||
private readonly FakeLeaderStore _store;
|
||||
private readonly string _podId;
|
||||
private readonly TestClock _clock;
|
||||
private readonly TimeSpan _ttl;
|
||||
|
||||
public ReferenceScanLeaderLease(FakeLeaderStore store, string podId, TestClock clock, TimeSpan ttl)
|
||||
{
|
||||
_store = store;
|
||||
_podId = podId;
|
||||
_clock = clock;
|
||||
_ttl = ttl;
|
||||
}
|
||||
|
||||
public Task<bool> TryAcquireOrRenewAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = _clock.UtcNow;
|
||||
var currentOwner = _store.Owner is not null && now < _store.ExpiresUtc ? _store.Owner : null;
|
||||
|
||||
if (currentOwner is null)
|
||||
{
|
||||
_store.Owner = _podId;
|
||||
_store.ExpiresUtc = now + _ttl;
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
if (string.Equals(currentOwner, _podId, StringComparison.Ordinal))
|
||||
{
|
||||
_store.ExpiresUtc = now + _ttl;
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using Emby.Server.Implementations.ScheduledTasks.Tasks;
|
||||
using MediaBrowser.Controller.ScheduledTasks;
|
||||
using MediaBrowser.Model.Tasks;
|
||||
using Xunit;
|
||||
|
||||
namespace Jellyfin.Server.Implementations.Tests.ScheduledTasks;
|
||||
|
||||
public class ScanLeaderOptionsTests
|
||||
{
|
||||
/// <summary>
|
||||
/// A gated key that matches no registered task silently stops gating anything, so the default
|
||||
/// set is pinned to the task keys that actually exist in the build.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void DefaultGatedTaskKeys_Should_MatchRegisteredScheduledTasks()
|
||||
{
|
||||
var registeredKeys = DiscoverScheduledTaskKeys();
|
||||
|
||||
Assert.NotEmpty(registeredKeys);
|
||||
Assert.Empty(new ScanLeaderOptions().GatedTaskKeys.Except(registeredKeys, StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
private static HashSet<string> DiscoverScheduledTaskKeys()
|
||||
{
|
||||
var keys = new HashSet<string>(StringComparer.Ordinal);
|
||||
var assemblies = new[]
|
||||
{
|
||||
typeof(DeleteTranscodeFileTask).Assembly,
|
||||
typeof(Jellyfin.MediaEncoding.Hls.ScheduledTasks.KeyframeExtractionScheduledTask).Assembly
|
||||
};
|
||||
|
||||
foreach (var type in assemblies.SelectMany(a => a.GetTypes()))
|
||||
{
|
||||
if (type.IsAbstract || type.IsInterface || !typeof(IScheduledTask).IsAssignableFrom(type))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Task keys are constant expressions, so an uninitialised instance is enough to read
|
||||
// them without standing up each task's dependency graph.
|
||||
var task = (IScheduledTask)RuntimeHelpers.GetUninitializedObject(type);
|
||||
keys.Add(task.Key);
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user