Compare commits
322 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f4c857b23 | |||
| ce7e9f0c3d | |||
| bb26760011 | |||
| af4e44173f | |||
| 2a09108d4f | |||
| 577d24d190 | |||
| 611bcf2c4a | |||
| e1ca272d6c | |||
| 086fdb8257 | |||
| 51261b0128 | |||
| 9e66708d87 | |||
| 6c54a02240 | |||
| 6f362c33c9 | |||
| ba4d487c65 | |||
| 00d0765152 | |||
| 6691b785c3 | |||
| 2adb13f50f | |||
| 1c98f4a074 | |||
| 393994a454 | |||
| ad50c4e433 | |||
| 483c739fb1 | |||
| 1c59e6afcb | |||
| d39ec60e2c | |||
| a9d6c749fb | |||
| 44b62dcc64 | |||
| a7919b9bac | |||
| a025655b4d | |||
| face8ac653 | |||
| b662ffa48f | |||
| 1965c68a76 | |||
| 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,84 @@
|
|||||||
|
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.
|
||||||
|
# Both projects attach to it through JELLYFIN_TEST_POSTGRES and give every test a database of
|
||||||
|
# its own, so nothing here depends on a docker daemon.
|
||||||
|
# Valkey runs in the step for the same reason, attached through JELLYFIN_TEST_REDIS.
|
||||||
|
- 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"
|
||||||
|
JELLYFIN_TEST_REDIS: "127.0.0.1:6379"
|
||||||
|
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 valkey-server
|
||||||
|
- 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"
|
||||||
|
- valkey-server --daemonize yes --bind 127.0.0.1 --port 6379 --save ''
|
||||||
|
- for i in $(seq 30); do valkey-cli -h 127.0.0.1 ping && break; sleep 1; done
|
||||||
|
- dotnet build tests/Jellyfin.Server.Tests/Jellyfin.Server.Tests.csproj -c Release
|
||||||
|
- dotnet build tests/Jellyfin.Database.Tests.PostgreSQL/Jellyfin.Database.Tests.PostgreSQL.csproj -c Release
|
||||||
|
- dotnet test tests/Jellyfin.Server.Tests/Jellyfin.Server.Tests.csproj -c Release --no-build --verbosity minimal --filter "Category=RequiresDocker"
|
||||||
|
- dotnet test tests/Jellyfin.Database.Tests.PostgreSQL/Jellyfin.Database.Tests.PostgreSQL.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.AutoMoq" Version="4.18.1" />
|
||||||
<PackageVersion Include="AutoFixture.Xunit3" Version="4.19.0" />
|
<PackageVersion Include="AutoFixture.Xunit3" Version="4.19.0" />
|
||||||
<PackageVersion Include="AutoFixture" Version="4.18.1" />
|
<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="BDInfo" Version="0.8.0" />
|
||||||
<PackageVersion Include="BitFaster.Caching" Version="2.6.1" />
|
<PackageVersion Include="BitFaster.Caching" Version="2.6.1" />
|
||||||
<PackageVersion Include="BlurHashSharp.SkiaSharp" Version="1.4.0-pre.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.Hosting.Abstractions" Version="10.0.11" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Http" 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" 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.Extensions.Options" Version="10.0.11" />
|
||||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.9.0" />
|
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.9.0" />
|
||||||
<PackageVersion Include="MimeTypes" Version="2.5.2" />
|
<PackageVersion Include="MimeTypes" Version="2.5.2" />
|
||||||
@@ -53,6 +55,8 @@
|
|||||||
<PackageVersion Include="Moq" Version="4.18.4" />
|
<PackageVersion Include="Moq" Version="4.18.4" />
|
||||||
<PackageVersion Include="NEbml" Version="1.1.0.5" />
|
<PackageVersion Include="NEbml" Version="1.1.0.5" />
|
||||||
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
|
<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="PDFtoImage" Version="5.2.1" />
|
||||||
<PackageVersion Include="PlaylistsNET" Version="1.4.1" />
|
<PackageVersion Include="PlaylistsNET" Version="1.4.1" />
|
||||||
<PackageVersion Include="prometheus-net.AspNetCore" Version="8.2.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.HarfBuzz" Version="3.119.4" />
|
||||||
<PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="3.119.4" />
|
<PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="3.119.4" />
|
||||||
<PackageVersion Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" />
|
<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="StyleCop.Analyzers" Version="1.2.0-beta.556" />
|
||||||
<PackageVersion Include="Svg.Skia" Version="3.7.0" />
|
<PackageVersion Include="Svg.Skia" Version="3.7.0" />
|
||||||
<PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="10.2.3" />
|
<PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="10.2.3" />
|
||||||
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.3" />
|
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.3" />
|
||||||
<PackageVersion Include="System.Text.Json" Version="10.0.11" />
|
<PackageVersion Include="System.Text.Json" Version="10.0.11" />
|
||||||
<PackageVersion Include="TagLibSharp" Version="2.3.0" />
|
<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="z440.atl.core" Version="7.16.0" />
|
||||||
<PackageVersion Include="TMDbLib" Version="3.0.0" />
|
<PackageVersion Include="TMDbLib" Version="3.0.0" />
|
||||||
<PackageVersion Include="UTF.Unknown" Version="2.7.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"]
|
||||||
@@ -87,6 +87,12 @@ namespace Emby.Server.Implementations.AppBase
|
|||||||
/// <value>The application paths.</value>
|
/// <value>The application paths.</value>
|
||||||
public IApplicationPaths CommonApplicationPaths { get; private set; }
|
public IApplicationPaths CommonApplicationPaths { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the bus announcing configuration writes to the other instances sharing this
|
||||||
|
/// configuration directory. Defaults to a no-op, which is the single-instance behaviour.
|
||||||
|
/// </summary>
|
||||||
|
public IConfigurationInvalidationBus InvalidationBus { get; set; } = NullConfigurationInvalidationBus.Instance;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the system configuration.
|
/// Gets or sets the system configuration.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -169,6 +175,8 @@ namespace Emby.Server.Implementations.AppBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
OnConfigurationUpdated();
|
OnConfigurationUpdated();
|
||||||
|
|
||||||
|
InvalidationBus.PublishLocalWrite(ConfigurationInvalidationScope.SystemConfiguration, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -350,6 +358,29 @@ namespace Emby.Server.Implementations.AppBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
OnNamedConfigurationUpdated(key, configuration);
|
OnNamedConfigurationUpdated(key, configuration);
|
||||||
|
|
||||||
|
InvalidationBus.PublishLocalWrite(ConfigurationInvalidationScope.NamedConfiguration, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void InvalidateCachedConfiguration(string? key)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(key))
|
||||||
|
{
|
||||||
|
lock (_configurationSyncLock)
|
||||||
|
{
|
||||||
|
_configuration = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reloads the system configuration off the shared file as a side effect of re-deriving
|
||||||
|
// the cache path from it, then tells the in-process consumers to re-read it.
|
||||||
|
OnConfigurationUpdated();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_configurations.TryRemove(key, out _);
|
||||||
|
|
||||||
|
OnNamedConfigurationUpdated(key, GetConfiguration(key));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -706,6 +706,8 @@ namespace Emby.Server.Implementations
|
|||||||
BaseItem.UserDataManager = Resolve<IUserDataManager>();
|
BaseItem.UserDataManager = Resolve<IUserDataManager>();
|
||||||
CollectionFolder.XmlSerializer = _xmlSerializer;
|
CollectionFolder.XmlSerializer = _xmlSerializer;
|
||||||
CollectionFolder.ApplicationHost = this;
|
CollectionFolder.ApplicationHost = this;
|
||||||
|
CollectionFolder.InvalidationBus = Resolve<IConfigurationInvalidationBus>();
|
||||||
|
ConfigurationManager.InvalidationBus = CollectionFolder.InvalidationBus;
|
||||||
Folder.UserViewManager = Resolve<IUserViewManager>();
|
Folder.UserViewManager = Resolve<IUserViewManager>();
|
||||||
Folder.CollectionManager = Resolve<ICollectionManager>();
|
Folder.CollectionManager = Resolve<ICollectionManager>();
|
||||||
Folder.LimitedConcurrencyLibraryScheduler = Resolve<ILimitedConcurrencyLibraryScheduler>();
|
Folder.LimitedConcurrencyLibraryScheduler = Resolve<ILimitedConcurrencyLibraryScheduler>();
|
||||||
@@ -790,6 +792,43 @@ namespace Emby.Server.Implementations
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Works out what a configuration update means for the ports this process bound at startup.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="boundHttpPort">The HTTP port this process is bound to.</param>
|
||||||
|
/// <param name="boundHttpsPort">The HTTPS port this process is bound to.</param>
|
||||||
|
/// <param name="configuredHttpPort">The HTTP port the shared configuration now carries.</param>
|
||||||
|
/// <param name="configuredHttpsPort">The HTTPS port the shared configuration now carries.</param>
|
||||||
|
/// <param name="isPortAuthorized">Whether the shared configuration still marks the port as authorized.</param>
|
||||||
|
/// <param name="isApplyingRemoteInvalidation">Whether this update is another instance's write being applied.</param>
|
||||||
|
/// <returns>What the update requires of this instance.</returns>
|
||||||
|
internal static PortChangeOutcome EvaluatePortChange(
|
||||||
|
int boundHttpPort,
|
||||||
|
int boundHttpsPort,
|
||||||
|
int configuredHttpPort,
|
||||||
|
int configuredHttpsPort,
|
||||||
|
bool isPortAuthorized,
|
||||||
|
bool isApplyingRemoteInvalidation)
|
||||||
|
{
|
||||||
|
// Nothing is bound yet, so nothing has gone stale.
|
||||||
|
if (boundHttpPort == 0 || boundHttpsPort == 0)
|
||||||
|
{
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (configuredHttpPort == boundHttpPort && configuredHttpsPort == boundHttpsPort)
|
||||||
|
{
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whoever wrote the change, this process is still listening on a port the configuration no
|
||||||
|
// longer names, so the pending restart is reported either way. The authorization flag belongs
|
||||||
|
// to the instance that made the change: it cleared the flag along with the port, and clearing
|
||||||
|
// it again here would write shared configuration on that instance's behalf and announce it a
|
||||||
|
// second time.
|
||||||
|
return new PortChangeOutcome(true, isPortAuthorized && !isApplyingRemoteInvalidation);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Called when [configuration updated].
|
/// Called when [configuration updated].
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -797,26 +836,24 @@ namespace Emby.Server.Implementations
|
|||||||
/// <param name="e">The <see cref="EventArgs"/> instance containing the event data.</param>
|
/// <param name="e">The <see cref="EventArgs"/> instance containing the event data.</param>
|
||||||
private void OnConfigurationUpdated(object sender, EventArgs e)
|
private void OnConfigurationUpdated(object sender, EventArgs e)
|
||||||
{
|
{
|
||||||
var requiresRestart = false;
|
|
||||||
var networkConfiguration = ConfigurationManager.GetNetworkConfiguration();
|
var networkConfiguration = ConfigurationManager.GetNetworkConfiguration();
|
||||||
|
|
||||||
// Don't do anything if these haven't been set yet
|
var portChange = EvaluatePortChange(
|
||||||
if (HttpPort != 0 && HttpsPort != 0)
|
HttpPort,
|
||||||
{
|
HttpsPort,
|
||||||
// Need to restart if ports have changed
|
networkConfiguration.InternalHttpPort,
|
||||||
if (networkConfiguration.InternalHttpPort != HttpPort
|
networkConfiguration.InternalHttpsPort,
|
||||||
|| networkConfiguration.InternalHttpsPort != HttpsPort)
|
ConfigurationManager.Configuration.IsPortAuthorized,
|
||||||
{
|
ConfigurationInvalidationContext.IsApplyingRemoteInvalidation);
|
||||||
if (ConfigurationManager.Configuration.IsPortAuthorized)
|
|
||||||
{
|
|
||||||
ConfigurationManager.Configuration.IsPortAuthorized = false;
|
|
||||||
ConfigurationManager.SaveConfiguration();
|
|
||||||
|
|
||||||
requiresRestart = true;
|
if (portChange.ClearsPortAuthorization)
|
||||||
}
|
{
|
||||||
}
|
ConfigurationManager.Configuration.IsPortAuthorized = false;
|
||||||
|
ConfigurationManager.SaveConfiguration();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var requiresRestart = portChange.RequiresRestart;
|
||||||
|
|
||||||
if (ValidateSslCertificate(networkConfiguration))
|
if (ValidateSslCertificate(networkConfiguration))
|
||||||
{
|
{
|
||||||
requiresRestart = true;
|
requiresRestart = true;
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using MediaBrowser.Common.Configuration;
|
||||||
|
using MediaBrowser.Controller.Entities;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Emby.Server.Implementations.Configuration
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Applies the configuration invalidations published by the other instances sharing this
|
||||||
|
/// configuration directory, dropping the local cache entry so the next read comes off the shared file.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ConfigurationInvalidationSubscriber : IHostedService
|
||||||
|
{
|
||||||
|
private readonly IConfigurationInvalidationBus _bus;
|
||||||
|
private readonly IConfigurationManager _configurationManager;
|
||||||
|
private readonly ILogger<ConfigurationInvalidationSubscriber> _logger;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="ConfigurationInvalidationSubscriber"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="bus">The invalidation bus.</param>
|
||||||
|
/// <param name="configurationManager">The configuration manager holding the cached configuration.</param>
|
||||||
|
/// <param name="logger">The logger.</param>
|
||||||
|
public ConfigurationInvalidationSubscriber(
|
||||||
|
IConfigurationInvalidationBus bus,
|
||||||
|
IConfigurationManager configurationManager,
|
||||||
|
ILogger<ConfigurationInvalidationSubscriber> logger)
|
||||||
|
{
|
||||||
|
_bus = bus;
|
||||||
|
_configurationManager = configurationManager;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task StartAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_bus.Subscribe(Apply);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
|
||||||
|
private void Apply(ConfigurationInvalidation invalidation)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Applying re-raises the same update events a local save raises, so the in-process
|
||||||
|
// consumers re-read. The scope tells those consumers that the write was somebody else's,
|
||||||
|
// so the ones that answer an update by writing neither repeat it nor publish it back.
|
||||||
|
using var scope = ConfigurationInvalidationContext.BeginApply();
|
||||||
|
|
||||||
|
switch (invalidation.Scope)
|
||||||
|
{
|
||||||
|
case ConfigurationInvalidationScope.SystemConfiguration:
|
||||||
|
_configurationManager.InvalidateCachedConfiguration(null);
|
||||||
|
break;
|
||||||
|
case ConfigurationInvalidationScope.NamedConfiguration when !string.IsNullOrEmpty(invalidation.Target):
|
||||||
|
_configurationManager.InvalidateCachedConfiguration(invalidation.Target);
|
||||||
|
break;
|
||||||
|
case ConfigurationInvalidationScope.LibraryOptions when !string.IsNullOrEmpty(invalidation.Target):
|
||||||
|
CollectionFolder.InvalidateLibraryOptions(invalidation.Target);
|
||||||
|
break;
|
||||||
|
case ConfigurationInvalidationScope.AllLibraryOptions:
|
||||||
|
CollectionFolder.InvalidateAllLibraryOptions();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogDebug("Applied {Scope} invalidation for {Target} from {OriginId}.", invalidation.Scope, invalidation.Target, invalidation.OriginId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to apply a {Scope} invalidation for {Target}.", invalidation.Scope, invalidation.Target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
using System;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Jellyfin.Extensions.Json;
|
||||||
|
using MediaBrowser.Common.Configuration;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using StackExchange.Redis;
|
||||||
|
|
||||||
|
namespace Emby.Server.Implementations.Configuration
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A Redis pub/sub <see cref="IConfigurationInvalidationBus"/>. Notices are broadcast on one channel
|
||||||
|
/// and every instance but the publisher applies them.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RedisConfigurationInvalidationBus : IConfigurationInvalidationBus
|
||||||
|
{
|
||||||
|
private const string ChannelName = "jellyfin:configinvalidation";
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
|
||||||
|
|
||||||
|
private readonly ISubscriber _subscriber;
|
||||||
|
private readonly ILogger<RedisConfigurationInvalidationBus> _logger;
|
||||||
|
private readonly string _originId;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="RedisConfigurationInvalidationBus"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="redis">The Redis connection multiplexer.</param>
|
||||||
|
/// <param name="logger">The logger.</param>
|
||||||
|
public RedisConfigurationInvalidationBus(IConnectionMultiplexer redis, ILogger<RedisConfigurationInvalidationBus> logger)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(redis);
|
||||||
|
|
||||||
|
_subscriber = redis.GetSubscriber();
|
||||||
|
_logger = logger;
|
||||||
|
_originId = Environment.GetEnvironmentVariable("JELLYFIN_INSTANCE_ID") ?? Environment.MachineName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Publish(ConfigurationInvalidationScope scope, string? target)
|
||||||
|
{
|
||||||
|
var invalidation = new ConfigurationInvalidation
|
||||||
|
{
|
||||||
|
Scope = scope,
|
||||||
|
Target = target,
|
||||||
|
OriginId = _originId
|
||||||
|
};
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Fire and forget: an admin saving configuration must never wait on, or fail because of,
|
||||||
|
// the bus. The write has already reached the shared directory by this point.
|
||||||
|
_subscriber.Publish(
|
||||||
|
RedisChannel.Literal(ChannelName),
|
||||||
|
JsonSerializer.Serialize(invalidation, _jsonOptions),
|
||||||
|
CommandFlags.FireAndForget);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to publish {Scope} invalidation for {Target}; other instances keep their cached copy until they restart.", scope, target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Subscribe(Action<ConfigurationInvalidation> handler)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(handler);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_subscriber.Subscribe(RedisChannel.Literal(ChannelName), (_, value) => Dispatch(handler, value));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to subscribe to configuration invalidations; this instance keeps its cached configuration until it restarts.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Dispatch(Action<ConfigurationInvalidation> handler, RedisValue value)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var invalidation = JsonSerializer.Deserialize<ConfigurationInvalidation>(value.ToString(), _jsonOptions);
|
||||||
|
if (invalidation is null || string.Equals(invalidation.OriginId, _originId, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
handler(invalidation);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to apply a configuration invalidation.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,6 +65,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Ignore" />
|
<PackageReference Include="Ignore" />
|
||||||
|
<PackageReference Include="StackExchange.Redis" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -2332,7 +2332,10 @@ namespace Emby.Server.Implementations.Library
|
|||||||
{
|
{
|
||||||
IOrderedEnumerable<BaseItem>? orderedItems = null;
|
IOrderedEnumerable<BaseItem>? orderedItems = null;
|
||||||
|
|
||||||
foreach (var orderBy in sortBy.Select(o => GetComparer(o, user)).Where(c => c is not null))
|
var comparers = sortBy.Select(o => GetComparer(o, user)).Where(c => c is not null).ToList();
|
||||||
|
items = PrefetchUserData(items, user, comparers);
|
||||||
|
|
||||||
|
foreach (var orderBy in comparers)
|
||||||
{
|
{
|
||||||
if (orderBy is RandomComparer)
|
if (orderBy is RandomComparer)
|
||||||
{
|
{
|
||||||
@@ -2364,14 +2367,14 @@ namespace Emby.Server.Implementations.Library
|
|||||||
{
|
{
|
||||||
IOrderedEnumerable<BaseItem>? orderedItems = null;
|
IOrderedEnumerable<BaseItem>? orderedItems = null;
|
||||||
|
|
||||||
foreach (var (name, sortOrder) in orderBy)
|
var comparers = orderBy
|
||||||
{
|
.Select(o => (Comparer: GetComparer(o.OrderBy, user), o.SortOrder))
|
||||||
var comparer = GetComparer(name, user);
|
.Where(c => c.Comparer is not null)
|
||||||
if (comparer is null)
|
.ToList();
|
||||||
{
|
items = PrefetchUserData(items, user, comparers.Select(c => c.Comparer).ToList());
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
foreach (var (comparer, sortOrder) in comparers)
|
||||||
|
{
|
||||||
if (comparer is RandomComparer)
|
if (comparer is RandomComparer)
|
||||||
{
|
{
|
||||||
var randomItems = items.ToArray();
|
var randomItems = items.ToArray();
|
||||||
@@ -2397,6 +2400,31 @@ namespace Emby.Server.Implementations.Library
|
|||||||
return orderedItems ?? items;
|
return orderedItems ?? items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The user comparers read user data per item, so without one batched read up front an
|
||||||
|
// in-memory sort would issue a database round trip per comparison.
|
||||||
|
private IEnumerable<BaseItem> PrefetchUserData(IEnumerable<BaseItem> items, User? user, IReadOnlyList<IBaseItemComparer?> comparers)
|
||||||
|
{
|
||||||
|
if (user is null)
|
||||||
|
{
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
var userComparers = comparers.OfType<IUserBaseItemComparer>().ToList();
|
||||||
|
if (userComparers.Count == 0)
|
||||||
|
{
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
var itemList = items as IReadOnlyList<BaseItem> ?? items.ToList();
|
||||||
|
var userData = _userDataManager.GetUserDataBatch(itemList, user);
|
||||||
|
foreach (var comparer in userComparers)
|
||||||
|
{
|
||||||
|
comparer.PrefetchedUserData = userData;
|
||||||
|
}
|
||||||
|
|
||||||
|
return itemList;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the comparer.
|
/// Gets the comparer.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -2,10 +2,8 @@
|
|||||||
|
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Globalization;
|
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using BitFaster.Caching.Lru;
|
|
||||||
using Jellyfin.Database.Implementations;
|
using Jellyfin.Database.Implementations;
|
||||||
using Jellyfin.Database.Implementations.Entities;
|
using Jellyfin.Database.Implementations.Entities;
|
||||||
using MediaBrowser.Controller.Configuration;
|
using MediaBrowser.Controller.Configuration;
|
||||||
@@ -27,7 +25,6 @@ namespace Emby.Server.Implementations.Library
|
|||||||
{
|
{
|
||||||
private readonly IServerConfigurationManager _config;
|
private readonly IServerConfigurationManager _config;
|
||||||
private readonly IDbContextFactory<JellyfinDbContext> _repository;
|
private readonly IDbContextFactory<JellyfinDbContext> _repository;
|
||||||
private readonly FastConcurrentLru<string, UserItemData> _cache;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="UserDataManager"/> class.
|
/// Initializes a new instance of the <see cref="UserDataManager"/> class.
|
||||||
@@ -40,7 +37,6 @@ namespace Emby.Server.Implementations.Library
|
|||||||
{
|
{
|
||||||
_config = config;
|
_config = config;
|
||||||
_repository = repository;
|
_repository = repository;
|
||||||
_cache = new FastConcurrentLru<string, UserItemData>(Environment.ProcessorCount, _config.Configuration.CacheSize, StringComparer.OrdinalIgnoreCase);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -77,11 +73,6 @@ namespace Emby.Server.Implementations.Library
|
|||||||
dbContext.SaveChanges();
|
dbContext.SaveChanges();
|
||||||
transaction.Commit();
|
transaction.Commit();
|
||||||
|
|
||||||
var userId = user.InternalId;
|
|
||||||
var cacheKey = GetCacheKey(userId, item.Id);
|
|
||||||
_cache.AddOrUpdate(cacheKey, userData);
|
|
||||||
item.UserData = dbContext.UserData.Where(e => e.ItemId == item.Id).AsNoTracking().ToArray(); // rehydrate the cached userdata
|
|
||||||
|
|
||||||
UserDataSaved?.Invoke(this, new UserDataSaveEventArgs
|
UserDataSaved?.Invoke(this, new UserDataSaveEventArgs
|
||||||
{
|
{
|
||||||
Keys = keys,
|
Keys = keys,
|
||||||
@@ -180,64 +171,41 @@ namespace Emby.Server.Implementations.Library
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Dictionary<Guid, UserItemData> GetUserDataBatch(IReadOnlyList<BaseItem> items, User user)
|
public Dictionary<Guid, UserItemData> GetUserDataBatch(IReadOnlyList<BaseItem> items, User user)
|
||||||
{
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(items);
|
||||||
|
ArgumentNullException.ThrowIfNull(user);
|
||||||
|
|
||||||
var result = new Dictionary<Guid, UserItemData>(items.Count);
|
var result = new Dictionary<Guid, UserItemData>(items.Count);
|
||||||
var itemsNeedingQuery = new List<(BaseItem Item, List<string> Keys)>();
|
if (items.Count == 0)
|
||||||
|
|
||||||
foreach (var item in items)
|
|
||||||
{
|
|
||||||
var cacheKey = GetCacheKey(user.InternalId, item.Id);
|
|
||||||
if (_cache.TryGet(cacheKey, out var cachedData))
|
|
||||||
{
|
|
||||||
result[item.Id] = cachedData;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var userDataRow = ResolveUserDataRow(item, item.UserData?.Where(e => e.UserId.Equals(user.Id)));
|
|
||||||
var userData = userDataRow is not null ? Map(userDataRow) : null;
|
|
||||||
if (userData is not null)
|
|
||||||
{
|
|
||||||
result[item.Id] = userData;
|
|
||||||
_cache.AddOrUpdate(cacheKey, userData);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var keys = item.GetUserDataKeys();
|
|
||||||
itemsNeedingQuery.Add((item, keys));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (itemsNeedingQuery.Count == 0)
|
|
||||||
{
|
{
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build a single query for all missing items. Fetch rows by item alone so rows kept
|
// Fetch rows by item alone so rows kept under keys from older metadata resolve the same
|
||||||
// under keys from older metadata resolve the same way as the in-memory path.
|
// way as the single item path.
|
||||||
var allItemIds = itemsNeedingQuery.Select(x => x.Item.Id).ToList();
|
var itemIds = items.Select(e => e.Id).Distinct().ToList();
|
||||||
using var context = _repository.CreateDbContext();
|
using var context = _repository.CreateDbContext();
|
||||||
var userDataArray = context.UserData
|
var userDataByItem = context.UserData
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(e => e.UserId.Equals(user.Id))
|
.Where(e => e.UserId.Equals(user.Id))
|
||||||
.WhereOneOrMany(allItemIds, e => e.ItemId)
|
.WhereOneOrMany(itemIds, e => e.ItemId)
|
||||||
.ToArray();
|
.ToArray()
|
||||||
|
.GroupBy(e => e.ItemId)
|
||||||
|
.ToDictionary(g => g.Key, g => g.ToArray());
|
||||||
|
|
||||||
var userDataByItem = userDataArray.GroupBy(e => e.ItemId).ToDictionary(g => g.Key, g => g.ToArray());
|
foreach (var item in items)
|
||||||
foreach (var (item, keys) in itemsNeedingQuery)
|
|
||||||
{
|
{
|
||||||
UserItemData userData;
|
if (result.ContainsKey(item.Id))
|
||||||
if (userDataByItem.TryGetValue(item.Id, out var itemUserData) && itemUserData.Length > 0)
|
|
||||||
{
|
{
|
||||||
userData = Map(ResolveUserDataRow(item, itemUserData)!);
|
continue;
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
userData = new UserItemData { Key = keys.Count > 0 ? keys[0] : string.Empty };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
result[item.Id] = userData;
|
var row = userDataByItem.TryGetValue(item.Id, out var itemUserData)
|
||||||
var cacheKey = GetCacheKey(user.InternalId, item.Id);
|
? ResolveUserDataRow(item, itemUserData)
|
||||||
_cache.AddOrUpdate(cacheKey, userData);
|
: null;
|
||||||
|
|
||||||
|
result[item.Id] = row is not null
|
||||||
|
? Map(row)
|
||||||
|
: new UserItemData { Key = item.GetUserDataKeys().FirstOrDefault() ?? string.Empty };
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
@@ -340,20 +308,19 @@ namespace Emby.Server.Implementations.Library
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the internal key.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>System.String.</returns>
|
|
||||||
private static string GetCacheKey(long internalUserId, Guid itemId)
|
|
||||||
{
|
|
||||||
return internalUserId.ToString(CultureInfo.InvariantCulture) + "-" + itemId.ToString("N", CultureInfo.InvariantCulture);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public UserItemData? GetUserData(User user, BaseItem item)
|
public UserItemData? GetUserData(User user, BaseItem item)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(user);
|
ArgumentNullException.ThrowIfNull(user);
|
||||||
var row = ResolveUserDataRow(item, item.UserData?.Where(e => e.UserId.Equals(user.Id)));
|
ArgumentNullException.ThrowIfNull(item);
|
||||||
|
|
||||||
|
using var dbContext = _repository.CreateDbContext();
|
||||||
|
var rows = dbContext.UserData
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(e => e.ItemId == item.Id && e.UserId == user.Id)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
var row = ResolveUserDataRow(item, rows);
|
||||||
return row is not null ? Map(row) : new UserItemData()
|
return row is not null ? Map(row) : new UserItemData()
|
||||||
{
|
{
|
||||||
Key = item.GetUserDataKeys()[0],
|
Key = item.GetUserDataKeys()[0],
|
||||||
@@ -536,16 +503,6 @@ namespace Emby.Server.Implementations.Library
|
|||||||
}
|
}
|
||||||
|
|
||||||
dbContext.SaveChanges();
|
dbContext.SaveChanges();
|
||||||
|
|
||||||
var cacheKey = GetCacheKey(user.InternalId, item.Id);
|
|
||||||
if (_cache.TryGet(cacheKey, out var cached))
|
|
||||||
{
|
|
||||||
cached.AudioStreamIndex = null;
|
|
||||||
cached.SubtitleStreamIndex = null;
|
|
||||||
_cache.AddOrUpdate(cacheKey, cached);
|
|
||||||
}
|
|
||||||
|
|
||||||
item.UserData = dbContext.UserData.Where(e => e.ItemId == item.Id).AsNoTracking().ToArray();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,14 @@
|
|||||||
|
namespace Emby.Server.Implementations
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// What a configuration update carrying different ports requires of the instance reading it.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="RequiresRestart">
|
||||||
|
/// Whether this process is still bound to a port the shared configuration no longer names, and so has
|
||||||
|
/// to report a pending restart.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="ClearsPortAuthorization">
|
||||||
|
/// Whether this instance is the one that has to clear the port authorization flag and save it.
|
||||||
|
/// </param>
|
||||||
|
internal readonly record struct PortChangeOutcome(bool RequiresRestart, bool ClearsPortAuthorization);
|
||||||
|
}
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Concurrent;
|
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Linq;
|
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using MediaBrowser.Common.Extensions;
|
using MediaBrowser.Common.Extensions;
|
||||||
@@ -30,12 +28,10 @@ namespace Emby.Server.Implementations.QuickConnect
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private const int Timeout = 10;
|
private const int Timeout = 10;
|
||||||
|
|
||||||
private readonly ConcurrentDictionary<string, QuickConnectResult> _currentRequests = new();
|
|
||||||
private readonly ConcurrentDictionary<string, (DateTime Timestamp, AuthenticationResult AuthenticationResult)> _authorizedSecrets = new();
|
|
||||||
|
|
||||||
private readonly IServerConfigurationManager _config;
|
private readonly IServerConfigurationManager _config;
|
||||||
private readonly ILogger<QuickConnectManager> _logger;
|
private readonly ILogger<QuickConnectManager> _logger;
|
||||||
private readonly ISessionManager _sessionManager;
|
private readonly ISessionManager _sessionManager;
|
||||||
|
private readonly IQuickConnectStore _store;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="QuickConnectManager"/> class.
|
/// Initializes a new instance of the <see cref="QuickConnectManager"/> class.
|
||||||
@@ -44,14 +40,17 @@ namespace Emby.Server.Implementations.QuickConnect
|
|||||||
/// <param name="config">Configuration.</param>
|
/// <param name="config">Configuration.</param>
|
||||||
/// <param name="logger">Logger.</param>
|
/// <param name="logger">Logger.</param>
|
||||||
/// <param name="sessionManager">Session Manager.</param>
|
/// <param name="sessionManager">Session Manager.</param>
|
||||||
|
/// <param name="store">Quick connect store.</param>
|
||||||
public QuickConnectManager(
|
public QuickConnectManager(
|
||||||
IServerConfigurationManager config,
|
IServerConfigurationManager config,
|
||||||
ILogger<QuickConnectManager> logger,
|
ILogger<QuickConnectManager> logger,
|
||||||
ISessionManager sessionManager)
|
ISessionManager sessionManager,
|
||||||
|
IQuickConnectStore store)
|
||||||
{
|
{
|
||||||
_config = config;
|
_config = config;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_sessionManager = sessionManager;
|
_sessionManager = sessionManager;
|
||||||
|
_store = store;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -69,7 +68,7 @@ namespace Emby.Server.Implementations.QuickConnect
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public QuickConnectResult TryConnect(AuthorizationInfo authorizationInfo)
|
public async Task<QuickConnectResult> TryConnect(AuthorizationInfo authorizationInfo)
|
||||||
{
|
{
|
||||||
ArgumentException.ThrowIfNullOrEmpty(authorizationInfo.DeviceId);
|
ArgumentException.ThrowIfNullOrEmpty(authorizationInfo.DeviceId);
|
||||||
ArgumentException.ThrowIfNullOrEmpty(authorizationInfo.Device);
|
ArgumentException.ThrowIfNullOrEmpty(authorizationInfo.Device);
|
||||||
@@ -77,7 +76,6 @@ namespace Emby.Server.Implementations.QuickConnect
|
|||||||
ArgumentException.ThrowIfNullOrEmpty(authorizationInfo.Version);
|
ArgumentException.ThrowIfNullOrEmpty(authorizationInfo.Version);
|
||||||
|
|
||||||
AssertActive();
|
AssertActive();
|
||||||
ExpireRequests();
|
|
||||||
|
|
||||||
var secret = GenerateSecureRandom();
|
var secret = GenerateSecureRandom();
|
||||||
var code = GenerateCode();
|
var code = GenerateCode();
|
||||||
@@ -90,19 +88,17 @@ namespace Emby.Server.Implementations.QuickConnect
|
|||||||
authorizationInfo.Client,
|
authorizationInfo.Client,
|
||||||
authorizationInfo.Version);
|
authorizationInfo.Version);
|
||||||
|
|
||||||
_currentRequests[code] = result;
|
await _store.SetRequestAsync(result, ExpiryOf(result)).ConfigureAwait(false);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public QuickConnectResult CheckRequestStatus(string secret)
|
public async Task<QuickConnectResult> CheckRequestStatus(string secret)
|
||||||
{
|
{
|
||||||
AssertActive();
|
AssertActive();
|
||||||
ExpireRequests();
|
|
||||||
|
|
||||||
string code = _currentRequests.Where(x => x.Value.Secret == secret).Select(x => x.Value.Code).DefaultIfEmpty(string.Empty).First();
|
var result = await _store.GetRequestBySecretAsync(secret).ConfigureAwait(false);
|
||||||
|
if (result is null)
|
||||||
if (!_currentRequests.TryGetValue(code, out QuickConnectResult? result))
|
|
||||||
{
|
{
|
||||||
throw new ResourceNotFoundException("Unable to find request with provided secret");
|
throw new ResourceNotFoundException("Unable to find request with provided secret");
|
||||||
}
|
}
|
||||||
@@ -136,21 +132,27 @@ namespace Emby.Server.Implementations.QuickConnect
|
|||||||
public async Task<bool> AuthorizeRequest(Guid userId, string code)
|
public async Task<bool> AuthorizeRequest(Guid userId, string code)
|
||||||
{
|
{
|
||||||
AssertActive();
|
AssertActive();
|
||||||
ExpireRequests();
|
|
||||||
|
|
||||||
if (!_currentRequests.TryGetValue(code, out QuickConnectResult? result))
|
var result = await _store.GetRequestByCodeAsync(code).ConfigureAwait(false);
|
||||||
|
if (result is null)
|
||||||
{
|
{
|
||||||
throw new ResourceNotFoundException("Unable to find request");
|
throw new ResourceNotFoundException("Unable to find request");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.Authenticated)
|
if (result.Authenticated)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("Request is already authorized");
|
throw new ConflictException("Request is already authorized");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Change the time on the request so it expires one minute into the future. It can't expire immediately as otherwise some clients wouldn't ever see that they have been authenticated.
|
// Change the time on the request so it expires one minute into the future. It can't expire immediately as otherwise some clients wouldn't ever see that they have been authenticated.
|
||||||
result.DateAdded = DateTime.UtcNow.Add(TimeSpan.FromMinutes(1));
|
result.DateAdded = DateTime.UtcNow.Add(TimeSpan.FromMinutes(1));
|
||||||
|
|
||||||
|
// The guard above is a read on shared state, so it cannot settle a race between instances; the claim can.
|
||||||
|
if (!await _store.TryClaimAuthorizationAsync(result.Secret, ExpiryOf(result)).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
throw await RefusedClaimAsync(result.Secret).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
var authenticationResult = await _sessionManager.AuthenticateDirect(new AuthenticationRequest
|
var authenticationResult = await _sessionManager.AuthenticateDirect(new AuthenticationRequest
|
||||||
{
|
{
|
||||||
UserId = userId,
|
UserId = userId,
|
||||||
@@ -160,9 +162,10 @@ namespace Emby.Server.Implementations.QuickConnect
|
|||||||
AppVersion = result.AppVersion
|
AppVersion = result.AppVersion
|
||||||
}).ConfigureAwait(false);
|
}).ConfigureAwait(false);
|
||||||
|
|
||||||
_authorizedSecrets[result.Secret] = (DateTime.UtcNow, authenticationResult);
|
|
||||||
result.Authenticated = true;
|
result.Authenticated = true;
|
||||||
_currentRequests[code] = result;
|
|
||||||
|
await _store.SetAuthorizationAsync(result.Secret, authenticationResult, DateTime.UtcNow.AddMinutes(Timeout)).ConfigureAwait(false);
|
||||||
|
await _store.SetRequestAsync(result, ExpiryOf(result)).ConfigureAwait(false);
|
||||||
|
|
||||||
_logger.LogDebug("Authorizing device with code {Code} to login as user {UserId}", code, userId);
|
_logger.LogDebug("Authorizing device with code {Code} to login as user {UserId}", code, userId);
|
||||||
|
|
||||||
@@ -170,17 +173,33 @@ namespace Emby.Server.Implementations.QuickConnect
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public AuthenticationResult GetAuthorizedRequest(string secret)
|
public async Task<AuthenticationResult> GetAuthorizedRequest(string secret)
|
||||||
{
|
{
|
||||||
AssertActive();
|
AssertActive();
|
||||||
ExpireRequests();
|
|
||||||
|
|
||||||
if (!_authorizedSecrets.TryGetValue(secret, out var result))
|
var result = await _store.GetAuthorizationAsync(secret).ConfigureAwait(false);
|
||||||
|
if (result is null)
|
||||||
{
|
{
|
||||||
throw new ResourceNotFoundException("Unable to find request");
|
throw new ResourceNotFoundException("Unable to find request");
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.AuthenticationResult;
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateTime ExpiryOf(QuickConnectResult request) => request.DateAdded.AddMinutes(Timeout);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Explains a refused claim. The claim outlives a failed mint on purpose, so it can mean either
|
||||||
|
/// that the request is authorized or that authorizing it did not finish; the two are told apart
|
||||||
|
/// by re-reading the request rather than reported as the same thing.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<ConflictException> RefusedClaimAsync(string secret)
|
||||||
|
{
|
||||||
|
var current = await _store.GetRequestBySecretAsync(secret).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return current?.Authenticated == true
|
||||||
|
? new ConflictException("Request is already authorized")
|
||||||
|
: new ConflictException("Request is being authorized elsewhere, or an earlier attempt to authorize it did not complete. Start quick connect again for a new code.");
|
||||||
}
|
}
|
||||||
|
|
||||||
private string GenerateSecureRandom(int length = 32)
|
private string GenerateSecureRandom(int length = 32)
|
||||||
@@ -190,42 +209,5 @@ namespace Emby.Server.Implementations.QuickConnect
|
|||||||
|
|
||||||
return Convert.ToHexString(bytes);
|
return Convert.ToHexString(bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Expire quick connect requests that are over the time limit. If <paramref name="expireAll"/> is true, all requests are unconditionally expired.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="expireAll">If true, all requests will be expired.</param>
|
|
||||||
private void ExpireRequests(bool expireAll = false)
|
|
||||||
{
|
|
||||||
// All requests before this timestamp have expired
|
|
||||||
var minTime = DateTime.UtcNow.AddMinutes(-Timeout);
|
|
||||||
|
|
||||||
// Expire stale connection requests
|
|
||||||
foreach (var (_, currentRequest) in _currentRequests)
|
|
||||||
{
|
|
||||||
if (expireAll || currentRequest.DateAdded < minTime)
|
|
||||||
{
|
|
||||||
var code = currentRequest.Code;
|
|
||||||
_logger.LogDebug("Removing expired request {Code}", code);
|
|
||||||
|
|
||||||
if (!_currentRequests.TryRemove(code, out _))
|
|
||||||
{
|
|
||||||
_logger.LogWarning("Request {Code} already expired", code);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var (secret, (timestamp, _)) in _authorizedSecrets)
|
|
||||||
{
|
|
||||||
if (expireAll || timestamp < minTime)
|
|
||||||
{
|
|
||||||
_logger.LogDebug("Removing expired secret {Secret}", secret);
|
|
||||||
if (!_authorizedSecrets.TryRemove(secret, out _))
|
|
||||||
{
|
|
||||||
_logger.LogWarning("Secret {Secret} already expired", secret);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
using System;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Extensions.Json;
|
||||||
|
using MediaBrowser.Common.Extensions;
|
||||||
|
using MediaBrowser.Controller.Authentication;
|
||||||
|
using MediaBrowser.Controller.QuickConnect;
|
||||||
|
using MediaBrowser.Model.QuickConnect;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using StackExchange.Redis;
|
||||||
|
|
||||||
|
namespace Emby.Server.Implementations.QuickConnect;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A Redis-backed <see cref="IQuickConnectStore"/> that lets the initiate, authorize and exchange legs
|
||||||
|
/// of a quick connect flow land on different instances. Expiry is the key TTL and an authorization is
|
||||||
|
/// claimed with a Lua check-and-set, so only one instance can ever mint a given secret's access token.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// There is no local fallback: a call Redis did not answer is inconclusive, and reporting it as a miss
|
||||||
|
/// would tell a polling client its secret is invalid. Quick connect is unavailable for as long as Redis
|
||||||
|
/// is, which password login is not.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class RedisQuickConnectStore : IQuickConnectStore
|
||||||
|
{
|
||||||
|
private const string KeyPrefix = "jellyfin:quickconnect:";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lua script writing the two keys a request is resolvable by in one step, so it can never be
|
||||||
|
/// reachable by its secret while the code the user is reading off the screen resolves to nothing.
|
||||||
|
/// </summary>
|
||||||
|
private const string SetRequestScript = @"
|
||||||
|
redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[3])
|
||||||
|
redis.call('SET', KEYS[2], ARGV[2], 'PX', ARGV[3])
|
||||||
|
return 1";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lua script for the atomic claim of the sole right to authorize a request: the request has to
|
||||||
|
/// exist and not already be authorized, and the claim marker is taken with <c>SET NX</c>, so of two
|
||||||
|
/// instances racing on one code exactly one goes on to mint an access token.
|
||||||
|
/// </summary>
|
||||||
|
private const string ClaimAuthorizationScript = @"
|
||||||
|
local raw = redis.call('GET', KEYS[1])
|
||||||
|
if not raw then return 0 end
|
||||||
|
if cjson.decode(raw)['Authenticated'] then return 0 end
|
||||||
|
if redis.call('SET', KEYS[2], '1', 'NX', 'PX', ARGV[1]) then return 1 end
|
||||||
|
return 0";
|
||||||
|
|
||||||
|
private readonly IDatabase _db;
|
||||||
|
private readonly ILogger<RedisQuickConnectStore> _logger;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="RedisQuickConnectStore"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="redis">The Redis connection multiplexer.</param>
|
||||||
|
/// <param name="logger">The logger.</param>
|
||||||
|
public RedisQuickConnectStore(IConnectionMultiplexer redis, ILogger<RedisQuickConnectStore> logger)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(redis);
|
||||||
|
|
||||||
|
_db = redis.GetDatabase();
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<QuickConnectResult?> GetRequestBySecretAsync(string secret, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var raw = await CallAsync(() => _db.StringGetAsync(RequestKey(secret))).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Deserialization is outside the guard: a malformed stored value is a fault of its own, not Redis
|
||||||
|
// being unavailable.
|
||||||
|
return raw.HasValue ? JsonSerializer.Deserialize<QuickConnectResult>(raw.ToString(), JsonDefaults.Options) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<QuickConnectResult?> GetRequestByCodeAsync(string code, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var secret = await CallAsync(() => _db.StringGetAsync(CodeKey(code))).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return secret.HasValue
|
||||||
|
? await GetRequestBySecretAsync(secret.ToString(), cancellationToken).ConfigureAwait(false)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task SetRequestAsync(QuickConnectResult request, DateTime expiresUtc, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
|
||||||
|
var ttl = expiresUtc - DateTime.UtcNow;
|
||||||
|
if (ttl <= TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(request, JsonDefaults.Options);
|
||||||
|
await CallAsync(() => _db.ScriptEvaluateAsync(
|
||||||
|
SetRequestScript,
|
||||||
|
keys: new RedisKey[] { RequestKey(request.Secret), CodeKey(request.Code) },
|
||||||
|
values: new RedisValue[] { json, request.Secret, (long)ttl.TotalMilliseconds })).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<bool> TryClaimAuthorizationAsync(string secret, DateTime expiresUtc, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var ttl = expiresUtc - DateTime.UtcNow;
|
||||||
|
if (ttl <= TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var claimed = (long?)await CallAsync(() => _db.ScriptEvaluateAsync(
|
||||||
|
ClaimAuthorizationScript,
|
||||||
|
keys: new RedisKey[] { RequestKey(secret), ClaimKey(secret) },
|
||||||
|
values: new RedisValue[] { (long)ttl.TotalMilliseconds })).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return claimed == 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task SetAuthorizationAsync(string secret, AuthenticationResult authenticationResult, DateTime expiresUtc, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var ttl = expiresUtc - DateTime.UtcNow;
|
||||||
|
if (ttl <= TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var json = JsonSerializer.Serialize(authenticationResult, JsonDefaults.Options);
|
||||||
|
await CallAsync(() => _db.StringSetAsync(AuthorizationKey(secret), json, ttl)).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<AuthenticationResult?> GetAuthorizationAsync(string secret, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var raw = await CallAsync(() => _db.StringGetAsync(AuthorizationKey(secret))).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return raw.HasValue
|
||||||
|
? JsonSerializer.Deserialize<AuthenticationResult>(raw.ToString(), JsonDefaults.Options)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string RequestKey(string secret) => KeyPrefix + "request:" + secret;
|
||||||
|
|
||||||
|
private static string CodeKey(string code) => KeyPrefix + "code:" + code;
|
||||||
|
|
||||||
|
private static string ClaimKey(string secret) => KeyPrefix + "claim:" + secret;
|
||||||
|
|
||||||
|
private static string AuthorizationKey(string secret) => KeyPrefix + "auth:" + secret;
|
||||||
|
|
||||||
|
private async Task<T> CallAsync<T>(Func<Task<T>> call)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await call().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is RedisException or RedisCommandException or TimeoutException)
|
||||||
|
{
|
||||||
|
_logger.LogError(exception, "Quick connect state could not be reached in Redis.");
|
||||||
|
throw new ServiceUnavailableException("Quick connect is temporarily unavailable.", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 Jellyfin.Extensions.Json;
|
||||||
using MediaBrowser.Common.Configuration;
|
using MediaBrowser.Common.Configuration;
|
||||||
using MediaBrowser.Common.Extensions;
|
using MediaBrowser.Common.Extensions;
|
||||||
|
using MediaBrowser.Controller.ScheduledTasks;
|
||||||
using MediaBrowser.Model.Tasks;
|
using MediaBrowser.Model.Tasks;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
@@ -27,6 +28,8 @@ public class ScheduledTaskWorker : IScheduledTaskWorker
|
|||||||
private readonly IApplicationPaths _applicationPaths;
|
private readonly IApplicationPaths _applicationPaths;
|
||||||
private readonly ILogger _logger;
|
private readonly ILogger _logger;
|
||||||
private readonly ITaskManager _taskManager;
|
private readonly ITaskManager _taskManager;
|
||||||
|
private readonly IScanLeaderLease _scanLeaderLease;
|
||||||
|
private readonly ScanLeaderOptions _scanLeaderOptions;
|
||||||
private readonly Lock _lastExecutionResultSyncLock = new();
|
private readonly Lock _lastExecutionResultSyncLock = new();
|
||||||
private bool _readFromFile;
|
private bool _readFromFile;
|
||||||
private TaskResult _lastExecutionResult;
|
private TaskResult _lastExecutionResult;
|
||||||
@@ -41,6 +44,8 @@ public class ScheduledTaskWorker : IScheduledTaskWorker
|
|||||||
/// <param name="applicationPaths">The application paths.</param>
|
/// <param name="applicationPaths">The application paths.</param>
|
||||||
/// <param name="taskManager">The task manager.</param>
|
/// <param name="taskManager">The task manager.</param>
|
||||||
/// <param name="logger">The logger.</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">
|
/// <exception cref="ArgumentNullException">
|
||||||
/// scheduledTask
|
/// scheduledTask
|
||||||
/// or
|
/// or
|
||||||
@@ -52,7 +57,13 @@ public class ScheduledTaskWorker : IScheduledTaskWorker
|
|||||||
/// or
|
/// or
|
||||||
/// logger.
|
/// logger.
|
||||||
/// </exception>
|
/// </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(scheduledTask);
|
||||||
ArgumentNullException.ThrowIfNull(applicationPaths);
|
ArgumentNullException.ThrowIfNull(applicationPaths);
|
||||||
@@ -63,6 +74,8 @@ public class ScheduledTaskWorker : IScheduledTaskWorker
|
|||||||
_applicationPaths = applicationPaths;
|
_applicationPaths = applicationPaths;
|
||||||
_taskManager = taskManager;
|
_taskManager = taskManager;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_scanLeaderLease = scanLeaderLease;
|
||||||
|
_scanLeaderOptions = scanLeaderOptions;
|
||||||
|
|
||||||
InitTriggerEvents();
|
InitTriggerEvents();
|
||||||
}
|
}
|
||||||
@@ -268,6 +281,20 @@ public class ScheduledTaskWorker : IScheduledTaskWorker
|
|||||||
|
|
||||||
trigger.Stop();
|
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);
|
_taskManager.QueueScheduledTask(ScheduledTask, trigger.TaskOptions);
|
||||||
|
|
||||||
await Task.Delay(1000).ConfigureAwait(false);
|
await Task.Delay(1000).ConfigureAwait(false);
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ using System.Linq;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Jellyfin.Data.Events;
|
using Jellyfin.Data.Events;
|
||||||
using MediaBrowser.Common.Configuration;
|
using MediaBrowser.Common.Configuration;
|
||||||
|
using MediaBrowser.Controller.ScheduledTasks;
|
||||||
using MediaBrowser.Model.Tasks;
|
using MediaBrowser.Model.Tasks;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
namespace Emby.Server.Implementations.ScheduledTasks;
|
namespace Emby.Server.Implementations.ScheduledTasks;
|
||||||
|
|
||||||
@@ -23,18 +25,26 @@ public class TaskManager : ITaskManager
|
|||||||
|
|
||||||
private readonly IApplicationPaths _applicationPaths;
|
private readonly IApplicationPaths _applicationPaths;
|
||||||
private readonly ILogger<TaskManager> _logger;
|
private readonly ILogger<TaskManager> _logger;
|
||||||
|
private readonly IScanLeaderLease? _scanLeaderLease;
|
||||||
|
private readonly ScanLeaderOptions? _scanLeaderOptions;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="TaskManager" /> class.
|
/// Initializes a new instance of the <see cref="TaskManager" /> class.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="applicationPaths">The application paths.</param>
|
/// <param name="applicationPaths">The application paths.</param>
|
||||||
/// <param name="logger">The logger.</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(
|
public TaskManager(
|
||||||
IApplicationPaths applicationPaths,
|
IApplicationPaths applicationPaths,
|
||||||
ILogger<TaskManager> logger)
|
ILogger<TaskManager> logger,
|
||||||
|
IScanLeaderLease? scanLeaderLease = null,
|
||||||
|
IOptions<ScanLeaderOptions>? scanLeaderOptions = null)
|
||||||
{
|
{
|
||||||
_applicationPaths = applicationPaths;
|
_applicationPaths = applicationPaths;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_scanLeaderLease = scanLeaderLease;
|
||||||
|
_scanLeaderOptions = scanLeaderOptions?.Value;
|
||||||
|
|
||||||
ScheduledTasks = [];
|
ScheduledTasks = [];
|
||||||
}
|
}
|
||||||
@@ -175,7 +185,7 @@ public class TaskManager : ITaskManager
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void AddTasks(IEnumerable<IScheduledTask> tasks)
|
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();
|
ScheduledTasks = ScheduledTasks.Concat(list).ToArray();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using System.Threading;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using MediaBrowser.Common.Configuration;
|
using MediaBrowser.Common.Configuration;
|
||||||
using MediaBrowser.Controller.IO;
|
using MediaBrowser.Controller.IO;
|
||||||
|
using MediaBrowser.Controller.MediaEncoding;
|
||||||
using MediaBrowser.Model.Globalization;
|
using MediaBrowser.Model.Globalization;
|
||||||
using MediaBrowser.Model.IO;
|
using MediaBrowser.Model.IO;
|
||||||
using MediaBrowser.Model.Tasks;
|
using MediaBrowser.Model.Tasks;
|
||||||
@@ -21,6 +22,7 @@ public class DeleteTranscodeFileTask : IScheduledTask, IConfigurableScheduledTas
|
|||||||
private readonly IConfigurationManager _configurationManager;
|
private readonly IConfigurationManager _configurationManager;
|
||||||
private readonly IFileSystem _fileSystem;
|
private readonly IFileSystem _fileSystem;
|
||||||
private readonly ILocalizationManager _localization;
|
private readonly ILocalizationManager _localization;
|
||||||
|
private readonly ITranscodeSessionStore _sessionStore;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="DeleteTranscodeFileTask"/> class.
|
/// 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="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
|
||||||
/// <param name="configurationManager">Instance of the <see cref="IConfigurationManager"/> 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="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
|
||||||
|
/// <param name="sessionStore">Instance of the <see cref="ITranscodeSessionStore"/> interface.</param>
|
||||||
public DeleteTranscodeFileTask(
|
public DeleteTranscodeFileTask(
|
||||||
ILogger<DeleteTranscodeFileTask> logger,
|
ILogger<DeleteTranscodeFileTask> logger,
|
||||||
IFileSystem fileSystem,
|
IFileSystem fileSystem,
|
||||||
IConfigurationManager configurationManager,
|
IConfigurationManager configurationManager,
|
||||||
ILocalizationManager localization)
|
ILocalizationManager localization,
|
||||||
|
ITranscodeSessionStore sessionStore)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_fileSystem = fileSystem;
|
_fileSystem = fileSystem;
|
||||||
_configurationManager = configurationManager;
|
_configurationManager = configurationManager;
|
||||||
_localization = localization;
|
_localization = localization;
|
||||||
|
_sessionStore = sessionStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -78,25 +83,39 @@ public class DeleteTranscodeFileTask : IScheduledTask, IConfigurableScheduledTas
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var minDateModified = DateTime.UtcNow.AddDays(-1);
|
var minDateModified = DateTime.UtcNow.AddDays(-1);
|
||||||
progress.Report(50);
|
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>
|
/// <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>
|
/// </summary>
|
||||||
/// <param name="directory">The directory.</param>
|
/// <param name="directory">The directory.</param>
|
||||||
/// <param name="minDateModified">The min date modified.</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="progress">The progress.</param>
|
||||||
/// <param name="cancellationToken">The task cancellation token.</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)
|
var filesToDelete = _fileSystem.GetFiles(directory, true)
|
||||||
.Where(f => _fileSystem.GetLastWriteTimeUtc(f) < minDateModified)
|
.Where(f => _fileSystem.GetLastWriteTimeUtc(f) < minDateModified)
|
||||||
.ToList();
|
.ToList();
|
||||||
@@ -112,6 +131,13 @@ public class DeleteTranscodeFileTask : IScheduledTask, IConfigurableScheduledTas
|
|||||||
|
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
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);
|
FileSystemHelper.DeleteFile(_fileSystem, file.FullName, _logger);
|
||||||
|
|
||||||
index++;
|
index++;
|
||||||
@@ -121,4 +147,24 @@ public class DeleteTranscodeFileTask : IScheduledTask, IConfigurableScheduledTas
|
|||||||
|
|
||||||
progress.Report(100);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Extensions.Json;
|
||||||
|
using MediaBrowser.Controller.Session;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using StackExchange.Redis;
|
||||||
|
|
||||||
|
namespace Emby.Server.Implementations.Session;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A Redis pub/sub <see cref="IPodMessageBus"/>. Every instance subscribes to a channel named after
|
||||||
|
/// itself, which keeps addressed delivery working without the instances being routable to each other.
|
||||||
|
/// A request is answered on the sender's own channel, so the sender learns what the receiver did with
|
||||||
|
/// it rather than only that something was subscribed.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RedisPodMessageBus : IPodMessageBus
|
||||||
|
{
|
||||||
|
private const string ChannelPrefix = "jellyfin:pod:";
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
|
||||||
|
|
||||||
|
private readonly ConcurrentDictionary<string, TaskCompletionSource<bool>> _pending = new(StringComparer.Ordinal);
|
||||||
|
private readonly ISubscriber _subscriber;
|
||||||
|
private readonly ILogger<RedisPodMessageBus> _logger;
|
||||||
|
private readonly TimeSpan _timeout;
|
||||||
|
|
||||||
|
private Func<PodMessage, Task<bool>>? _handler;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="RedisPodMessageBus"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="redis">The Redis connection multiplexer.</param>
|
||||||
|
/// <param name="options">The session directory configuration options.</param>
|
||||||
|
/// <param name="podId">The identity of this instance.</param>
|
||||||
|
/// <param name="logger">The logger.</param>
|
||||||
|
public RedisPodMessageBus(
|
||||||
|
IConnectionMultiplexer redis,
|
||||||
|
IOptions<SessionDirectoryOptions> options,
|
||||||
|
string podId,
|
||||||
|
ILogger<RedisPodMessageBus> logger)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(redis);
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(podId);
|
||||||
|
|
||||||
|
_subscriber = redis.GetSubscriber();
|
||||||
|
_logger = logger;
|
||||||
|
_timeout = TimeSpan.FromSeconds(Math.Max(1, options.Value.OperationTimeoutSeconds));
|
||||||
|
PodId = podId;
|
||||||
|
|
||||||
|
// A bus that cannot subscribe can only send, so every request it makes waits out the timeout and
|
||||||
|
// nothing routed here is ever answered. The caller degrades the pair to single-instance instead.
|
||||||
|
_subscriber.Subscribe(RedisChannel.Literal(ChannelPrefix + PodId), (_, value) => Dispatch(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string PodId { get; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<bool> RequestAsync(string targetPod, PodMessage message, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(targetPod);
|
||||||
|
ArgumentNullException.ThrowIfNull(message);
|
||||||
|
|
||||||
|
message.OriginPod = PodId;
|
||||||
|
message.CorrelationId = Guid.NewGuid().ToString("N");
|
||||||
|
|
||||||
|
var acknowledged = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
_pending[message.CorrelationId] = acknowledged;
|
||||||
|
|
||||||
|
// Publish and acknowledgement share one deadline, so a request is bounded by the timeout rather
|
||||||
|
// than by twice it.
|
||||||
|
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
deadline.CancelAfter(_timeout);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var subscribers = await _subscriber.PublishAsync(
|
||||||
|
RedisChannel.Literal(ChannelPrefix + targetPod),
|
||||||
|
JsonSerializer.Serialize(message, _jsonOptions)).WaitAsync(deadline.Token).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (subscribers == 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await acknowledged.Task.WaitAsync(deadline.Token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Instance {TargetPod} did not acknowledge a {Kind} message within {Timeout}.", targetPod, message.Kind, _timeout);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to send a {Kind} message to {TargetPod}.", message.Kind, targetPod);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_pending.TryRemove(message.CorrelationId, out _);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Subscribe(Func<PodMessage, Task<bool>> handler)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(handler);
|
||||||
|
|
||||||
|
_handler = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void Dispatch(RedisValue value)
|
||||||
|
{
|
||||||
|
PodMessage? message = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
message = JsonSerializer.Deserialize<PodMessage>(value.ToString(), _jsonOptions);
|
||||||
|
if (message is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(message.Kind, PodMessage.AckKind, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
if (_pending.TryRemove(message.CorrelationId, out var acknowledged))
|
||||||
|
{
|
||||||
|
acknowledged.TrySetResult(message.Handled);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var handler = _handler;
|
||||||
|
var handled = handler is not null && await handler(message).ConfigureAwait(false);
|
||||||
|
|
||||||
|
await AcknowledgeAsync(message, handled).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to handle a message routed to this instance.");
|
||||||
|
|
||||||
|
if (message is not null && !string.Equals(message.Kind, PodMessage.AckKind, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
await AcknowledgeAsync(message, false).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AcknowledgeAsync(PodMessage message, bool handled)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(message.CorrelationId) || string.IsNullOrEmpty(message.OriginPod))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ack = new PodMessage
|
||||||
|
{
|
||||||
|
Kind = PodMessage.AckKind,
|
||||||
|
OriginPod = PodId,
|
||||||
|
CorrelationId = message.CorrelationId,
|
||||||
|
Handled = handled
|
||||||
|
};
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _subscriber.PublishAsync(
|
||||||
|
RedisChannel.Literal(ChannelPrefix + message.OriginPod),
|
||||||
|
JsonSerializer.Serialize(ack, _jsonOptions)).WaitAsync(_timeout).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to acknowledge a {Kind} message to {OriginPod}.", message.Kind, message.OriginPod);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Extensions.Json;
|
||||||
|
using MediaBrowser.Controller.Session;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using StackExchange.Redis;
|
||||||
|
|
||||||
|
namespace Emby.Server.Implementations.Session;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A Redis-backed <see cref="ISessionDirectory"/>. A session is owned by the instance holding its
|
||||||
|
/// connection: ownership is claimed through a check-and-set against a connection epoch handed out by
|
||||||
|
/// Redis, so an instance that only served a request for the session cannot take it from the instance the
|
||||||
|
/// device is actually connected to, and no instance's clock is compared against another's. Each entry is
|
||||||
|
/// a key with an expiry, so the sessions of an instance that stops refreshing them disappear on their own.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RedisSessionDirectory : ISessionDirectory
|
||||||
|
{
|
||||||
|
private const string KeyPrefix = "jellyfin:session:";
|
||||||
|
private const string OwnerKeyPrefix = "jellyfin:sessionowner:";
|
||||||
|
private const string EpochKeyPrefix = "jellyfin:sessionepoch:";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lua script for an atomic ownership claim. The owner key holds <c>epoch|pod</c>, where the epoch is
|
||||||
|
/// zero for an instance that holds no connection. Another instance takes ownership only by presenting
|
||||||
|
/// a connection epoch newer than the recorded one, so neither the instances serving the session's
|
||||||
|
/// requests nor two instances without a connection can take it from the one that has it.
|
||||||
|
/// </summary>
|
||||||
|
private const string ClaimScript = @"
|
||||||
|
redis.call('PEXPIRE', KEYS[3], ARGV[5])
|
||||||
|
local current = redis.call('GET', KEYS[1])
|
||||||
|
if current then
|
||||||
|
local separator = string.find(current, '|', 1, true)
|
||||||
|
local connected = tonumber(string.sub(current, 1, separator - 1))
|
||||||
|
local owner = string.sub(current, separator + 1)
|
||||||
|
local claiming = tonumber(ARGV[2])
|
||||||
|
if owner ~= ARGV[1] and (claiming == 0 or claiming <= connected) then
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
redis.call('SET', KEYS[1], ARGV[2] .. '|' .. ARGV[1], 'PX', ARGV[4])
|
||||||
|
redis.call('SET', KEYS[2], ARGV[3], 'PX', ARGV[4])
|
||||||
|
return 1";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lua script for an atomic, ownership-checked removal, so that an instance ending its own copy of a
|
||||||
|
/// session cannot erase the entry of the instance still holding the connection.
|
||||||
|
/// </summary>
|
||||||
|
private const string ReleaseScript = @"
|
||||||
|
local current = redis.call('GET', KEYS[1])
|
||||||
|
if not current then return 0 end
|
||||||
|
local separator = string.find(current, '|', 1, true)
|
||||||
|
if string.sub(current, separator + 1) ~= ARGV[1] then return 0 end
|
||||||
|
redis.call('DEL', KEYS[1], KEYS[2])
|
||||||
|
return 1";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lua script allocating the next connection epoch. The counter outlives the entries that reference
|
||||||
|
/// it, so it never restarts underneath a recorded epoch.
|
||||||
|
/// </summary>
|
||||||
|
private const string EpochScript = @"
|
||||||
|
local epoch = redis.call('INCR', KEYS[1])
|
||||||
|
redis.call('PEXPIRE', KEYS[1], ARGV[1])
|
||||||
|
return epoch";
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
|
||||||
|
|
||||||
|
private readonly IConnectionMultiplexer _redis;
|
||||||
|
private readonly IDatabase _db;
|
||||||
|
private readonly SessionDirectoryOptions _options;
|
||||||
|
private readonly ILogger<RedisSessionDirectory> _logger;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="RedisSessionDirectory"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="redis">The Redis connection multiplexer.</param>
|
||||||
|
/// <param name="options">The session directory configuration options.</param>
|
||||||
|
/// <param name="logger">The logger.</param>
|
||||||
|
public RedisSessionDirectory(
|
||||||
|
IConnectionMultiplexer redis,
|
||||||
|
IOptions<SessionDirectoryOptions> options,
|
||||||
|
ILogger<RedisSessionDirectory> logger)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(redis);
|
||||||
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
|
|
||||||
|
_redis = redis;
|
||||||
|
_db = redis.GetDatabase();
|
||||||
|
_options = options.Value;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
private long EntryTtlMs => Math.Max(1, _options.EntryTtlSeconds) * 1000L;
|
||||||
|
|
||||||
|
// Outlives the entries that name an epoch, so a live entry never outlives the counter it came from.
|
||||||
|
private long EpochTtlMs => EntryTtlMs * 4;
|
||||||
|
|
||||||
|
private TimeSpan OperationTimeout => TimeSpan.FromSeconds(Math.Max(1, _options.OperationTimeoutSeconds));
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<long> AllocateConnectionEpochAsync(string sessionId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(sessionId);
|
||||||
|
|
||||||
|
var epoch = (long?)await _db.ScriptEvaluateAsync(
|
||||||
|
EpochScript,
|
||||||
|
keys: new RedisKey[] { EpochKeyPrefix + sessionId },
|
||||||
|
values: new RedisValue[] { EpochTtlMs }).WaitAsync(OperationTimeout, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return epoch ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<bool> PublishAsync(SessionDirectoryEntry entry, long connectionEpoch, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(entry);
|
||||||
|
|
||||||
|
var sessionId = entry.Session?.Id;
|
||||||
|
if (string.IsNullOrEmpty(sessionId))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var claimed = (long?)await _db.ScriptEvaluateAsync(
|
||||||
|
ClaimScript,
|
||||||
|
keys: new RedisKey[] { OwnerKeyPrefix + sessionId, KeyPrefix + sessionId, EpochKeyPrefix + sessionId },
|
||||||
|
values: new RedisValue[]
|
||||||
|
{
|
||||||
|
entry.OwnerPod,
|
||||||
|
connectionEpoch.ToString(CultureInfo.InvariantCulture),
|
||||||
|
JsonSerializer.Serialize(entry, _jsonOptions),
|
||||||
|
EntryTtlMs,
|
||||||
|
EpochTtlMs
|
||||||
|
}).WaitAsync(OperationTimeout, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return claimed == 1;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to publish session {SessionId}; it stays invisible to the other instances.", sessionId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task RemoveAsync(string sessionId, string ownerPod, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _db.ScriptEvaluateAsync(
|
||||||
|
ReleaseScript,
|
||||||
|
keys: new RedisKey[] { OwnerKeyPrefix + sessionId, KeyPrefix + sessionId },
|
||||||
|
values: new RedisValue[] { ownerPod }).WaitAsync(OperationTimeout, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to remove session {SessionId}; it expires on its own.", sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<SessionDirectoryEntry?> GetAsync(string sessionId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// A store that cannot be read says nothing about where the session is, so the failure is raised
|
||||||
|
// rather than reported as "no such session", which would be acted on as a local-only session.
|
||||||
|
var raw = await _db.StringGetAsync(KeyPrefix + sessionId).WaitAsync(OperationTimeout, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return raw.HasValue ? Deserialize(raw) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<IReadOnlyList<SessionDirectoryEntry>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var entries = new List<SessionDirectoryEntry>();
|
||||||
|
|
||||||
|
foreach (var server in _redis.GetServers())
|
||||||
|
{
|
||||||
|
if (!server.IsConnected)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var keys = new List<RedisKey>();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await foreach (var key in server.KeysAsync(database: _db.Database, pattern: KeyPrefix + "*", pageSize: 1000).WithCancellation(cancellationToken).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
keys.Add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Degrade to the sessions that could be read rather than failing the request outright.
|
||||||
|
_logger.LogWarning(ex, "Failed to list the session directory on {Server}; its sessions are not reported.", server.EndPoint);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var raw in await Task.WhenAll(keys.Select(key => ReadAsync(key, cancellationToken))).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
if (!raw.HasValue)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var entry = Deserialize(raw);
|
||||||
|
if (entry?.Session is not null)
|
||||||
|
{
|
||||||
|
entries.Add(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One unreadable key must not discard the entries that did load.
|
||||||
|
private async Task<RedisValue> ReadAsync(RedisKey key, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await _db.StringGetAsync(key).WaitAsync(OperationTimeout, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to read a session directory entry.");
|
||||||
|
return RedisValue.Null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private SessionDirectoryEntry? Deserialize(RedisValue raw)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return JsonSerializer.Deserialize<SessionDirectoryEntry>(raw.ToString(), _jsonOptions);
|
||||||
|
}
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to deserialize a session directory entry.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Extensions.Json;
|
||||||
|
using MediaBrowser.Common.Extensions;
|
||||||
|
using MediaBrowser.Controller.Session;
|
||||||
|
using MediaBrowser.Model.Session;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Emby.Server.Implementations.Session;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stands in for the websocket of a session another instance holds: messages are forwarded to that
|
||||||
|
/// instance, which writes them to the connection it owns and reports back whether it did.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RemoteSessionController : ISessionController
|
||||||
|
{
|
||||||
|
private readonly IPodMessageBus _bus;
|
||||||
|
private readonly ILogger _logger;
|
||||||
|
private readonly string _ownerPod;
|
||||||
|
private readonly string _sessionId;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="RemoteSessionController"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="bus">The cross-instance bus.</param>
|
||||||
|
/// <param name="logger">The logger.</param>
|
||||||
|
/// <param name="ownerPod">The instance holding the connection.</param>
|
||||||
|
/// <param name="sessionId">The session identifier.</param>
|
||||||
|
/// <param name="supportsMediaControl">Whether the owner reported the session as controllable.</param>
|
||||||
|
/// <param name="holdsConnection">Whether the owner reported that it holds the session's connection.</param>
|
||||||
|
public RemoteSessionController(IPodMessageBus bus, ILogger logger, string ownerPod, string sessionId, bool supportsMediaControl, bool holdsConnection)
|
||||||
|
{
|
||||||
|
_bus = bus;
|
||||||
|
_logger = logger;
|
||||||
|
_ownerPod = ownerPod;
|
||||||
|
_sessionId = sessionId;
|
||||||
|
SupportsMediaControl = supportsMediaControl;
|
||||||
|
IsSessionActive = holdsConnection;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool IsSessionActive { get; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool SupportsMediaControl { get; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task SendMessage<T>(SessionMessageType name, Guid messageId, T data, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var routed = new RoutedSessionMessage
|
||||||
|
{
|
||||||
|
SessionId = _sessionId,
|
||||||
|
MessageType = name,
|
||||||
|
MessageId = messageId,
|
||||||
|
Data = JsonSerializer.Serialize(data, JsonDefaults.Options)
|
||||||
|
};
|
||||||
|
|
||||||
|
var delivered = await _bus.RequestAsync(
|
||||||
|
_ownerPod,
|
||||||
|
new PodMessage
|
||||||
|
{
|
||||||
|
Kind = RoutedSessionMessage.Kind,
|
||||||
|
Payload = JsonSerializer.Serialize(routed, JsonDefaults.Options)
|
||||||
|
},
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (!delivered)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Instance {OwnerPod} did not write the {MessageType} message for session {SessionId} to a connection.", _ownerPod, name, _sessionId);
|
||||||
|
|
||||||
|
throw new ResourceNotFoundException(
|
||||||
|
string.Format(CultureInfo.InvariantCulture, "No instance holds a connection to session {0}.", _sessionId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -114,11 +114,11 @@ namespace Emby.Server.Implementations.Session
|
|||||||
public async Task ProcessWebSocketConnectedAsync(IWebSocketConnection connection, HttpContext httpContext)
|
public async Task ProcessWebSocketConnectedAsync(IWebSocketConnection connection, HttpContext httpContext)
|
||||||
{
|
{
|
||||||
var session = await RequestHelpers.GetSession(_sessionManager, _userManager, httpContext).ConfigureAwait(false);
|
var session = await RequestHelpers.GetSession(_sessionManager, _userManager, httpContext).ConfigureAwait(false);
|
||||||
EnsureController(session, connection);
|
await EnsureController(session, connection).ConfigureAwait(false);
|
||||||
await KeepAliveWebSocket(connection).ConfigureAwait(false);
|
await KeepAliveWebSocket(connection).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void EnsureController(SessionInfo session, IWebSocketConnection connection)
|
private async Task EnsureController(SessionInfo session, IWebSocketConnection connection)
|
||||||
{
|
{
|
||||||
var controllerInfo = session.EnsureController<WebSocketController>(
|
var controllerInfo = session.EnsureController<WebSocketController>(
|
||||||
s => new WebSocketController(_loggerFactory.CreateLogger<WebSocketController>(), s, _sessionManager));
|
s => new WebSocketController(_loggerFactory.CreateLogger<WebSocketController>(), s, _sessionManager));
|
||||||
@@ -126,7 +126,7 @@ namespace Emby.Server.Implementations.Session
|
|||||||
var controller = (WebSocketController)controllerInfo.Item1;
|
var controller = (WebSocketController)controllerInfo.Item1;
|
||||||
controller.AddWebSocket(connection);
|
controller.AddWebSocket(connection);
|
||||||
|
|
||||||
_sessionManager.OnSessionControllerConnected(session);
|
await _sessionManager.OnSessionControllerConnected(session).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using Jellyfin.Data.Enums;
|
using Jellyfin.Data.Enums;
|
||||||
using Jellyfin.Database.Implementations.Entities;
|
using Jellyfin.Database.Implementations.Entities;
|
||||||
using MediaBrowser.Controller.Entities;
|
using MediaBrowser.Controller.Entities;
|
||||||
@@ -27,6 +28,12 @@ namespace Emby.Server.Implementations.Sorting
|
|||||||
/// <value>The user manager.</value>
|
/// <value>The user manager.</value>
|
||||||
public IUserManager UserManager { get; set; }
|
public IUserManager UserManager { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the prefetched user data.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The prefetched user data.</value>
|
||||||
|
public IReadOnlyDictionary<Guid, UserItemData> PrefetchedUserData { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the user data manager.
|
/// Gets or sets the user data manager.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -57,7 +64,7 @@ namespace Emby.Server.Implementations.Sorting
|
|||||||
/// <returns>DateTime.</returns>
|
/// <returns>DateTime.</returns>
|
||||||
private DateTime GetDate(BaseItem x)
|
private DateTime GetDate(BaseItem x)
|
||||||
{
|
{
|
||||||
var userdata = UserDataManager.GetUserData(User, x);
|
var userdata = this.GetUserData(x);
|
||||||
|
|
||||||
if (userdata is not null && userdata.LastPlayedDate.HasValue)
|
if (userdata is not null && userdata.LastPlayedDate.HasValue)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
#nullable disable
|
#nullable disable
|
||||||
#pragma warning disable CS1591
|
#pragma warning disable CS1591
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using Jellyfin.Data.Enums;
|
using Jellyfin.Data.Enums;
|
||||||
using Jellyfin.Database.Implementations.Entities;
|
using Jellyfin.Database.Implementations.Entities;
|
||||||
using MediaBrowser.Controller.Entities;
|
using MediaBrowser.Controller.Entities;
|
||||||
@@ -35,6 +37,12 @@ namespace Emby.Server.Implementations.Sorting
|
|||||||
/// <value>The user manager.</value>
|
/// <value>The user manager.</value>
|
||||||
public IUserManager UserManager { get; set; }
|
public IUserManager UserManager { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the prefetched user data.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The prefetched user data.</value>
|
||||||
|
public IReadOnlyDictionary<Guid, UserItemData> PrefetchedUserData { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Compares the specified x.
|
/// Compares the specified x.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -53,7 +61,7 @@ namespace Emby.Server.Implementations.Sorting
|
|||||||
/// <returns>DateTime.</returns>
|
/// <returns>DateTime.</returns>
|
||||||
private int GetValue(BaseItem x)
|
private int GetValue(BaseItem x)
|
||||||
{
|
{
|
||||||
return x.IsFavoriteOrLiked(User, userItemData: null) ? 0 : 1;
|
return x.IsFavoriteOrLiked(User, this.GetUserData(x)) ? 0 : 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
#pragma warning disable CS1591
|
#pragma warning disable CS1591
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using Jellyfin.Data.Enums;
|
using Jellyfin.Data.Enums;
|
||||||
using Jellyfin.Database.Implementations.Entities;
|
using Jellyfin.Database.Implementations.Entities;
|
||||||
using MediaBrowser.Controller.Entities;
|
using MediaBrowser.Controller.Entities;
|
||||||
@@ -36,6 +38,12 @@ namespace Emby.Server.Implementations.Sorting
|
|||||||
/// <value>The user manager.</value>
|
/// <value>The user manager.</value>
|
||||||
public IUserManager UserManager { get; set; }
|
public IUserManager UserManager { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the prefetched user data.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The prefetched user data.</value>
|
||||||
|
public IReadOnlyDictionary<Guid, UserItemData> PrefetchedUserData { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Compares the specified x.
|
/// Compares the specified x.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -54,7 +62,7 @@ namespace Emby.Server.Implementations.Sorting
|
|||||||
/// <returns>DateTime.</returns>
|
/// <returns>DateTime.</returns>
|
||||||
private int GetValue(BaseItem x)
|
private int GetValue(BaseItem x)
|
||||||
{
|
{
|
||||||
return x.IsPlayed(User, userItemData: null) ? 0 : 1;
|
return x.IsPlayed(User, this.GetUserData(x)) ? 0 : 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
#pragma warning disable CS1591
|
#pragma warning disable CS1591
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using Jellyfin.Data.Enums;
|
using Jellyfin.Data.Enums;
|
||||||
using Jellyfin.Database.Implementations.Entities;
|
using Jellyfin.Database.Implementations.Entities;
|
||||||
using MediaBrowser.Controller.Entities;
|
using MediaBrowser.Controller.Entities;
|
||||||
@@ -36,6 +38,12 @@ namespace Emby.Server.Implementations.Sorting
|
|||||||
/// <value>The user manager.</value>
|
/// <value>The user manager.</value>
|
||||||
public IUserManager UserManager { get; set; }
|
public IUserManager UserManager { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the prefetched user data.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The prefetched user data.</value>
|
||||||
|
public IReadOnlyDictionary<Guid, UserItemData> PrefetchedUserData { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Compares the specified x.
|
/// Compares the specified x.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -54,7 +62,7 @@ namespace Emby.Server.Implementations.Sorting
|
|||||||
/// <returns>DateTime.</returns>
|
/// <returns>DateTime.</returns>
|
||||||
private int GetValue(BaseItem x)
|
private int GetValue(BaseItem x)
|
||||||
{
|
{
|
||||||
return x.IsUnplayed(User, userItemData: null) ? 0 : 1;
|
return x.IsUnplayed(User, this.GetUserData(x)) ? 0 : 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using Jellyfin.Data.Enums;
|
using Jellyfin.Data.Enums;
|
||||||
using Jellyfin.Database.Implementations.Entities;
|
using Jellyfin.Database.Implementations.Entities;
|
||||||
using MediaBrowser.Controller.Entities;
|
using MediaBrowser.Controller.Entities;
|
||||||
@@ -38,6 +40,12 @@ namespace Emby.Server.Implementations.Sorting
|
|||||||
/// <value>The user manager.</value>
|
/// <value>The user manager.</value>
|
||||||
public IUserManager UserManager { get; set; }
|
public IUserManager UserManager { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the prefetched user data.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The prefetched user data.</value>
|
||||||
|
public IReadOnlyDictionary<Guid, UserItemData> PrefetchedUserData { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Compares the specified x.
|
/// Compares the specified x.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -56,7 +64,7 @@ namespace Emby.Server.Implementations.Sorting
|
|||||||
/// <returns>DateTime.</returns>
|
/// <returns>DateTime.</returns>
|
||||||
private int GetValue(BaseItem x)
|
private int GetValue(BaseItem x)
|
||||||
{
|
{
|
||||||
var userdata = UserDataManager.GetUserData(User, x);
|
var userdata = this.GetUserData(x);
|
||||||
|
|
||||||
return userdata is null ? 0 : userdata.PlayCount;
|
return userdata is null ? 0 : userdata.PlayCount;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ using MediaBrowser.Controller.Configuration;
|
|||||||
using MediaBrowser.Controller.Dto;
|
using MediaBrowser.Controller.Dto;
|
||||||
using MediaBrowser.Controller.Entities;
|
using MediaBrowser.Controller.Entities;
|
||||||
using MediaBrowser.Controller.Library;
|
using MediaBrowser.Controller.Library;
|
||||||
|
using MediaBrowser.Controller.Persistence;
|
||||||
using MediaBrowser.Controller.TV;
|
using MediaBrowser.Controller.TV;
|
||||||
using MediaBrowser.Model.Querying;
|
using MediaBrowser.Model.Querying;
|
||||||
using Episode = MediaBrowser.Controller.Entities.TV.Episode;
|
using Episode = MediaBrowser.Controller.Entities.TV.Episode;
|
||||||
@@ -124,53 +125,100 @@ namespace Emby.Server.Implementations.TV
|
|||||||
|
|
||||||
var batchResult = _libraryManager.GetNextUpEpisodesBatch(query, seriesKeys, includeSpecials, includeRewatching);
|
var batchResult = _libraryManager.GetNextUpEpisodesBatch(query, seriesKeys, includeSpecials, includeRewatching);
|
||||||
|
|
||||||
var nextUpList = new List<(DateTime LastWatchedDate, Episode Episode)>();
|
var results = new List<NextUpEpisodeBatchResult>(seriesKeys.Count);
|
||||||
|
|
||||||
foreach (var seriesKey in seriesKeys)
|
foreach (var seriesKey in seriesKeys)
|
||||||
{
|
{
|
||||||
if (!batchResult.TryGetValue(seriesKey, out var result))
|
if (batchResult.TryGetValue(seriesKey, out var result))
|
||||||
{
|
{
|
||||||
continue;
|
results.Add(result);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var nextEpisode = DetermineNextEpisode(result, user, includeSpecials, request.EnableResumable, false);
|
// The selection below tests the played state of every episode it considers, so read the whole
|
||||||
|
// series batch in one query rather than one query per series.
|
||||||
|
var selectionCandidates = new List<BaseItem>();
|
||||||
|
foreach (var result in results)
|
||||||
|
{
|
||||||
|
AddCandidate(selectionCandidates, result.NextUp);
|
||||||
|
AddCandidate(selectionCandidates, result.LastWatched);
|
||||||
|
AddCandidate(selectionCandidates, result.NextPlayedForRewatching);
|
||||||
|
AddCandidate(selectionCandidates, result.LastWatchedForRewatching);
|
||||||
|
|
||||||
|
if (result.Specials is not null)
|
||||||
|
{
|
||||||
|
selectionCandidates.AddRange(result.Specials);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var selectionUserData = _userDataManager.GetUserDataBatch(selectionCandidates, user);
|
||||||
|
|
||||||
|
var candidates = new List<NextUpCandidate>();
|
||||||
|
foreach (var result in results)
|
||||||
|
{
|
||||||
|
var nextEpisode = SelectNextEpisode(result, user, includeSpecials, includePlayed: false, selectionUserData);
|
||||||
if (nextEpisode is not null)
|
if (nextEpisode is not null)
|
||||||
{
|
{
|
||||||
// The last played date and the version that was actually played live on the version item's user data
|
candidates.Add(new NextUpCandidate(nextEpisode, result.LastWatched, !request.EnableResumable));
|
||||||
// The played state propagated to the sibling versions carries no date
|
|
||||||
var (playedVersion, lastPlayedDate) = GetMostRecentlyPlayedVersion(result.LastWatched, user);
|
|
||||||
nextEpisode = GetPreferredVersion(nextEpisode, result.LastWatched, playedVersion);
|
|
||||||
|
|
||||||
DateTime lastWatchedDate = DateTime.MinValue;
|
|
||||||
if (result.LastWatched is not null)
|
|
||||||
{
|
|
||||||
lastWatchedDate = lastPlayedDate ?? DateTime.MinValue.AddDays(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
nextUpList.Add((lastWatchedDate, nextEpisode));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (includeRewatching)
|
if (includeRewatching)
|
||||||
{
|
{
|
||||||
var nextPlayedEpisode = DetermineNextEpisodeForRewatching(result, user, includeSpecials);
|
var nextPlayedEpisode = SelectNextEpisode(result, user, includeSpecials, includePlayed: true, selectionUserData);
|
||||||
|
|
||||||
if (nextPlayedEpisode is not null)
|
if (nextPlayedEpisode is not null)
|
||||||
{
|
{
|
||||||
var (playedVersion, lastPlayedDate) = GetMostRecentlyPlayedVersion(result.LastWatchedForRewatching, user);
|
// A rewatch suggestion is dropped once it has been resumed, whatever the request asked for.
|
||||||
nextPlayedEpisode = GetPreferredVersion(nextPlayedEpisode, result.LastWatchedForRewatching, playedVersion);
|
candidates.Add(new NextUpCandidate(nextPlayedEpisode, result.LastWatchedForRewatching, true));
|
||||||
|
|
||||||
DateTime rewatchLastWatchedDate = DateTime.MinValue;
|
|
||||||
if (result.LastWatchedForRewatching is not null)
|
|
||||||
{
|
|
||||||
rewatchLastWatchedDate = lastPlayedDate ?? DateTime.MinValue.AddDays(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
nextUpList.Add((rewatchLastWatchedDate, nextPlayedEpisode));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The resume progress may live on an alternate version, so read every version in one query.
|
||||||
|
var episodeVersions = new List<BaseItem>();
|
||||||
|
foreach (var candidate in candidates)
|
||||||
|
{
|
||||||
|
if (candidate.DropWhenResumed)
|
||||||
|
{
|
||||||
|
candidate.EpisodeVersions = candidate.Episode.GetAllVersions();
|
||||||
|
episodeVersions.AddRange(candidate.EpisodeVersions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (episodeVersions.Count > 0)
|
||||||
|
{
|
||||||
|
var resumeUserData = _userDataManager.GetUserDataBatch(episodeVersions, user);
|
||||||
|
candidates.RemoveAll(candidate => candidate.EpisodeVersions
|
||||||
|
.Any(version => GetUserData(user, version, resumeUserData)?.PlaybackPositionTicks > 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The last played date and the version that was actually played live on the version item's user data
|
||||||
|
// The played state propagated to the sibling versions carries no date
|
||||||
|
var lastWatchedVersions = new List<BaseItem>();
|
||||||
|
foreach (var candidate in candidates)
|
||||||
|
{
|
||||||
|
if (candidate.LastWatched is Video lastWatchedVideo)
|
||||||
|
{
|
||||||
|
candidate.LastWatchedVersions = lastWatchedVideo.GetAllVersions();
|
||||||
|
lastWatchedVersions.AddRange(candidate.LastWatchedVersions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastWatchedUserData = _userDataManager.GetUserDataBatch(lastWatchedVersions, user);
|
||||||
|
|
||||||
|
var nextUpList = new List<(DateTime LastWatchedDate, Episode Episode)>(candidates.Count);
|
||||||
|
foreach (var candidate in candidates)
|
||||||
|
{
|
||||||
|
var (playedVersion, lastPlayedDate) = GetMostRecentlyPlayedVersion(candidate.LastWatchedVersions, user, lastWatchedUserData);
|
||||||
|
var nextEpisode = GetPreferredVersion(candidate.Episode, candidate.LastWatched, playedVersion);
|
||||||
|
|
||||||
|
DateTime lastWatchedDate = DateTime.MinValue;
|
||||||
|
if (candidate.LastWatched is not null)
|
||||||
|
{
|
||||||
|
lastWatchedDate = lastPlayedDate ?? DateTime.MinValue.AddDays(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
nextUpList.Add((lastWatchedDate, nextEpisode));
|
||||||
|
}
|
||||||
|
|
||||||
var sortedEpisodes = nextUpList
|
var sortedEpisodes = nextUpList
|
||||||
.OrderByDescending(x => x.LastWatchedDate)
|
.OrderByDescending(x => x.LastWatchedDate)
|
||||||
.Select(x => (BaseItem)x.Episode);
|
.Select(x => (BaseItem)x.Episode);
|
||||||
@@ -178,12 +226,25 @@ namespace Emby.Server.Implementations.TV
|
|||||||
return GetResult(sortedEpisodes, request);
|
return GetResult(sortedEpisodes, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Episode? DetermineNextEpisode(
|
private static void AddCandidate(List<BaseItem> candidates, BaseItem? item)
|
||||||
MediaBrowser.Controller.Persistence.NextUpEpisodeBatchResult result,
|
{
|
||||||
|
if (item is not null)
|
||||||
|
{
|
||||||
|
candidates.Add(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private UserItemData? GetUserData(User user, BaseItem item, IReadOnlyDictionary<Guid, UserItemData> prefetchedUserData)
|
||||||
|
=> prefetchedUserData.TryGetValue(item.Id, out var userData)
|
||||||
|
? userData
|
||||||
|
: _userDataManager.GetUserData(user, item);
|
||||||
|
|
||||||
|
private Episode? SelectNextEpisode(
|
||||||
|
NextUpEpisodeBatchResult result,
|
||||||
User user,
|
User user,
|
||||||
bool includeSpecials,
|
bool includeSpecials,
|
||||||
bool includeResumable,
|
bool includePlayed,
|
||||||
bool includePlayed)
|
IReadOnlyDictionary<Guid, UserItemData> prefetchedUserData)
|
||||||
{
|
{
|
||||||
var nextEpisode = (includePlayed ? result.NextPlayedForRewatching : result.NextUp) as Episode;
|
var nextEpisode = (includePlayed ? result.NextPlayedForRewatching : result.NextUp) as Episode;
|
||||||
var lastWatchedEpisode = (includePlayed ? result.LastWatchedForRewatching : result.LastWatched) as Episode;
|
var lastWatchedEpisode = (includePlayed ? result.LastWatchedForRewatching : result.LastWatched) as Episode;
|
||||||
@@ -217,60 +278,41 @@ namespace Emby.Server.Implementations.TV
|
|||||||
|
|
||||||
if (!includePlayed)
|
if (!includePlayed)
|
||||||
{
|
{
|
||||||
sortedEpisodes = sortedEpisodes.Where(episode => _userDataManager.GetUserData(user, episode) is not { Played: true });
|
sortedEpisodes = sortedEpisodes.Where(episode => GetUserData(user, episode, prefetchedUserData) is not { Played: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
nextEpisode = sortedEpisodes.FirstOrDefault();
|
nextEpisode = sortedEpisodes.FirstOrDefault();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nextEpisode is not null && !includeResumable)
|
|
||||||
{
|
|
||||||
// The resume progress may live on an alternate version
|
|
||||||
foreach (var version in nextEpisode.GetAllVersions())
|
|
||||||
{
|
|
||||||
if (_userDataManager.GetUserData(user, version)?.PlaybackPositionTicks > 0)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nextEpisode;
|
return nextEpisode;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Episode? DetermineNextEpisodeForRewatching(
|
|
||||||
MediaBrowser.Controller.Persistence.NextUpEpisodeBatchResult result,
|
|
||||||
User user,
|
|
||||||
bool includeSpecials)
|
|
||||||
{
|
|
||||||
return DetermineNextEpisode(result, user, includeSpecials, includeResumable: false, includePlayed: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the version of the last watched episode that was actually played, together with its last played date.
|
/// Gets the version of the last watched episode that was actually played, together with its last played date.
|
||||||
/// The version that was played carries the most recent LastPlayedDate.
|
/// The version that was played carries the most recent LastPlayedDate.
|
||||||
/// dates.
|
/// dates.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="lastWatched">The last watched episode (any version).</param>
|
/// <param name="versions">The versions of the last watched episode.</param>
|
||||||
/// <param name="user">The user.</param>
|
/// <param name="user">The user.</param>
|
||||||
|
/// <param name="prefetchedUserData">User data read for every version up front.</param>
|
||||||
/// <returns>The played version and its last played date.</returns>
|
/// <returns>The played version and its last played date.</returns>
|
||||||
private (Video? PlayedVersion, DateTime? LastPlayedDate) GetMostRecentlyPlayedVersion(BaseItem? lastWatched, User user)
|
private (Video? PlayedVersion, DateTime? LastPlayedDate) GetMostRecentlyPlayedVersion(
|
||||||
|
IReadOnlyList<Video> versions,
|
||||||
|
User user,
|
||||||
|
IReadOnlyDictionary<Guid, UserItemData> prefetchedUserData)
|
||||||
{
|
{
|
||||||
if (lastWatched is not Video lastWatchedVideo)
|
if (versions.Count == 0)
|
||||||
{
|
{
|
||||||
return (null, null);
|
return (null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
var versions = lastWatchedVideo.GetAllVersions();
|
|
||||||
var userDataByVersion = _userDataManager.GetUserDataBatch(versions, user);
|
|
||||||
|
|
||||||
var playedVersion = VersionPlaybackSelector.SelectMostRecentlyPlayed(
|
var playedVersion = VersionPlaybackSelector.SelectMostRecentlyPlayed(
|
||||||
versions,
|
versions,
|
||||||
version => userDataByVersion.GetValueOrDefault(version.Id),
|
version => GetUserData(user, version, prefetchedUserData),
|
||||||
data => data.LastPlayedDate.HasValue);
|
data => data.LastPlayedDate.HasValue);
|
||||||
|
|
||||||
return (playedVersion, playedVersion is null ? null : userDataByVersion[playedVersion.Id].LastPlayedDate);
|
return (playedVersion, playedVersion is null ? null : GetUserData(user, playedVersion, prefetchedUserData)?.LastPlayedDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -346,5 +388,28 @@ namespace Emby.Server.Implementations.TV
|
|||||||
totalCount,
|
totalCount,
|
||||||
items.ToArray());
|
items.ToArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An episode picked for Next Up, together with the versions its user data is read from.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class NextUpCandidate
|
||||||
|
{
|
||||||
|
public NextUpCandidate(Episode episode, BaseItem? lastWatched, bool dropWhenResumed)
|
||||||
|
{
|
||||||
|
Episode = episode;
|
||||||
|
LastWatched = lastWatched;
|
||||||
|
DropWhenResumed = dropWhenResumed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Episode Episode { get; }
|
||||||
|
|
||||||
|
public BaseItem? LastWatched { get; }
|
||||||
|
|
||||||
|
public bool DropWhenResumed { get; }
|
||||||
|
|
||||||
|
public IReadOnlyList<Video> EpisodeVersions { get; set; } = [];
|
||||||
|
|
||||||
|
public IReadOnlyList<Video> LastWatchedVersions { get; set; } = [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,10 +50,10 @@ public class DevicesController : BaseJellyfinApiController
|
|||||||
/// <returns>An <see cref="OkResult"/> containing the list of devices.</returns>
|
/// <returns>An <see cref="OkResult"/> containing the list of devices.</returns>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[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);
|
userId = RequestHelpers.GetUserId(User, userId);
|
||||||
return _deviceManager.GetDevicesForUser(userId);
|
return await _deviceManager.GetDevicesForUser(userId).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -66,9 +66,9 @@ public class DevicesController : BaseJellyfinApiController
|
|||||||
[HttpGet("Info")]
|
[HttpGet("Info")]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[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)
|
if (deviceInfo is null)
|
||||||
{
|
{
|
||||||
return NotFound();
|
return NotFound();
|
||||||
@@ -87,9 +87,9 @@ public class DevicesController : BaseJellyfinApiController
|
|||||||
[HttpGet("Options")]
|
[HttpGet("Options")]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[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)
|
if (deviceInfo is null)
|
||||||
{
|
{
|
||||||
return NotFound();
|
return NotFound();
|
||||||
@@ -127,7 +127,12 @@ public class DevicesController : BaseJellyfinApiController
|
|||||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
public async Task<ActionResult> DeleteDevice([FromQuery] string[] id)
|
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))
|
if (devices.Any(f => f is null))
|
||||||
{
|
{
|
||||||
return BadRequest();
|
return BadRequest();
|
||||||
@@ -135,7 +140,7 @@ public class DevicesController : BaseJellyfinApiController
|
|||||||
|
|
||||||
foreach (var device in devices)
|
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)
|
foreach (var session in sessions.Items)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ using Microsoft.AspNetCore.Authorization;
|
|||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
namespace Jellyfin.Api.Controllers;
|
namespace Jellyfin.Api.Controllers;
|
||||||
|
|
||||||
@@ -44,6 +45,8 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
private const EncoderPreset DefaultEventEncoderPreset = EncoderPreset.superfast;
|
private const EncoderPreset DefaultEventEncoderPreset = EncoderPreset.superfast;
|
||||||
private const TranscodingJobType TranscodingJobType = MediaBrowser.Controller.MediaEncoding.TranscodingJobType.Hls;
|
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 _minFFmpegFlacInMp4 = new Version(6, 0);
|
||||||
private readonly Version _minFFmpegX265BframeInFmp4 = new Version(7, 0, 1);
|
private readonly Version _minFFmpegX265BframeInFmp4 = new Version(7, 0, 1);
|
||||||
private readonly Version _minFFmpegHlsSegmentOptions = new Version(5, 0);
|
private readonly Version _minFFmpegHlsSegmentOptions = new Version(5, 0);
|
||||||
@@ -60,6 +63,8 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
private readonly IDynamicHlsPlaylistGenerator _dynamicHlsPlaylistGenerator;
|
private readonly IDynamicHlsPlaylistGenerator _dynamicHlsPlaylistGenerator;
|
||||||
private readonly DynamicHlsHelper _dynamicHlsHelper;
|
private readonly DynamicHlsHelper _dynamicHlsHelper;
|
||||||
private readonly EncodingOptions _encodingOptions;
|
private readonly EncodingOptions _encodingOptions;
|
||||||
|
private readonly ITranscodeSessionStore _transcodeSessionStore;
|
||||||
|
private readonly TranscodeStoreOptions _transcodeStoreOptions;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="DynamicHlsController"/> class.
|
/// 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="dynamicHlsHelper">Instance of <see cref="DynamicHlsHelper"/>.</param>
|
||||||
/// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param>
|
/// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param>
|
||||||
/// <param name="dynamicHlsPlaylistGenerator">Instance of <see cref="IDynamicHlsPlaylistGenerator"/>.</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(
|
public DynamicHlsController(
|
||||||
ILibraryManager libraryManager,
|
ILibraryManager libraryManager,
|
||||||
IUserManager userManager,
|
IUserManager userManager,
|
||||||
@@ -86,7 +93,9 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
ILogger<DynamicHlsController> logger,
|
ILogger<DynamicHlsController> logger,
|
||||||
DynamicHlsHelper dynamicHlsHelper,
|
DynamicHlsHelper dynamicHlsHelper,
|
||||||
EncodingHelper encodingHelper,
|
EncodingHelper encodingHelper,
|
||||||
IDynamicHlsPlaylistGenerator dynamicHlsPlaylistGenerator)
|
IDynamicHlsPlaylistGenerator dynamicHlsPlaylistGenerator,
|
||||||
|
ITranscodeSessionStore transcodeSessionStore,
|
||||||
|
IOptions<TranscodeStoreOptions> transcodeStoreOptions)
|
||||||
{
|
{
|
||||||
_libraryManager = libraryManager;
|
_libraryManager = libraryManager;
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
@@ -99,6 +108,8 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
_dynamicHlsHelper = dynamicHlsHelper;
|
_dynamicHlsHelper = dynamicHlsHelper;
|
||||||
_encodingHelper = encodingHelper;
|
_encodingHelper = encodingHelper;
|
||||||
_dynamicHlsPlaylistGenerator = dynamicHlsPlaylistGenerator;
|
_dynamicHlsPlaylistGenerator = dynamicHlsPlaylistGenerator;
|
||||||
|
_transcodeSessionStore = transcodeSessionStore;
|
||||||
|
_transcodeStoreOptions = transcodeStoreOptions.Value;
|
||||||
|
|
||||||
_encodingOptions = serverConfigurationManager.GetEncodingOptions();
|
_encodingOptions = serverConfigurationManager.GetEncodingOptions();
|
||||||
}
|
}
|
||||||
@@ -306,15 +317,24 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
// If the playlist doesn't already exist, startup ffmpeg
|
// If the playlist doesn't already exist, startup ffmpeg
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
var isHaMode = await IsHaTakeoverAsync(playSessionId, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
job = await _transcodeManager.StartFfMpeg(
|
job = await _transcodeManager.StartFfMpeg(
|
||||||
state,
|
state,
|
||||||
playlistPath,
|
playlistPath,
|
||||||
GetCommandLineArguments(playlistPath, state, true, 0),
|
GetCommandLineArguments(playlistPath, state, true, 0, isHaMode),
|
||||||
Request.HttpContext.User.GetUserId(),
|
Request.HttpContext.User.GetUserId(),
|
||||||
TranscodingJobType,
|
TranscodingJobType,
|
||||||
cancellationTokenSource)
|
cancellationTokenSource)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
job.IsLiveOutput = true;
|
job.IsLiveOutput = true;
|
||||||
|
await RegisterTranscodeSessionAsync(
|
||||||
|
playSessionId ?? string.Empty,
|
||||||
|
mediaSourceId ?? string.Empty,
|
||||||
|
playlistPath,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
StartLeaseRenewal(playSessionId ?? string.Empty, cancellationToken);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -1511,14 +1531,23 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
|
|
||||||
streamingRequest.StartTimeTicks = streamingRequest.CurrentRuntimeTicks;
|
streamingRequest.StartTimeTicks = streamingRequest.CurrentRuntimeTicks;
|
||||||
|
|
||||||
|
var isHaMode = await IsHaTakeoverAsync(streamingRequest.PlaySessionId, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
state.WaitForPath = segmentPath;
|
state.WaitForPath = segmentPath;
|
||||||
job = await _transcodeManager.StartFfMpeg(
|
job = await _transcodeManager.StartFfMpeg(
|
||||||
state,
|
state,
|
||||||
playlistPath,
|
playlistPath,
|
||||||
GetCommandLineArguments(playlistPath, state, false, segmentId),
|
GetCommandLineArguments(playlistPath, state, false, segmentId, isHaMode),
|
||||||
Request.HttpContext.User.GetUserId(),
|
Request.HttpContext.User.GetUserId(),
|
||||||
TranscodingJobType,
|
TranscodingJobType,
|
||||||
cancellationTokenSource).ConfigureAwait(false);
|
cancellationTokenSource).ConfigureAwait(false);
|
||||||
|
await RegisterTranscodeSessionAsync(
|
||||||
|
streamingRequest.PlaySessionId ?? string.Empty,
|
||||||
|
streamingRequest.MediaSourceId ?? string.Empty,
|
||||||
|
playlistPath,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
StartLeaseRenewal(streamingRequest.PlaySessionId ?? string.Empty, cancellationToken);
|
||||||
}
|
}
|
||||||
catch
|
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)
|
internal static async Task WaitForActiveTranscodingRequests(TranscodingJob? job, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
while (job?.ActiveRequestCount > 0)
|
while (job?.ActiveRequestCount > 0)
|
||||||
@@ -1554,6 +1608,116 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
private static double[] GetSegmentLengths(StreamState state)
|
private static double[] GetSegmentLengths(StreamState state)
|
||||||
=> GetSegmentLengthsInternal(state.RunTimeTicks ?? 0, state.SegmentLength);
|
=> 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)
|
internal static double[] GetSegmentLengthsInternal(long runtimeTicks, int segmentlength)
|
||||||
{
|
{
|
||||||
var segmentLengthTicks = TimeSpan.FromSeconds(segmentlength).Ticks;
|
var segmentLengthTicks = TimeSpan.FromSeconds(segmentlength).Ticks;
|
||||||
@@ -1575,7 +1739,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
return segments;
|
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 videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions);
|
||||||
var threads = EncodingHelper.GetNumberOfThreads(state, _encodingOptions, videoCodec);
|
var threads = EncodingHelper.GetNumberOfThreads(state, _encodingOptions, videoCodec);
|
||||||
@@ -1588,10 +1752,13 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
var outputExtension = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer);
|
var outputExtension = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer);
|
||||||
var outputTsArg = outputPrefix + "%d" + outputExtension;
|
var outputTsArg = outputPrefix + "%d" + outputExtension;
|
||||||
|
|
||||||
|
var effectiveSegmentLength = GetEffectiveSegmentLength(isHaMode, state.SegmentLength, _encodingOptions);
|
||||||
|
var hlsListSize = GetHlsListSize(isHaMode, _encodingOptions);
|
||||||
|
|
||||||
var segmentFormat = string.Empty;
|
var segmentFormat = string.Empty;
|
||||||
var segmentContainer = outputExtension.TrimStart('.');
|
var segmentContainer = outputExtension.TrimStart('.');
|
||||||
var inputModifier = _encodingHelper.GetInputModifier(state, _encodingOptions, segmentContainer);
|
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))
|
if (string.Equals(segmentContainer, "ts", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
@@ -1644,10 +1811,10 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
_encodingHelper.GetInputArgument(state, _encodingOptions, segmentContainer),
|
_encodingHelper.GetInputArgument(state, _encodingOptions, segmentContainer),
|
||||||
threads,
|
threads,
|
||||||
mapArgs,
|
mapArgs,
|
||||||
GetVideoArguments(state, startNumber, isEventPlaylist, segmentContainer),
|
GetVideoArguments(state, startNumber, isEventPlaylist, segmentContainer, effectiveSegmentLength),
|
||||||
GetAudioArguments(state),
|
GetAudioArguments(state),
|
||||||
maxMuxingQueueSize,
|
maxMuxingQueueSize,
|
||||||
state.SegmentLength.ToString(CultureInfo.InvariantCulture),
|
effectiveSegmentLength.ToString(CultureInfo.InvariantCulture),
|
||||||
segmentFormat,
|
segmentFormat,
|
||||||
startNumber.ToString(CultureInfo.InvariantCulture),
|
startNumber.ToString(CultureInfo.InvariantCulture),
|
||||||
baseUrlParam,
|
baseUrlParam,
|
||||||
@@ -1784,8 +1951,9 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
/// <param name="startNumber">The first number in the hls sequence.</param>
|
/// <param name="startNumber">The first number in the hls sequence.</param>
|
||||||
/// <param name="isEventPlaylist">Whether the playlist is EVENT or VOD.</param>
|
/// <param name="isEventPlaylist">Whether the playlist is EVENT or VOD.</param>
|
||||||
/// <param name="segmentContainer">The segment container.</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>
|
/// <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)
|
if (state.VideoStream is null)
|
||||||
{
|
{
|
||||||
@@ -1860,7 +2028,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
args += _encodingHelper.GetVideoQualityParam(state, codec, _encodingOptions, isEventPlaylist ? DefaultEventEncoderPreset : DefaultVodEncoderPreset);
|
args += _encodingHelper.GetVideoQualityParam(state, codec, _encodingOptions, isEventPlaylist ? DefaultEventEncoderPreset : DefaultVodEncoderPreset);
|
||||||
|
|
||||||
// Set the key frame params for video encoding to match the hls segment time.
|
// 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.
|
// Currently b-frames in libx265 breaks the FMP4-HLS playback on iOS, disable it for now.
|
||||||
if (string.Equals(codec, "libx265", StringComparison.OrdinalIgnoreCase)
|
if (string.Equals(codec, "libx265", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|||||||
@@ -50,16 +50,18 @@ public class QuickConnectController : BaseJellyfinApiController
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <response code="200">Quick connect request successfully created.</response>
|
/// <response code="200">Quick connect request successfully created.</response>
|
||||||
/// <response code="401">Quick connect is not active on this server.</response>
|
/// <response code="401">Quick connect is not active on this server.</response>
|
||||||
|
/// <response code="503">Quick connect state is unavailable.</response>
|
||||||
/// <returns>A <see cref="QuickConnectResult"/> with a secret and code for future use or an error message.</returns>
|
/// <returns>A <see cref="QuickConnectResult"/> with a secret and code for future use or an error message.</returns>
|
||||||
[HttpPost("Initiate")]
|
[HttpPost("Initiate")]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
|
||||||
public async Task<ActionResult<QuickConnectResult>> InitiateQuickConnect()
|
public async Task<ActionResult<QuickConnectResult>> InitiateQuickConnect()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var auth = await _authContext.GetAuthorizationInfo(Request).ConfigureAwait(false);
|
var auth = await _authContext.GetAuthorizationInfo(Request).ConfigureAwait(false);
|
||||||
return _quickConnect.TryConnect(auth);
|
return await _quickConnect.TryConnect(auth).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
catch (AuthenticationException)
|
catch (AuthenticationException)
|
||||||
{
|
{
|
||||||
@@ -73,15 +75,17 @@ public class QuickConnectController : BaseJellyfinApiController
|
|||||||
/// <param name="secret">Secret previously returned from the Initiate endpoint.</param>
|
/// <param name="secret">Secret previously returned from the Initiate endpoint.</param>
|
||||||
/// <response code="200">Quick connect result returned.</response>
|
/// <response code="200">Quick connect result returned.</response>
|
||||||
/// <response code="404">Unknown quick connect secret.</response>
|
/// <response code="404">Unknown quick connect secret.</response>
|
||||||
|
/// <response code="503">Quick connect state is unavailable.</response>
|
||||||
/// <returns>An updated <see cref="QuickConnectResult"/>.</returns>
|
/// <returns>An updated <see cref="QuickConnectResult"/>.</returns>
|
||||||
[HttpGet("Connect")]
|
[HttpGet("Connect")]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
public ActionResult<QuickConnectResult> GetQuickConnectState([FromQuery, Required] string secret)
|
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
|
||||||
|
public async Task<ActionResult<QuickConnectResult>> GetQuickConnectState([FromQuery, Required] string secret)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return _quickConnect.CheckRequestStatus(secret);
|
return await _quickConnect.CheckRequestStatus(secret).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
catch (ResourceNotFoundException)
|
catch (ResourceNotFoundException)
|
||||||
{
|
{
|
||||||
@@ -100,11 +104,15 @@ public class QuickConnectController : BaseJellyfinApiController
|
|||||||
/// <param name="userId">The user the authorize. Access to the requested user is required.</param>
|
/// <param name="userId">The user the authorize. Access to the requested user is required.</param>
|
||||||
/// <response code="200">Quick connect result authorized successfully.</response>
|
/// <response code="200">Quick connect result authorized successfully.</response>
|
||||||
/// <response code="403">Unknown user id.</response>
|
/// <response code="403">Unknown user id.</response>
|
||||||
|
/// <response code="409">Request is already authorized, or authorizing it did not complete.</response>
|
||||||
|
/// <response code="503">Quick connect state is unavailable.</response>
|
||||||
/// <returns>Boolean indicating if the authorization was successful.</returns>
|
/// <returns>Boolean indicating if the authorization was successful.</returns>
|
||||||
[HttpPost("Authorize")]
|
[HttpPost("Authorize")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
[ProducesResponseType(StatusCodes.Status403Forbidden)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
|
||||||
public async Task<ActionResult<bool>> AuthorizeQuickConnect([FromQuery, Required] string code, [FromQuery] Guid? userId = null)
|
public async Task<ActionResult<bool>> AuthorizeQuickConnect([FromQuery, Required] string code, [FromQuery] Guid? userId = null)
|
||||||
{
|
{
|
||||||
userId = RequestHelpers.GetUserId(User, userId);
|
userId = RequestHelpers.GetUserId(User, userId);
|
||||||
|
|||||||
@@ -52,18 +52,19 @@ public class SessionController : BaseJellyfinApiController
|
|||||||
[HttpGet("Sessions")]
|
[HttpGet("Sessions")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
public ActionResult<IReadOnlyList<SessionInfoDto>> GetSessions(
|
public async Task<ActionResult<IReadOnlyList<SessionInfoDto>>> GetSessions(
|
||||||
[FromQuery] Guid? controllableByUserId,
|
[FromQuery] Guid? controllableByUserId,
|
||||||
[FromQuery] string? deviceId,
|
[FromQuery] string? deviceId,
|
||||||
[FromQuery] int? activeWithinSeconds)
|
[FromQuery] int? activeWithinSeconds)
|
||||||
{
|
{
|
||||||
Guid? controllableUserToCheck = controllableByUserId is null ? null : RequestHelpers.GetUserId(User, controllableByUserId);
|
Guid? controllableUserToCheck = controllableByUserId is null ? null : RequestHelpers.GetUserId(User, controllableByUserId);
|
||||||
var result = _sessionManager.GetSessions(
|
var result = await _sessionManager.GetSessions(
|
||||||
User.GetUserId(),
|
User.GetUserId(),
|
||||||
deviceId,
|
deviceId,
|
||||||
activeWithinSeconds,
|
activeWithinSeconds,
|
||||||
controllableUserToCheck,
|
controllableUserToCheck,
|
||||||
User.GetIsApiKey());
|
User.GetIsApiKey(),
|
||||||
|
HttpContext.RequestAborted).ConfigureAwait(false);
|
||||||
|
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
@@ -310,10 +311,10 @@ public class SessionController : BaseJellyfinApiController
|
|||||||
[FromRoute, Required] string sessionId,
|
[FromRoute, Required] string sessionId,
|
||||||
[FromRoute, Required] Guid userId)
|
[FromRoute, Required] Guid userId)
|
||||||
{
|
{
|
||||||
_sessionManager.AddAdditionalUser(
|
await _sessionManager.AddAdditionalUser(
|
||||||
await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false),
|
await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false),
|
||||||
sessionId,
|
sessionId,
|
||||||
userId);
|
userId).ConfigureAwait(false);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,10 +332,10 @@ public class SessionController : BaseJellyfinApiController
|
|||||||
[FromRoute, Required] string sessionId,
|
[FromRoute, Required] string sessionId,
|
||||||
[FromRoute, Required] Guid userId)
|
[FromRoute, Required] Guid userId)
|
||||||
{
|
{
|
||||||
_sessionManager.RemoveAdditionalUser(
|
await _sessionManager.RemoveAdditionalUser(
|
||||||
await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false),
|
await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false),
|
||||||
sessionId,
|
sessionId,
|
||||||
userId);
|
userId).ConfigureAwait(false);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,13 +365,13 @@ public class SessionController : BaseJellyfinApiController
|
|||||||
id = currentSessionId;
|
id = currentSessionId;
|
||||||
}
|
}
|
||||||
|
|
||||||
_sessionManager.ReportCapabilities(currentSessionId, id, new ClientCapabilities
|
await _sessionManager.ReportCapabilities(currentSessionId, id, new ClientCapabilities
|
||||||
{
|
{
|
||||||
PlayableMediaTypes = playableMediaTypes,
|
PlayableMediaTypes = playableMediaTypes,
|
||||||
SupportedCommands = supportedCommands,
|
SupportedCommands = supportedCommands,
|
||||||
SupportsMediaControl = supportsMediaControl,
|
SupportsMediaControl = supportsMediaControl,
|
||||||
SupportsPersistentIdentifier = supportsPersistentIdentifier
|
SupportsPersistentIdentifier = supportsPersistentIdentifier
|
||||||
});
|
}).ConfigureAwait(false);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -394,7 +395,7 @@ public class SessionController : BaseJellyfinApiController
|
|||||||
id = currentSessionId;
|
id = currentSessionId;
|
||||||
}
|
}
|
||||||
|
|
||||||
_sessionManager.ReportCapabilities(currentSessionId, id, capabilities.ToClientCapabilities());
|
await _sessionManager.ReportCapabilities(currentSessionId, id, capabilities.ToClientCapabilities()).ConfigureAwait(false);
|
||||||
|
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
@@ -415,7 +416,7 @@ public class SessionController : BaseJellyfinApiController
|
|||||||
{
|
{
|
||||||
var currentSessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
|
var currentSessionId = await RequestHelpers.GetSessionId(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
|
||||||
|
|
||||||
_sessionManager.ReportNowViewingItem(currentSessionId, sessionId ?? currentSessionId, itemId);
|
await _sessionManager.ReportNowViewingItem(currentSessionId, sessionId ?? currentSessionId, itemId).ConfigureAwait(false);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ public class TvShowsController : BaseJellyfinApiController
|
|||||||
StartIndex = startIndex,
|
StartIndex = startIndex,
|
||||||
User = user,
|
User = user,
|
||||||
EnableTotalRecordCount = enableTotalRecordCount,
|
EnableTotalRecordCount = enableTotalRecordCount,
|
||||||
NextUpDateCutoff = nextUpDateCutoff ?? DateTime.MinValue,
|
NextUpDateCutoff = nextUpDateCutoff?.ToUniversalTime() ?? DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc),
|
||||||
EnableResumable = enableResumable,
|
EnableResumable = enableResumable,
|
||||||
EnableRewatching = enableRewatching
|
EnableRewatching = enableRewatching
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -241,15 +241,19 @@ public class UserController : BaseJellyfinApiController
|
|||||||
/// <param name="request">The <see cref="QuickConnectDto"/> request.</param>
|
/// <param name="request">The <see cref="QuickConnectDto"/> request.</param>
|
||||||
/// <response code="200">User authenticated.</response>
|
/// <response code="200">User authenticated.</response>
|
||||||
/// <response code="400">Missing token.</response>
|
/// <response code="400">Missing token.</response>
|
||||||
|
/// <response code="404">Unknown or unauthorized quick connect secret.</response>
|
||||||
|
/// <response code="503">Quick connect state is unavailable.</response>
|
||||||
/// <returns>A <see cref="Task"/> containing an <see cref="AuthenticationRequest"/> with information about the new session.</returns>
|
/// <returns>A <see cref="Task"/> containing an <see cref="AuthenticationRequest"/> with information about the new session.</returns>
|
||||||
[HttpPost("AuthenticateWithQuickConnect")]
|
[HttpPost("AuthenticateWithQuickConnect")]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
|
||||||
[Tags("Authentication")]
|
[Tags("Authentication")]
|
||||||
public ActionResult<AuthenticationResult> AuthenticateWithQuickConnect([FromBody, Required] QuickConnectDto request)
|
public async Task<ActionResult<AuthenticationResult>> AuthenticateWithQuickConnect([FromBody, Required] QuickConnectDto request)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return _quickConnectManager.GetAuthorizedRequest(request.Secret);
|
return await _quickConnectManager.GetAuthorizedRequest(request.Secret).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
catch (SecurityException e)
|
catch (SecurityException e)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -131,6 +131,8 @@ public class ExceptionMiddleware
|
|||||||
FileNotFoundException => StatusCodes.Status404NotFound,
|
FileNotFoundException => StatusCodes.Status404NotFound,
|
||||||
ResourceNotFoundException => StatusCodes.Status404NotFound,
|
ResourceNotFoundException => StatusCodes.Status404NotFound,
|
||||||
MethodNotAllowedException => StatusCodes.Status405MethodNotAllowed,
|
MethodNotAllowedException => StatusCodes.Status405MethodNotAllowed,
|
||||||
|
ConflictException => StatusCodes.Status409Conflict,
|
||||||
|
ServiceUnavailableException => StatusCodes.Status503ServiceUnavailable,
|
||||||
_ => StatusCodes.Status500InternalServerError
|
_ => StatusCodes.Status500InternalServerError
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,8 +31,6 @@ namespace Jellyfin.Server.Implementations.Devices
|
|||||||
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
|
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
|
||||||
private readonly IUserManager _userManager;
|
private readonly IUserManager _userManager;
|
||||||
private readonly ConcurrentDictionary<string, ClientCapabilities> _capabilitiesMap = new();
|
private readonly ConcurrentDictionary<string, ClientCapabilities> _capabilitiesMap = new();
|
||||||
private readonly ConcurrentDictionary<int, Device> _devices;
|
|
||||||
private readonly ConcurrentDictionary<string, DeviceOptions> _deviceOptions;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="DeviceManager"/> class.
|
/// Initializes a new instance of the <see cref="DeviceManager"/> class.
|
||||||
@@ -43,23 +41,6 @@ namespace Jellyfin.Server.Implementations.Devices
|
|||||||
{
|
{
|
||||||
_dbProvider = dbProvider;
|
_dbProvider = dbProvider;
|
||||||
_userManager = userManager;
|
_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 />
|
/// <inheritdoc />
|
||||||
@@ -89,8 +70,6 @@ namespace Jellyfin.Server.Implementations.Devices
|
|||||||
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
_deviceOptions[deviceId] = deviceOptions;
|
|
||||||
|
|
||||||
DeviceOptionsUpdated?.Invoke(this, new GenericEventArgs<Tuple<string, DeviceOptions>>(new Tuple<string, 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);
|
dbContext.Devices.Add(device);
|
||||||
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
||||||
_devices.TryAdd(device.Id, device);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return device;
|
return device;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <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 />
|
/// <inheritdoc />
|
||||||
@@ -133,43 +115,79 @@ namespace Jellyfin.Server.Implementations.Devices
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <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();
|
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||||
_deviceOptions.TryGetValue(id, out var deviceOption);
|
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);
|
if (device is null)
|
||||||
return deviceInfo is null ? null : ToDeviceInfoDto(deviceInfo);
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var deviceOption = await dbContext.DeviceOptions
|
||||||
|
.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(dev => dev.DeviceId == id)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return ToDeviceInfoDto(ToDeviceInfo(device, deviceOption));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public QueryResult<Device> GetDevices(DeviceQuery query)
|
public async Task<QueryResult<Device>> GetDevices(DeviceQuery query)
|
||||||
{
|
{
|
||||||
IEnumerable<Device> devices = _devices.Values
|
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||||
.Where(device => !query.UserId.HasValue || device.UserId.Equals(query.UserId.Value))
|
await using (dbContext.ConfigureAwait(false))
|
||||||
.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)
|
|
||||||
{
|
{
|
||||||
devices = devices.Skip(query.Skip.Value);
|
IQueryable<Device> filtered = dbContext.Devices.AsNoTracking();
|
||||||
}
|
|
||||||
|
|
||||||
if (query.Limit.HasValue && query.Limit.Value > 0)
|
if (query.UserId.HasValue)
|
||||||
{
|
{
|
||||||
devices = devices.Take(query.Limit.Value);
|
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 />
|
/// <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>(
|
return new QueryResult<DeviceInfo>(
|
||||||
devices.StartIndex,
|
devices.StartIndex,
|
||||||
@@ -178,38 +196,49 @@ namespace Jellyfin.Server.Implementations.Devices
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public QueryResult<DeviceInfoDto> GetDevicesForUser(Guid? userId)
|
public async Task<QueryResult<DeviceInfoDto>> GetDevicesForUser(Guid? userId)
|
||||||
{
|
{
|
||||||
IEnumerable<Device> devices = _devices.Values
|
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||||
.OrderByDescending(d => d.DateLastActivity)
|
await using (dbContext.ConfigureAwait(false))
|
||||||
.ThenBy(d => d.DeviceId);
|
|
||||||
|
|
||||||
if (!userId.IsNullOrEmpty())
|
|
||||||
{
|
{
|
||||||
var user = _userManager.GetUserById(userId.Value);
|
IEnumerable<Device> devices = await dbContext.Devices
|
||||||
if (user is null)
|
.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 />
|
/// <inheritdoc />
|
||||||
public async Task DeleteDevice(Device device)
|
public async Task DeleteDevice(Device device)
|
||||||
{
|
{
|
||||||
_devices.TryRemove(device.Id, out _);
|
|
||||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||||
await using (dbContext.ConfigureAwait(false))
|
await using (dbContext.ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
@@ -229,8 +258,6 @@ namespace Jellyfin.Server.Implementations.Devices
|
|||||||
dbContext.Devices.Update(device);
|
dbContext.Devices.Update(device);
|
||||||
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
_devices[device.Id] = device;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ using System.Reflection;
|
|||||||
using Jellyfin.Database.Implementations;
|
using Jellyfin.Database.Implementations;
|
||||||
using Jellyfin.Database.Implementations.DbConfiguration;
|
using Jellyfin.Database.Implementations.DbConfiguration;
|
||||||
using Jellyfin.Database.Implementations.Locking;
|
using Jellyfin.Database.Implementations.Locking;
|
||||||
|
using Jellyfin.Database.Providers.PostgreSQL;
|
||||||
using Jellyfin.Database.Providers.Sqlite;
|
using Jellyfin.Database.Providers.Sqlite;
|
||||||
using MediaBrowser.Common.Configuration;
|
using MediaBrowser.Common.Configuration;
|
||||||
using MediaBrowser.Controller.Configuration;
|
using MediaBrowser.Controller.Configuration;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Npgsql;
|
||||||
using JellyfinDbProviderFactory = System.Func<System.IServiceProvider, Jellyfin.Database.Implementations.IJellyfinDatabaseProvider>;
|
using JellyfinDbProviderFactory = System.Func<System.IServiceProvider, Jellyfin.Database.Implementations.IJellyfinDatabaseProvider>;
|
||||||
|
|
||||||
namespace Jellyfin.Server.Implementations.Extensions;
|
namespace Jellyfin.Server.Implementations.Extensions;
|
||||||
@@ -24,6 +26,13 @@ public static class ServiceCollectionExtensions
|
|||||||
private static IEnumerable<Type> DatabaseProviderTypes()
|
private static IEnumerable<Type> DatabaseProviderTypes()
|
||||||
{
|
{
|
||||||
yield return typeof(SqliteDatabaseProvider);
|
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()
|
private static IDictionary<string, JellyfinDbProviderFactory> GetSupportedDbProviders()
|
||||||
@@ -123,6 +132,50 @@ public static class ServiceCollectionExtensions
|
|||||||
|
|
||||||
serviceCollection.AddSingleton<IJellyfinDatabaseProvider>(providerFactory!);
|
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)
|
switch (efCoreConfiguration.LockingBehavior)
|
||||||
{
|
{
|
||||||
case DatabaseLockingBehaviorTypes.NoLock:
|
case DatabaseLockingBehaviorTypes.NoLock:
|
||||||
|
|||||||
@@ -219,9 +219,11 @@ public sealed partial class BaseItemRepository
|
|||||||
}
|
}
|
||||||
else
|
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
|
representativeIds = masterQuery
|
||||||
.GroupBy(e => e.PresentationUniqueKey)
|
.Where(e => !masterQuery.Any(o => o.PresentationUniqueKey == e.PresentationUniqueKey && o.Id.CompareTo(e.Id) < 0))
|
||||||
.Select(g => g.Min(e => e.Id))
|
.Select(e => e.Id)
|
||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -96,22 +96,36 @@ public sealed partial class BaseItemRepository
|
|||||||
// primary version (PrimaryVersionId is null) so detail pages and actions target it instead
|
// 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
|
// 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.
|
// 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);
|
var enableGroupByPresentationUniqueKey = EnableGroupByPresentationUniqueKey(filter);
|
||||||
if (enableGroupByPresentationUniqueKey && filter.GroupBySeriesPresentationUniqueKey)
|
if (enableGroupByPresentationUniqueKey && filter.GroupBySeriesPresentationUniqueKey)
|
||||||
{
|
{
|
||||||
var groupedIds = dbQuery.GroupBy(e => new { e.PresentationUniqueKey, e.SeriesPresentationUniqueKey })
|
var groupedIds = candidates
|
||||||
.Select(g => g.Where(e => e.PrimaryVersionId == null).Min(e => (Guid?)e.Id) ?? g.Min(e => (Guid?)e.Id));
|
.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));
|
dbQuery = context.BaseItems.AsNoTracking().Where(e => groupedIds.Contains(e.Id));
|
||||||
}
|
}
|
||||||
else if (enableGroupByPresentationUniqueKey)
|
else if (enableGroupByPresentationUniqueKey)
|
||||||
{
|
{
|
||||||
var groupedIds = dbQuery.GroupBy(e => e.PresentationUniqueKey)
|
var groupedIds = candidates
|
||||||
.Select(g => g.Where(e => e.PrimaryVersionId == null).Min(e => (Guid?)e.Id) ?? g.Min(e => (Guid?)e.Id));
|
.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));
|
dbQuery = context.BaseItems.AsNoTracking().Where(e => groupedIds.Contains(e.Id));
|
||||||
}
|
}
|
||||||
else if (filter.GroupBySeriesPresentationUniqueKey)
|
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));
|
dbQuery = context.BaseItems.AsNoTracking().Where(e => groupedIds.Contains(e.Id));
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
<ProjectReference Include="..\MediaBrowser.Controller\MediaBrowser.Controller.csproj" />
|
<ProjectReference Include="..\MediaBrowser.Controller\MediaBrowser.Controller.csproj" />
|
||||||
<ProjectReference Include="..\MediaBrowser.Model\MediaBrowser.Model.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.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" />
|
<ProjectReference Include="..\src\Jellyfin.Database\Jellyfin.Database.Providers.Sqlite\Jellyfin.Database.Providers.Sqlite.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -126,95 +126,95 @@ namespace Jellyfin.Server.Implementations.Security
|
|||||||
return authInfo;
|
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);
|
var dbContext = await _jellyfinDbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||||
await using (dbContext.ConfigureAwait(false))
|
await using (dbContext.ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
var device = _deviceManager.GetDevices(
|
var key = await dbContext.ApiKeys.FirstOrDefaultAsync(apiKey => apiKey.AccessToken == token).ConfigureAwait(false);
|
||||||
new DeviceQuery { AccessToken = token }).Items.FirstOrDefault();
|
if (key is not null)
|
||||||
|
|
||||||
if (device is not null)
|
|
||||||
{
|
{
|
||||||
authInfo.IsAuthenticated = true;
|
authInfo.IsAuthenticated = true;
|
||||||
var updateToken = false;
|
authInfo.Client = key.Name;
|
||||||
|
authInfo.Token = key.AccessToken;
|
||||||
// TODO: Remove these checks for IsNullOrWhiteSpace
|
|
||||||
if (string.IsNullOrWhiteSpace(authInfo.Client))
|
|
||||||
{
|
|
||||||
authInfo.Client = device.AppName;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(authInfo.DeviceId))
|
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))
|
if (string.IsNullOrWhiteSpace(authInfo.Device))
|
||||||
{
|
{
|
||||||
authInfo.Device = device.DeviceName;
|
authInfo.Device = _serverApplicationHost.Name;
|
||||||
}
|
|
||||||
else if (!string.Equals(authInfo.Device, device.DeviceName, StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
if (allowTokenInfoUpdate)
|
|
||||||
{
|
|
||||||
updateToken = true;
|
|
||||||
device.DeviceName = authInfo.Device;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(authInfo.Version))
|
if (string.IsNullOrWhiteSpace(authInfo.Version))
|
||||||
{
|
{
|
||||||
authInfo.Version = device.AppVersion;
|
authInfo.Version = _serverApplicationHost.ApplicationVersionString;
|
||||||
}
|
|
||||||
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)
|
authInfo.IsApiKey = true;
|
||||||
{
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return authInfo;
|
return authInfo;
|
||||||
|
|||||||
@@ -61,10 +61,10 @@ public sealed class DeviceAccessHost : IHostedService
|
|||||||
|
|
||||||
private async Task UpdateDeviceAccess(User user)
|
private async Task UpdateDeviceAccess(User user)
|
||||||
{
|
{
|
||||||
var existing = _deviceManager.GetDevices(new DeviceQuery
|
var existing = (await _deviceManager.GetDevices(new DeviceQuery
|
||||||
{
|
{
|
||||||
UserId = user.Id
|
UserId = user.Id
|
||||||
}).Items;
|
}).ConfigureAwait(false)).Items;
|
||||||
|
|
||||||
foreach (var device in existing)
|
foreach (var device in existing)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ using System;
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using Emby.Server.Implementations;
|
using Emby.Server.Implementations;
|
||||||
|
using Emby.Server.Implementations.MediaEncoding;
|
||||||
|
using Emby.Server.Implementations.ScheduledTasks;
|
||||||
using Emby.Server.Implementations.Session;
|
using Emby.Server.Implementations.Session;
|
||||||
using Jellyfin.Api.WebSocketListeners;
|
using Jellyfin.Api.WebSocketListeners;
|
||||||
using Jellyfin.Database.Implementations;
|
using Jellyfin.Database.Implementations;
|
||||||
using Jellyfin.Drawing;
|
using Jellyfin.Drawing;
|
||||||
using Jellyfin.Drawing.Skia;
|
using Jellyfin.Drawing.Skia;
|
||||||
using Jellyfin.LiveTv;
|
using Jellyfin.LiveTv;
|
||||||
|
using Jellyfin.Server.Extensions;
|
||||||
using Jellyfin.Server.Implementations.Activity;
|
using Jellyfin.Server.Implementations.Activity;
|
||||||
using Jellyfin.Server.Implementations.Devices;
|
using Jellyfin.Server.Implementations.Devices;
|
||||||
using Jellyfin.Server.Implementations.Events;
|
using Jellyfin.Server.Implementations.Events;
|
||||||
@@ -23,6 +26,7 @@ using MediaBrowser.Controller.Drawing;
|
|||||||
using MediaBrowser.Controller.Events;
|
using MediaBrowser.Controller.Events;
|
||||||
using MediaBrowser.Controller.Library;
|
using MediaBrowser.Controller.Library;
|
||||||
using MediaBrowser.Controller.Lyrics;
|
using MediaBrowser.Controller.Lyrics;
|
||||||
|
using MediaBrowser.Controller.MediaEncoding;
|
||||||
using MediaBrowser.Controller.Net;
|
using MediaBrowser.Controller.Net;
|
||||||
using MediaBrowser.Controller.Security;
|
using MediaBrowser.Controller.Security;
|
||||||
using MediaBrowser.Controller.Trickplay;
|
using MediaBrowser.Controller.Trickplay;
|
||||||
@@ -39,6 +43,8 @@ namespace Jellyfin.Server
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class CoreAppHost : ApplicationHost
|
public class CoreAppHost : ApplicationHost
|
||||||
{
|
{
|
||||||
|
private readonly IConfiguration _startupConfig;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="CoreAppHost" /> class.
|
/// Initializes a new instance of the <see cref="CoreAppHost" /> class.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -57,6 +63,7 @@ namespace Jellyfin.Server
|
|||||||
options,
|
options,
|
||||||
startupConfig)
|
startupConfig)
|
||||||
{
|
{
|
||||||
|
_startupConfig = startupConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
@@ -98,6 +105,25 @@ namespace Jellyfin.Server
|
|||||||
|
|
||||||
serviceCollection.AddScoped<IAuthenticationManager, AuthenticationManager>();
|
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);
|
||||||
|
|
||||||
|
// Configuration invalidation bus: propagates shared-configuration and library-option writes
|
||||||
|
// to the other instances. Redis-backed when configured, no-op otherwise.
|
||||||
|
serviceCollection.AddConfigurationInvalidationBus(_startupConfig, Logger);
|
||||||
|
|
||||||
|
// Session directory: publishes which instance holds which session and routes remote-control
|
||||||
|
// messages to it. Redis-backed when configured, no-op otherwise.
|
||||||
|
serviceCollection.AddSessionDirectory(_startupConfig, Logger);
|
||||||
|
|
||||||
|
// Quick connect store: shares in-flight quick connect requests so the initiate, authorize and
|
||||||
|
// exchange legs can land on different instances. Redis-backed when configured, local otherwise.
|
||||||
|
serviceCollection.AddQuickConnectStore(_startupConfig, Logger);
|
||||||
|
|
||||||
foreach (var type in GetExportTypes<ILyricProvider>())
|
foreach (var type in GetExportTypes<ILyricProvider>())
|
||||||
{
|
{
|
||||||
serviceCollection.AddSingleton(typeof(ILyricProvider), type);
|
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,74 @@
|
|||||||
|
using System;
|
||||||
|
using Emby.Server.Implementations.Configuration;
|
||||||
|
using MediaBrowser.Common.Configuration;
|
||||||
|
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 shared-configuration invalidation bus.
|
||||||
|
/// </summary>
|
||||||
|
public static class ConfigurationInvalidationServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Registers the invalidation bus, Redis-backed when a connection string is configured and no-op
|
||||||
|
/// otherwise, and the subscriber applying the notices other instances publish.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The connection string is only set for a multi-instance deployment, which is the only shape where
|
||||||
|
/// one instance can write the shared configuration directory behind another's back.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="serviceCollection">The service collection.</param>
|
||||||
|
/// <param name="configuration">The configuration to read the Redis connection string from.</param>
|
||||||
|
/// <param name="logger">The logger to report the selected bus on.</param>
|
||||||
|
/// <returns>The updated service collection.</returns>
|
||||||
|
public static IServiceCollection AddConfigurationInvalidationBus(
|
||||||
|
this IServiceCollection serviceCollection,
|
||||||
|
IConfiguration configuration,
|
||||||
|
ILogger logger)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(configuration);
|
||||||
|
ArgumentNullException.ThrowIfNull(logger);
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(configuration[TranscodeStoreOptions.RedisConnectionStringKey]))
|
||||||
|
{
|
||||||
|
logger.LogInformation(
|
||||||
|
"Configuration invalidation bus: {Bus}. Shared-configuration and library-visibility changes stay local to the instance that made them; set {Key} to propagate them.",
|
||||||
|
nameof(NullConfigurationInvalidationBus),
|
||||||
|
TranscodeStoreOptions.RedisConnectionStringKey);
|
||||||
|
|
||||||
|
return serviceCollection.AddSingleton<IConfigurationInvalidationBus>(NullConfigurationInvalidationBus.Instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogInformation(
|
||||||
|
"Configuration invalidation bus: {Bus}. Shared-configuration and library-visibility changes propagate to every instance.",
|
||||||
|
nameof(RedisConfigurationInvalidationBus));
|
||||||
|
|
||||||
|
serviceCollection.AddSingleton<IConfigurationInvalidationBus>(sp =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return new RedisConfigurationInvalidationBus(
|
||||||
|
sp.GetRequiredService<IConnectionMultiplexer>(),
|
||||||
|
sp.GetRequiredService<ILogger<RedisConfigurationInvalidationBus>>());
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Fail open: an unreachable Redis degrades to the single-instance behaviour of every
|
||||||
|
// instance keeping its own cached configuration, rather than aborting startup.
|
||||||
|
sp.GetRequiredService<ILogger<CoreAppHost>>().LogError(
|
||||||
|
ex,
|
||||||
|
"Redis is configured but unavailable, so shared-configuration changes will not propagate between instances. Check {Key}.",
|
||||||
|
TranscodeStoreOptions.RedisConnectionStringKey);
|
||||||
|
|
||||||
|
return NullConfigurationInvalidationBus.Instance;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return serviceCollection.AddHostedService<ConfigurationInvalidationSubscriber>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using System;
|
||||||
|
using Emby.Server.Implementations.QuickConnect;
|
||||||
|
using MediaBrowser.Controller.MediaEncoding;
|
||||||
|
using MediaBrowser.Controller.QuickConnect;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using StackExchange.Redis;
|
||||||
|
|
||||||
|
namespace Jellyfin.Server.Extensions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extensions for registering the quick connect store.
|
||||||
|
/// </summary>
|
||||||
|
public static class QuickConnectStoreServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Registers the quick connect store, Redis-backed when a connection string is configured and
|
||||||
|
/// process-local otherwise, and reports the selected store at <see cref="LogLevel.Information"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The connection string is only set for a multi-instance deployment, which is the only shape where
|
||||||
|
/// the initiate, authorize and exchange legs of one flow can land on different instances. Set but
|
||||||
|
/// unreachable is a misconfigured deployment rather than a single-instance one, so it fails rather
|
||||||
|
/// than quietly handing out a store the other instances cannot see.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="serviceCollection">The service collection.</param>
|
||||||
|
/// <param name="configuration">The configuration to read the Redis connection string from.</param>
|
||||||
|
/// <param name="logger">The logger to report the selected store on.</param>
|
||||||
|
/// <returns>The updated service collection.</returns>
|
||||||
|
public static IServiceCollection AddQuickConnectStore(
|
||||||
|
this IServiceCollection serviceCollection,
|
||||||
|
IConfiguration configuration,
|
||||||
|
ILogger logger)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(configuration);
|
||||||
|
ArgumentNullException.ThrowIfNull(logger);
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(configuration[TranscodeStoreOptions.RedisConnectionStringKey]))
|
||||||
|
{
|
||||||
|
logger.LogInformation(
|
||||||
|
"Quick connect store: {Store}. A quick connect flow has to complete against one instance; set {Key} to share it.",
|
||||||
|
nameof(InMemoryQuickConnectStore),
|
||||||
|
TranscodeStoreOptions.RedisConnectionStringKey);
|
||||||
|
|
||||||
|
return serviceCollection.AddSingleton<IQuickConnectStore, InMemoryQuickConnectStore>();
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogInformation(
|
||||||
|
"Quick connect store: {Store}. Quick connect flows complete across any instance.",
|
||||||
|
nameof(RedisQuickConnectStore));
|
||||||
|
|
||||||
|
return serviceCollection.AddSingleton<IQuickConnectStore>(sp => new RedisQuickConnectStore(
|
||||||
|
sp.GetRequiredService<IConnectionMultiplexer>(),
|
||||||
|
sp.GetRequiredService<ILogger<RedisQuickConnectStore>>()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,94 @@
|
|||||||
|
using System;
|
||||||
|
using Emby.Server.Implementations.Session;
|
||||||
|
using MediaBrowser.Controller.MediaEncoding;
|
||||||
|
using MediaBrowser.Controller.Session;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using StackExchange.Redis;
|
||||||
|
|
||||||
|
namespace Jellyfin.Server.Extensions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extensions for registering the session directory and the instance-addressed message bus.
|
||||||
|
/// </summary>
|
||||||
|
public static class SessionDirectoryServiceCollectionExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Registers the session directory and message bus, Redis-backed when a connection string is
|
||||||
|
/// configured and no-op otherwise, and reports the selection at <see cref="LogLevel.Information"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="serviceCollection">The service collection.</param>
|
||||||
|
/// <param name="configuration">The configuration to read <c>Jellyfin:SessionDirectory</c> from.</param>
|
||||||
|
/// <param name="logger">The logger to report the selection on.</param>
|
||||||
|
/// <returns>The updated service collection.</returns>
|
||||||
|
public static IServiceCollection AddSessionDirectory(
|
||||||
|
this IServiceCollection serviceCollection,
|
||||||
|
IConfiguration configuration,
|
||||||
|
ILogger logger)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(configuration);
|
||||||
|
ArgumentNullException.ThrowIfNull(logger);
|
||||||
|
|
||||||
|
serviceCollection.Configure<SessionDirectoryOptions>(configuration.GetSection(SessionDirectoryOptions.ConfigurationSection));
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(configuration[TranscodeStoreOptions.RedisConnectionStringKey]))
|
||||||
|
{
|
||||||
|
logger.LogInformation(
|
||||||
|
"Session directory: {Directory}. The session list and remote control only reach the sessions this instance holds; set {Key} to share them.",
|
||||||
|
nameof(NullSessionDirectory),
|
||||||
|
TranscodeStoreOptions.RedisConnectionStringKey);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
logger.LogInformation(
|
||||||
|
"Session directory: {Directory}. Sessions are visible to, and controllable from, every instance.",
|
||||||
|
nameof(RedisSessionDirectory));
|
||||||
|
|
||||||
|
serviceCollection.AddSingleton(SharedSessionServices.Create);
|
||||||
|
}
|
||||||
|
|
||||||
|
serviceCollection.AddSingleton<IPodMessageBus>(sp => sp.GetService<SharedSessionServices>()?.Bus ?? NullPodMessageBus.Instance);
|
||||||
|
|
||||||
|
return serviceCollection.AddSingleton<ISessionDirectory>(sp => sp.GetService<SharedSessionServices>()?.Directory ?? NullSessionDirectory.Instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A Redis directory paired with a no-op bus would leave every routed command permanently
|
||||||
|
// undeliverable, so the pair is built together and falls back together.
|
||||||
|
private sealed class SharedSessionServices
|
||||||
|
{
|
||||||
|
private SharedSessionServices(ISessionDirectory directory, IPodMessageBus bus)
|
||||||
|
{
|
||||||
|
Directory = directory;
|
||||||
|
Bus = bus;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ISessionDirectory Directory { get; }
|
||||||
|
|
||||||
|
public IPodMessageBus Bus { get; }
|
||||||
|
|
||||||
|
// Fail open: an unreachable Redis degrades to the single-instance behaviour rather than aborting startup.
|
||||||
|
public static SharedSessionServices Create(IServiceProvider serviceProvider)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var redis = serviceProvider.GetRequiredService<IConnectionMultiplexer>();
|
||||||
|
var options = serviceProvider.GetRequiredService<IOptions<SessionDirectoryOptions>>();
|
||||||
|
|
||||||
|
return new SharedSessionServices(
|
||||||
|
new RedisSessionDirectory(redis, options, serviceProvider.GetRequiredService<ILogger<RedisSessionDirectory>>()),
|
||||||
|
new RedisPodMessageBus(redis, options, PodIdentity.Current, serviceProvider.GetRequiredService<ILogger<RedisPodMessageBus>>()));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
serviceProvider.GetRequiredService<ILogger<CoreAppHost>>().LogError(
|
||||||
|
ex,
|
||||||
|
"Redis is configured but unavailable, so sessions will not be shared between instances. Check {Key}.",
|
||||||
|
TranscodeStoreOptions.RedisConnectionStringKey);
|
||||||
|
|
||||||
|
return new SharedSessionServices(NullSessionDirectory.Instance, NullPodMessageBus.Instance);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.Console" />
|
||||||
<PackageReference Include="Serilog.Sinks.File" />
|
<PackageReference Include="Serilog.Sinks.File" />
|
||||||
<PackageReference Include="Serilog.Sinks.Graylog" />
|
<PackageReference Include="Serilog.Sinks.Graylog" />
|
||||||
|
<PackageReference Include="StackExchange.Redis" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -36,7 +36,9 @@ internal class MigrateRatingLevels : IDatabaseMigrationRoutine
|
|||||||
_logger.LogInformation("Recalculating parental rating levels based on rating string.");
|
_logger.LogInformation("Recalculating parental rating levels based on rating string.");
|
||||||
using var context = _provider.CreateDbContext();
|
using var context = _provider.CreateDbContext();
|
||||||
using var transaction = context.Database.BeginTransaction();
|
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)
|
foreach (var rating in ratings)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(rating))
|
if (string.IsNullOrEmpty(rating))
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ using Jellyfin.Server.ServerSetupApp;
|
|||||||
using MediaBrowser.Common.Configuration;
|
using MediaBrowser.Common.Configuration;
|
||||||
using MediaBrowser.Common.Net;
|
using MediaBrowser.Common.Net;
|
||||||
using MediaBrowser.Controller;
|
using MediaBrowser.Controller;
|
||||||
|
using MediaBrowser.Controller.Configuration;
|
||||||
using Microsoft.AspNetCore.Hosting;
|
using Microsoft.AspNetCore.Hosting;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
@@ -304,6 +305,10 @@ namespace Jellyfin.Server
|
|||||||
.AddJellyfinDbContext(startupConfigurationManager, startupConfig)
|
.AddJellyfinDbContext(startupConfigurationManager, startupConfig)
|
||||||
.AddSingleton<IApplicationPaths>(appPaths)
|
.AddSingleton<IApplicationPaths>(appPaths)
|
||||||
.AddSingleton<ServerApplicationPaths>(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();
|
.RegisterStartupLogger();
|
||||||
|
|
||||||
var startupService = migrationStartupServiceProvider.BuildServiceProvider();
|
var startupService = migrationStartupServiceProvider.BuildServiceProvider();
|
||||||
@@ -386,6 +391,8 @@ namespace Jellyfin.Server
|
|||||||
.AddInMemoryCollection(inMemoryDefaultConfig)
|
.AddInMemoryCollection(inMemoryDefaultConfig)
|
||||||
.AddJsonFile(LoggingConfigFileDefault, optional: false, reloadOnChange: true)
|
.AddJsonFile(LoggingConfigFileDefault, optional: false, reloadOnChange: true)
|
||||||
.AddJsonFile(LoggingConfigFileSystem, optional: true, reloadOnChange: true)
|
.AddJsonFile(LoggingConfigFileSystem, optional: true, reloadOnChange: true)
|
||||||
|
// Added before the prefixed source so an explicit JELLYFIN_ variable still wins.
|
||||||
|
.AddJellyfinSectionEnvironmentVariables()
|
||||||
.AddEnvironmentVariables("JELLYFIN_")
|
.AddEnvironmentVariables("JELLYFIN_")
|
||||||
.AddInMemoryCollection(commandLineOpts.ConvertToConfig());
|
.AddInMemoryCollection(commandLineOpts.ConvertToConfig());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Server.Tests", "te
|
|||||||
EndProject
|
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}"
|
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
|
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}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Providers.Tests", "tests\Jellyfin.Providers.Tests\Jellyfin.Providers.Tests.csproj", "{A964008C-2136-4716-B6CB-B3426C22320A}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C}"
|
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
|
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}"
|
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
|
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}"
|
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
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.CodeAnalysis", "src\Jellyfin.CodeAnalysis\Jellyfin.CodeAnalysis.csproj", "{11643D0F-6761-4EF7-AB71-6F9F8DE00714}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.CodeAnalysis", "src\Jellyfin.CodeAnalysis\Jellyfin.CodeAnalysis.csproj", "{11643D0F-6761-4EF7-AB71-6F9F8DE00714}"
|
||||||
EndProject
|
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}"
|
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
|
EndProject
|
||||||
Global
|
Global
|
||||||
@@ -219,6 +227,10 @@ Global
|
|||||||
{68B0B823-A5AC-4E8B-82EA-965AAC7BF76E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{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.ActiveCfg = Release|Any CPU
|
||||||
{68B0B823-A5AC-4E8B-82EA-965AAC7BF76E}.Release|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
|
||||||
{A964008C-2136-4716-B6CB-B3426C22320A}.Debug|Any CPU.Build.0 = 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
|
{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}.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.ActiveCfg = Release|Any CPU
|
||||||
{A5590358-33CC-4B39-BDE7-DC62FEB03C76}.Release|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
|
||||||
{8C9F9221-8415-496C-B1F5-E7756F03FA59}.Debug|Any CPU.Build.0 = 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
|
{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}.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.ActiveCfg = Release|Any CPU
|
||||||
{11643D0F-6761-4EF7-AB71-6F9F8DE00714}.Release|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
|
||||||
{E24A279C-9A37-419A-8F9C-853C11FBE753}.Debug|Any CPU.Build.0 = 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
|
{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}
|
{42816EA8-4511-4CBF-A9C7-7791D5DDDAE6} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
|
||||||
{3ADBCD8C-C0F2-4956-8FDC-35D686B74CF9} = {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}
|
{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}
|
{A964008C-2136-4716-B6CB-B3426C22320A} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
|
||||||
{750B8757-BE3D-4F8C-941A-FBAD94904ADA} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C}
|
{750B8757-BE3D-4F8C-941A-FBAD94904ADA} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C}
|
||||||
{332A5C7A-F907-47CA-910E-BE6F7371B9E0} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
|
{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}
|
{8C6B2B13-58A4-4506-9DAB-1F882A093FE0} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C}
|
||||||
{4C54CE05-69C8-48FA-8785-39F7F6DB1CAD} = {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}
|
{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}
|
{8C9F9221-8415-496C-B1F5-E7756F03FA59} = {4C54CE05-69C8-48FA-8785-39F7F6DB1CAD}
|
||||||
{11643D0F-6761-4EF7-AB71-6F9F8DE00714} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C}
|
{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}
|
{E24A279C-9A37-419A-8F9C-853C11FBE753} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
namespace MediaBrowser.Common.Configuration
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A notice that one instance has written shared configuration, so every other instance has to drop
|
||||||
|
/// its locally cached copy and read the shared file again.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ConfigurationInvalidation
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the cache this notice refers to.
|
||||||
|
/// </summary>
|
||||||
|
public ConfigurationInvalidationScope Scope { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets what was invalidated within the scope: the configuration key for
|
||||||
|
/// <see cref="ConfigurationInvalidationScope.NamedConfiguration"/>, the library path for
|
||||||
|
/// <see cref="ConfigurationInvalidationScope.LibraryOptions"/>, and <c>null</c> otherwise.
|
||||||
|
/// </summary>
|
||||||
|
public string? Target { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the identity of the instance that published the notice, so it can ignore its own.
|
||||||
|
/// </summary>
|
||||||
|
public string? OriginId { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
namespace MediaBrowser.Common.Configuration
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Extensions for <see cref="IConfigurationInvalidationBus"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static class ConfigurationInvalidationBusExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Announces a write this instance originated, and stays silent for a write induced by an
|
||||||
|
/// invalidation another instance published.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This is the backstop for the write path: a consumer reacting to an applied invalidation by
|
||||||
|
/// writing - including a plugin that knows nothing about the bus - cannot turn that write into a
|
||||||
|
/// notice of its own.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="bus">The bus.</param>
|
||||||
|
/// <param name="scope">The cache that was written.</param>
|
||||||
|
/// <param name="target">The configuration key or library path that was written, if any.</param>
|
||||||
|
public static void PublishLocalWrite(this IConfigurationInvalidationBus bus, ConfigurationInvalidationScope scope, string? target)
|
||||||
|
{
|
||||||
|
if (ConfigurationInvalidationContext.IsApplyingRemoteInvalidation)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bus.Publish(scope, target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
|
||||||
|
namespace MediaBrowser.Common.Configuration
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Marks the flow of control that is applying an invalidation published by another instance, so the
|
||||||
|
/// reactions to it can tell a remote write apart from a local one.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Applying an invalidation raises the same update events a local save raises, because the in-process
|
||||||
|
/// consumers have to re-read the configuration either way. Some of those consumers answer an update by
|
||||||
|
/// writing, and that write must neither repeat what the publishing instance already did nor fan back
|
||||||
|
/// out over the bus. The flag rides the execution context, so it reaches the queued and asynchronous
|
||||||
|
/// event handlers as well as the synchronous ones.
|
||||||
|
/// </remarks>
|
||||||
|
public static class ConfigurationInvalidationContext
|
||||||
|
{
|
||||||
|
private static readonly AsyncLocal<bool> _applyingRemoteInvalidation = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a value indicating whether the current flow of control is applying an invalidation
|
||||||
|
/// published by another instance rather than handling a local save.
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsApplyingRemoteInvalidation => _applyingRemoteInvalidation.Value;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Marks the current flow of control as applying a remote invalidation until the returned scope is
|
||||||
|
/// disposed.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>The scope to dispose once the invalidation has been applied.</returns>
|
||||||
|
public static IDisposable BeginApply() => new ApplyScope();
|
||||||
|
|
||||||
|
private sealed class ApplyScope : IDisposable
|
||||||
|
{
|
||||||
|
private readonly bool _previous;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public ApplyScope()
|
||||||
|
{
|
||||||
|
_previous = _applyingRemoteInvalidation.Value;
|
||||||
|
_applyingRemoteInvalidation.Value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_disposed = true;
|
||||||
|
_applyingRemoteInvalidation.Value = _previous;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
namespace MediaBrowser.Common.Configuration
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Identifies which locally cached copy of the shared configuration a
|
||||||
|
/// <see cref="ConfigurationInvalidation"/> refers to.
|
||||||
|
/// </summary>
|
||||||
|
public enum ConfigurationInvalidationScope
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The system configuration cached by the configuration manager.
|
||||||
|
/// </summary>
|
||||||
|
SystemConfiguration = 0,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A single named configuration, identified by its key.
|
||||||
|
/// </summary>
|
||||||
|
NamedConfiguration = 1,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The library options of a single collection folder, identified by its path.
|
||||||
|
/// </summary>
|
||||||
|
LibraryOptions = 2,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The library options of every collection folder, for changes to the library structure itself.
|
||||||
|
/// </summary>
|
||||||
|
AllLibraryOptions = 3
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace MediaBrowser.Common.Configuration
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Carries cache-invalidation notices between the instances that share one configuration directory.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The shared directory carries the content; this bus only carries the fact that it changed. Every
|
||||||
|
/// implementation is expected to fail open: a bus that cannot deliver must not throw into the write
|
||||||
|
/// path, leaving each instance on its own locally cached copy until it restarts.
|
||||||
|
/// </remarks>
|
||||||
|
public interface IConfigurationInvalidationBus
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Announces that this instance has written shared configuration.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="scope">The cache that was written.</param>
|
||||||
|
/// <param name="target">The configuration key or library path that was written, if any.</param>
|
||||||
|
void Publish(ConfigurationInvalidationScope scope, string? target);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers the handler invoked for notices published by other instances.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="handler">The handler applying the invalidation locally.</param>
|
||||||
|
void Subscribe(Action<ConfigurationInvalidation> handler);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -85,6 +85,20 @@ namespace MediaBrowser.Common.Configuration
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="factories">The factories.</param>
|
/// <param name="factories">The factories.</param>
|
||||||
void AddParts(IEnumerable<IConfigurationFactory> factories);
|
void AddParts(IEnumerable<IConfigurationFactory> factories);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Drops the locally cached copy of configuration another instance has written to the shared
|
||||||
|
/// configuration directory, so the next read reloads it, and raises the local update event.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// An implementation predating the invalidation bus keeps the default, which reports that it
|
||||||
|
/// cannot drop its cache rather than quietly leaving it stale. The caller applying a remote
|
||||||
|
/// notice treats that as a failed apply and logs it.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="key">The named configuration key, or <c>null</c> for the system configuration.</param>
|
||||||
|
/// <exception cref="NotSupportedException">The implementation cannot drop its cached configuration.</exception>
|
||||||
|
void InvalidateCachedConfiguration(string? key)
|
||||||
|
=> throw new NotSupportedException(GetType().Name + " cannot drop configuration cached from the shared configuration directory, so writes by other instances will not be picked up.");
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class ConfigurationManagerExtensions
|
public static class ConfigurationManagerExtensions
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace MediaBrowser.Common.Configuration
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A no-op <see cref="IConfigurationInvalidationBus"/> used by single-instance installs and whenever
|
||||||
|
/// the shared bus is unavailable. Every instance keeps its own cached configuration, which is the
|
||||||
|
/// behaviour of an install that has only one.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class NullConfigurationInvalidationBus : IConfigurationInvalidationBus
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the shared instance.
|
||||||
|
/// </summary>
|
||||||
|
public static NullConfigurationInvalidationBus Instance { get; } = new NullConfigurationInvalidationBus();
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Publish(ConfigurationInvalidationScope scope, string? target)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Subscribe(Action<ConfigurationInvalidation> handler)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace MediaBrowser.Common.Extensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Thrown when the current state of a resource does not allow the requested operation.
|
||||||
|
/// </summary>
|
||||||
|
public class ConflictException : Exception
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="ConflictException" /> class.
|
||||||
|
/// </summary>
|
||||||
|
public ConflictException()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="ConflictException" /> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="message">The message.</param>
|
||||||
|
public ConflictException(string message)
|
||||||
|
: base(message)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="ConflictException" /> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="message">The message.</param>
|
||||||
|
/// <param name="innerException">The exception that caused this one.</param>
|
||||||
|
public ConflictException(string message, Exception innerException)
|
||||||
|
: base(message, innerException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace MediaBrowser.Common.Extensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Thrown when an operation cannot be answered because a backing service is unreachable, rather than
|
||||||
|
/// because the thing it was asked about does not exist.
|
||||||
|
/// </summary>
|
||||||
|
public class ServiceUnavailableException : Exception
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="ServiceUnavailableException" /> class.
|
||||||
|
/// </summary>
|
||||||
|
public ServiceUnavailableException()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="ServiceUnavailableException" /> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="message">The message.</param>
|
||||||
|
public ServiceUnavailableException(string message)
|
||||||
|
: base(message)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="ServiceUnavailableException" /> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="message">The message.</param>
|
||||||
|
/// <param name="innerException">The exception that caused this one.</param>
|
||||||
|
public ServiceUnavailableException(string message, Exception innerException)
|
||||||
|
: base(message, innerException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,29 +47,29 @@ public interface IDeviceManager
|
|||||||
/// Gets the device information.
|
/// Gets the device information.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="id">The identifier.</param>
|
/// <param name="id">The identifier.</param>
|
||||||
/// <returns>DeviceInfoDto.</returns>
|
/// <returns>A <see cref="Task"/> representing the retrieval of the device information.</returns>
|
||||||
DeviceInfoDto? GetDevice(string id);
|
Task<DeviceInfoDto?> GetDevice(string id);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets devices based on the provided query.
|
/// Gets devices based on the provided query.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="query">The device query.</param>
|
/// <param name="query">The device query.</param>
|
||||||
/// <returns>A <see cref="Task{QueryResult}"/> representing the retrieval of the devices.</returns>
|
/// <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>
|
/// <summary>
|
||||||
/// Gets device information based on the provided query.
|
/// Gets device information based on the provided query.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="query">The device query.</param>
|
/// <param name="query">The device query.</param>
|
||||||
/// <returns>A <see cref="Task{QueryResult}"/> representing the retrieval of the device information.</returns>
|
/// <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>
|
/// <summary>
|
||||||
/// Gets the device information.
|
/// Gets the device information.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="userId">The user's id, or <c>null</c>.</param>
|
/// <param name="userId">The user's id, or <c>null</c>.</param>
|
||||||
/// <returns>IEnumerable<DeviceInfoDto>.</returns>
|
/// <returns>A <see cref="Task{QueryResult}"/> representing the retrieval of the device information.</returns>
|
||||||
QueryResult<DeviceInfoDto> GetDevicesForUser(Guid? userId);
|
Task<QueryResult<DeviceInfoDto>> GetDevicesForUser(Guid? userId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Deletes a device.
|
/// Deletes a device.
|
||||||
@@ -105,8 +105,8 @@ public interface IDeviceManager
|
|||||||
/// Gets the options of a device.
|
/// Gets the options of a device.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="deviceId">The device id.</param>
|
/// <param name="deviceId">The device id.</param>
|
||||||
/// <returns><see cref="DeviceOptions"/> of the device.</returns>
|
/// <returns>A <see cref="Task"/> representing the retrieval of the <see cref="DeviceOptions"/> of the device.</returns>
|
||||||
DeviceOptionsDto? GetDeviceOptions(string deviceId);
|
Task<DeviceOptionsDto?> GetDeviceOptions(string deviceId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the dto for client capabilities.
|
/// Gets the dto for client capabilities.
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ using System.Threading.Tasks;
|
|||||||
using Jellyfin.Data.Enums;
|
using Jellyfin.Data.Enums;
|
||||||
using Jellyfin.Database.Implementations.Entities;
|
using Jellyfin.Database.Implementations.Entities;
|
||||||
using Jellyfin.Extensions.Json;
|
using Jellyfin.Extensions.Json;
|
||||||
|
using MediaBrowser.Common.Configuration;
|
||||||
using MediaBrowser.Controller.IO;
|
using MediaBrowser.Controller.IO;
|
||||||
using MediaBrowser.Controller.Library;
|
using MediaBrowser.Controller.Library;
|
||||||
using MediaBrowser.Controller.Providers;
|
using MediaBrowser.Controller.Providers;
|
||||||
@@ -70,6 +71,12 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
|
|
||||||
public static IServerApplicationHost ApplicationHost { get; set; }
|
public static IServerApplicationHost ApplicationHost { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the bus announcing library option writes to the other instances sharing this
|
||||||
|
/// configuration directory. Defaults to a no-op, which is the single-instance behaviour.
|
||||||
|
/// </summary>
|
||||||
|
public static IConfigurationInvalidationBus InvalidationBus { get; set; } = NullConfigurationInvalidationBus.Instance;
|
||||||
|
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public override bool SupportsPlayedStatus => false;
|
public override bool SupportsPlayedStatus => false;
|
||||||
|
|
||||||
@@ -188,11 +195,36 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
XmlSerializer.SerializeToFile(clone, GetLibraryOptionsPath(path));
|
XmlSerializer.SerializeToFile(clone, GetLibraryOptionsPath(path));
|
||||||
|
|
||||||
LibraryOptionsUpdated?.Invoke(null, new LibraryOptionsUpdatedEventArgs(path, options));
|
LibraryOptionsUpdated?.Invoke(null, new LibraryOptionsUpdatedEventArgs(path, options));
|
||||||
|
|
||||||
|
InvalidationBus.PublishLocalWrite(ConfigurationInvalidationScope.LibraryOptions, path);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void OnCollectionFolderChange()
|
/// <summary>
|
||||||
|
/// Drops the cached options of one library so the next read comes off <c>options.xml</c> again.
|
||||||
|
/// Applied on the instances that did not write, and so does not publish.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path">The library path.</param>
|
||||||
|
public static void InvalidateLibraryOptions(string path)
|
||||||
|
{
|
||||||
|
_libraryOptions.TryRemove(path, out _);
|
||||||
|
|
||||||
|
LibraryOptionsUpdated?.Invoke(null, new LibraryOptionsUpdatedEventArgs(path, GetLibraryOptions(path)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Drops every cached library option set. Applied on the instances that did not write, and so does
|
||||||
|
/// not publish.
|
||||||
|
/// </summary>
|
||||||
|
public static void InvalidateAllLibraryOptions()
|
||||||
=> _libraryOptions.Clear();
|
=> _libraryOptions.Clear();
|
||||||
|
|
||||||
|
public static void OnCollectionFolderChange()
|
||||||
|
{
|
||||||
|
InvalidateAllLibraryOptions();
|
||||||
|
|
||||||
|
InvalidationBus.PublishLocalWrite(ConfigurationInvalidationScope.AllLibraryOptions, null);
|
||||||
|
}
|
||||||
|
|
||||||
public override bool IsSaveLocalMetadataEnabled()
|
public override bool IsSaveLocalMetadataEnabled()
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -449,19 +449,26 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
IUserDataManager userDataManager,
|
IUserDataManager userDataManager,
|
||||||
ILibraryManager libraryManager)
|
ILibraryManager libraryManager)
|
||||||
{
|
{
|
||||||
var filtered = items.Where(i => Filter(i, user, query, userDataManager, libraryManager));
|
var itemList = items as IReadOnlyList<BaseItem> ?? items.ToList();
|
||||||
|
|
||||||
|
// The user data checks below run per item, so read them all in one query up front.
|
||||||
|
var userDataBatch = user is not null && RequiresUserData(query)
|
||||||
|
? userDataManager.GetUserDataBatch(itemList, user)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
var filtered = itemList.Where(i => Filter(i, user, query, userDataManager, libraryManager, userDataBatch));
|
||||||
|
|
||||||
if (query.IsPlayed.HasValue && user is not null)
|
if (query.IsPlayed.HasValue && user is not null)
|
||||||
{
|
{
|
||||||
var itemList = filtered.ToList();
|
var filteredList = filtered.ToList();
|
||||||
var folderIds = itemList.OfType<Folder>().Select(f => f.Id).ToList();
|
var folderIds = filteredList.OfType<Folder>().Select(f => f.Id).ToList();
|
||||||
|
|
||||||
if (folderIds.Count > 0)
|
if (folderIds.Count > 0)
|
||||||
{
|
{
|
||||||
var counts = libraryManager.GetPlayedAndTotalCountBatch(folderIds, user);
|
var counts = libraryManager.GetPlayedAndTotalCountBatch(folderIds, user);
|
||||||
var isPlayedValue = query.IsPlayed.Value;
|
var isPlayedValue = query.IsPlayed.Value;
|
||||||
|
|
||||||
return itemList.Where(item =>
|
return filteredList.Where(item =>
|
||||||
{
|
{
|
||||||
if (item is Folder)
|
if (item is Folder)
|
||||||
{
|
{
|
||||||
@@ -473,7 +480,7 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return itemList;
|
return filteredList;
|
||||||
}
|
}
|
||||||
|
|
||||||
return filtered;
|
return filtered;
|
||||||
@@ -515,12 +522,29 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
itemsArray);
|
itemsArray);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool RequiresUserData(InternalItemsQuery query)
|
||||||
|
=> query.IsLiked.HasValue
|
||||||
|
|| query.IsFavoriteOrLiked.HasValue
|
||||||
|
|| query.IsFavorite.HasValue
|
||||||
|
|| query.IsResumable.HasValue
|
||||||
|
|| query.IsPlayed.HasValue;
|
||||||
|
|
||||||
|
private static UserItemData GetUserData(
|
||||||
|
IUserDataManager userDataManager,
|
||||||
|
User user,
|
||||||
|
BaseItem item,
|
||||||
|
Dictionary<Guid, UserItemData> userDataBatch)
|
||||||
|
=> userDataBatch is not null && userDataBatch.TryGetValue(item.Id, out var userData)
|
||||||
|
? userData
|
||||||
|
: userDataManager.GetUserData(user, item);
|
||||||
|
|
||||||
private static bool Filter(
|
private static bool Filter(
|
||||||
BaseItem item,
|
BaseItem item,
|
||||||
User user,
|
User user,
|
||||||
InternalItemsQuery query,
|
InternalItemsQuery query,
|
||||||
IUserDataManager userDataManager,
|
IUserDataManager userDataManager,
|
||||||
ILibraryManager libraryManager)
|
ILibraryManager libraryManager,
|
||||||
|
Dictionary<Guid, UserItemData> userDataBatch)
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrEmpty(query.NameStartsWith) && !item.SortName.StartsWith(query.NameStartsWith, StringComparison.InvariantCultureIgnoreCase))
|
if (!string.IsNullOrEmpty(query.NameStartsWith) && !item.SortName.StartsWith(query.NameStartsWith, StringComparison.InvariantCultureIgnoreCase))
|
||||||
{
|
{
|
||||||
@@ -568,7 +592,7 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
|
|
||||||
if (query.IsLiked.HasValue)
|
if (query.IsLiked.HasValue)
|
||||||
{
|
{
|
||||||
userData = userDataManager.GetUserData(user, item);
|
userData = GetUserData(userDataManager, user, item, userDataBatch);
|
||||||
if (!userData.Likes.HasValue || userData.Likes != query.IsLiked.Value)
|
if (!userData.Likes.HasValue || userData.Likes != query.IsLiked.Value)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
@@ -577,7 +601,7 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
|
|
||||||
if (query.IsFavoriteOrLiked.HasValue)
|
if (query.IsFavoriteOrLiked.HasValue)
|
||||||
{
|
{
|
||||||
userData ??= userDataManager.GetUserData(user, item);
|
userData ??= GetUserData(userDataManager, user, item, userDataBatch);
|
||||||
var isFavoriteOrLiked = userData.IsFavorite || (userData.Likes ?? false);
|
var isFavoriteOrLiked = userData.IsFavorite || (userData.Likes ?? false);
|
||||||
|
|
||||||
if (isFavoriteOrLiked != query.IsFavoriteOrLiked.Value)
|
if (isFavoriteOrLiked != query.IsFavoriteOrLiked.Value)
|
||||||
@@ -588,7 +612,7 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
|
|
||||||
if (query.IsFavorite.HasValue)
|
if (query.IsFavorite.HasValue)
|
||||||
{
|
{
|
||||||
userData ??= userDataManager.GetUserData(user, item);
|
userData ??= GetUserData(userDataManager, user, item, userDataBatch);
|
||||||
if (userData.IsFavorite != query.IsFavorite.Value)
|
if (userData.IsFavorite != query.IsFavorite.Value)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
@@ -597,7 +621,7 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
|
|
||||||
if (query.IsResumable.HasValue)
|
if (query.IsResumable.HasValue)
|
||||||
{
|
{
|
||||||
userData ??= userDataManager.GetUserData(user, item);
|
userData ??= GetUserData(userDataManager, user, item, userDataBatch);
|
||||||
var isResumable = userData.PlaybackPositionTicks > 0;
|
var isResumable = userData.PlaybackPositionTicks > 0;
|
||||||
|
|
||||||
if (isResumable != query.IsResumable.Value)
|
if (isResumable != query.IsResumable.Value)
|
||||||
@@ -612,7 +636,7 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
// Folders are batch-filtered by the collection Filter() overload.
|
// Folders are batch-filtered by the collection Filter() overload.
|
||||||
if (!item.IsFolder)
|
if (!item.IsFolder)
|
||||||
{
|
{
|
||||||
userData ??= userDataManager.GetUserData(user, item);
|
userData ??= GetUserData(userDataManager, user, item, userDataBatch);
|
||||||
if (item.IsPlayed(user, userData) != query.IsPlayed.Value)
|
if (item.IsPlayed(user, userData) != query.IsPlayed.Value)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using MediaBrowser.Common.Extensions;
|
||||||
using MediaBrowser.Controller.Authentication;
|
using MediaBrowser.Controller.Authentication;
|
||||||
using MediaBrowser.Controller.Net;
|
using MediaBrowser.Controller.Net;
|
||||||
using MediaBrowser.Model.QuickConnect;
|
using MediaBrowser.Model.QuickConnect;
|
||||||
@@ -21,17 +22,19 @@ namespace MediaBrowser.Controller.QuickConnect
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="authorizationInfo">The initiator authorization info.</param>
|
/// <param name="authorizationInfo">The initiator authorization info.</param>
|
||||||
/// <returns>A quick connect result with tokens to proceed or throws an exception if not active.</returns>
|
/// <returns>A quick connect result with tokens to proceed or throws an exception if not active.</returns>
|
||||||
QuickConnectResult TryConnect(AuthorizationInfo authorizationInfo);
|
Task<QuickConnectResult> TryConnect(AuthorizationInfo authorizationInfo);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Checks the status of an individual request.
|
/// Checks the status of an individual request.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="secret">Unique secret identifier of the request.</param>
|
/// <param name="secret">Unique secret identifier of the request.</param>
|
||||||
/// <returns>Quick connect result.</returns>
|
/// <returns>Quick connect result.</returns>
|
||||||
QuickConnectResult CheckRequestStatus(string secret);
|
Task<QuickConnectResult> CheckRequestStatus(string secret);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Authorizes a quick connect request to connect as the calling user.
|
/// Authorizes a quick connect request to connect as the calling user. A request can be authorized
|
||||||
|
/// once: a second attempt, including one following an attempt that failed part way, throws
|
||||||
|
/// <see cref="ConflictException"/> and the user has to start quick connect again for a new code.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="userId">User id.</param>
|
/// <param name="userId">User id.</param>
|
||||||
/// <param name="code">Identifying code for the request.</param>
|
/// <param name="code">Identifying code for the request.</param>
|
||||||
@@ -39,10 +42,11 @@ namespace MediaBrowser.Controller.QuickConnect
|
|||||||
Task<bool> AuthorizeRequest(Guid userId, string code);
|
Task<bool> AuthorizeRequest(Guid userId, string code);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the authorized request for the secret.
|
/// Gets the authorized request for the secret. The read does not consume the authorization, so a
|
||||||
|
/// client that retries the exchange gets the same access token until the authorization expires.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="secret">The secret.</param>
|
/// <param name="secret">The secret.</param>
|
||||||
/// <returns>The authentication result.</returns>
|
/// <returns>The authentication result.</returns>
|
||||||
AuthenticationResult GetAuthorizedRequest(string secret);
|
Task<AuthenticationResult> GetAuthorizedRequest(string secret);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using MediaBrowser.Common.Extensions;
|
||||||
|
using MediaBrowser.Controller.Authentication;
|
||||||
|
using MediaBrowser.Model.QuickConnect;
|
||||||
|
|
||||||
|
namespace MediaBrowser.Controller.QuickConnect;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Holds the state of in-flight quick connect requests. The three legs of a quick connect flow -
|
||||||
|
/// initiate, authorize and exchange - can each land on a different instance, so the state has to be
|
||||||
|
/// reachable from all of them.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A shared implementation that cannot reach its backend throws <see cref="ServiceUnavailableException"/>
|
||||||
|
/// rather than reporting a miss, because a miss tells a polling client its secret is invalid.
|
||||||
|
/// </remarks>
|
||||||
|
public interface IQuickConnectStore
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up a pending request by the secret handed to the initiating client.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="secret">The request secret.</param>
|
||||||
|
/// <param name="cancellationToken">A cancellation token.</param>
|
||||||
|
/// <returns>The request, or <c>null</c> when it is unknown or has expired.</returns>
|
||||||
|
Task<QuickConnectResult?> GetRequestBySecretAsync(string secret, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Looks up a pending request by the code shown to the user.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="code">The user facing code.</param>
|
||||||
|
/// <param name="cancellationToken">A cancellation token.</param>
|
||||||
|
/// <returns>The request, or <c>null</c> when it is unknown or has expired.</returns>
|
||||||
|
Task<QuickConnectResult?> GetRequestByCodeAsync(string code, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stores a new or updated request until <paramref name="expiresUtc"/>, resolvable by both its secret
|
||||||
|
/// and its code or by neither. A request already past <paramref name="expiresUtc"/> is not stored.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The request to store.</param>
|
||||||
|
/// <param name="expiresUtc">The instant the request stops being resolvable.</param>
|
||||||
|
/// <param name="cancellationToken">A cancellation token.</param>
|
||||||
|
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||||
|
Task SetRequestAsync(QuickConnectResult request, DateTime expiresUtc, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Atomically claims the sole right to authorize the request behind <paramref name="secret"/>, so
|
||||||
|
/// that two instances racing on one code cannot both mint an access token. The claim is never
|
||||||
|
/// released: a mint that failed after writing its token would otherwise be retried into a second one,
|
||||||
|
/// so a request whose authorization failed has to be started again.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="secret">The request secret.</param>
|
||||||
|
/// <param name="expiresUtc">The instant the claim lapses, after which the request can be authorized again.</param>
|
||||||
|
/// <param name="cancellationToken">A cancellation token.</param>
|
||||||
|
/// <returns><c>true</c> when this caller may go on to authorize the request; <c>false</c> when it is unknown, expired, already authorized or claimed elsewhere.</returns>
|
||||||
|
Task<bool> TryClaimAuthorizationAsync(string secret, DateTime expiresUtc, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stores the authentication minted for an authorized request until <paramref name="expiresUtc"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="secret">The request secret the client exchanges.</param>
|
||||||
|
/// <param name="authenticationResult">The authentication to hand out.</param>
|
||||||
|
/// <param name="expiresUtc">The instant the authentication stops being exchangeable.</param>
|
||||||
|
/// <param name="cancellationToken">A cancellation token.</param>
|
||||||
|
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||||
|
Task SetAuthorizationAsync(string secret, AuthenticationResult authenticationResult, DateTime expiresUtc, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads the authentication for <paramref name="secret"/>. The read does not consume it, so a client
|
||||||
|
/// that retries an exchange gets the same access token for as long as the authentication lives.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="secret">The request secret.</param>
|
||||||
|
/// <param name="cancellationToken">A cancellation token.</param>
|
||||||
|
/// <returns>The authentication, or <c>null</c> when the secret is unknown or has expired.</returns>
|
||||||
|
Task<AuthenticationResult?> GetAuthorizationAsync(string secret, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using MediaBrowser.Controller.Authentication;
|
||||||
|
using MediaBrowser.Model.QuickConnect;
|
||||||
|
|
||||||
|
namespace MediaBrowser.Controller.QuickConnect;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A process-local <see cref="IQuickConnectStore"/>, and the single-instance default. Nothing it holds
|
||||||
|
/// is visible to another instance, so a deployment running more than one has to configure a shared
|
||||||
|
/// store instead.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class InMemoryQuickConnectStore : IQuickConnectStore
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<string, Entry<QuickConnectResult>> _requests = new(StringComparer.Ordinal);
|
||||||
|
private readonly ConcurrentDictionary<string, Entry<AuthenticationResult>> _authorizations = new(StringComparer.Ordinal);
|
||||||
|
private readonly ConcurrentDictionary<string, DateTime> _authorizationClaims = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<QuickConnectResult?> GetRequestBySecretAsync(string secret, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
Expire();
|
||||||
|
return Task.FromResult(_requests.TryGetValue(secret, out var entry) ? entry.Value : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<QuickConnectResult?> GetRequestByCodeAsync(string code, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
Expire();
|
||||||
|
return Task.FromResult(_requests.Values
|
||||||
|
.Select(entry => entry.Value)
|
||||||
|
.FirstOrDefault(request => string.Equals(request.Code, code, StringComparison.Ordinal)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task SetRequestAsync(QuickConnectResult request, DateTime expiresUtc, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
|
||||||
|
Expire();
|
||||||
|
if (expiresUtc > DateTime.UtcNow)
|
||||||
|
{
|
||||||
|
_requests[request.Secret] = new Entry<QuickConnectResult>(expiresUtc, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<bool> TryClaimAuthorizationAsync(string secret, DateTime expiresUtc, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
Expire();
|
||||||
|
if (!_requests.TryGetValue(secret, out var entry) || entry.Value.Authenticated)
|
||||||
|
{
|
||||||
|
return Task.FromResult(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.FromResult(_authorizationClaims.TryAdd(secret, expiresUtc));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task SetAuthorizationAsync(string secret, AuthenticationResult authenticationResult, DateTime expiresUtc, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
Expire();
|
||||||
|
if (expiresUtc > DateTime.UtcNow)
|
||||||
|
{
|
||||||
|
_authorizations[secret] = new Entry<AuthenticationResult>(expiresUtc, authenticationResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<AuthenticationResult?> GetAuthorizationAsync(string secret, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
Expire();
|
||||||
|
return Task.FromResult(_authorizations.TryGetValue(secret, out var entry) ? entry.Value : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Expire()
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
|
foreach (var (secret, entry) in _requests)
|
||||||
|
{
|
||||||
|
if (entry.ExpiresUtc <= now)
|
||||||
|
{
|
||||||
|
_requests.TryRemove(secret, out _);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var (secret, entry) in _authorizations)
|
||||||
|
{
|
||||||
|
if (entry.ExpiresUtc <= now)
|
||||||
|
{
|
||||||
|
_authorizations.TryRemove(secret, out _);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var (secret, expiresUtc) in _authorizationClaims)
|
||||||
|
{
|
||||||
|
if (expiresUtc <= now)
|
||||||
|
{
|
||||||
|
_authorizationClaims.TryRemove(secret, out _);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record Entry<T>(DateTime ExpiresUtc, T Value);
|
||||||
|
}
|
||||||
@@ -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,56 @@
|
|||||||
|
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",
|
||||||
|
"DownloadLyrics",
|
||||||
|
"DownloadSubtitles",
|
||||||
|
"TmdbRefreshUpcomingEpisodes",
|
||||||
|
"RefreshTrickplayImages",
|
||||||
|
"MoveTrickplayImages",
|
||||||
|
"RefreshInternetChannels",
|
||||||
|
"RefreshGuide",
|
||||||
|
"PluginUpdates"
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace MediaBrowser.Controller.Session;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Point-to-point delivery between instances: every instance listens on a channel of its own, so a
|
||||||
|
/// message can be addressed to the one instance holding a given connection.
|
||||||
|
/// </summary>
|
||||||
|
public interface IPodMessageBus
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the identity of this instance.
|
||||||
|
/// </summary>
|
||||||
|
string PodId { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends a message to one instance and waits for that instance to report what it did with it. The
|
||||||
|
/// number of subscribers only proves the target's connection to the broker is up, so delivery is
|
||||||
|
/// taken from the acknowledgement of the instance that has to act on the message.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="targetPod">The instance to deliver to.</param>
|
||||||
|
/// <param name="message">The message.</param>
|
||||||
|
/// <param name="cancellationToken">The cancellation token.</param>
|
||||||
|
/// <returns><c>true</c> if the target acknowledged having carried the message out.</returns>
|
||||||
|
Task<bool> RequestAsync(string targetPod, PodMessage message, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers a handler for the messages addressed to this instance. Whatever the handler returns is
|
||||||
|
/// sent back to the origin as the acknowledgement.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="handler">The handler.</param>
|
||||||
|
void Subscribe(Func<PodMessage, Task<bool>> handler);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace MediaBrowser.Controller.Session;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The shared record of which instance holds which session. Entries expire, so an instance that stops
|
||||||
|
/// refreshing them drops out of every other instance's view instead of lingering.
|
||||||
|
/// </summary>
|
||||||
|
public interface ISessionDirectory
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Allocates the next connection epoch for a session. The counter lives in the shared store, so the
|
||||||
|
/// epochs of every instance are ordered by one clock instead of being compared across machines.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="sessionId">The session identifier.</param>
|
||||||
|
/// <param name="cancellationToken">The cancellation token.</param>
|
||||||
|
/// <returns>The allocated epoch, which is greater than every epoch allocated for the session before it.</returns>
|
||||||
|
Task<long> AllocateConnectionEpochAsync(string sessionId, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Claims a session for the publishing instance and restarts its expiry. The claim is refused when
|
||||||
|
/// another instance holds the connection, so an instance that merely served a request for the session
|
||||||
|
/// cannot take ownership of it.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="entry">The entry.</param>
|
||||||
|
/// <param name="connectionEpoch">The epoch of the publishing instance's connection to the session, or zero when it holds none.</param>
|
||||||
|
/// <param name="cancellationToken">The cancellation token.</param>
|
||||||
|
/// <returns><c>true</c> if the entry was written.</returns>
|
||||||
|
Task<bool> PublishAsync(SessionDirectoryEntry entry, long connectionEpoch, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes an entry, but only while the calling instance still owns it.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="sessionId">The session identifier.</param>
|
||||||
|
/// <param name="ownerPod">The instance requesting the removal.</param>
|
||||||
|
/// <param name="cancellationToken">The cancellation token.</param>
|
||||||
|
/// <returns>A task representing the operation.</returns>
|
||||||
|
Task RemoveAsync(string sessionId, string ownerPod, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets one entry by session identifier.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="sessionId">The session identifier.</param>
|
||||||
|
/// <param name="cancellationToken">The cancellation token.</param>
|
||||||
|
/// <returns>The entry, or <c>null</c> when the session is in no instance's directory.</returns>
|
||||||
|
/// <exception cref="System.Exception">The store could not be read. An unreadable store is not an absent session.</exception>
|
||||||
|
Task<SessionDirectoryEntry?> GetAsync(string sessionId, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets every entry that has not expired.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cancellationToken">The cancellation token.</param>
|
||||||
|
/// <returns>The entries.</returns>
|
||||||
|
Task<IReadOnlyList<SessionDirectoryEntry>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -80,7 +80,8 @@ namespace MediaBrowser.Controller.Session
|
|||||||
/// Used to report that a session controller has connected.
|
/// Used to report that a session controller has connected.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="session">The session.</param>
|
/// <param name="session">The session.</param>
|
||||||
void OnSessionControllerConnected(SessionInfo session);
|
/// <returns>A task representing the operation.</returns>
|
||||||
|
Task OnSessionControllerConnected(SessionInfo session);
|
||||||
|
|
||||||
void UpdateDeviceName(string sessionId, string reportedDeviceName);
|
void UpdateDeviceName(string sessionId, string reportedDeviceName);
|
||||||
|
|
||||||
@@ -241,7 +242,8 @@ namespace MediaBrowser.Controller.Session
|
|||||||
/// <param name="controllingSessionId">The controlling session identifier.</param>
|
/// <param name="controllingSessionId">The controlling session identifier.</param>
|
||||||
/// <param name="sessionId">The session identifier.</param>
|
/// <param name="sessionId">The session identifier.</param>
|
||||||
/// <param name="userId">The user identifier.</param>
|
/// <param name="userId">The user identifier.</param>
|
||||||
void AddAdditionalUser(string controllingSessionId, string sessionId, Guid userId);
|
/// <returns>A task representing the operation.</returns>
|
||||||
|
Task AddAdditionalUser(string controllingSessionId, string sessionId, Guid userId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Removes the additional user.
|
/// Removes the additional user.
|
||||||
@@ -249,7 +251,8 @@ namespace MediaBrowser.Controller.Session
|
|||||||
/// <param name="controllingSessionId">The controlling session identifier.</param>
|
/// <param name="controllingSessionId">The controlling session identifier.</param>
|
||||||
/// <param name="sessionId">The session identifier.</param>
|
/// <param name="sessionId">The session identifier.</param>
|
||||||
/// <param name="userId">The user identifier.</param>
|
/// <param name="userId">The user identifier.</param>
|
||||||
void RemoveAdditionalUser(string controllingSessionId, string sessionId, Guid userId);
|
/// <returns>A task representing the operation.</returns>
|
||||||
|
Task RemoveAdditionalUser(string controllingSessionId, string sessionId, Guid userId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Reports the now viewing item.
|
/// Reports the now viewing item.
|
||||||
@@ -257,7 +260,8 @@ namespace MediaBrowser.Controller.Session
|
|||||||
/// <param name="controllingSessionId">The controlling session identifier.</param>
|
/// <param name="controllingSessionId">The controlling session identifier.</param>
|
||||||
/// <param name="sessionId">The session identifier.</param>
|
/// <param name="sessionId">The session identifier.</param>
|
||||||
/// <param name="itemId">The item identifier.</param>
|
/// <param name="itemId">The item identifier.</param>
|
||||||
void ReportNowViewingItem(string controllingSessionId, string sessionId, string itemId);
|
/// <returns>A task representing the operation.</returns>
|
||||||
|
Task ReportNowViewingItem(string controllingSessionId, string sessionId, string itemId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Authenticates the new session.
|
/// Authenticates the new session.
|
||||||
@@ -274,7 +278,8 @@ namespace MediaBrowser.Controller.Session
|
|||||||
/// <param name="controllingSessionId">The controlling session identifier.</param>
|
/// <param name="controllingSessionId">The controlling session identifier.</param>
|
||||||
/// <param name="sessionId">The session identifier.</param>
|
/// <param name="sessionId">The session identifier.</param>
|
||||||
/// <param name="capabilities">The capabilities.</param>
|
/// <param name="capabilities">The capabilities.</param>
|
||||||
void ReportCapabilities(string controllingSessionId, string sessionId, ClientCapabilities capabilities);
|
/// <returns>A task representing the operation.</returns>
|
||||||
|
Task ReportCapabilities(string controllingSessionId, string sessionId, ClientCapabilities capabilities);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Reports the transcoding information.
|
/// Reports the transcoding information.
|
||||||
@@ -306,8 +311,9 @@ namespace MediaBrowser.Controller.Session
|
|||||||
/// <param name="activeWithinSeconds">Active within session limit.</param>
|
/// <param name="activeWithinSeconds">Active within session limit.</param>
|
||||||
/// <param name="controllableUserToCheck">Filter for sessions remote controllable for this user.</param>
|
/// <param name="controllableUserToCheck">Filter for sessions remote controllable for this user.</param>
|
||||||
/// <param name="isApiKey">Is the request authenticated with API key.</param>
|
/// <param name="isApiKey">Is the request authenticated with API key.</param>
|
||||||
/// <returns>IReadOnlyList{SessionInfoDto}.</returns>
|
/// <param name="cancellationToken">The cancellation token.</param>
|
||||||
IReadOnlyList<SessionInfoDto> GetSessions(Guid userId, string deviceId, int? activeWithinSeconds, Guid? controllableUserToCheck, bool isApiKey);
|
/// <returns>IReadOnlyList{SessionInfoDto}, including the sessions held by the other instances.</returns>
|
||||||
|
Task<IReadOnlyList<SessionInfoDto>> GetSessions(Guid userId, string deviceId, int? activeWithinSeconds, Guid? controllableUserToCheck, bool isApiKey, CancellationToken cancellationToken);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the session by authentication token.
|
/// Gets the session by authentication token.
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace MediaBrowser.Controller.Session;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The single-instance <see cref="IPodMessageBus"/>: there is no other instance to reach.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class NullPodMessageBus : IPodMessageBus
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the shared instance.
|
||||||
|
/// </summary>
|
||||||
|
public static NullPodMessageBus Instance { get; } = new NullPodMessageBus();
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string PodId => PodIdentity.Current;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<bool> RequestAsync(string targetPod, PodMessage message, CancellationToken cancellationToken = default)
|
||||||
|
=> Task.FromResult(false);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Subscribe(Func<PodMessage, Task<bool>> handler)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace MediaBrowser.Controller.Session;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The single-instance <see cref="ISessionDirectory"/>: nothing is published and no session is held
|
||||||
|
/// anywhere but here, which is exactly the behaviour of a deployment without a shared store.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class NullSessionDirectory : ISessionDirectory
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the shared instance.
|
||||||
|
/// </summary>
|
||||||
|
public static NullSessionDirectory Instance { get; } = new NullSessionDirectory();
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<long> AllocateConnectionEpochAsync(string sessionId, CancellationToken cancellationToken = default)
|
||||||
|
=> Task.FromResult(0L);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<bool> PublishAsync(SessionDirectoryEntry entry, long connectionEpoch, CancellationToken cancellationToken = default)
|
||||||
|
=> Task.FromResult(false);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task RemoveAsync(string sessionId, string ownerPod, CancellationToken cancellationToken = default)
|
||||||
|
=> Task.CompletedTask;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<SessionDirectoryEntry?> GetAsync(string sessionId, CancellationToken cancellationToken = default)
|
||||||
|
=> Task.FromResult<SessionDirectoryEntry?>(null);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<IReadOnlyList<SessionDirectoryEntry>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||||
|
=> Task.FromResult<IReadOnlyList<SessionDirectoryEntry>>(Array.Empty<SessionDirectoryEntry>());
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace MediaBrowser.Controller.Session;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The identity of this instance among the replicas sharing a deployment.
|
||||||
|
/// </summary>
|
||||||
|
public static class PodIdentity
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the identity of this instance.
|
||||||
|
/// </summary>
|
||||||
|
public static string Current => Environment.GetEnvironmentVariable("JELLYFIN_INSTANCE_ID") ?? Environment.MachineName;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
namespace MediaBrowser.Controller.Session;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An envelope addressed to one instance. <see cref="Kind"/> names the payload so that features other
|
||||||
|
/// than session routing can share the same channel.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PodMessage
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The <see cref="Kind"/> of the acknowledgement the receiving instance sends back.
|
||||||
|
/// </summary>
|
||||||
|
public const string AckKind = "Ack";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the payload discriminator.
|
||||||
|
/// </summary>
|
||||||
|
public string Kind { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the identity of the sending instance.
|
||||||
|
/// </summary>
|
||||||
|
public string OriginPod { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the identifier tying an acknowledgement to the message it answers.
|
||||||
|
/// </summary>
|
||||||
|
public string CorrelationId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets a value indicating whether the receiving instance carried the message out. Only
|
||||||
|
/// meaningful on an <see cref="AckKind"/> message.
|
||||||
|
/// </summary>
|
||||||
|
public bool Handled { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the serialized payload.
|
||||||
|
/// </summary>
|
||||||
|
public string Payload { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user