mirror of
https://github.com/jellyfin/jellyfin.git
synced 2026-06-07 16:28:56 +01:00
Compare commits
266 Commits
fix/ci-wor
...
v10.11.11
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fbd873929 | ||
|
|
39958ad9e5 | ||
|
|
7bde1ac224 | ||
|
|
143aee7e9e | ||
|
|
8c65dfefa1 | ||
|
|
869d8d3abc | ||
|
|
8d0534195d | ||
|
|
36af7fa7bf | ||
|
|
4b4b4cd94d | ||
|
|
f2b358a3c8 | ||
|
|
95600752a3 | ||
|
|
7249b5744c | ||
|
|
d8b0034a50 | ||
|
|
ed379a1882 | ||
|
|
984e67c067 | ||
|
|
eece62a90b | ||
|
|
e5c34e7096 | ||
|
|
9526083523 | ||
|
|
c4fb0285fc | ||
|
|
620b7a2495 | ||
|
|
ac9aa273ab | ||
|
|
72360ba292 | ||
|
|
706000cfce | ||
|
|
734145ab98 | ||
|
|
89d32a9525 | ||
|
|
19a35a6159 | ||
|
|
8743c22551 | ||
|
|
d1ba366f97 | ||
|
|
999de06d6b | ||
|
|
6435600a9c | ||
|
|
e52e448c30 | ||
|
|
f1137a9587 | ||
|
|
6de99306ec | ||
|
|
03ff69a6e1 | ||
|
|
94d0f7b1ac | ||
|
|
e83a7e62f2 | ||
|
|
445c6c9448 | ||
|
|
5f3189af41 | ||
|
|
b278dcf475 | ||
|
|
f7d80ae9e6 | ||
|
|
a023b9c88d | ||
|
|
40f35f6094 | ||
|
|
2b6fc19842 | ||
|
|
8c29098c8a | ||
|
|
758ee0af76 | ||
|
|
2e19c247ef | ||
|
|
511f90d6d3 | ||
|
|
1ae45519d0 | ||
|
|
586fa01e46 | ||
|
|
2ac0edc052 | ||
|
|
b37ebec5f6 | ||
|
|
938c043596 | ||
|
|
46a53d0605 | ||
|
|
97f88743b8 | ||
|
|
2c62d40f0d | ||
|
|
dca3cc74b7 | ||
|
|
be095f85ab | ||
|
|
f51c63e244 | ||
|
|
cc678383c9 | ||
|
|
ba0720a555 | ||
|
|
417df3df57 | ||
|
|
169e48ac00 | ||
|
|
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 | ||
|
|
01ae62aa49 | ||
|
|
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 | ||
|
|
d2df6adc16 | ||
|
|
877251bcae |
@@ -3,7 +3,7 @@
|
|||||||
"isRoot": true,
|
"isRoot": true,
|
||||||
"tools": {
|
"tools": {
|
||||||
"dotnet-ef": {
|
"dotnet-ef": {
|
||||||
"version": "9.0.10",
|
"version": "9.0.11",
|
||||||
"commands": [
|
"commands": [
|
||||||
"dotnet-ef"
|
"dotnet-ef"
|
||||||
]
|
]
|
||||||
|
|||||||
10
.github/workflows/ci-codeql-analysis.yml
vendored
10
.github/workflows/ci-codeql-analysis.yml
vendored
@@ -20,18 +20,18 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
- name: Setup .NET
|
- name: Setup .NET
|
||||||
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
|
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
|
||||||
with:
|
with:
|
||||||
dotnet-version: '9.0.x'
|
dotnet-version: '9.0.x'
|
||||||
|
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@16140ae1a102900babc80a33c44059580f687047 # v4.30.9
|
uses: github/codeql-action/init@fe4161a26a8629af62121b670040955b330f9af2 # v4.31.6
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
queries: +security-extended
|
queries: +security-extended
|
||||||
- name: Autobuild
|
- name: Autobuild
|
||||||
uses: github/codeql-action/autobuild@16140ae1a102900babc80a33c44059580f687047 # v4.30.9
|
uses: github/codeql-action/autobuild@fe4161a26a8629af62121b670040955b330f9af2 # v4.31.6
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@16140ae1a102900babc80a33c44059580f687047 # v4.30.9
|
uses: github/codeql-action/analyze@fe4161a26a8629af62121b670040955b330f9af2 # v4.31.6
|
||||||
|
|||||||
20
.github/workflows/ci-compat.yml
vendored
20
.github/workflows/ci-compat.yml
vendored
@@ -1,6 +1,6 @@
|
|||||||
name: ABI Compatibility
|
name: ABI Compatibility
|
||||||
on:
|
on:
|
||||||
pull_request_target:
|
pull_request:
|
||||||
|
|
||||||
permissions: {}
|
permissions: {}
|
||||||
|
|
||||||
@@ -11,13 +11,13 @@ jobs:
|
|||||||
permissions: read-all
|
permissions: read-all
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha }}
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||||
|
|
||||||
- name: Setup .NET
|
- name: Setup .NET
|
||||||
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
|
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
|
||||||
with:
|
with:
|
||||||
dotnet-version: '9.0.x'
|
dotnet-version: '9.0.x'
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ jobs:
|
|||||||
dotnet build Jellyfin.Server -o ./out
|
dotnet build Jellyfin.Server -o ./out
|
||||||
|
|
||||||
- name: Upload Head
|
- name: Upload Head
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||||
with:
|
with:
|
||||||
name: abi-head
|
name: abi-head
|
||||||
retention-days: 14
|
retention-days: 14
|
||||||
@@ -40,14 +40,14 @@ jobs:
|
|||||||
permissions: read-all
|
permissions: read-all
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha }}
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Setup .NET
|
- name: Setup .NET
|
||||||
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
|
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
|
||||||
with:
|
with:
|
||||||
dotnet-version: '9.0.x'
|
dotnet-version: '9.0.x'
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ jobs:
|
|||||||
dotnet build Jellyfin.Server -o ./out
|
dotnet build Jellyfin.Server -o ./out
|
||||||
|
|
||||||
- name: Upload Head
|
- name: Upload Head
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||||
with:
|
with:
|
||||||
name: abi-base
|
name: abi-base
|
||||||
retention-days: 14
|
retention-days: 14
|
||||||
@@ -77,7 +77,7 @@ jobs:
|
|||||||
pull-requests: write # to create or update comment (peter-evans/create-or-update-comment)
|
pull-requests: write # to create or update comment (peter-evans/create-or-update-comment)
|
||||||
|
|
||||||
name: ABI - Difference
|
name: ABI - Difference
|
||||||
if: ${{ github.event_name == 'pull_request_target' }}
|
if: ${{ github.event_name == 'pull_request' }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs:
|
needs:
|
||||||
- abi-head
|
- abi-head
|
||||||
@@ -85,13 +85,13 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Download abi-head
|
- name: Download abi-head
|
||||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||||
with:
|
with:
|
||||||
name: abi-head
|
name: abi-head
|
||||||
path: abi-head
|
path: abi-head
|
||||||
|
|
||||||
- name: Download abi-base
|
- name: Download abi-base
|
||||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||||
with:
|
with:
|
||||||
name: abi-base
|
name: abi-base
|
||||||
path: abi-base
|
path: abi-base
|
||||||
|
|||||||
30
.github/workflows/ci-openapi.yml
vendored
30
.github/workflows/ci-openapi.yml
vendored
@@ -5,7 +5,7 @@ on:
|
|||||||
- master
|
- master
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
pull_request_target:
|
pull_request:
|
||||||
|
|
||||||
permissions: {}
|
permissions: {}
|
||||||
|
|
||||||
@@ -16,18 +16,18 @@ jobs:
|
|||||||
permissions: read-all
|
permissions: read-all
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha }}
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||||
- name: Setup .NET
|
- name: Setup .NET
|
||||||
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
|
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
|
||||||
with:
|
with:
|
||||||
dotnet-version: '9.0.x'
|
dotnet-version: '9.0.x'
|
||||||
- name: Generate openapi.json
|
- name: Generate openapi.json
|
||||||
run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests"
|
run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests"
|
||||||
- name: Upload openapi.json
|
- name: Upload openapi.json
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||||
with:
|
with:
|
||||||
name: openapi-head
|
name: openapi-head
|
||||||
retention-days: 14
|
retention-days: 14
|
||||||
@@ -41,7 +41,7 @@ jobs:
|
|||||||
permissions: read-all
|
permissions: read-all
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha }}
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||||
@@ -55,13 +55,13 @@ jobs:
|
|||||||
ANCESTOR_REF=$(git merge-base upstream/${{ github.base_ref }} origin/$HEAD_REF)
|
ANCESTOR_REF=$(git merge-base upstream/${{ github.base_ref }} origin/$HEAD_REF)
|
||||||
git checkout --progress --force $ANCESTOR_REF
|
git checkout --progress --force $ANCESTOR_REF
|
||||||
- name: Setup .NET
|
- name: Setup .NET
|
||||||
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
|
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
|
||||||
with:
|
with:
|
||||||
dotnet-version: '9.0.x'
|
dotnet-version: '9.0.x'
|
||||||
- name: Generate openapi.json
|
- name: Generate openapi.json
|
||||||
run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests"
|
run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests"
|
||||||
- name: Upload openapi.json
|
- name: Upload openapi.json
|
||||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||||
with:
|
with:
|
||||||
name: openapi-base
|
name: openapi-base
|
||||||
retention-days: 14
|
retention-days: 14
|
||||||
@@ -73,19 +73,19 @@ jobs:
|
|||||||
pull-requests: write # to create or update comment (peter-evans/create-or-update-comment)
|
pull-requests: write # to create or update comment (peter-evans/create-or-update-comment)
|
||||||
|
|
||||||
name: OpenAPI - Difference
|
name: OpenAPI - Difference
|
||||||
if: ${{ github.event_name == 'pull_request_target' }}
|
if: ${{ github.event_name == 'pull_request' }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs:
|
needs:
|
||||||
- openapi-head
|
- openapi-head
|
||||||
- openapi-base
|
- openapi-base
|
||||||
steps:
|
steps:
|
||||||
- name: Download openapi-head
|
- name: Download openapi-head
|
||||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||||
with:
|
with:
|
||||||
name: openapi-head
|
name: openapi-head
|
||||||
path: openapi-head
|
path: openapi-head
|
||||||
- name: Download openapi-base
|
- name: Download openapi-base
|
||||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||||
with:
|
with:
|
||||||
name: openapi-base
|
name: openapi-base
|
||||||
path: openapi-base
|
path: openapi-base
|
||||||
@@ -148,7 +148,7 @@ jobs:
|
|||||||
|
|
||||||
publish-unstable:
|
publish-unstable:
|
||||||
name: OpenAPI - Publish Unstable Spec
|
name: OpenAPI - Publish Unstable Spec
|
||||||
if: ${{ github.event_name != 'pull_request_target' && !startsWith(github.ref, 'refs/tags/v') && contains(github.repository_owner, 'jellyfin') }}
|
if: ${{ github.event_name != 'pull_request' && !startsWith(github.ref, 'refs/tags/v') && contains(github.repository_owner, 'jellyfin') }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs:
|
needs:
|
||||||
- openapi-head
|
- openapi-head
|
||||||
@@ -158,7 +158,7 @@ jobs:
|
|||||||
run: |-
|
run: |-
|
||||||
echo "JELLYFIN_VERSION=$(date +'%Y%m%d%H%M%S')" >> $GITHUB_ENV
|
echo "JELLYFIN_VERSION=$(date +'%Y%m%d%H%M%S')" >> $GITHUB_ENV
|
||||||
- name: Download openapi-head
|
- name: Download openapi-head
|
||||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||||
with:
|
with:
|
||||||
name: openapi-head
|
name: openapi-head
|
||||||
path: openapi-head
|
path: openapi-head
|
||||||
@@ -172,7 +172,7 @@ jobs:
|
|||||||
strip_components: 1
|
strip_components: 1
|
||||||
target: "/srv/incoming/openapi/unstable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}"
|
target: "/srv/incoming/openapi/unstable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}"
|
||||||
- name: Move openapi.json (unstable) into place
|
- name: Move openapi.json (unstable) into place
|
||||||
uses: appleboy/ssh-action@2ead5e36573f08b82fbfce1504f1a4b05a647c6f # v1.2.2
|
uses: appleboy/ssh-action@823bd89e131d8d508129f9443cad5855e9ba96f0 # v1.2.4
|
||||||
with:
|
with:
|
||||||
host: "${{ secrets.REPO_HOST }}"
|
host: "${{ secrets.REPO_HOST }}"
|
||||||
username: "${{ secrets.REPO_USER }}"
|
username: "${{ secrets.REPO_USER }}"
|
||||||
@@ -220,7 +220,7 @@ jobs:
|
|||||||
run: |-
|
run: |-
|
||||||
echo "JELLYFIN_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
|
echo "JELLYFIN_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
|
||||||
- name: Download openapi-head
|
- name: Download openapi-head
|
||||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||||
with:
|
with:
|
||||||
name: openapi-head
|
name: openapi-head
|
||||||
path: openapi-head
|
path: openapi-head
|
||||||
@@ -234,7 +234,7 @@ jobs:
|
|||||||
strip_components: 1
|
strip_components: 1
|
||||||
target: "/srv/incoming/openapi/stable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}"
|
target: "/srv/incoming/openapi/stable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}"
|
||||||
- name: Move openapi.json (stable) into place
|
- name: Move openapi.json (stable) into place
|
||||||
uses: appleboy/ssh-action@2ead5e36573f08b82fbfce1504f1a4b05a647c6f # v1.2.2
|
uses: appleboy/ssh-action@823bd89e131d8d508129f9443cad5855e9ba96f0 # v1.2.4
|
||||||
with:
|
with:
|
||||||
host: "${{ secrets.REPO_HOST }}"
|
host: "${{ secrets.REPO_HOST }}"
|
||||||
username: "${{ secrets.REPO_USER }}"
|
username: "${{ secrets.REPO_USER }}"
|
||||||
|
|||||||
6
.github/workflows/ci-tests.yml
vendored
6
.github/workflows/ci-tests.yml
vendored
@@ -20,9 +20,9 @@ jobs:
|
|||||||
|
|
||||||
runs-on: "${{ matrix.os }}"
|
runs-on: "${{ matrix.os }}"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
|
|
||||||
- uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
|
- uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
|
||||||
with:
|
with:
|
||||||
dotnet-version: ${{ env.SDK_VERSION }}
|
dotnet-version: ${{ env.SDK_VERSION }}
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ jobs:
|
|||||||
--verbosity minimal
|
--verbosity minimal
|
||||||
|
|
||||||
- name: Merge code coverage results
|
- name: Merge code coverage results
|
||||||
uses: danielpalme/ReportGenerator-GitHub-Action@9870ed167742d546b99962ff815fcc1098355ed8 # v5.4.17
|
uses: danielpalme/ReportGenerator-GitHub-Action@ee0ae774f6d3afedcbd1683c1ab21b83670bdf8e # v5.5.1
|
||||||
with:
|
with:
|
||||||
reports: "**/coverage.cobertura.xml"
|
reports: "**/coverage.cobertura.xml"
|
||||||
targetdir: "merged/"
|
targetdir: "merged/"
|
||||||
|
|||||||
8
.github/workflows/commands.yml
vendored
8
.github/workflows/commands.yml
vendored
@@ -4,7 +4,7 @@ on:
|
|||||||
types:
|
types:
|
||||||
- created
|
- created
|
||||||
- edited
|
- edited
|
||||||
pull_request_target:
|
pull_request:
|
||||||
types:
|
types:
|
||||||
- labeled
|
- labeled
|
||||||
- synchronize
|
- synchronize
|
||||||
@@ -24,7 +24,7 @@ jobs:
|
|||||||
reactions: '+1'
|
reactions: '+1'
|
||||||
|
|
||||||
- name: Checkout the latest code
|
- name: Checkout the latest code
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.JF_BOT_TOKEN }}
|
token: ${{ secrets.JF_BOT_TOKEN }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -40,11 +40,11 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: pull in script
|
- name: pull in script
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
repository: jellyfin/jellyfin-triage-script
|
repository: jellyfin/jellyfin-triage-script
|
||||||
- name: install python
|
- name: install python
|
||||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||||
with:
|
with:
|
||||||
python-version: '3.14'
|
python-version: '3.14'
|
||||||
cache: 'pip'
|
cache: 'pip'
|
||||||
|
|||||||
2
.github/workflows/issue-stale.yml
vendored
2
.github/workflows/issue-stale.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: ${{ contains(github.repository, 'jellyfin/') }}
|
if: ${{ contains(github.repository, 'jellyfin/') }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
|
- uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
|
||||||
with:
|
with:
|
||||||
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||||
ascending: true
|
ascending: true
|
||||||
|
|||||||
4
.github/workflows/issue-template-check.yml
vendored
4
.github/workflows/issue-template-check.yml
vendored
@@ -10,11 +10,11 @@ jobs:
|
|||||||
issues: write
|
issues: write
|
||||||
steps:
|
steps:
|
||||||
- name: pull in script
|
- name: pull in script
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
repository: jellyfin/jellyfin-triage-script
|
repository: jellyfin/jellyfin-triage-script
|
||||||
- name: install python
|
- name: install python
|
||||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||||
with:
|
with:
|
||||||
python-version: '3.14'
|
python-version: '3.14'
|
||||||
cache: 'pip'
|
cache: 'pip'
|
||||||
|
|||||||
2
.github/workflows/project-automation.yml
vendored
2
.github/workflows/project-automation.yml
vendored
@@ -4,7 +4,7 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
pull_request_target:
|
pull_request:
|
||||||
issue_comment:
|
issue_comment:
|
||||||
|
|
||||||
permissions: {}
|
permissions: {}
|
||||||
|
|||||||
4
.github/workflows/pull-request-conflict.yml
vendored
4
.github/workflows/pull-request-conflict.yml
vendored
@@ -4,7 +4,7 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
pull_request_target:
|
pull_request:
|
||||||
issue_comment:
|
issue_comment:
|
||||||
|
|
||||||
permissions: {}
|
permissions: {}
|
||||||
@@ -16,7 +16,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Apply label
|
- name: Apply label
|
||||||
uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 # v3.0.3
|
uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 # v3.0.3
|
||||||
if: ${{ github.event_name == 'push' || github.event_name == 'pull_request_target'}}
|
if: ${{ github.event_name == 'push' || github.event_name == 'pull_request'}}
|
||||||
with:
|
with:
|
||||||
dirtyLabel: 'merge conflict'
|
dirtyLabel: 'merge conflict'
|
||||||
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'
|
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'
|
||||||
|
|||||||
2
.github/workflows/pull-request-stale.yaml
vendored
2
.github/workflows/pull-request-stale.yaml
vendored
@@ -15,7 +15,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: ${{ contains(github.repository, 'jellyfin/') }}
|
if: ${{ contains(github.repository, 'jellyfin/') }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
|
- uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
|
||||||
with:
|
with:
|
||||||
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||||
ascending: true
|
ascending: true
|
||||||
|
|||||||
4
.github/workflows/release-bump-version.yaml
vendored
4
.github/workflows/release-bump-version.yaml
vendored
@@ -33,7 +33,7 @@ jobs:
|
|||||||
yq-version: v4.9.8
|
yq-version: v4.9.8
|
||||||
|
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ env.TAG_BRANCH }}
|
ref: ${{ env.TAG_BRANCH }}
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ jobs:
|
|||||||
NEXT_VERSION: ${{ github.event.inputs.NEXT_VERSION }}
|
NEXT_VERSION: ${{ github.event.inputs.NEXT_VERSION }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ env.TAG_BRANCH }}
|
ref: ${{ env.TAG_BRANCH }}
|
||||||
|
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -277,3 +277,7 @@ apiclient/generated
|
|||||||
|
|
||||||
# Omnisharp crash logs
|
# Omnisharp crash logs
|
||||||
mono_crash.*.json
|
mono_crash.*.json
|
||||||
|
|
||||||
|
# Devcontainer temp files
|
||||||
|
.devcontainer/devcontainer-lock.json
|
||||||
|
dotnet/
|
||||||
|
|||||||
@@ -117,6 +117,7 @@
|
|||||||
- [sachk](https://github.com/sachk)
|
- [sachk](https://github.com/sachk)
|
||||||
- [sammyrc34](https://github.com/sammyrc34)
|
- [sammyrc34](https://github.com/sammyrc34)
|
||||||
- [samuel9554](https://github.com/samuel9554)
|
- [samuel9554](https://github.com/samuel9554)
|
||||||
|
- [SapientGuardian](https://github.com/SapientGuardian)
|
||||||
- [scheidleon](https://github.com/scheidleon)
|
- [scheidleon](https://github.com/scheidleon)
|
||||||
- [sebPomme](https://github.com/sebPomme)
|
- [sebPomme](https://github.com/sebPomme)
|
||||||
- [SegiH](https://github.com/SegiH)
|
- [SegiH](https://github.com/SegiH)
|
||||||
@@ -205,6 +206,8 @@
|
|||||||
- [theshoeshiner](https://github.com/theshoeshiner)
|
- [theshoeshiner](https://github.com/theshoeshiner)
|
||||||
- [TokerX](https://github.com/TokerX)
|
- [TokerX](https://github.com/TokerX)
|
||||||
- [GeneMarks](https://github.com/GeneMarks)
|
- [GeneMarks](https://github.com/GeneMarks)
|
||||||
|
- [martenumberto](https://github.com/martenumberto)
|
||||||
|
- [MarcoCoreDuo](https://github.com/MarcoCoreDuo)
|
||||||
|
|
||||||
# Emby Contributors
|
# Emby Contributors
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<!-- Run "dotnet list package (dash,dash)outdated" to see the latest versions of each package.-->
|
<!-- Run "dotnet list package (dash,dash)outdated" to see the latest versions of each package.-->
|
||||||
<ItemGroup Label="Package Dependencies">
|
<ItemGroup Label="Package Dependencies">
|
||||||
<PackageVersion Include="AsyncKeyedLock" Version="7.1.7" />
|
<PackageVersion Include="AsyncKeyedLock" Version="7.1.8" />
|
||||||
<PackageVersion Include="AutoFixture.AutoMoq" Version="4.18.1" />
|
<PackageVersion Include="AutoFixture.AutoMoq" Version="4.18.1" />
|
||||||
<PackageVersion Include="AutoFixture.Xunit2" Version="4.18.1" />
|
<PackageVersion Include="AutoFixture.Xunit2" Version="4.18.1" />
|
||||||
<PackageVersion Include="AutoFixture" Version="4.18.1" />
|
<PackageVersion Include="AutoFixture" Version="4.18.1" />
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
<PackageVersion Include="Diacritics" Version="4.0.17" />
|
<PackageVersion Include="Diacritics" Version="4.0.17" />
|
||||||
<PackageVersion Include="DiscUtils.Udf" Version="0.16.13" />
|
<PackageVersion Include="DiscUtils.Udf" Version="0.16.13" />
|
||||||
<PackageVersion Include="DotNet.Glob" Version="3.1.3" />
|
<PackageVersion Include="DotNet.Glob" Version="3.1.3" />
|
||||||
<PackageVersion Include="FsCheck.Xunit" Version="3.3.1" />
|
<PackageVersion Include="FsCheck.Xunit" Version="3.3.2" />
|
||||||
<PackageVersion Include="HarfBuzzSharp.NativeAssets.Linux" Version="8.3.1.1" />
|
<PackageVersion Include="HarfBuzzSharp.NativeAssets.Linux" Version="8.3.1.1" />
|
||||||
<PackageVersion Include="ICU4N.Transliterator" Version="60.1.0-alpha.356" />
|
<PackageVersion Include="ICU4N.Transliterator" Version="60.1.0-alpha.356" />
|
||||||
<PackageVersion Include="IDisposableAnalyzers" Version="4.0.8" />
|
<PackageVersion Include="IDisposableAnalyzers" Version="4.0.8" />
|
||||||
@@ -26,33 +26,33 @@
|
|||||||
<PackageVersion Include="libse" Version="4.0.12" />
|
<PackageVersion Include="libse" Version="4.0.12" />
|
||||||
<PackageVersion Include="LrcParser" Version="2025.623.0" />
|
<PackageVersion Include="LrcParser" Version="2025.623.0" />
|
||||||
<PackageVersion Include="MetaBrainz.MusicBrainz" Version="6.1.0" />
|
<PackageVersion Include="MetaBrainz.MusicBrainz" Version="6.1.0" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="9.0.10" />
|
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="9.0.11" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.10" />
|
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.11" />
|
||||||
<PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="4.14.0" />
|
<PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="4.14.0" />
|
||||||
<PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="4.14.0" />
|
<PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="4.14.0" />
|
||||||
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" />
|
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" />
|
||||||
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" />
|
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" />
|
||||||
<PackageVersion Include="Microsoft.Data.Sqlite" Version="9.0.10" />
|
<PackageVersion Include="Microsoft.Data.Sqlite" Version="9.0.11" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.10" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.11" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.10" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.11" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.10" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.11" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.10" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.11" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.10" />
|
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.11" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="9.0.10" />
|
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="9.0.11" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.10" />
|
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.11" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.10" />
|
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.11" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.10" />
|
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.11" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="9.0.10" />
|
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="9.0.11" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.10" />
|
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.11" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.10" />
|
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.11" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="9.0.10" />
|
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="9.0.11" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="9.0.10" />
|
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="9.0.11" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.10" />
|
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.11" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="9.0.10" />
|
<PackageVersion Include="Microsoft.Extensions.Http" Version="9.0.11" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.10" />
|
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.11" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.10" />
|
<PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.11" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="9.0.10" />
|
<PackageVersion Include="Microsoft.Extensions.Options" Version="9.0.11" />
|
||||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.0" />
|
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||||
<PackageVersion Include="MimeTypes" Version="2.5.2" />
|
<PackageVersion Include="MimeTypes" Version="2.5.2" />
|
||||||
<PackageVersion Include="Morestachio" Version="5.0.1.631" />
|
<PackageVersion Include="Morestachio" Version="5.0.1.631" />
|
||||||
<PackageVersion Include="Moq" Version="4.18.4" />
|
<PackageVersion Include="Moq" Version="4.18.4" />
|
||||||
@@ -62,13 +62,13 @@
|
|||||||
<PackageVersion Include="prometheus-net.AspNetCore" Version="8.2.1" />
|
<PackageVersion Include="prometheus-net.AspNetCore" Version="8.2.1" />
|
||||||
<PackageVersion Include="prometheus-net.DotNetRuntime" Version="4.4.1" />
|
<PackageVersion Include="prometheus-net.DotNetRuntime" Version="4.4.1" />
|
||||||
<PackageVersion Include="prometheus-net" Version="8.2.1" />
|
<PackageVersion Include="prometheus-net" Version="8.2.1" />
|
||||||
<PackageVersion Include="Polly" Version="8.6.4" />
|
<PackageVersion Include="Polly" Version="8.6.5" />
|
||||||
<PackageVersion Include="Serilog.AspNetCore" Version="9.0.0" />
|
<PackageVersion Include="Serilog.AspNetCore" Version="9.0.0" />
|
||||||
<PackageVersion Include="Serilog.Enrichers.Thread" Version="4.0.0" />
|
<PackageVersion Include="Serilog.Enrichers.Thread" Version="4.0.0" />
|
||||||
<PackageVersion Include="Serilog.Expressions" Version="5.0.0" />
|
<PackageVersion Include="Serilog.Expressions" Version="5.0.0" />
|
||||||
<PackageVersion Include="Serilog.Settings.Configuration" Version="9.0.0" />
|
<PackageVersion Include="Serilog.Settings.Configuration" Version="9.0.0" />
|
||||||
<PackageVersion Include="Serilog.Sinks.Async" Version="2.1.0" />
|
<PackageVersion Include="Serilog.Sinks.Async" Version="2.1.0" />
|
||||||
<PackageVersion Include="Serilog.Sinks.Console" Version="6.0.0" />
|
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||||
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
|
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||||
<PackageVersion Include="Serilog.Sinks.Graylog" Version="3.1.1" />
|
<PackageVersion Include="Serilog.Sinks.Graylog" Version="3.1.1" />
|
||||||
<PackageVersion Include="SerilogAnalyzer" Version="0.15.0" />
|
<PackageVersion Include="SerilogAnalyzer" Version="0.15.0" />
|
||||||
@@ -84,11 +84,11 @@
|
|||||||
<PackageVersion Include="Swashbuckle.AspNetCore" Version="6.2.3" />
|
<PackageVersion Include="Swashbuckle.AspNetCore" Version="6.2.3" />
|
||||||
<PackageVersion Include="System.Globalization" Version="4.3.0" />
|
<PackageVersion Include="System.Globalization" Version="4.3.0" />
|
||||||
<PackageVersion Include="System.Linq.Async" Version="6.0.3" />
|
<PackageVersion Include="System.Linq.Async" Version="6.0.3" />
|
||||||
<PackageVersion Include="System.Text.Encoding.CodePages" Version="9.0.10" />
|
<PackageVersion Include="System.Text.Encoding.CodePages" Version="9.0.11" />
|
||||||
<PackageVersion Include="System.Text.Json" Version="9.0.10" />
|
<PackageVersion Include="System.Text.Json" Version="9.0.11" />
|
||||||
<PackageVersion Include="System.Threading.Tasks.Dataflow" Version="9.0.10" />
|
<PackageVersion Include="System.Threading.Tasks.Dataflow" Version="9.0.11" />
|
||||||
<PackageVersion Include="TagLibSharp" Version="2.3.0" />
|
<PackageVersion Include="TagLibSharp" Version="2.3.0" />
|
||||||
<PackageVersion Include="z440.atl.core" Version="7.5.0" />
|
<PackageVersion Include="z440.atl.core" Version="7.9.0" />
|
||||||
<PackageVersion Include="TMDbLib" Version="2.3.0" />
|
<PackageVersion Include="TMDbLib" Version="2.3.0" />
|
||||||
<PackageVersion Include="UTF.Unknown" Version="2.6.0" />
|
<PackageVersion Include="UTF.Unknown" Version="2.6.0" />
|
||||||
<PackageVersion Include="Xunit.Priority" Version="1.1.6" />
|
<PackageVersion Include="Xunit.Priority" Version="1.1.6" />
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Authors>Jellyfin Contributors</Authors>
|
<Authors>Jellyfin Contributors</Authors>
|
||||||
<PackageId>Jellyfin.Naming</PackageId>
|
<PackageId>Jellyfin.Naming</PackageId>
|
||||||
<VersionPrefix>10.11.0</VersionPrefix>
|
<VersionPrefix>10.11.11</VersionPrefix>
|
||||||
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
||||||
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -10,12 +10,17 @@ namespace Emby.Naming.TV
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static partial class SeasonPathParser
|
public static partial class SeasonPathParser
|
||||||
{
|
{
|
||||||
[GeneratedRegex(@"^\s*((?<seasonnumber>(?>\d+))(?:st|nd|rd|th|\.)*(?!\s*[Ee]\d+))\s*(?:[[시즌]*|[シーズン]*|[sS](?:eason|æson|aison|taffel|eries|tagione|äsong|eizoen|easong|ezon|ezona|ezóna|ezonul)*|[tT](?:emporada)*|[kK](?:ausi)*|[Сс](?:езон)*)\s*(?<rightpart>.*)$")]
|
private static readonly Regex CleanNameRegex = new(@"[ ._\-\[\]]", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
[GeneratedRegex(@"^\s*((?<seasonnumber>(?>\d+))(?:st|nd|rd|th|\.)*(?!\s*[Ee]\d+))\s*(?:[[시즌]*|[シーズン]*|[sS](?:eason|æson|aison|taffel|eries|tagione|äsong|eizoen|easong|ezon|ezona|ezóna|ezonul)*|[tT](?:emporada)*|[kK](?:ausi)*|[Сс](?:езон)*)\s*(?<rightpart>.*)$", RegexOptions.IgnoreCase)]
|
||||||
private static partial Regex ProcessPre();
|
private static partial Regex ProcessPre();
|
||||||
|
|
||||||
[GeneratedRegex(@"^\s*(?:[[시즌]*|[シーズン]*|[sS](?:eason|æson|aison|taffel|eries|tagione|äsong|eizoen|easong|ezon|ezona|ezóna|ezonul)*|[tT](?:emporada)*|[kK](?:ausi)*|[Сс](?:езон)*)\s*(?<seasonnumber>(?>\d+)(?!\s*[Ee]\d+))(?<rightpart>.*)$")]
|
[GeneratedRegex(@"^\s*(?:[[시즌]*|[シーズン]*|[sS](?:eason|æson|aison|taffel|eries|tagione|äsong|eizoen|easong|ezon|ezona|ezóna|ezonul)*|[tT](?:emporada)*|[kK](?:ausi)*|[Сс](?:езон)*)\s*(?<seasonnumber>\d+?)(?=\d{3,4}p|[^\d]|$)(?!\s*[Ee]\d)(?<rightpart>.*)$", RegexOptions.IgnoreCase)]
|
||||||
private static partial Regex ProcessPost();
|
private static partial Regex ProcessPost();
|
||||||
|
|
||||||
|
[GeneratedRegex(@"[sS](\d{1,4})(?!\d|[eE]\d)(?=\.|_|-|\[|\]|\s|$)", RegexOptions.None)]
|
||||||
|
private static partial Regex SeasonPrefix();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Attempts to parse season number from path.
|
/// Attempts to parse season number from path.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -56,45 +61,35 @@ namespace Emby.Naming.TV
|
|||||||
bool supportSpecialAliases,
|
bool supportSpecialAliases,
|
||||||
bool supportNumericSeasonFolders)
|
bool supportNumericSeasonFolders)
|
||||||
{
|
{
|
||||||
string filename = Path.GetFileName(path);
|
var fileName = Path.GetFileName(path);
|
||||||
filename = Regex.Replace(filename, "[ ._-]", string.Empty);
|
|
||||||
|
var seasonPrefixMatch = SeasonPrefix().Match(fileName);
|
||||||
|
if (seasonPrefixMatch.Success &&
|
||||||
|
int.TryParse(seasonPrefixMatch.Groups[1].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var val))
|
||||||
|
{
|
||||||
|
return (val, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
string filename = CleanNameRegex.Replace(fileName, string.Empty);
|
||||||
|
|
||||||
if (parentFolderName is not null)
|
if (parentFolderName is not null)
|
||||||
{
|
{
|
||||||
parentFolderName = Regex.Replace(parentFolderName, "[ ._-]", string.Empty);
|
var cleanParent = CleanNameRegex.Replace(parentFolderName, string.Empty);
|
||||||
filename = filename.Replace(parentFolderName, string.Empty, StringComparison.OrdinalIgnoreCase);
|
filename = filename.Replace(cleanParent, string.Empty, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (supportSpecialAliases)
|
if (supportSpecialAliases &&
|
||||||
{
|
(filename.Equals("specials", StringComparison.OrdinalIgnoreCase) ||
|
||||||
if (string.Equals(filename, "specials", StringComparison.OrdinalIgnoreCase))
|
filename.Equals("extras", StringComparison.OrdinalIgnoreCase)))
|
||||||
{
|
{
|
||||||
return (0, true);
|
return (0, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.Equals(filename, "extras", StringComparison.OrdinalIgnoreCase))
|
if (supportNumericSeasonFolders &&
|
||||||
{
|
int.TryParse(filename, NumberStyles.Integer, CultureInfo.InvariantCulture, out val))
|
||||||
return (0, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (supportNumericSeasonFolders)
|
|
||||||
{
|
|
||||||
if (int.TryParse(filename, NumberStyles.Integer, CultureInfo.InvariantCulture, out var val))
|
|
||||||
{
|
{
|
||||||
return (val, true);
|
return (val, true);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (filename.StartsWith('s'))
|
|
||||||
{
|
|
||||||
var testFilename = filename.AsSpan()[1..];
|
|
||||||
|
|
||||||
if (int.TryParse(testFilename, NumberStyles.Integer, CultureInfo.InvariantCulture, out var val))
|
|
||||||
{
|
|
||||||
return (val, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var preMatch = ProcessPre().Match(filename);
|
var preMatch = ProcessPre().Match(filename);
|
||||||
if (preMatch.Success)
|
if (preMatch.Success)
|
||||||
@@ -113,9 +108,11 @@ namespace Emby.Naming.TV
|
|||||||
var numberString = match.Groups["seasonnumber"];
|
var numberString = match.Groups["seasonnumber"];
|
||||||
if (numberString.Success)
|
if (numberString.Success)
|
||||||
{
|
{
|
||||||
var seasonNumber = int.Parse(numberString.Value, CultureInfo.InvariantCulture);
|
if (int.TryParse(numberString.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seasonNumber))
|
||||||
|
{
|
||||||
return (seasonNumber, true);
|
return (seasonNumber, true);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (null, false);
|
return (null, false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,10 +107,20 @@ namespace Emby.Server.Implementations.AppBase
|
|||||||
|
|
||||||
private void CheckOrCreateMarker(string path, string markerName, bool recursive = false)
|
private void CheckOrCreateMarker(string path, string markerName, bool recursive = false)
|
||||||
{
|
{
|
||||||
var otherMarkers = GetMarkers(path, recursive).FirstOrDefault(e => Path.GetFileName(e) != markerName);
|
string? otherMarkers = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
otherMarkers = GetMarkers(path, recursive).FirstOrDefault(e => !Path.GetFileName(e.AsSpan()).Equals(markerName, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Error while checking for marker files, assume none exist and keep going
|
||||||
|
// TODO: add some logging
|
||||||
|
}
|
||||||
|
|
||||||
if (otherMarkers is not null)
|
if (otherMarkers is not null)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException($"Exepected to find only {markerName} but found marker for {otherMarkers}.");
|
throw new InvalidOperationException($"Expected to find only {markerName} but found marker for {otherMarkers}.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var markerPath = Path.Combine(path, markerName);
|
var markerPath = Path.Combine(path, markerName);
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ public class ChapterManager : IChapterManager
|
|||||||
|
|
||||||
if (saveChapters && changesMade)
|
if (saveChapters && changesMade)
|
||||||
{
|
{
|
||||||
_chapterRepository.SaveChapters(video.Id, chapters);
|
SaveChapters(video, chapters);
|
||||||
}
|
}
|
||||||
|
|
||||||
DeleteDeadImages(currentImages, chapters);
|
DeleteDeadImages(currentImages, chapters);
|
||||||
@@ -234,7 +234,9 @@ public class ChapterManager : IChapterManager
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void SaveChapters(Video video, IReadOnlyList<ChapterInfo> chapters)
|
public void SaveChapters(Video video, IReadOnlyList<ChapterInfo> chapters)
|
||||||
{
|
{
|
||||||
_chapterRepository.SaveChapters(video.Id, chapters);
|
// Remove any chapters that are outside of the runtime of the video
|
||||||
|
var validChapters = chapters.Where(c => c.StartPositionTicks < video.RunTimeTicks).ToList();
|
||||||
|
_chapterRepository.SaveChapters(video.Id, validChapters);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|||||||
@@ -1051,16 +1051,16 @@ namespace Emby.Server.Implementations.Dto
|
|||||||
|
|
||||||
// Include artists that are not in the database yet, e.g., just added via metadata editor
|
// Include artists that are not in the database yet, e.g., just added via metadata editor
|
||||||
// var foundArtists = artistItems.Items.Select(i => i.Item1.Name).ToList();
|
// var foundArtists = artistItems.Items.Select(i => i.Item1.Name).ToList();
|
||||||
dto.ArtistItems = _libraryManager.GetArtists([.. hasArtist.Artists.Where(e => !string.IsNullOrWhiteSpace(e))])
|
var artistsLookup = _libraryManager.GetArtists([.. hasArtist.Artists.Where(e => !string.IsNullOrWhiteSpace(e))]);
|
||||||
.Where(e => e.Value.Length > 0)
|
|
||||||
.Select(i =>
|
dto.ArtistItems = hasArtist.Artists
|
||||||
{
|
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||||
return new NameGuidPair
|
.Distinct()
|
||||||
{
|
.Select(name => artistsLookup.TryGetValue(name, out var artists) && artists.Length > 0
|
||||||
Name = i.Key,
|
? new NameGuidPair { Name = name, Id = artists[0].Id }
|
||||||
Id = i.Value.First().Id
|
: null)
|
||||||
};
|
.Where(item => item is not null)
|
||||||
}).Where(i => i is not null).ToArray();
|
.ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item is IHasAlbumArtist hasAlbumArtist)
|
if (item is IHasAlbumArtist hasAlbumArtist)
|
||||||
@@ -1085,31 +1085,16 @@ namespace Emby.Server.Implementations.Dto
|
|||||||
// })
|
// })
|
||||||
// .ToList();
|
// .ToList();
|
||||||
|
|
||||||
|
var albumArtistsLookup = _libraryManager.GetArtists([.. hasAlbumArtist.AlbumArtists.Where(e => !string.IsNullOrWhiteSpace(e))]);
|
||||||
|
|
||||||
dto.AlbumArtists = hasAlbumArtist.AlbumArtists
|
dto.AlbumArtists = hasAlbumArtist.AlbumArtists
|
||||||
// .Except(foundArtists, new DistinctNameComparer())
|
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||||
.Select(i =>
|
.Distinct()
|
||||||
{
|
.Select(name => albumArtistsLookup.TryGetValue(name, out var albumArtists) && albumArtists.Length > 0
|
||||||
// This should not be necessary but we're seeing some cases of it
|
? new NameGuidPair { Name = name, Id = albumArtists[0].Id }
|
||||||
if (string.IsNullOrEmpty(i))
|
: null)
|
||||||
{
|
.Where(item => item is not null)
|
||||||
return null;
|
.ToArray();
|
||||||
}
|
|
||||||
|
|
||||||
var artist = _libraryManager.GetArtist(i, new DtoOptions(false)
|
|
||||||
{
|
|
||||||
EnableImages = false
|
|
||||||
});
|
|
||||||
if (artist is not null)
|
|
||||||
{
|
|
||||||
return new NameGuidPair
|
|
||||||
{
|
|
||||||
Name = artist.Name,
|
|
||||||
Id = artist.Id
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}).Where(i => i is not null).ToArray();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add video info
|
// Add video info
|
||||||
|
|||||||
@@ -352,6 +352,12 @@ namespace Emby.Server.Implementations.IO
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var fileInfo = _fileSystem.GetFileSystemInfo(path);
|
||||||
|
if (DotIgnoreIgnoreRule.IsIgnored(fileInfo, null))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Ignore certain files, If the parent of an ignored path has a change event, ignore that too
|
// Ignore certain files, If the parent of an ignored path has a change event, ignore that too
|
||||||
foreach (var i in _tempIgnoredPaths.Keys)
|
foreach (var i in _tempIgnoredPaths.Keys)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using System.Linq;
|
|||||||
using System.Security;
|
using System.Security;
|
||||||
using Jellyfin.Extensions;
|
using Jellyfin.Extensions;
|
||||||
using MediaBrowser.Common.Configuration;
|
using MediaBrowser.Common.Configuration;
|
||||||
|
using MediaBrowser.Controller.IO;
|
||||||
using MediaBrowser.Model.IO;
|
using MediaBrowser.Model.IO;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
@@ -152,6 +153,10 @@ namespace Emby.Server.Implementations.IO
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void MoveDirectory(string source, string destination)
|
public void MoveDirectory(string source, string destination)
|
||||||
{
|
{
|
||||||
|
// Make sure parent directory of target exists
|
||||||
|
var parent = Directory.GetParent(destination);
|
||||||
|
parent?.Create();
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Directory.Move(source, destination);
|
Directory.Move(source, destination);
|
||||||
@@ -248,48 +253,41 @@ namespace Emby.Server.Implementations.IO
|
|||||||
{
|
{
|
||||||
result.IsDirectory = info is DirectoryInfo || (info.Attributes & FileAttributes.Directory) == FileAttributes.Directory;
|
result.IsDirectory = info is DirectoryInfo || (info.Attributes & FileAttributes.Directory) == FileAttributes.Directory;
|
||||||
|
|
||||||
// if (!result.IsDirectory)
|
|
||||||
// {
|
|
||||||
// result.IsHidden = (info.Attributes & FileAttributes.Hidden) == FileAttributes.Hidden;
|
|
||||||
// }
|
|
||||||
|
|
||||||
if (info is FileInfo fileInfo)
|
if (info is FileInfo fileInfo)
|
||||||
{
|
{
|
||||||
result.Length = fileInfo.Length;
|
result.CreationTimeUtc = GetCreationTimeUtc(info);
|
||||||
|
result.LastWriteTimeUtc = GetLastWriteTimeUtc(info);
|
||||||
// Issue #2354 get the size of files behind symbolic links. Also Enum.HasFlag is bad as it boxes!
|
if (fileInfo.LinkTarget is not null)
|
||||||
if ((fileInfo.Attributes & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint)
|
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using (var fileHandle = File.OpenHandle(fileInfo.FullName, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
|
var targetFileInfo = FileSystemHelper.ResolveLinkTarget(fileInfo, returnFinalTarget: true);
|
||||||
|
if (targetFileInfo is not null)
|
||||||
{
|
{
|
||||||
result.Length = RandomAccess.GetLength(fileHandle);
|
result.Exists = targetFileInfo.Exists;
|
||||||
|
if (result.Exists)
|
||||||
|
{
|
||||||
|
result.Length = targetFileInfo.Length;
|
||||||
|
result.CreationTimeUtc = GetCreationTimeUtc(targetFileInfo);
|
||||||
|
result.LastWriteTimeUtc = GetLastWriteTimeUtc(targetFileInfo);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (FileNotFoundException ex)
|
else
|
||||||
{
|
{
|
||||||
// Dangling symlinks cannot be detected before opening the file unfortunately...
|
|
||||||
_logger.LogError(ex, "Reading the file size of the symlink at {Path} failed. Marking the file as not existing.", fileInfo.FullName);
|
|
||||||
result.Exists = false;
|
result.Exists = false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
catch (UnauthorizedAccessException ex)
|
catch (UnauthorizedAccessException ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Reading the file at {Path} failed due to a permissions exception.", fileInfo.FullName);
|
_logger.LogError(ex, "Reading the file at {Path} failed due to a permissions exception.", fileInfo.FullName);
|
||||||
}
|
}
|
||||||
catch (IOException ex)
|
}
|
||||||
|
else
|
||||||
{
|
{
|
||||||
// IOException generally means the file is not accessible due to filesystem issues
|
result.Length = fileInfo.Length;
|
||||||
// Catch this exception and mark the file as not exist to ignore it
|
|
||||||
_logger.LogError(ex, "Reading the file at {Path} failed due to an IO Exception. Marking the file as not existing", fileInfo.FullName);
|
|
||||||
result.Exists = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
result.CreationTimeUtc = GetCreationTimeUtc(info);
|
|
||||||
result.LastWriteTimeUtc = GetLastWriteTimeUtc(info);
|
|
||||||
}
|
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
result.IsDirectory = info is DirectoryInfo;
|
result.IsDirectory = info is DirectoryInfo;
|
||||||
@@ -499,8 +497,17 @@ namespace Emby.Server.Implementations.IO
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public virtual bool AreEqual(string path1, string path2)
|
public virtual bool AreEqual(string path1, string path2)
|
||||||
{
|
{
|
||||||
return Path.TrimEndingDirectorySeparator(path1).Equals(
|
if (string.IsNullOrWhiteSpace(path1) || string.IsNullOrWhiteSpace(path2))
|
||||||
Path.TrimEndingDirectorySeparator(path2),
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalized1 = Path.TrimEndingDirectorySeparator(path1);
|
||||||
|
var normalized2 = Path.TrimEndingDirectorySeparator(path2);
|
||||||
|
|
||||||
|
return string.Equals(
|
||||||
|
normalized1,
|
||||||
|
normalized2,
|
||||||
_isEnvironmentCaseInsensitive ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal);
|
_isEnvironmentCaseInsensitive ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -267,8 +267,11 @@ namespace Emby.Server.Implementations.Images
|
|||||||
{
|
{
|
||||||
var image = item.GetImageInfo(type, 0);
|
var image = item.GetImageInfo(type, 0);
|
||||||
|
|
||||||
if (image is not null)
|
if (image is null)
|
||||||
{
|
{
|
||||||
|
return GetItemsWithImages(item).Count is not 0;
|
||||||
|
}
|
||||||
|
|
||||||
if (!image.IsLocalFile)
|
if (!image.IsLocalFile)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
@@ -283,7 +286,6 @@ namespace Emby.Server.Implementations.Images
|
|||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,5 +98,11 @@ namespace Emby.Server.Implementations.Images
|
|||||||
|
|
||||||
return base.CreateImage(item, itemsWithImages, outputPath, imageType, imageIndex);
|
return base.CreateImage(item, itemsWithImages, outputPath, imageType, imageIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected override bool HasChangedByDate(BaseItem item, ItemImageInfo image)
|
||||||
|
{
|
||||||
|
var age = DateTime.UtcNow - image.DateModified;
|
||||||
|
return age.TotalDays > 7;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
using MediaBrowser.Controller.Entities;
|
using MediaBrowser.Controller.Entities;
|
||||||
|
using MediaBrowser.Controller.IO;
|
||||||
using MediaBrowser.Controller.Resolvers;
|
using MediaBrowser.Controller.Resolvers;
|
||||||
using MediaBrowser.Model.IO;
|
using MediaBrowser.Model.IO;
|
||||||
|
|
||||||
@@ -11,28 +13,24 @@ namespace Emby.Server.Implementations.Library;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class DotIgnoreIgnoreRule : IResolverIgnoreRule
|
public class DotIgnoreIgnoreRule : IResolverIgnoreRule
|
||||||
{
|
{
|
||||||
|
private static readonly bool IsWindows = OperatingSystem.IsWindows();
|
||||||
|
|
||||||
private static FileInfo? FindIgnoreFile(DirectoryInfo directory)
|
private static FileInfo? FindIgnoreFile(DirectoryInfo directory)
|
||||||
{
|
{
|
||||||
var ignoreFile = new FileInfo(Path.Join(directory.FullName, ".ignore"));
|
for (var current = directory; current is not null; current = current.Parent)
|
||||||
if (ignoreFile.Exists)
|
|
||||||
{
|
{
|
||||||
return ignoreFile;
|
var ignorePath = Path.Join(current.FullName, ".ignore");
|
||||||
|
if (File.Exists(ignorePath))
|
||||||
|
{
|
||||||
|
return new FileInfo(ignorePath);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var parentDir = directory.Parent;
|
|
||||||
if (parentDir is null)
|
|
||||||
{
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return FindIgnoreFile(parentDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public bool ShouldIgnore(FileSystemMetadata fileInfo, BaseItem? parent)
|
public bool ShouldIgnore(FileSystemMetadata fileInfo, BaseItem? parent) => IsIgnored(fileInfo, parent);
|
||||||
{
|
|
||||||
return IsIgnored(fileInfo, parent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Checks whether or not the file is ignored.
|
/// Checks whether or not the file is ignored.
|
||||||
@@ -42,60 +40,101 @@ public class DotIgnoreIgnoreRule : IResolverIgnoreRule
|
|||||||
/// <returns>True if the file should be ignored.</returns>
|
/// <returns>True if the file should be ignored.</returns>
|
||||||
public static bool IsIgnored(FileSystemMetadata fileInfo, BaseItem? parent)
|
public static bool IsIgnored(FileSystemMetadata fileInfo, BaseItem? parent)
|
||||||
{
|
{
|
||||||
if (fileInfo.IsDirectory)
|
var searchDirectory = fileInfo.IsDirectory
|
||||||
{
|
? new DirectoryInfo(fileInfo.FullName)
|
||||||
var dirIgnoreFile = FindIgnoreFile(new DirectoryInfo(fileInfo.FullName));
|
: new DirectoryInfo(Path.GetDirectoryName(fileInfo.FullName) ?? string.Empty);
|
||||||
if (dirIgnoreFile is null)
|
|
||||||
|
if (string.IsNullOrEmpty(searchDirectory.FullName))
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fast path in case the ignore files isn't a symlink and is empty
|
var ignoreFile = FindIgnoreFile(searchDirectory);
|
||||||
if ((dirIgnoreFile.Attributes & FileAttributes.ReparsePoint) == 0
|
|
||||||
&& dirIgnoreFile.Length == 0)
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ignore the directory only if the .ignore file is empty
|
|
||||||
// evaluate individual files otherwise
|
|
||||||
return string.IsNullOrWhiteSpace(GetFileContent(dirIgnoreFile));
|
|
||||||
}
|
|
||||||
|
|
||||||
var parentDirPath = Path.GetDirectoryName(fileInfo.FullName);
|
|
||||||
if (string.IsNullOrEmpty(parentDirPath))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
var folder = new DirectoryInfo(parentDirPath);
|
|
||||||
var ignoreFile = FindIgnoreFile(folder);
|
|
||||||
if (ignoreFile is null)
|
if (ignoreFile is null)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
string ignoreFileString = GetFileContent(ignoreFile);
|
// Fast path in case the ignore files isn't a symlink and is empty
|
||||||
|
if (ignoreFile.LinkTarget is null && ignoreFile.Length == 0)
|
||||||
if (string.IsNullOrWhiteSpace(ignoreFileString))
|
|
||||||
{
|
{
|
||||||
// Ignore directory if we just have the file
|
// Ignore directory if we just have the file
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var content = GetFileContent(ignoreFile);
|
||||||
|
return string.IsNullOrWhiteSpace(content)
|
||||||
|
|| CheckIgnoreRules(fileInfo.FullName, content, fileInfo.IsDirectory);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool CheckIgnoreRules(string path, string ignoreFileContent, bool isDirectory)
|
||||||
|
{
|
||||||
// If file has content, base ignoring off the content .gitignore-style rules
|
// If file has content, base ignoring off the content .gitignore-style rules
|
||||||
var ignoreRules = ignoreFileString.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
var rules = ignoreFileContent.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
return CheckIgnoreRules(path, rules, isDirectory);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks whether a path should be ignored based on an array of ignore rules.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path">The path to check.</param>
|
||||||
|
/// <param name="rules">The array of ignore rules.</param>
|
||||||
|
/// <param name="isDirectory">Whether the path is a directory.</param>
|
||||||
|
/// <returns>True if the path should be ignored.</returns>
|
||||||
|
internal static bool CheckIgnoreRules(string path, string[] rules, bool isDirectory)
|
||||||
|
=> CheckIgnoreRules(path, rules, isDirectory, IsWindows);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks whether a path should be ignored based on an array of ignore rules.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path">The path to check.</param>
|
||||||
|
/// <param name="rules">The array of ignore rules.</param>
|
||||||
|
/// <param name="isDirectory">Whether the path is a directory.</param>
|
||||||
|
/// <param name="normalizePath">Whether to normalize backslashes to forward slashes (for Windows paths).</param>
|
||||||
|
/// <returns>True if the path should be ignored.</returns>
|
||||||
|
internal static bool CheckIgnoreRules(string path, string[] rules, bool isDirectory, bool normalizePath)
|
||||||
|
{
|
||||||
var ignore = new Ignore.Ignore();
|
var ignore = new Ignore.Ignore();
|
||||||
ignore.Add(ignoreRules);
|
|
||||||
|
|
||||||
return ignore.IsIgnored(fileInfo.FullName);
|
// Add each rule individually to catch and skip invalid patterns
|
||||||
|
var validRulesAdded = 0;
|
||||||
|
foreach (var rule in rules)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ignore.Add(rule);
|
||||||
|
validRulesAdded++;
|
||||||
|
}
|
||||||
|
catch (RegexParseException)
|
||||||
|
{
|
||||||
|
// Ignore invalid patterns
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string GetFileContent(FileInfo dirIgnoreFile)
|
// If no valid rules were added, fall back to ignoring everything (like an empty .ignore file)
|
||||||
|
if (validRulesAdded == 0)
|
||||||
{
|
{
|
||||||
using (var reader = dirIgnoreFile.OpenText())
|
return true;
|
||||||
{
|
|
||||||
return reader.ReadToEnd();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mitigate the problem of the Ignore library not handling Windows paths correctly.
|
||||||
|
// See https://github.com/jellyfin/jellyfin/issues/15484
|
||||||
|
var pathToCheck = normalizePath ? path.NormalizePath('/') : path;
|
||||||
|
|
||||||
|
// Add trailing slash for directories to match "folder/"
|
||||||
|
if (isDirectory)
|
||||||
|
{
|
||||||
|
pathToCheck = string.Concat(pathToCheck.AsSpan().TrimEnd('/'), "/");
|
||||||
|
}
|
||||||
|
|
||||||
|
return ignore.IsIgnored(pathToCheck);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetFileContent(FileInfo ignoreFile)
|
||||||
|
{
|
||||||
|
ignoreFile = FileSystemHelper.ResolveLinkTarget(ignoreFile, returnFinalTarget: true) ?? ignoreFile;
|
||||||
|
return ignoreFile.Exists
|
||||||
|
? File.ReadAllText(ignoreFile.FullName)
|
||||||
|
: string.Empty;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ namespace Emby.Server.Implementations.Library
|
|||||||
"**/lost+found",
|
"**/lost+found",
|
||||||
"**/subs/**",
|
"**/subs/**",
|
||||||
"**/subs",
|
"**/subs",
|
||||||
|
"**/.snapshots/**",
|
||||||
|
"**/.snapshots",
|
||||||
|
"**/.snapshot/**",
|
||||||
|
"**/.snapshot",
|
||||||
|
|
||||||
// Trickplay files
|
// Trickplay files
|
||||||
"**/*.trickplay",
|
"**/*.trickplay",
|
||||||
|
|||||||
@@ -457,6 +457,12 @@ namespace Emby.Server.Implementations.Library
|
|||||||
_cache.TryRemove(child.Id, out _);
|
_cache.TryRemove(child.Id, out _);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (parent is Folder folder)
|
||||||
|
{
|
||||||
|
folder.Children = null;
|
||||||
|
folder.UserData = null;
|
||||||
|
}
|
||||||
|
|
||||||
ReportItemRemoved(item, parent);
|
ReportItemRemoved(item, parent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1052,6 +1058,7 @@ namespace Emby.Server.Implementations.Library
|
|||||||
{
|
{
|
||||||
IncludeItemTypes = [BaseItemKind.MusicArtist],
|
IncludeItemTypes = [BaseItemKind.MusicArtist],
|
||||||
Name = name,
|
Name = name,
|
||||||
|
UseRawName = true,
|
||||||
DtoOptions = options
|
DtoOptions = options
|
||||||
}).Cast<MusicArtist>()
|
}).Cast<MusicArtist>()
|
||||||
.OrderBy(i => i.IsAccessedByName ? 1 : 0)
|
.OrderBy(i => i.IsAccessedByName ? 1 : 0)
|
||||||
@@ -1993,6 +2000,12 @@ namespace Emby.Server.Implementations.Library
|
|||||||
RegisterItem(item);
|
RegisterItem(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (parent is Folder folder)
|
||||||
|
{
|
||||||
|
folder.Children = null;
|
||||||
|
folder.UserData = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (ItemAdded is not null)
|
if (ItemAdded is not null)
|
||||||
{
|
{
|
||||||
foreach (var item in items)
|
foreach (var item in items)
|
||||||
@@ -2150,6 +2163,12 @@ namespace Emby.Server.Implementations.Library
|
|||||||
|
|
||||||
_itemRepository.SaveItems(items, cancellationToken);
|
_itemRepository.SaveItems(items, cancellationToken);
|
||||||
|
|
||||||
|
if (parent is Folder folder)
|
||||||
|
{
|
||||||
|
folder.Children = null;
|
||||||
|
folder.UserData = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (ItemUpdated is not null)
|
if (ItemUpdated is not null)
|
||||||
{
|
{
|
||||||
foreach (var item in items)
|
foreach (var item in items)
|
||||||
@@ -2183,6 +2202,12 @@ namespace Emby.Server.Implementations.Library
|
|||||||
public Task UpdateItemAsync(BaseItem item, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken)
|
public Task UpdateItemAsync(BaseItem item, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken)
|
||||||
=> UpdateItemsAsync([item], parent, updateReason, cancellationToken);
|
=> UpdateItemsAsync([item], parent, updateReason, cancellationToken);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task ReattachUserDataAsync(BaseItem item, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await _itemRepository.ReattachUserDataAsync(item, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task RunMetadataSavers(BaseItem item, ItemUpdateType updateReason)
|
public async Task RunMetadataSavers(BaseItem item, ItemUpdateType updateReason)
|
||||||
{
|
{
|
||||||
if (item.IsFileProtocol)
|
if (item.IsFileProtocol)
|
||||||
@@ -3176,19 +3201,7 @@ namespace Emby.Server.Implementations.Library
|
|||||||
var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath;
|
var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath;
|
||||||
var virtualFolderPath = Path.Combine(rootFolderPath, virtualFolderName);
|
var virtualFolderPath = Path.Combine(rootFolderPath, virtualFolderName);
|
||||||
|
|
||||||
var shortcutFilename = Path.GetFileNameWithoutExtension(path);
|
CreateShortcut(virtualFolderPath, pathInfo);
|
||||||
|
|
||||||
var lnk = Path.Combine(virtualFolderPath, shortcutFilename + ShortcutFileExtension);
|
|
||||||
|
|
||||||
while (File.Exists(lnk))
|
|
||||||
{
|
|
||||||
shortcutFilename += "1";
|
|
||||||
lnk = Path.Combine(virtualFolderPath, shortcutFilename + ShortcutFileExtension);
|
|
||||||
}
|
|
||||||
|
|
||||||
_fileSystem.CreateShortcut(lnk, _appHost.ReverseVirtualPath(path));
|
|
||||||
|
|
||||||
RemoveContentTypeOverrides(path);
|
|
||||||
|
|
||||||
if (saveLibraryOptions)
|
if (saveLibraryOptions)
|
||||||
{
|
{
|
||||||
@@ -3353,5 +3366,24 @@ namespace Emby.Server.Implementations.Library
|
|||||||
|
|
||||||
return item is UserRootFolder || item.IsVisibleStandalone(user);
|
return item is UserRootFolder || item.IsVisibleStandalone(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void CreateShortcut(string virtualFolderPath, MediaPathInfo pathInfo)
|
||||||
|
{
|
||||||
|
var path = pathInfo.Path;
|
||||||
|
var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath;
|
||||||
|
|
||||||
|
var shortcutFilename = Path.GetFileNameWithoutExtension(path);
|
||||||
|
|
||||||
|
var lnk = Path.Combine(virtualFolderPath, shortcutFilename + ShortcutFileExtension);
|
||||||
|
|
||||||
|
while (File.Exists(lnk))
|
||||||
|
{
|
||||||
|
shortcutFilename += "1";
|
||||||
|
lnk = Path.Combine(virtualFolderPath, shortcutFilename + ShortcutFileExtension);
|
||||||
|
}
|
||||||
|
|
||||||
|
_fileSystem.CreateShortcut(lnk, _appHost.ReverseVirtualPath(path));
|
||||||
|
RemoveContentTypeOverrides(path);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -226,6 +226,11 @@ namespace Emby.Server.Implementations.Library
|
|||||||
/// <inheritdoc />>
|
/// <inheritdoc />>
|
||||||
public MediaProtocol GetPathProtocol(string path)
|
public MediaProtocol GetPathProtocol(string path)
|
||||||
{
|
{
|
||||||
|
if (string.IsNullOrEmpty(path))
|
||||||
|
{
|
||||||
|
return MediaProtocol.File;
|
||||||
|
}
|
||||||
|
|
||||||
if (path.StartsWith("Rtsp", StringComparison.OrdinalIgnoreCase))
|
if (path.StartsWith("Rtsp", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
return MediaProtocol.Rtsp;
|
return MediaProtocol.Rtsp;
|
||||||
|
|||||||
@@ -28,7 +28,9 @@ namespace Emby.Server.Implementations.Library
|
|||||||
|
|
||||||
public IReadOnlyList<BaseItem> GetInstantMixFromSong(Audio item, User? user, DtoOptions dtoOptions)
|
public IReadOnlyList<BaseItem> GetInstantMixFromSong(Audio item, User? user, DtoOptions dtoOptions)
|
||||||
{
|
{
|
||||||
return GetInstantMixFromGenres(item.Genres, user, dtoOptions);
|
var instantMixItems = GetInstantMixFromGenres(item.Genres, user, dtoOptions);
|
||||||
|
|
||||||
|
return [item, .. instantMixItems.Where(i => !i.Id.Equals(item.Id))];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ using System;
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using Jellyfin.Extensions;
|
||||||
using MediaBrowser.Common.Configuration;
|
using MediaBrowser.Common.Configuration;
|
||||||
using MediaBrowser.Controller.Configuration;
|
using MediaBrowser.Controller.Configuration;
|
||||||
using MediaBrowser.Controller.Entities;
|
using MediaBrowser.Controller.Entities;
|
||||||
using MediaBrowser.Controller.IO;
|
using MediaBrowser.Controller.IO;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace Emby.Server.Implementations.Library;
|
namespace Emby.Server.Implementations.Library;
|
||||||
|
|
||||||
@@ -14,18 +16,22 @@ namespace Emby.Server.Implementations.Library;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class PathManager : IPathManager
|
public class PathManager : IPathManager
|
||||||
{
|
{
|
||||||
|
private readonly ILogger<PathManager> _logger;
|
||||||
private readonly IServerConfigurationManager _config;
|
private readonly IServerConfigurationManager _config;
|
||||||
private readonly IApplicationPaths _appPaths;
|
private readonly IApplicationPaths _appPaths;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="PathManager"/> class.
|
/// Initializes a new instance of the <see cref="PathManager"/> class.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="logger">The logger.</param>
|
||||||
/// <param name="config">The server configuration manager.</param>
|
/// <param name="config">The server configuration manager.</param>
|
||||||
/// <param name="appPaths">The application paths.</param>
|
/// <param name="appPaths">The application paths.</param>
|
||||||
public PathManager(
|
public PathManager(
|
||||||
|
ILogger<PathManager> logger,
|
||||||
IServerConfigurationManager config,
|
IServerConfigurationManager config,
|
||||||
IApplicationPaths appPaths)
|
IApplicationPaths appPaths)
|
||||||
{
|
{
|
||||||
|
_logger = logger;
|
||||||
_config = config;
|
_config = config;
|
||||||
_appPaths = appPaths;
|
_appPaths = appPaths;
|
||||||
}
|
}
|
||||||
@@ -35,9 +41,16 @@ public class PathManager : IPathManager
|
|||||||
private string AttachmentCachePath => Path.Combine(_appPaths.DataPath, "attachments");
|
private string AttachmentCachePath => Path.Combine(_appPaths.DataPath, "attachments");
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public string GetAttachmentPath(string mediaSourceId, string fileName)
|
public string? GetAttachmentPath(string mediaSourceId, string fileName)
|
||||||
{
|
{
|
||||||
return Path.Combine(GetAttachmentFolderPath(mediaSourceId), fileName);
|
var safeName = PathHelper.GetSafeLeafFileName(fileName);
|
||||||
|
if (safeName is null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Rejecting attachment filename '{FileName}' for MediaSource {MediaSourceId}: not a valid leaf name.", fileName, mediaSourceId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Path.Combine(GetAttachmentFolderPath(mediaSourceId), safeName);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|||||||
@@ -369,13 +369,16 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies
|
|||||||
// We need to only look at the name of this actual item (not parents)
|
// We need to only look at the name of this actual item (not parents)
|
||||||
var justName = item.IsInMixedFolder ? Path.GetFileName(item.Path.AsSpan()) : Path.GetFileName(item.ContainingFolderPath.AsSpan());
|
var justName = item.IsInMixedFolder ? Path.GetFileName(item.Path.AsSpan()) : Path.GetFileName(item.ContainingFolderPath.AsSpan());
|
||||||
|
|
||||||
if (!justName.IsEmpty)
|
|
||||||
{
|
|
||||||
// Check for TMDb id
|
|
||||||
var tmdbid = justName.GetAttributeValue("tmdbid");
|
var tmdbid = justName.GetAttributeValue("tmdbid");
|
||||||
item.TrySetProviderId(MetadataProvider.Tmdb, tmdbid);
|
|
||||||
|
// If not in a mixed folder and ID not found in folder path, check filename
|
||||||
|
if (string.IsNullOrEmpty(tmdbid) && !item.IsInMixedFolder)
|
||||||
|
{
|
||||||
|
tmdbid = Path.GetFileName(item.Path.AsSpan()).GetAttributeValue("tmdbid");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
item.TrySetProviderId(MetadataProvider.Tmdb, tmdbid);
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(item.Path))
|
if (!string.IsNullOrEmpty(item.Path))
|
||||||
{
|
{
|
||||||
// Check for IMDb id - we use full media path, as we can assume that this will match in any use case (whether id in parent dir or in file name)
|
// Check for IMDb id - we use full media path, as we can assume that this will match in any use case (whether id in parent dir or in file name)
|
||||||
|
|||||||
@@ -236,12 +236,16 @@ namespace Emby.Server.Implementations.Library
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public UserItemData? GetUserData(User user, BaseItem item)
|
public UserItemData GetUserData(User user, BaseItem item)
|
||||||
{
|
{
|
||||||
return item.UserData?.Where(e => e.UserId.Equals(user.Id)).Select(Map).FirstOrDefault() ?? new UserItemData()
|
var cacheKey = GetCacheKey(user.InternalId, item.Id);
|
||||||
|
return _cache.GetOrAdd(
|
||||||
|
cacheKey,
|
||||||
|
(k, i) => i.UserData?.Where(e => e.UserId.Equals(user.Id)).Select(Map).FirstOrDefault() ?? new UserItemData()
|
||||||
{
|
{
|
||||||
Key = item.GetUserDataKeys()[0],
|
Key = i.GetUserDataKeys()[0],
|
||||||
};
|
},
|
||||||
|
item);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ namespace Emby.Server.Implementations.Localization
|
|||||||
|
|
||||||
private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
|
private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
|
||||||
|
|
||||||
|
private readonly ConcurrentDictionary<string, CultureDto?> _cultureCache = new(StringComparer.OrdinalIgnoreCase);
|
||||||
private List<CultureDto> _cultures = [];
|
private List<CultureDto> _cultures = [];
|
||||||
|
|
||||||
private FrozenDictionary<string, string> _iso6392BtoT = null!;
|
private FrozenDictionary<string, string> _iso6392BtoT = null!;
|
||||||
@@ -161,6 +162,7 @@ namespace Emby.Server.Implementations.Localization
|
|||||||
list.Add(new CultureDto(name, displayname, twoCharName, threeLetterNames));
|
list.Add(new CultureDto(name, displayname, twoCharName, threeLetterNames));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_cultureCache.Clear();
|
||||||
_cultures = list;
|
_cultures = list;
|
||||||
_iso6392BtoT = iso6392BtoTdict.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
|
_iso6392BtoT = iso6392BtoTdict.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
@@ -169,20 +171,31 @@ namespace Emby.Server.Implementations.Localization
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public CultureDto? FindLanguageInfo(string language)
|
public CultureDto? FindLanguageInfo(string language)
|
||||||
{
|
{
|
||||||
// TODO language should ideally be a ReadOnlySpan but moq cannot mock ref structs
|
if (string.IsNullOrEmpty(language))
|
||||||
for (var i = 0; i < _cultures.Count; i++)
|
|
||||||
{
|
{
|
||||||
var culture = _cultures[i];
|
return null;
|
||||||
if (language.Equals(culture.DisplayName, StringComparison.OrdinalIgnoreCase)
|
}
|
||||||
|| language.Equals(culture.Name, StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| culture.ThreeLetterISOLanguageNames.Contains(language, StringComparison.OrdinalIgnoreCase)
|
return _cultureCache.GetOrAdd(
|
||||||
|| language.Equals(culture.TwoLetterISOLanguageName, StringComparison.OrdinalIgnoreCase))
|
language,
|
||||||
|
static (lang, cultures) =>
|
||||||
|
{
|
||||||
|
// TODO language should ideally be a ReadOnlySpan but moq cannot mock ref structs
|
||||||
|
for (var i = 0; i < cultures.Count; i++)
|
||||||
|
{
|
||||||
|
var culture = cultures[i];
|
||||||
|
if (lang.Equals(culture.DisplayName, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| lang.Equals(culture.Name, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| culture.ThreeLetterISOLanguageNames.Contains(lang, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| lang.Equals(culture.TwoLetterISOLanguageName, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
return culture;
|
return culture;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return default;
|
return null;
|
||||||
|
},
|
||||||
|
_cultures);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -311,15 +324,19 @@ namespace Emby.Server.Implementations.Localization
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Fall back to server default language for ratings check
|
// Fall back to server default language for ratings check
|
||||||
// If it has no ratings, use the US ratings
|
var ratingsDictionary = GetParentalRatingsDictionary();
|
||||||
var ratingsDictionary = GetParentalRatingsDictionary() ?? GetParentalRatingsDictionary("us");
|
|
||||||
if (ratingsDictionary is not null && ratingsDictionary.TryGetValue(rating, out ParentalRatingScore? value))
|
if (ratingsDictionary is not null && ratingsDictionary.TryGetValue(rating, out ParentalRatingScore? value))
|
||||||
{
|
{
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we don't find anything, check all ratings systems
|
// If we don't find anything, check all ratings systems, starting with US
|
||||||
|
if (_allParentalRatings.TryGetValue("us", out var usRatings) && usRatings.TryGetValue(rating, out var usValue))
|
||||||
|
{
|
||||||
|
return usValue;
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var dictionary in _allParentalRatings.Values)
|
foreach (var dictionary in _allParentalRatings.Values)
|
||||||
{
|
{
|
||||||
if (dictionary.TryGetValue(rating, out var value))
|
if (dictionary.TryGetValue(rating, out var value))
|
||||||
|
|||||||
@@ -347,8 +347,8 @@ pli||pi|Pali|pali
|
|||||||
pol||pl|Polish|polonais
|
pol||pl|Polish|polonais
|
||||||
pon|||Pohnpeian|pohnpei
|
pon|||Pohnpeian|pohnpei
|
||||||
por||pt|Portuguese|portugais
|
por||pt|Portuguese|portugais
|
||||||
por||pt-pt|Portuguese (Portugal)|portugais (pt-pt)
|
pop||pt-pt|Portuguese (Portugal)|portugais (pt-pt)
|
||||||
por||pt-br|Portuguese (Brazil)|portugais (pt-br)
|
pob||pt-br|Portuguese (Brazil)|portugais (pt-br)
|
||||||
pra|||Prakrit languages|prâkrit, langues
|
pra|||Prakrit languages|prâkrit, langues
|
||||||
pro|||Provençal, Old (to 1500)|provençal ancien (jusqu'à 1500)
|
pro|||Provençal, Old (to 1500)|provençal ancien (jusqu'à 1500)
|
||||||
pus||ps|Pushto; Pashto|pachto
|
pus||ps|Pushto; Pashto|pachto
|
||||||
|
|||||||
@@ -244,6 +244,7 @@ namespace Emby.Server.Implementations.Playlists
|
|||||||
|
|
||||||
// Update the playlist in the repository
|
// Update the playlist in the repository
|
||||||
playlist.LinkedChildren = [.. playlist.LinkedChildren, .. childrenToAdd];
|
playlist.LinkedChildren = [.. playlist.LinkedChildren, .. childrenToAdd];
|
||||||
|
playlist.DateLastMediaAdded = DateTime.UtcNow;
|
||||||
|
|
||||||
await UpdatePlaylistInternal(playlist).ConfigureAwait(false);
|
await UpdatePlaylistInternal(playlist).ConfigureAwait(false);
|
||||||
|
|
||||||
|
|||||||
@@ -271,9 +271,9 @@ namespace Emby.Server.Implementations.Session
|
|||||||
user.LastActivityDate = activityDate;
|
user.LastActivityDate = activityDate;
|
||||||
await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
|
await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
catch (DbUpdateConcurrencyException e)
|
catch (DbUpdateConcurrencyException)
|
||||||
{
|
{
|
||||||
_logger.LogDebug(e, "Error updating user's last activity date.");
|
_logger.LogDebug("Error updating user's last activity date due to concurrency conflict. This is an expected event.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1175,7 +1175,8 @@ namespace Emby.Server.Implementations.Session
|
|||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
private SessionInfoDto ToSessionInfoDto(SessionInfo sessionInfo)
|
/// <inheritdoc />
|
||||||
|
public SessionInfoDto ToSessionInfoDto(SessionInfo sessionInfo)
|
||||||
{
|
{
|
||||||
return new SessionInfoDto
|
return new SessionInfoDto
|
||||||
{
|
{
|
||||||
@@ -2042,7 +2043,7 @@ namespace Emby.Server.Implementations.Session
|
|||||||
{
|
{
|
||||||
CheckDisposed();
|
CheckDisposed();
|
||||||
|
|
||||||
var adminUserIds = _userManager.Users
|
var adminUserIds = _userManager.GetUsers()
|
||||||
.Where(i => i.HasPermission(PermissionKind.IsAdministrator))
|
.Where(i => i.HasPermission(PermissionKind.IsAdministrator))
|
||||||
.Select(i => i.Id)
|
.Select(i => i.Id)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|||||||
@@ -156,6 +156,11 @@ namespace Emby.Server.Implementations.Updates
|
|||||||
_logger.LogError(ex, "The URL configured for the plugin repository manifest URL is not valid: {Manifest}", manifest);
|
_logger.LogError(ex, "The URL configured for the plugin repository manifest URL is not valid: {Manifest}", manifest);
|
||||||
return Array.Empty<PackageInfo>();
|
return Array.Empty<PackageInfo>();
|
||||||
}
|
}
|
||||||
|
catch (NotSupportedException ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "The URL scheme configured for the plugin repository is not supported: {Manifest}", manifest);
|
||||||
|
return Array.Empty<PackageInfo>();
|
||||||
|
}
|
||||||
catch (HttpRequestException ex)
|
catch (HttpRequestException ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "An error occurred while accessing the plugin manifest: {Manifest}", manifest);
|
_logger.LogError(ex, "An error occurred while accessing the plugin manifest: {Manifest}", manifest);
|
||||||
@@ -223,15 +228,14 @@ namespace Emby.Server.Implementations.Updates
|
|||||||
Guid id = default,
|
Guid id = default,
|
||||||
Version? specificVersion = null)
|
Version? specificVersion = null)
|
||||||
{
|
{
|
||||||
if (name is not null)
|
|
||||||
{
|
|
||||||
availablePackages = availablePackages.Where(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!id.IsEmpty())
|
if (!id.IsEmpty())
|
||||||
{
|
{
|
||||||
availablePackages = availablePackages.Where(x => x.Id.Equals(id));
|
availablePackages = availablePackages.Where(x => x.Id.Equals(id));
|
||||||
}
|
}
|
||||||
|
else if (name is not null)
|
||||||
|
{
|
||||||
|
availablePackages = availablePackages.Where(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
if (specificVersion is not null)
|
if (specificVersion is not null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -92,18 +92,18 @@ public class AudioController : BaseJellyfinApiController
|
|||||||
[ProducesAudioFile]
|
[ProducesAudioFile]
|
||||||
public async Task<ActionResult> GetAudioStream(
|
public async Task<ActionResult> GetAudioStream(
|
||||||
[FromRoute, Required] Guid itemId,
|
[FromRoute, Required] Guid itemId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? container,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? container,
|
||||||
[FromQuery] bool? @static,
|
[FromQuery] bool? @static,
|
||||||
[FromQuery] string? @params,
|
[FromQuery] string? @params,
|
||||||
[FromQuery] string? tag,
|
[FromQuery] string? tag,
|
||||||
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
||||||
[FromQuery] string? playSessionId,
|
[FromQuery] string? playSessionId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
|
||||||
[FromQuery] int? segmentLength,
|
[FromQuery] int? segmentLength,
|
||||||
[FromQuery] int? minSegments,
|
[FromQuery] int? minSegments,
|
||||||
[FromQuery] string? mediaSourceId,
|
[FromQuery] string? mediaSourceId,
|
||||||
[FromQuery] string? deviceId,
|
[FromQuery] string? deviceId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
|
||||||
[FromQuery] bool? enableAutoStreamCopy,
|
[FromQuery] bool? enableAutoStreamCopy,
|
||||||
[FromQuery] bool? allowVideoStreamCopy,
|
[FromQuery] bool? allowVideoStreamCopy,
|
||||||
[FromQuery] bool? allowAudioStreamCopy,
|
[FromQuery] bool? allowAudioStreamCopy,
|
||||||
@@ -114,7 +114,7 @@ public class AudioController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? audioChannels,
|
[FromQuery] int? audioChannels,
|
||||||
[FromQuery] int? maxAudioChannels,
|
[FromQuery] int? maxAudioChannels,
|
||||||
[FromQuery] string? profile,
|
[FromQuery] string? profile,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
|
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
|
||||||
[FromQuery] float? framerate,
|
[FromQuery] float? framerate,
|
||||||
[FromQuery] float? maxFramerate,
|
[FromQuery] float? maxFramerate,
|
||||||
[FromQuery] bool? copyTimestamps,
|
[FromQuery] bool? copyTimestamps,
|
||||||
@@ -133,8 +133,8 @@ public class AudioController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? cpuCoreLimit,
|
[FromQuery] int? cpuCoreLimit,
|
||||||
[FromQuery] string? liveStreamId,
|
[FromQuery] string? liveStreamId,
|
||||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
|
||||||
[FromQuery] string? transcodeReasons,
|
[FromQuery] string? transcodeReasons,
|
||||||
[FromQuery] int? audioStreamIndex,
|
[FromQuery] int? audioStreamIndex,
|
||||||
[FromQuery] int? videoStreamIndex,
|
[FromQuery] int? videoStreamIndex,
|
||||||
@@ -259,18 +259,18 @@ public class AudioController : BaseJellyfinApiController
|
|||||||
[ProducesAudioFile]
|
[ProducesAudioFile]
|
||||||
public async Task<ActionResult> GetAudioStreamByContainer(
|
public async Task<ActionResult> GetAudioStreamByContainer(
|
||||||
[FromRoute, Required] Guid itemId,
|
[FromRoute, Required] Guid itemId,
|
||||||
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string container,
|
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string container,
|
||||||
[FromQuery] bool? @static,
|
[FromQuery] bool? @static,
|
||||||
[FromQuery] string? @params,
|
[FromQuery] string? @params,
|
||||||
[FromQuery] string? tag,
|
[FromQuery] string? tag,
|
||||||
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
||||||
[FromQuery] string? playSessionId,
|
[FromQuery] string? playSessionId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
|
||||||
[FromQuery] int? segmentLength,
|
[FromQuery] int? segmentLength,
|
||||||
[FromQuery] int? minSegments,
|
[FromQuery] int? minSegments,
|
||||||
[FromQuery] string? mediaSourceId,
|
[FromQuery] string? mediaSourceId,
|
||||||
[FromQuery] string? deviceId,
|
[FromQuery] string? deviceId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
|
||||||
[FromQuery] bool? enableAutoStreamCopy,
|
[FromQuery] bool? enableAutoStreamCopy,
|
||||||
[FromQuery] bool? allowVideoStreamCopy,
|
[FromQuery] bool? allowVideoStreamCopy,
|
||||||
[FromQuery] bool? allowAudioStreamCopy,
|
[FromQuery] bool? allowAudioStreamCopy,
|
||||||
@@ -281,7 +281,7 @@ public class AudioController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? audioChannels,
|
[FromQuery] int? audioChannels,
|
||||||
[FromQuery] int? maxAudioChannels,
|
[FromQuery] int? maxAudioChannels,
|
||||||
[FromQuery] string? profile,
|
[FromQuery] string? profile,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
|
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
|
||||||
[FromQuery] float? framerate,
|
[FromQuery] float? framerate,
|
||||||
[FromQuery] float? maxFramerate,
|
[FromQuery] float? maxFramerate,
|
||||||
[FromQuery] bool? copyTimestamps,
|
[FromQuery] bool? copyTimestamps,
|
||||||
@@ -300,8 +300,8 @@ public class AudioController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? cpuCoreLimit,
|
[FromQuery] int? cpuCoreLimit,
|
||||||
[FromQuery] string? liveStreamId,
|
[FromQuery] string? liveStreamId,
|
||||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
|
||||||
[FromQuery] string? transcodeReasons,
|
[FromQuery] string? transcodeReasons,
|
||||||
[FromQuery] int? audioStreamIndex,
|
[FromQuery] int? audioStreamIndex,
|
||||||
[FromQuery] int? videoStreamIndex,
|
[FromQuery] int? videoStreamIndex,
|
||||||
|
|||||||
@@ -167,18 +167,18 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[ProducesPlaylistFile]
|
[ProducesPlaylistFile]
|
||||||
public async Task<ActionResult> GetLiveHlsStream(
|
public async Task<ActionResult> GetLiveHlsStream(
|
||||||
[FromRoute, Required] Guid itemId,
|
[FromRoute, Required] Guid itemId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? container,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? container,
|
||||||
[FromQuery] bool? @static,
|
[FromQuery] bool? @static,
|
||||||
[FromQuery] string? @params,
|
[FromQuery] string? @params,
|
||||||
[FromQuery] string? tag,
|
[FromQuery] string? tag,
|
||||||
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
||||||
[FromQuery] string? playSessionId,
|
[FromQuery] string? playSessionId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
|
||||||
[FromQuery] int? segmentLength,
|
[FromQuery] int? segmentLength,
|
||||||
[FromQuery] int? minSegments,
|
[FromQuery] int? minSegments,
|
||||||
[FromQuery] string? mediaSourceId,
|
[FromQuery] string? mediaSourceId,
|
||||||
[FromQuery] string? deviceId,
|
[FromQuery] string? deviceId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
|
||||||
[FromQuery] bool? enableAutoStreamCopy,
|
[FromQuery] bool? enableAutoStreamCopy,
|
||||||
[FromQuery] bool? allowVideoStreamCopy,
|
[FromQuery] bool? allowVideoStreamCopy,
|
||||||
[FromQuery] bool? allowAudioStreamCopy,
|
[FromQuery] bool? allowAudioStreamCopy,
|
||||||
@@ -189,7 +189,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? audioChannels,
|
[FromQuery] int? audioChannels,
|
||||||
[FromQuery] int? maxAudioChannels,
|
[FromQuery] int? maxAudioChannels,
|
||||||
[FromQuery] string? profile,
|
[FromQuery] string? profile,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
|
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
|
||||||
[FromQuery] float? framerate,
|
[FromQuery] float? framerate,
|
||||||
[FromQuery] float? maxFramerate,
|
[FromQuery] float? maxFramerate,
|
||||||
[FromQuery] bool? copyTimestamps,
|
[FromQuery] bool? copyTimestamps,
|
||||||
@@ -208,8 +208,8 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? cpuCoreLimit,
|
[FromQuery] int? cpuCoreLimit,
|
||||||
[FromQuery] string? liveStreamId,
|
[FromQuery] string? liveStreamId,
|
||||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
|
||||||
[FromQuery] string? transcodeReasons,
|
[FromQuery] string? transcodeReasons,
|
||||||
[FromQuery] int? audioStreamIndex,
|
[FromQuery] int? audioStreamIndex,
|
||||||
[FromQuery] int? videoStreamIndex,
|
[FromQuery] int? videoStreamIndex,
|
||||||
@@ -416,12 +416,12 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] string? tag,
|
[FromQuery] string? tag,
|
||||||
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
||||||
[FromQuery] string? playSessionId,
|
[FromQuery] string? playSessionId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
|
||||||
[FromQuery] int? segmentLength,
|
[FromQuery] int? segmentLength,
|
||||||
[FromQuery] int? minSegments,
|
[FromQuery] int? minSegments,
|
||||||
[FromQuery, Required] string mediaSourceId,
|
[FromQuery, Required] string mediaSourceId,
|
||||||
[FromQuery] string? deviceId,
|
[FromQuery] string? deviceId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
|
||||||
[FromQuery] bool? enableAutoStreamCopy,
|
[FromQuery] bool? enableAutoStreamCopy,
|
||||||
[FromQuery] bool? allowVideoStreamCopy,
|
[FromQuery] bool? allowVideoStreamCopy,
|
||||||
[FromQuery] bool? allowAudioStreamCopy,
|
[FromQuery] bool? allowAudioStreamCopy,
|
||||||
@@ -432,7 +432,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? audioChannels,
|
[FromQuery] int? audioChannels,
|
||||||
[FromQuery] int? maxAudioChannels,
|
[FromQuery] int? maxAudioChannels,
|
||||||
[FromQuery] string? profile,
|
[FromQuery] string? profile,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
|
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
|
||||||
[FromQuery] float? framerate,
|
[FromQuery] float? framerate,
|
||||||
[FromQuery] float? maxFramerate,
|
[FromQuery] float? maxFramerate,
|
||||||
[FromQuery] bool? copyTimestamps,
|
[FromQuery] bool? copyTimestamps,
|
||||||
@@ -453,8 +453,8 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? cpuCoreLimit,
|
[FromQuery] int? cpuCoreLimit,
|
||||||
[FromQuery] string? liveStreamId,
|
[FromQuery] string? liveStreamId,
|
||||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
|
||||||
[FromQuery] string? transcodeReasons,
|
[FromQuery] string? transcodeReasons,
|
||||||
[FromQuery] int? audioStreamIndex,
|
[FromQuery] int? audioStreamIndex,
|
||||||
[FromQuery] int? videoStreamIndex,
|
[FromQuery] int? videoStreamIndex,
|
||||||
@@ -592,12 +592,12 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] string? tag,
|
[FromQuery] string? tag,
|
||||||
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
||||||
[FromQuery] string? playSessionId,
|
[FromQuery] string? playSessionId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
|
||||||
[FromQuery] int? segmentLength,
|
[FromQuery] int? segmentLength,
|
||||||
[FromQuery] int? minSegments,
|
[FromQuery] int? minSegments,
|
||||||
[FromQuery, Required] string mediaSourceId,
|
[FromQuery, Required] string mediaSourceId,
|
||||||
[FromQuery] string? deviceId,
|
[FromQuery] string? deviceId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
|
||||||
[FromQuery] bool? enableAutoStreamCopy,
|
[FromQuery] bool? enableAutoStreamCopy,
|
||||||
[FromQuery] bool? allowVideoStreamCopy,
|
[FromQuery] bool? allowVideoStreamCopy,
|
||||||
[FromQuery] bool? allowAudioStreamCopy,
|
[FromQuery] bool? allowAudioStreamCopy,
|
||||||
@@ -609,7 +609,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? audioChannels,
|
[FromQuery] int? audioChannels,
|
||||||
[FromQuery] int? maxAudioChannels,
|
[FromQuery] int? maxAudioChannels,
|
||||||
[FromQuery] string? profile,
|
[FromQuery] string? profile,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
|
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
|
||||||
[FromQuery] float? framerate,
|
[FromQuery] float? framerate,
|
||||||
[FromQuery] float? maxFramerate,
|
[FromQuery] float? maxFramerate,
|
||||||
[FromQuery] bool? copyTimestamps,
|
[FromQuery] bool? copyTimestamps,
|
||||||
@@ -628,8 +628,8 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? cpuCoreLimit,
|
[FromQuery] int? cpuCoreLimit,
|
||||||
[FromQuery] string? liveStreamId,
|
[FromQuery] string? liveStreamId,
|
||||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
|
||||||
[FromQuery] string? transcodeReasons,
|
[FromQuery] string? transcodeReasons,
|
||||||
[FromQuery] int? audioStreamIndex,
|
[FromQuery] int? audioStreamIndex,
|
||||||
[FromQuery] int? videoStreamIndex,
|
[FromQuery] int? videoStreamIndex,
|
||||||
@@ -762,12 +762,12 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] string? tag,
|
[FromQuery] string? tag,
|
||||||
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
||||||
[FromQuery] string? playSessionId,
|
[FromQuery] string? playSessionId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
|
||||||
[FromQuery] int? segmentLength,
|
[FromQuery] int? segmentLength,
|
||||||
[FromQuery] int? minSegments,
|
[FromQuery] int? minSegments,
|
||||||
[FromQuery] string? mediaSourceId,
|
[FromQuery] string? mediaSourceId,
|
||||||
[FromQuery] string? deviceId,
|
[FromQuery] string? deviceId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
|
||||||
[FromQuery] bool? enableAutoStreamCopy,
|
[FromQuery] bool? enableAutoStreamCopy,
|
||||||
[FromQuery] bool? allowVideoStreamCopy,
|
[FromQuery] bool? allowVideoStreamCopy,
|
||||||
[FromQuery] bool? allowAudioStreamCopy,
|
[FromQuery] bool? allowAudioStreamCopy,
|
||||||
@@ -778,7 +778,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? audioChannels,
|
[FromQuery] int? audioChannels,
|
||||||
[FromQuery] int? maxAudioChannels,
|
[FromQuery] int? maxAudioChannels,
|
||||||
[FromQuery] string? profile,
|
[FromQuery] string? profile,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
|
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
|
||||||
[FromQuery] float? framerate,
|
[FromQuery] float? framerate,
|
||||||
[FromQuery] float? maxFramerate,
|
[FromQuery] float? maxFramerate,
|
||||||
[FromQuery] bool? copyTimestamps,
|
[FromQuery] bool? copyTimestamps,
|
||||||
@@ -799,8 +799,8 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? cpuCoreLimit,
|
[FromQuery] int? cpuCoreLimit,
|
||||||
[FromQuery] string? liveStreamId,
|
[FromQuery] string? liveStreamId,
|
||||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
|
||||||
[FromQuery] string? transcodeReasons,
|
[FromQuery] string? transcodeReasons,
|
||||||
[FromQuery] int? audioStreamIndex,
|
[FromQuery] int? audioStreamIndex,
|
||||||
[FromQuery] int? videoStreamIndex,
|
[FromQuery] int? videoStreamIndex,
|
||||||
@@ -934,12 +934,12 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] string? tag,
|
[FromQuery] string? tag,
|
||||||
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
||||||
[FromQuery] string? playSessionId,
|
[FromQuery] string? playSessionId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
|
||||||
[FromQuery] int? segmentLength,
|
[FromQuery] int? segmentLength,
|
||||||
[FromQuery] int? minSegments,
|
[FromQuery] int? minSegments,
|
||||||
[FromQuery] string? mediaSourceId,
|
[FromQuery] string? mediaSourceId,
|
||||||
[FromQuery] string? deviceId,
|
[FromQuery] string? deviceId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
|
||||||
[FromQuery] bool? enableAutoStreamCopy,
|
[FromQuery] bool? enableAutoStreamCopy,
|
||||||
[FromQuery] bool? allowVideoStreamCopy,
|
[FromQuery] bool? allowVideoStreamCopy,
|
||||||
[FromQuery] bool? allowAudioStreamCopy,
|
[FromQuery] bool? allowAudioStreamCopy,
|
||||||
@@ -951,7 +951,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? audioChannels,
|
[FromQuery] int? audioChannels,
|
||||||
[FromQuery] int? maxAudioChannels,
|
[FromQuery] int? maxAudioChannels,
|
||||||
[FromQuery] string? profile,
|
[FromQuery] string? profile,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
|
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
|
||||||
[FromQuery] float? framerate,
|
[FromQuery] float? framerate,
|
||||||
[FromQuery] float? maxFramerate,
|
[FromQuery] float? maxFramerate,
|
||||||
[FromQuery] bool? copyTimestamps,
|
[FromQuery] bool? copyTimestamps,
|
||||||
@@ -970,8 +970,8 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? cpuCoreLimit,
|
[FromQuery] int? cpuCoreLimit,
|
||||||
[FromQuery] string? liveStreamId,
|
[FromQuery] string? liveStreamId,
|
||||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
|
||||||
[FromQuery] string? transcodeReasons,
|
[FromQuery] string? transcodeReasons,
|
||||||
[FromQuery] int? audioStreamIndex,
|
[FromQuery] int? audioStreamIndex,
|
||||||
[FromQuery] int? videoStreamIndex,
|
[FromQuery] int? videoStreamIndex,
|
||||||
@@ -1107,7 +1107,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromRoute, Required] Guid itemId,
|
[FromRoute, Required] Guid itemId,
|
||||||
[FromRoute, Required] string playlistId,
|
[FromRoute, Required] string playlistId,
|
||||||
[FromRoute, Required] int segmentId,
|
[FromRoute, Required] int segmentId,
|
||||||
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string container,
|
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string container,
|
||||||
[FromQuery, Required] long runtimeTicks,
|
[FromQuery, Required] long runtimeTicks,
|
||||||
[FromQuery, Required] long actualSegmentLengthTicks,
|
[FromQuery, Required] long actualSegmentLengthTicks,
|
||||||
[FromQuery] bool? @static,
|
[FromQuery] bool? @static,
|
||||||
@@ -1115,12 +1115,12 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] string? tag,
|
[FromQuery] string? tag,
|
||||||
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
||||||
[FromQuery] string? playSessionId,
|
[FromQuery] string? playSessionId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
|
||||||
[FromQuery] int? segmentLength,
|
[FromQuery] int? segmentLength,
|
||||||
[FromQuery] int? minSegments,
|
[FromQuery] int? minSegments,
|
||||||
[FromQuery] string? mediaSourceId,
|
[FromQuery] string? mediaSourceId,
|
||||||
[FromQuery] string? deviceId,
|
[FromQuery] string? deviceId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
|
||||||
[FromQuery] bool? enableAutoStreamCopy,
|
[FromQuery] bool? enableAutoStreamCopy,
|
||||||
[FromQuery] bool? allowVideoStreamCopy,
|
[FromQuery] bool? allowVideoStreamCopy,
|
||||||
[FromQuery] bool? allowAudioStreamCopy,
|
[FromQuery] bool? allowAudioStreamCopy,
|
||||||
@@ -1131,7 +1131,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? audioChannels,
|
[FromQuery] int? audioChannels,
|
||||||
[FromQuery] int? maxAudioChannels,
|
[FromQuery] int? maxAudioChannels,
|
||||||
[FromQuery] string? profile,
|
[FromQuery] string? profile,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
|
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
|
||||||
[FromQuery] float? framerate,
|
[FromQuery] float? framerate,
|
||||||
[FromQuery] float? maxFramerate,
|
[FromQuery] float? maxFramerate,
|
||||||
[FromQuery] bool? copyTimestamps,
|
[FromQuery] bool? copyTimestamps,
|
||||||
@@ -1152,8 +1152,8 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? cpuCoreLimit,
|
[FromQuery] int? cpuCoreLimit,
|
||||||
[FromQuery] string? liveStreamId,
|
[FromQuery] string? liveStreamId,
|
||||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
|
||||||
[FromQuery] string? transcodeReasons,
|
[FromQuery] string? transcodeReasons,
|
||||||
[FromQuery] int? audioStreamIndex,
|
[FromQuery] int? audioStreamIndex,
|
||||||
[FromQuery] int? videoStreamIndex,
|
[FromQuery] int? videoStreamIndex,
|
||||||
@@ -1292,7 +1292,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromRoute, Required] Guid itemId,
|
[FromRoute, Required] Guid itemId,
|
||||||
[FromRoute, Required] string playlistId,
|
[FromRoute, Required] string playlistId,
|
||||||
[FromRoute, Required] int segmentId,
|
[FromRoute, Required] int segmentId,
|
||||||
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string container,
|
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string container,
|
||||||
[FromQuery, Required] long runtimeTicks,
|
[FromQuery, Required] long runtimeTicks,
|
||||||
[FromQuery, Required] long actualSegmentLengthTicks,
|
[FromQuery, Required] long actualSegmentLengthTicks,
|
||||||
[FromQuery] bool? @static,
|
[FromQuery] bool? @static,
|
||||||
@@ -1300,12 +1300,12 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] string? tag,
|
[FromQuery] string? tag,
|
||||||
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
||||||
[FromQuery] string? playSessionId,
|
[FromQuery] string? playSessionId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
|
||||||
[FromQuery] int? segmentLength,
|
[FromQuery] int? segmentLength,
|
||||||
[FromQuery] int? minSegments,
|
[FromQuery] int? minSegments,
|
||||||
[FromQuery] string? mediaSourceId,
|
[FromQuery] string? mediaSourceId,
|
||||||
[FromQuery] string? deviceId,
|
[FromQuery] string? deviceId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
|
||||||
[FromQuery] bool? enableAutoStreamCopy,
|
[FromQuery] bool? enableAutoStreamCopy,
|
||||||
[FromQuery] bool? allowVideoStreamCopy,
|
[FromQuery] bool? allowVideoStreamCopy,
|
||||||
[FromQuery] bool? allowAudioStreamCopy,
|
[FromQuery] bool? allowAudioStreamCopy,
|
||||||
@@ -1317,7 +1317,7 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? audioChannels,
|
[FromQuery] int? audioChannels,
|
||||||
[FromQuery] int? maxAudioChannels,
|
[FromQuery] int? maxAudioChannels,
|
||||||
[FromQuery] string? profile,
|
[FromQuery] string? profile,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
|
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
|
||||||
[FromQuery] float? framerate,
|
[FromQuery] float? framerate,
|
||||||
[FromQuery] float? maxFramerate,
|
[FromQuery] float? maxFramerate,
|
||||||
[FromQuery] bool? copyTimestamps,
|
[FromQuery] bool? copyTimestamps,
|
||||||
@@ -1336,8 +1336,8 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? cpuCoreLimit,
|
[FromQuery] int? cpuCoreLimit,
|
||||||
[FromQuery] string? liveStreamId,
|
[FromQuery] string? liveStreamId,
|
||||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
|
||||||
[FromQuery] string? transcodeReasons,
|
[FromQuery] string? transcodeReasons,
|
||||||
[FromQuery] int? audioStreamIndex,
|
[FromQuery] int? audioStreamIndex,
|
||||||
[FromQuery] int? videoStreamIndex,
|
[FromQuery] int? videoStreamIndex,
|
||||||
@@ -1421,10 +1421,20 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
cancellationTokenSource.Token)
|
cancellationTokenSource.Token)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
var mediaSourceId = state.BaseRequest.MediaSourceId;
|
var mediaSourceId = state.BaseRequest.MediaSourceId;
|
||||||
|
double fps = state.TargetFramerate ?? 0.0f;
|
||||||
|
int segmentLength = state.SegmentLength * 1000;
|
||||||
|
|
||||||
|
// If video is transcoded and framerate is fractional (i.e. 23.976), we need to slightly adjust segment length
|
||||||
|
if (!EncodingHelper.IsCopyCodec(state.OutputVideoCodec) && Math.Abs(fps - Math.Floor(fps + 0.001f)) > 0.001)
|
||||||
|
{
|
||||||
|
double nearestIntFramerate = Math.Ceiling(fps);
|
||||||
|
segmentLength = (int)Math.Ceiling(segmentLength * (nearestIntFramerate / fps));
|
||||||
|
}
|
||||||
|
|
||||||
var request = new CreateMainPlaylistRequest(
|
var request = new CreateMainPlaylistRequest(
|
||||||
mediaSourceId is null ? null : Guid.Parse(mediaSourceId),
|
mediaSourceId is null ? null : Guid.Parse(mediaSourceId),
|
||||||
state.MediaPath,
|
state.MediaPath,
|
||||||
state.SegmentLength * 1000,
|
segmentLength,
|
||||||
state.RunTimeTicks ?? 0,
|
state.RunTimeTicks ?? 0,
|
||||||
state.Request.SegmentContainer ?? string.Empty,
|
state.Request.SegmentContainer ?? string.Empty,
|
||||||
"hls1/main/",
|
"hls1/main/",
|
||||||
@@ -1625,8 +1635,11 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
|
|
||||||
var useLegacySegmentOption = _mediaEncoder.EncoderVersion < _minFFmpegHlsSegmentOptions;
|
var useLegacySegmentOption = _mediaEncoder.EncoderVersion < _minFFmpegHlsSegmentOptions;
|
||||||
|
|
||||||
|
if (state.VideoStream is not null && state.IsOutputVideo)
|
||||||
|
{
|
||||||
// fMP4 needs this flag to write the audio packet DTS/PTS including the initial delay into MOOF::TRAF::TFDT
|
// fMP4 needs this flag to write the audio packet DTS/PTS including the initial delay into MOOF::TRAF::TFDT
|
||||||
hlsArguments += $" {(useLegacySegmentOption ? "-hls_ts_options" : "-hls_segment_options")} movflags=+frag_discont";
|
hlsArguments += $" {(useLegacySegmentOption ? "-hls_ts_options" : "-hls_segment_options")} movflags=+frag_discont";
|
||||||
|
}
|
||||||
|
|
||||||
segmentFormat = "fmp4" + outputFmp4HeaderArg;
|
segmentFormat = "fmp4" + outputFmp4HeaderArg;
|
||||||
}
|
}
|
||||||
@@ -1836,8 +1849,9 @@ public class DynamicHlsController : BaseJellyfinApiController
|
|||||||
{
|
{
|
||||||
if (isActualOutputVideoCodecHevc)
|
if (isActualOutputVideoCodecHevc)
|
||||||
{
|
{
|
||||||
// Prefer dvh1 to dvhe
|
// Use hvc1 for 8.4. This is what Dolby uses for its official sample streams. Tagging with dvh1 would break some players with strict tag checking like Apple Safari.
|
||||||
args += " -tag:v:0 dvh1 -strict -2";
|
var codecTag = state.VideoStream.VideoRangeType == VideoRangeType.DOVIWithHLG ? "hvc1" : "dvh1";
|
||||||
|
args += $" -tag:v:0 {codecTag} -strict -2";
|
||||||
}
|
}
|
||||||
else if (isActualOutputVideoCodecAv1)
|
else if (isActualOutputVideoCodecAv1)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -418,7 +418,7 @@ public class ItemUpdateController : BaseJellyfinApiController
|
|||||||
{
|
{
|
||||||
if (item is IHasAlbumArtist hasAlbumArtists)
|
if (item is IHasAlbumArtist hasAlbumArtists)
|
||||||
{
|
{
|
||||||
hasAlbumArtists.AlbumArtists = Array.ConvertAll(request.AlbumArtists, i => i.Name);
|
hasAlbumArtists.AlbumArtists = Array.ConvertAll(request.AlbumArtists, i => i.Name.Trim());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,7 +426,7 @@ public class ItemUpdateController : BaseJellyfinApiController
|
|||||||
{
|
{
|
||||||
if (item is IHasArtist hasArtists)
|
if (item is IHasArtist hasArtists)
|
||||||
{
|
{
|
||||||
hasArtists.Artists = Array.ConvertAll(request.ArtistItems, i => i.Name);
|
hasArtists.Artists = Array.ConvertAll(request.ArtistItems, i => i.Name.Trim());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ using MediaBrowser.Controller.Entities;
|
|||||||
using MediaBrowser.Controller.Entities.Audio;
|
using MediaBrowser.Controller.Entities.Audio;
|
||||||
using MediaBrowser.Controller.Entities.Movies;
|
using MediaBrowser.Controller.Entities.Movies;
|
||||||
using MediaBrowser.Controller.Entities.TV;
|
using MediaBrowser.Controller.Entities.TV;
|
||||||
|
using MediaBrowser.Controller.IO;
|
||||||
using MediaBrowser.Controller.Library;
|
using MediaBrowser.Controller.Library;
|
||||||
using MediaBrowser.Controller.Providers;
|
using MediaBrowser.Controller.Providers;
|
||||||
using MediaBrowser.Model.Activity;
|
using MediaBrowser.Model.Activity;
|
||||||
@@ -700,7 +701,18 @@ public class LibraryController : BaseJellyfinApiController
|
|||||||
// Quotes are valid in linux. They'll possibly cause issues here.
|
// Quotes are valid in linux. They'll possibly cause issues here.
|
||||||
var filename = Path.GetFileName(item.Path)?.Replace("\"", string.Empty, StringComparison.Ordinal);
|
var filename = Path.GetFileName(item.Path)?.Replace("\"", string.Empty, StringComparison.Ordinal);
|
||||||
|
|
||||||
return PhysicalFile(item.Path, MimeTypes.GetMimeType(item.Path), filename, true);
|
var filePath = item.Path;
|
||||||
|
if (item.IsFileProtocol)
|
||||||
|
{
|
||||||
|
// PhysicalFile does not work well with symlinks at the moment.
|
||||||
|
var resolved = FileSystemHelper.ResolveLinkTarget(filePath, returnFinalTarget: true);
|
||||||
|
if (resolved is not null && resolved.Exists)
|
||||||
|
{
|
||||||
|
filePath = resolved.FullName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return PhysicalFile(filePath, MimeTypes.GetMimeType(filePath), filename, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -342,6 +342,17 @@ public class LibraryStructureController : BaseJellyfinApiController
|
|||||||
return NotFound();
|
return NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LibraryOptions options = item.GetLibraryOptions();
|
||||||
|
foreach (var mediaPath in request.LibraryOptions!.PathInfos)
|
||||||
|
{
|
||||||
|
if (options.PathInfos.Any(i => i.Path == mediaPath.Path))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
_libraryManager.CreateShortcut(item.Path, mediaPath);
|
||||||
|
}
|
||||||
|
|
||||||
item.UpdateLibraryOptions(request.LibraryOptions);
|
item.UpdateLibraryOptions(request.LibraryOptions);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -458,7 +458,7 @@ public class LiveTvController : BaseJellyfinApiController
|
|||||||
/// <returns>A <see cref="NoContentResult"/>.</returns>
|
/// <returns>A <see cref="NoContentResult"/>.</returns>
|
||||||
[HttpPost("Tuners/{tunerId}/Reset")]
|
[HttpPost("Tuners/{tunerId}/Reset")]
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
[Authorize(Policy = Policies.LiveTvManagement)]
|
[Authorize(Policy = Policies.RequiresElevation)]
|
||||||
public async Task<ActionResult> ResetTuner([FromRoute, Required] string tunerId)
|
public async Task<ActionResult> ResetTuner([FromRoute, Required] string tunerId)
|
||||||
{
|
{
|
||||||
await _liveTvManager.ResetTuner(tunerId, CancellationToken.None).ConfigureAwait(false);
|
await _liveTvManager.ResetTuner(tunerId, CancellationToken.None).ConfigureAwait(false);
|
||||||
@@ -983,7 +983,7 @@ public class LiveTvController : BaseJellyfinApiController
|
|||||||
/// <response code="200">Created tuner host returned.</response>
|
/// <response code="200">Created tuner host returned.</response>
|
||||||
/// <returns>A <see cref="OkResult"/> containing the created tuner host.</returns>
|
/// <returns>A <see cref="OkResult"/> containing the created tuner host.</returns>
|
||||||
[HttpPost("TunerHosts")]
|
[HttpPost("TunerHosts")]
|
||||||
[Authorize(Policy = Policies.LiveTvManagement)]
|
[Authorize(Policy = Policies.RequiresElevation)]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<TunerHostInfo>> AddTunerHost([FromBody] TunerHostInfo tunerHostInfo)
|
public async Task<ActionResult<TunerHostInfo>> AddTunerHost([FromBody] TunerHostInfo tunerHostInfo)
|
||||||
=> await _tunerHostManager.SaveTunerHost(tunerHostInfo).ConfigureAwait(false);
|
=> await _tunerHostManager.SaveTunerHost(tunerHostInfo).ConfigureAwait(false);
|
||||||
@@ -995,7 +995,7 @@ public class LiveTvController : BaseJellyfinApiController
|
|||||||
/// <response code="204">Tuner host deleted.</response>
|
/// <response code="204">Tuner host deleted.</response>
|
||||||
/// <returns>A <see cref="NoContentResult"/>.</returns>
|
/// <returns>A <see cref="NoContentResult"/>.</returns>
|
||||||
[HttpDelete("TunerHosts")]
|
[HttpDelete("TunerHosts")]
|
||||||
[Authorize(Policy = Policies.LiveTvManagement)]
|
[Authorize(Policy = Policies.RequiresElevation)]
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
public ActionResult DeleteTunerHost([FromQuery] string? id)
|
public ActionResult DeleteTunerHost([FromQuery] string? id)
|
||||||
{
|
{
|
||||||
@@ -1028,7 +1028,7 @@ public class LiveTvController : BaseJellyfinApiController
|
|||||||
/// <response code="200">Created listings provider returned.</response>
|
/// <response code="200">Created listings provider returned.</response>
|
||||||
/// <returns>A <see cref="OkResult"/> containing the created listings provider.</returns>
|
/// <returns>A <see cref="OkResult"/> containing the created listings provider.</returns>
|
||||||
[HttpPost("ListingProviders")]
|
[HttpPost("ListingProviders")]
|
||||||
[Authorize(Policy = Policies.LiveTvManagement)]
|
[Authorize(Policy = Policies.RequiresElevation)]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
[SuppressMessage("Microsoft.Performance", "CA5350:RemoveSha1", MessageId = "AddListingProvider", Justification = "Imported from ServiceStack")]
|
[SuppressMessage("Microsoft.Performance", "CA5350:RemoveSha1", MessageId = "AddListingProvider", Justification = "Imported from ServiceStack")]
|
||||||
public async Task<ActionResult<ListingsProviderInfo>> AddListingProvider(
|
public async Task<ActionResult<ListingsProviderInfo>> AddListingProvider(
|
||||||
@@ -1054,7 +1054,7 @@ public class LiveTvController : BaseJellyfinApiController
|
|||||||
/// <response code="204">Listing provider deleted.</response>
|
/// <response code="204">Listing provider deleted.</response>
|
||||||
/// <returns>A <see cref="NoContentResult"/>.</returns>
|
/// <returns>A <see cref="NoContentResult"/>.</returns>
|
||||||
[HttpDelete("ListingProviders")]
|
[HttpDelete("ListingProviders")]
|
||||||
[Authorize(Policy = Policies.LiveTvManagement)]
|
[Authorize(Policy = Policies.RequiresElevation)]
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
public ActionResult DeleteListingProvider([FromQuery] string? id)
|
public ActionResult DeleteListingProvider([FromQuery] string? id)
|
||||||
{
|
{
|
||||||
@@ -1087,7 +1087,7 @@ public class LiveTvController : BaseJellyfinApiController
|
|||||||
/// <response code="200">Available countries returned.</response>
|
/// <response code="200">Available countries returned.</response>
|
||||||
/// <returns>A <see cref="FileResult"/> containing the available countries.</returns>
|
/// <returns>A <see cref="FileResult"/> containing the available countries.</returns>
|
||||||
[HttpGet("ListingProviders/SchedulesDirect/Countries")]
|
[HttpGet("ListingProviders/SchedulesDirect/Countries")]
|
||||||
[Authorize(Policy = Policies.LiveTvAccess)]
|
[Authorize(Policy = Policies.RequiresElevation)]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
[ProducesFile(MediaTypeNames.Application.Json)]
|
[ProducesFile(MediaTypeNames.Application.Json)]
|
||||||
public async Task<ActionResult> GetSchedulesDirectCountries()
|
public async Task<ActionResult> GetSchedulesDirectCountries()
|
||||||
@@ -1108,7 +1108,7 @@ public class LiveTvController : BaseJellyfinApiController
|
|||||||
/// <response code="200">Channel mapping options returned.</response>
|
/// <response code="200">Channel mapping options returned.</response>
|
||||||
/// <returns>An <see cref="OkResult"/> containing the channel mapping options.</returns>
|
/// <returns>An <see cref="OkResult"/> containing the channel mapping options.</returns>
|
||||||
[HttpGet("ChannelMappingOptions")]
|
[HttpGet("ChannelMappingOptions")]
|
||||||
[Authorize(Policy = Policies.LiveTvAccess)]
|
[Authorize(Policy = Policies.RequiresElevation)]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
public Task<ChannelMappingOptionsDto> GetChannelMappingOptions([FromQuery] string? providerId)
|
public Task<ChannelMappingOptionsDto> GetChannelMappingOptions([FromQuery] string? providerId)
|
||||||
=> _listingsManager.GetChannelMappingOptions(providerId);
|
=> _listingsManager.GetChannelMappingOptions(providerId);
|
||||||
@@ -1120,7 +1120,7 @@ public class LiveTvController : BaseJellyfinApiController
|
|||||||
/// <response code="200">Created channel mapping returned.</response>
|
/// <response code="200">Created channel mapping returned.</response>
|
||||||
/// <returns>An <see cref="OkResult"/> containing the created channel mapping.</returns>
|
/// <returns>An <see cref="OkResult"/> containing the created channel mapping.</returns>
|
||||||
[HttpPost("ChannelMappings")]
|
[HttpPost("ChannelMappings")]
|
||||||
[Authorize(Policy = Policies.LiveTvManagement)]
|
[Authorize(Policy = Policies.RequiresElevation)]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
public Task<TunerChannelMapping> SetChannelMapping([FromBody, Required] SetChannelMappingDto dto)
|
public Task<TunerChannelMapping> SetChannelMapping([FromBody, Required] SetChannelMappingDto dto)
|
||||||
=> _listingsManager.SetChannelMapping(dto.ProviderId, dto.TunerChannelId, dto.ProviderChannelId);
|
=> _listingsManager.SetChannelMapping(dto.ProviderId, dto.TunerChannelId, dto.ProviderChannelId);
|
||||||
@@ -1144,7 +1144,7 @@ public class LiveTvController : BaseJellyfinApiController
|
|||||||
/// <returns>An <see cref="OkResult"/> containing the tuners.</returns>
|
/// <returns>An <see cref="OkResult"/> containing the tuners.</returns>
|
||||||
[HttpGet("Tuners/Discvover", Name = "DiscvoverTuners")]
|
[HttpGet("Tuners/Discvover", Name = "DiscvoverTuners")]
|
||||||
[HttpGet("Tuners/Discover")]
|
[HttpGet("Tuners/Discover")]
|
||||||
[Authorize(Policy = Policies.LiveTvManagement)]
|
[Authorize(Policy = Policies.RequiresElevation)]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
public IAsyncEnumerable<TunerHostInfo> DiscoverTuners([FromQuery] bool newDevicesOnly = false)
|
public IAsyncEnumerable<TunerHostInfo> DiscoverTuners([FromQuery] bool newDevicesOnly = false)
|
||||||
=> _tunerHostManager.DiscoverTuners(newDevicesOnly);
|
=> _tunerHostManager.DiscoverTuners(newDevicesOnly);
|
||||||
@@ -1192,7 +1192,7 @@ public class LiveTvController : BaseJellyfinApiController
|
|||||||
[ProducesVideoFile]
|
[ProducesVideoFile]
|
||||||
public ActionResult GetLiveStreamFile(
|
public ActionResult GetLiveStreamFile(
|
||||||
[FromRoute, Required] string streamId,
|
[FromRoute, Required] string streamId,
|
||||||
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string container)
|
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string container)
|
||||||
{
|
{
|
||||||
var liveStreamInfo = _mediaSourceManager.GetLiveStreamInfoByUniqueId(streamId);
|
var liveStreamInfo = _mediaSourceManager.GetLiveStreamInfoByUniqueId(streamId);
|
||||||
if (liveStreamInfo is null)
|
if (liveStreamInfo is null)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
using System;
|
||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.Linq;
|
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Jellyfin.Api.Constants;
|
using Jellyfin.Api.Constants;
|
||||||
using Jellyfin.Api.Models.StartupDtos;
|
using Jellyfin.Api.Models.StartupDtos;
|
||||||
@@ -111,7 +111,7 @@ public class StartupController : BaseJellyfinApiController
|
|||||||
{
|
{
|
||||||
// TODO: Remove this method when startup wizard no longer requires an existing user.
|
// TODO: Remove this method when startup wizard no longer requires an existing user.
|
||||||
await _userManager.InitializeAsync().ConfigureAwait(false);
|
await _userManager.InitializeAsync().ConfigureAwait(false);
|
||||||
var user = _userManager.Users.First();
|
var user = _userManager.GetFirstUser() ?? throw new InvalidOperationException("No user exists after initialization.");
|
||||||
return new StartupUserDto
|
return new StartupUserDto
|
||||||
{
|
{
|
||||||
Name = user.Username
|
Name = user.Username
|
||||||
@@ -131,22 +131,29 @@ public class StartupController : BaseJellyfinApiController
|
|||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
public async Task<ActionResult> UpdateStartupUser([FromBody] StartupUserDto startupUserDto)
|
public async Task<ActionResult> UpdateStartupUser([FromBody] StartupUserDto startupUserDto)
|
||||||
{
|
{
|
||||||
var user = _userManager.Users.First();
|
var user = _userManager.GetFirstUser();
|
||||||
|
if (user is null)
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(startupUserDto.Password))
|
if (string.IsNullOrWhiteSpace(startupUserDto.Password))
|
||||||
{
|
{
|
||||||
return BadRequest("Password must not be empty");
|
return BadRequest("Password must not be empty");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (startupUserDto.Name is not null)
|
|
||||||
{
|
|
||||||
user.Username = startupUserDto.Name;
|
|
||||||
}
|
|
||||||
|
|
||||||
await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
|
await _userManager.UpdateUserAsync(user).ConfigureAwait(false);
|
||||||
|
|
||||||
|
#pragma warning disable CA1309 // Use ordinal string comparison
|
||||||
|
if (startupUserDto.Name is not null && !startupUserDto.Name.Equals(user.Username, StringComparison.InvariantCultureIgnoreCase))
|
||||||
|
{
|
||||||
|
await _userManager.RenameUser(user.Id, user.Username, startupUserDto.Name).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
#pragma warning restore CA1309 // Use ordinal string comparison
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(startupUserDto.Password))
|
if (!string.IsNullOrEmpty(startupUserDto.Password))
|
||||||
{
|
{
|
||||||
await _userManager.ChangePassword(user, startupUserDto.Password).ConfigureAwait(false);
|
await _userManager.ChangePassword(user.Id, startupUserDto.Password).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
return NoContent();
|
return NoContent();
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ public class SyncPlayController : BaseJellyfinApiController
|
|||||||
[FromBody, Required] NewGroupRequestDto requestData)
|
[FromBody, Required] NewGroupRequestDto requestData)
|
||||||
{
|
{
|
||||||
var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
|
var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
|
||||||
var syncPlayRequest = new NewGroupRequest(requestData.GroupName);
|
var syncPlayRequest = new NewGroupRequest(requestData.GroupName.Trim());
|
||||||
return Ok(_syncPlayManager.NewGroup(currentSession, syncPlayRequest, CancellationToken.None));
|
return Ok(_syncPlayManager.NewGroup(currentSession, syncPlayRequest, CancellationToken.None));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ public class TrickplayController : BaseJellyfinApiController
|
|||||||
[FromRoute, Required] int index,
|
[FromRoute, Required] int index,
|
||||||
[FromQuery] Guid? mediaSourceId)
|
[FromQuery] Guid? mediaSourceId)
|
||||||
{
|
{
|
||||||
var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId());
|
var item = _libraryManager.GetItemById<BaseItem>(mediaSourceId ?? itemId, User.GetUserId());
|
||||||
if (item is null)
|
if (item is null)
|
||||||
{
|
{
|
||||||
return NotFound();
|
return NotFound();
|
||||||
|
|||||||
@@ -102,13 +102,13 @@ public class UniversalAudioController : BaseJellyfinApiController
|
|||||||
[FromQuery] string? mediaSourceId,
|
[FromQuery] string? mediaSourceId,
|
||||||
[FromQuery] string? deviceId,
|
[FromQuery] string? deviceId,
|
||||||
[FromQuery] Guid? userId,
|
[FromQuery] Guid? userId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
|
||||||
[FromQuery] int? maxAudioChannels,
|
[FromQuery] int? maxAudioChannels,
|
||||||
[FromQuery] int? transcodingAudioChannels,
|
[FromQuery] int? transcodingAudioChannels,
|
||||||
[FromQuery] int? maxStreamingBitrate,
|
[FromQuery] int? maxStreamingBitrate,
|
||||||
[FromQuery] int? audioBitRate,
|
[FromQuery] int? audioBitRate,
|
||||||
[FromQuery] long? startTimeTicks,
|
[FromQuery] long? startTimeTicks,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? transcodingContainer,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? transcodingContainer,
|
||||||
[FromQuery] MediaStreamProtocol? transcodingProtocol,
|
[FromQuery] MediaStreamProtocol? transcodingProtocol,
|
||||||
[FromQuery] int? maxAudioSampleRate,
|
[FromQuery] int? maxAudioSampleRate,
|
||||||
[FromQuery] int? maxAudioBitDepth,
|
[FromQuery] int? maxAudioBitDepth,
|
||||||
|
|||||||
@@ -288,7 +288,7 @@ public class UserController : BaseJellyfinApiController
|
|||||||
|
|
||||||
if (request.ResetPassword)
|
if (request.ResetPassword)
|
||||||
{
|
{
|
||||||
await _userManager.ResetPassword(user).ConfigureAwait(false);
|
await _userManager.ResetPassword(user.Id).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -306,7 +306,7 @@ public class UserController : BaseJellyfinApiController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await _userManager.ChangePassword(user, request.NewPw ?? string.Empty).ConfigureAwait(false);
|
await _userManager.ChangePassword(user.Id, request.NewPw ?? string.Empty).ConfigureAwait(false);
|
||||||
|
|
||||||
var currentToken = User.GetToken();
|
var currentToken = User.GetToken();
|
||||||
|
|
||||||
@@ -392,7 +392,7 @@ public class UserController : BaseJellyfinApiController
|
|||||||
|
|
||||||
if (!string.Equals(user.Username, updateUser.Name, StringComparison.Ordinal))
|
if (!string.Equals(user.Username, updateUser.Name, StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
await _userManager.RenameUser(user, updateUser.Name).ConfigureAwait(false);
|
await _userManager.RenameUser(user.Id, user.Username, updateUser.Name).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
await _userManager.UpdateConfigurationAsync(requestUserId, updateUser.Configuration).ConfigureAwait(false);
|
await _userManager.UpdateConfigurationAsync(requestUserId, updateUser.Configuration).ConfigureAwait(false);
|
||||||
@@ -448,7 +448,7 @@ public class UserController : BaseJellyfinApiController
|
|||||||
// If removing admin access
|
// If removing admin access
|
||||||
if (!newPolicy.IsAdministrator && user.HasPermission(PermissionKind.IsAdministrator))
|
if (!newPolicy.IsAdministrator && user.HasPermission(PermissionKind.IsAdministrator))
|
||||||
{
|
{
|
||||||
if (_userManager.Users.Count(i => i.HasPermission(PermissionKind.IsAdministrator)) == 1)
|
if (_userManager.GetUsers().Count(i => i.HasPermission(PermissionKind.IsAdministrator)) == 1)
|
||||||
{
|
{
|
||||||
return StatusCode(StatusCodes.Status403Forbidden, "There must be at least one user in the system with administrative access.");
|
return StatusCode(StatusCodes.Status403Forbidden, "There must be at least one user in the system with administrative access.");
|
||||||
}
|
}
|
||||||
@@ -463,7 +463,7 @@ public class UserController : BaseJellyfinApiController
|
|||||||
// If disabling
|
// If disabling
|
||||||
if (newPolicy.IsDisabled && !user.HasPermission(PermissionKind.IsDisabled))
|
if (newPolicy.IsDisabled && !user.HasPermission(PermissionKind.IsDisabled))
|
||||||
{
|
{
|
||||||
if (_userManager.Users.Count(i => !i.HasPermission(PermissionKind.IsDisabled)) == 1)
|
if (_userManager.GetUsers().Count(i => !i.HasPermission(PermissionKind.IsDisabled)) == 1)
|
||||||
{
|
{
|
||||||
return StatusCode(StatusCodes.Status403Forbidden, "There must be at least one enabled user in the system.");
|
return StatusCode(StatusCodes.Status403Forbidden, "There must be at least one enabled user in the system.");
|
||||||
}
|
}
|
||||||
@@ -545,7 +545,7 @@ public class UserController : BaseJellyfinApiController
|
|||||||
// no need to authenticate password for new user
|
// no need to authenticate password for new user
|
||||||
if (request.Password is not null)
|
if (request.Password is not null)
|
||||||
{
|
{
|
||||||
await _userManager.ChangePassword(newUser, request.Password).ConfigureAwait(false);
|
await _userManager.ChangePassword(newUser.Id, request.Password).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
var result = _userManager.GetUserDto(newUser, HttpContext.GetNormalizedRemoteIP().ToString());
|
var result = _userManager.GetUserDto(newUser, HttpContext.GetNormalizedRemoteIP().ToString());
|
||||||
@@ -620,7 +620,7 @@ public class UserController : BaseJellyfinApiController
|
|||||||
|
|
||||||
private IEnumerable<UserDto> Get(bool? isHidden, bool? isDisabled, bool filterByDevice, bool filterByNetwork)
|
private IEnumerable<UserDto> Get(bool? isHidden, bool? isDisabled, bool filterByDevice, bool filterByNetwork)
|
||||||
{
|
{
|
||||||
var users = _userManager.Users;
|
var users = _userManager.GetUsers();
|
||||||
|
|
||||||
if (isDisabled.HasValue)
|
if (isDisabled.HasValue)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -315,18 +315,18 @@ public class VideosController : BaseJellyfinApiController
|
|||||||
[ProducesVideoFile]
|
[ProducesVideoFile]
|
||||||
public async Task<ActionResult> GetVideoStream(
|
public async Task<ActionResult> GetVideoStream(
|
||||||
[FromRoute, Required] Guid itemId,
|
[FromRoute, Required] Guid itemId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? container,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? container,
|
||||||
[FromQuery] bool? @static,
|
[FromQuery] bool? @static,
|
||||||
[FromQuery] string? @params,
|
[FromQuery] string? @params,
|
||||||
[FromQuery] string? tag,
|
[FromQuery] string? tag,
|
||||||
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
[FromQuery, ParameterObsolete] string? deviceProfileId,
|
||||||
[FromQuery] string? playSessionId,
|
[FromQuery] string? playSessionId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
|
||||||
[FromQuery] int? segmentLength,
|
[FromQuery] int? segmentLength,
|
||||||
[FromQuery] int? minSegments,
|
[FromQuery] int? minSegments,
|
||||||
[FromQuery] string? mediaSourceId,
|
[FromQuery] string? mediaSourceId,
|
||||||
[FromQuery] string? deviceId,
|
[FromQuery] string? deviceId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
|
||||||
[FromQuery] bool? enableAutoStreamCopy,
|
[FromQuery] bool? enableAutoStreamCopy,
|
||||||
[FromQuery] bool? allowVideoStreamCopy,
|
[FromQuery] bool? allowVideoStreamCopy,
|
||||||
[FromQuery] bool? allowAudioStreamCopy,
|
[FromQuery] bool? allowAudioStreamCopy,
|
||||||
@@ -337,7 +337,7 @@ public class VideosController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? audioChannels,
|
[FromQuery] int? audioChannels,
|
||||||
[FromQuery] int? maxAudioChannels,
|
[FromQuery] int? maxAudioChannels,
|
||||||
[FromQuery] string? profile,
|
[FromQuery] string? profile,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
|
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
|
||||||
[FromQuery] float? framerate,
|
[FromQuery] float? framerate,
|
||||||
[FromQuery] float? maxFramerate,
|
[FromQuery] float? maxFramerate,
|
||||||
[FromQuery] bool? copyTimestamps,
|
[FromQuery] bool? copyTimestamps,
|
||||||
@@ -358,8 +358,8 @@ public class VideosController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? cpuCoreLimit,
|
[FromQuery] int? cpuCoreLimit,
|
||||||
[FromQuery] string? liveStreamId,
|
[FromQuery] string? liveStreamId,
|
||||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
|
||||||
[FromQuery] string? transcodeReasons,
|
[FromQuery] string? transcodeReasons,
|
||||||
[FromQuery] int? audioStreamIndex,
|
[FromQuery] int? audioStreamIndex,
|
||||||
[FromQuery] int? videoStreamIndex,
|
[FromQuery] int? videoStreamIndex,
|
||||||
@@ -556,18 +556,18 @@ public class VideosController : BaseJellyfinApiController
|
|||||||
[ProducesVideoFile]
|
[ProducesVideoFile]
|
||||||
public Task<ActionResult> GetVideoStreamByContainer(
|
public Task<ActionResult> GetVideoStreamByContainer(
|
||||||
[FromRoute, Required] Guid itemId,
|
[FromRoute, Required] Guid itemId,
|
||||||
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string container,
|
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string container,
|
||||||
[FromQuery] bool? @static,
|
[FromQuery] bool? @static,
|
||||||
[FromQuery] string? @params,
|
[FromQuery] string? @params,
|
||||||
[FromQuery] string? tag,
|
[FromQuery] string? tag,
|
||||||
[FromQuery] string? deviceProfileId,
|
[FromQuery] string? deviceProfileId,
|
||||||
[FromQuery] string? playSessionId,
|
[FromQuery] string? playSessionId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
|
||||||
[FromQuery] int? segmentLength,
|
[FromQuery] int? segmentLength,
|
||||||
[FromQuery] int? minSegments,
|
[FromQuery] int? minSegments,
|
||||||
[FromQuery] string? mediaSourceId,
|
[FromQuery] string? mediaSourceId,
|
||||||
[FromQuery] string? deviceId,
|
[FromQuery] string? deviceId,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
|
||||||
[FromQuery] bool? enableAutoStreamCopy,
|
[FromQuery] bool? enableAutoStreamCopy,
|
||||||
[FromQuery] bool? allowVideoStreamCopy,
|
[FromQuery] bool? allowVideoStreamCopy,
|
||||||
[FromQuery] bool? allowAudioStreamCopy,
|
[FromQuery] bool? allowAudioStreamCopy,
|
||||||
@@ -578,7 +578,7 @@ public class VideosController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? audioChannels,
|
[FromQuery] int? audioChannels,
|
||||||
[FromQuery] int? maxAudioChannels,
|
[FromQuery] int? maxAudioChannels,
|
||||||
[FromQuery] string? profile,
|
[FromQuery] string? profile,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
|
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
|
||||||
[FromQuery] float? framerate,
|
[FromQuery] float? framerate,
|
||||||
[FromQuery] float? maxFramerate,
|
[FromQuery] float? maxFramerate,
|
||||||
[FromQuery] bool? copyTimestamps,
|
[FromQuery] bool? copyTimestamps,
|
||||||
@@ -599,8 +599,8 @@ public class VideosController : BaseJellyfinApiController
|
|||||||
[FromQuery] int? cpuCoreLimit,
|
[FromQuery] int? cpuCoreLimit,
|
||||||
[FromQuery] string? liveStreamId,
|
[FromQuery] string? liveStreamId,
|
||||||
[FromQuery] bool? enableMpegtsM2TsMode,
|
[FromQuery] bool? enableMpegtsM2TsMode,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
|
||||||
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
|
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
|
||||||
[FromQuery] string? transcodeReasons,
|
[FromQuery] string? transcodeReasons,
|
||||||
[FromQuery] int? audioStreamIndex,
|
[FromQuery] int? audioStreamIndex,
|
||||||
[FromQuery] int? videoStreamIndex,
|
[FromQuery] int? videoStreamIndex,
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
|
using System;
|
||||||
using System.Net.Mime;
|
using System.Net.Mime;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc.Formatters;
|
using Microsoft.AspNetCore.Mvc.Formatters;
|
||||||
|
|
||||||
namespace Jellyfin.Api.Formatters;
|
namespace Jellyfin.Api.Formatters;
|
||||||
@@ -6,7 +10,7 @@ namespace Jellyfin.Api.Formatters;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Xml output formatter.
|
/// Xml output formatter.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class XmlOutputFormatter : StringOutputFormatter
|
public sealed class XmlOutputFormatter : TextOutputFormatter
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="XmlOutputFormatter"/> class.
|
/// Initializes a new instance of the <see cref="XmlOutputFormatter"/> class.
|
||||||
@@ -15,5 +19,24 @@ public sealed class XmlOutputFormatter : StringOutputFormatter
|
|||||||
{
|
{
|
||||||
SupportedMediaTypes.Clear();
|
SupportedMediaTypes.Clear();
|
||||||
SupportedMediaTypes.Add(MediaTypeNames.Text.Xml);
|
SupportedMediaTypes.Add(MediaTypeNames.Text.Xml);
|
||||||
|
|
||||||
|
SupportedEncodings.Add(Encoding.UTF8);
|
||||||
|
SupportedEncodings.Add(Encoding.Unicode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override async Task WriteResponseBodyAsync(OutputFormatterWriteContext context, Encoding selectedEncoding)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(context);
|
||||||
|
ArgumentNullException.ThrowIfNull(selectedEncoding);
|
||||||
|
|
||||||
|
var valueAsString = context.Object?.ToString();
|
||||||
|
if (string.IsNullOrEmpty(valueAsString))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var response = context.HttpContext.Response;
|
||||||
|
await response.WriteAsync(valueAsString, selectedEncoding).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ public class DynamicHlsHelper
|
|||||||
// from universal audio service, need to override the AudioCodec when the actual request differs from original query
|
// from universal audio service, need to override the AudioCodec when the actual request differs from original query
|
||||||
if (!string.Equals(state.OutputAudioCodec, _httpContextAccessor.HttpContext.Request.Query["AudioCodec"].ToString(), StringComparison.OrdinalIgnoreCase))
|
if (!string.Equals(state.OutputAudioCodec, _httpContextAccessor.HttpContext.Request.Query["AudioCodec"].ToString(), StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
var newQuery = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(_httpContextAccessor.HttpContext.Request.QueryString.ToString());
|
var newQuery = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(queryString);
|
||||||
newQuery["AudioCodec"] = state.OutputAudioCodec;
|
newQuery["AudioCodec"] = state.OutputAudioCodec;
|
||||||
queryString = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(string.Empty, newQuery);
|
queryString = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(string.Empty, newQuery);
|
||||||
}
|
}
|
||||||
@@ -173,10 +173,21 @@ public class DynamicHlsHelper
|
|||||||
queryString += "&TranscodeReasons=" + state.Request.TranscodeReasons;
|
queryString += "&TranscodeReasons=" + state.Request.TranscodeReasons;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Main stream
|
// Video rotation metadata is only supported in fMP4 remuxing
|
||||||
var playlistUrl = isLiveStream ? "live.m3u8" : "main.m3u8";
|
if (state.VideoStream is not null
|
||||||
|
&& state.VideoRequest is not null
|
||||||
|
&& (state.VideoStream?.Rotation ?? 0) != 0
|
||||||
|
&& EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
|
||||||
|
&& !string.IsNullOrWhiteSpace(state.Request.SegmentContainer)
|
||||||
|
&& !string.Equals(state.Request.SegmentContainer, "mp4", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
queryString += "&AllowVideoStreamCopy=false";
|
||||||
|
}
|
||||||
|
|
||||||
playlistUrl += queryString;
|
// Main stream
|
||||||
|
var baseUrl = isLiveStream ? "live.m3u8" : "main.m3u8";
|
||||||
|
var playlistUrl = baseUrl + queryString;
|
||||||
|
var playlistQuery = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(queryString);
|
||||||
|
|
||||||
var subtitleStreams = state.MediaSource
|
var subtitleStreams = state.MediaSource
|
||||||
.MediaStreams
|
.MediaStreams
|
||||||
@@ -198,37 +209,36 @@ public class DynamicHlsHelper
|
|||||||
AddSubtitles(state, subtitleStreams, builder, _httpContextAccessor.HttpContext.User);
|
AddSubtitles(state, subtitleStreams, builder, _httpContextAccessor.HttpContext.User);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Video rotation metadata is only supported in fMP4 remuxing
|
|
||||||
if (state.VideoStream is not null
|
|
||||||
&& state.VideoRequest is not null
|
|
||||||
&& (state.VideoStream?.Rotation ?? 0) != 0
|
|
||||||
&& EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
|
|
||||||
&& !string.IsNullOrWhiteSpace(state.Request.SegmentContainer)
|
|
||||||
&& !string.Equals(state.Request.SegmentContainer, "mp4", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
playlistUrl += "&AllowVideoStreamCopy=false";
|
|
||||||
}
|
|
||||||
|
|
||||||
var basicPlaylist = AppendPlaylist(builder, state, playlistUrl, totalBitrate, subtitleGroup);
|
var basicPlaylist = AppendPlaylist(builder, state, playlistUrl, totalBitrate, subtitleGroup);
|
||||||
|
|
||||||
if (state.VideoStream is not null && state.VideoRequest is not null)
|
if (state.VideoStream is not null && state.VideoRequest is not null)
|
||||||
{
|
{
|
||||||
var encodingOptions = _serverConfigurationManager.GetEncodingOptions();
|
var encodingOptions = _serverConfigurationManager.GetEncodingOptions();
|
||||||
|
|
||||||
// Provide SDR HEVC entrance for backward compatibility.
|
// Provide AV1 and HEVC SDR entrances for backward compatibility.
|
||||||
if (encodingOptions.AllowHevcEncoding
|
foreach (var sdrVideoCodec in new[] { "av1", "hevc" })
|
||||||
&& !encodingOptions.AllowAv1Encoding
|
{
|
||||||
|
var isAv1EncodingAllowed = encodingOptions.AllowAv1Encoding
|
||||||
|
&& string.Equals(sdrVideoCodec, "av1", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& string.Equals(state.ActualOutputVideoCodec, "av1", StringComparison.OrdinalIgnoreCase);
|
||||||
|
var isHevcEncodingAllowed = encodingOptions.AllowHevcEncoding
|
||||||
|
&& string.Equals(sdrVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase);
|
||||||
|
var isEncodingAllowed = isAv1EncodingAllowed || isHevcEncodingAllowed;
|
||||||
|
|
||||||
|
if (isEncodingAllowed
|
||||||
&& EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
|
&& EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
|
||||||
&& state.VideoStream.VideoRange == VideoRange.HDR
|
&& state.VideoStream.VideoRange == VideoRange.HDR)
|
||||||
&& string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
{
|
||||||
var requestedVideoProfiles = state.GetRequestedProfiles("hevc");
|
// Force AV1 and HEVC Main Profile and disable video stream copy.
|
||||||
if (requestedVideoProfiles is not null && requestedVideoProfiles.Length > 0)
|
state.OutputVideoCodec = sdrVideoCodec;
|
||||||
{
|
|
||||||
// Force HEVC Main Profile and disable video stream copy.
|
var sdrPlaylistQuery = playlistQuery;
|
||||||
state.OutputVideoCodec = "hevc";
|
sdrPlaylistQuery["VideoCodec"] = sdrVideoCodec;
|
||||||
var sdrVideoUrl = ReplaceProfile(playlistUrl, "hevc", string.Join(',', requestedVideoProfiles), "main");
|
sdrPlaylistQuery[sdrVideoCodec + "-profile"] = "main";
|
||||||
sdrVideoUrl += "&AllowVideoStreamCopy=false";
|
sdrPlaylistQuery["AllowVideoStreamCopy"] = "false";
|
||||||
|
|
||||||
|
var sdrVideoUrl = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(baseUrl, sdrPlaylistQuery);
|
||||||
|
|
||||||
// HACK: Use the same bitrate so that the client can choose by other attributes, such as color range.
|
// HACK: Use the same bitrate so that the client can choose by other attributes, such as color range.
|
||||||
AppendPlaylist(builder, state, sdrVideoUrl, totalBitrate, subtitleGroup);
|
AppendPlaylist(builder, state, sdrVideoUrl, totalBitrate, subtitleGroup);
|
||||||
@@ -238,12 +248,30 @@ public class DynamicHlsHelper
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Provide H.264 SDR entrance for backward compatibility.
|
||||||
|
if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
|
||||||
|
&& state.VideoStream.VideoRange == VideoRange.HDR)
|
||||||
|
{
|
||||||
|
// Force H.264 and disable video stream copy.
|
||||||
|
state.OutputVideoCodec = "h264";
|
||||||
|
|
||||||
|
var sdrPlaylistQuery = playlistQuery;
|
||||||
|
sdrPlaylistQuery["VideoCodec"] = "h264";
|
||||||
|
sdrPlaylistQuery["AllowVideoStreamCopy"] = "false";
|
||||||
|
|
||||||
|
var sdrVideoUrl = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(baseUrl, sdrPlaylistQuery);
|
||||||
|
|
||||||
|
// HACK: Use the same bitrate so that the client can choose by other attributes, such as color range.
|
||||||
|
AppendPlaylist(builder, state, sdrVideoUrl, totalBitrate, subtitleGroup);
|
||||||
|
|
||||||
|
// Restore the video codec
|
||||||
|
state.OutputVideoCodec = "copy";
|
||||||
|
}
|
||||||
|
|
||||||
// Provide Level 5.0 entrance for backward compatibility.
|
// Provide Level 5.0 entrance for backward compatibility.
|
||||||
// e.g. Apple A10 chips refuse the master playlist containing SDR HEVC Main Level 5.1 video,
|
// e.g. Apple A10 chips refuse the master playlist containing SDR HEVC Main Level 5.1 video,
|
||||||
// but in fact it is capable of playing videos up to Level 6.1.
|
// but in fact it is capable of playing videos up to Level 6.1.
|
||||||
if (encodingOptions.AllowHevcEncoding
|
if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
|
||||||
&& !encodingOptions.AllowAv1Encoding
|
|
||||||
&& EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
|
|
||||||
&& state.VideoStream.Level.HasValue
|
&& state.VideoStream.Level.HasValue
|
||||||
&& state.VideoStream.Level > 150
|
&& state.VideoStream.Level > 150
|
||||||
&& state.VideoStream.VideoRange == VideoRange.SDR
|
&& state.VideoStream.VideoRange == VideoRange.SDR
|
||||||
@@ -273,12 +301,15 @@ public class DynamicHlsHelper
|
|||||||
var variation = GetBitrateVariation(totalBitrate);
|
var variation = GetBitrateVariation(totalBitrate);
|
||||||
|
|
||||||
var newBitrate = totalBitrate - variation;
|
var newBitrate = totalBitrate - variation;
|
||||||
var variantUrl = ReplaceVideoBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation);
|
var variantQuery = playlistQuery;
|
||||||
|
variantQuery["VideoBitrate"] = (requestedVideoBitrate - variation).ToString(CultureInfo.InvariantCulture);
|
||||||
|
var variantUrl = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(baseUrl, variantQuery);
|
||||||
AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup);
|
AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup);
|
||||||
|
|
||||||
variation *= 2;
|
variation *= 2;
|
||||||
newBitrate = totalBitrate - variation;
|
newBitrate = totalBitrate - variation;
|
||||||
variantUrl = ReplaceVideoBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation);
|
variantQuery["VideoBitrate"] = (requestedVideoBitrate - variation).ToString(CultureInfo.InvariantCulture);
|
||||||
|
variantUrl = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(baseUrl, variantQuery);
|
||||||
AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup);
|
AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -863,23 +894,6 @@ public class DynamicHlsHelper
|
|||||||
return variation;
|
return variation;
|
||||||
}
|
}
|
||||||
|
|
||||||
private string ReplaceVideoBitrate(string url, int oldValue, int newValue)
|
|
||||||
{
|
|
||||||
return url.Replace(
|
|
||||||
"videobitrate=" + oldValue.ToString(CultureInfo.InvariantCulture),
|
|
||||||
"videobitrate=" + newValue.ToString(CultureInfo.InvariantCulture),
|
|
||||||
StringComparison.OrdinalIgnoreCase);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string ReplaceProfile(string url, string codec, string oldValue, string newValue)
|
|
||||||
{
|
|
||||||
string profileStr = codec + "-profile=";
|
|
||||||
return url.Replace(
|
|
||||||
profileStr + oldValue,
|
|
||||||
profileStr + newValue,
|
|
||||||
StringComparison.OrdinalIgnoreCase);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string ReplacePlaylistCodecsField(StringBuilder playlist, StringBuilder oldValue, StringBuilder newValue)
|
private string ReplacePlaylistCodecsField(StringBuilder playlist, StringBuilder oldValue, StringBuilder newValue)
|
||||||
{
|
{
|
||||||
var oldPlaylist = playlist.ToString();
|
var oldPlaylist = playlist.ToString();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Jellyfin.Api.Extensions;
|
using Jellyfin.Api.Extensions;
|
||||||
@@ -17,9 +18,7 @@ using MediaBrowser.Controller.MediaEncoding;
|
|||||||
using MediaBrowser.Controller.Streaming;
|
using MediaBrowser.Controller.Streaming;
|
||||||
using MediaBrowser.Model.Dlna;
|
using MediaBrowser.Model.Dlna;
|
||||||
using MediaBrowser.Model.Dto;
|
using MediaBrowser.Model.Dto;
|
||||||
using MediaBrowser.Model.Entities;
|
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Http.HttpResults;
|
|
||||||
using Microsoft.Net.Http.Headers;
|
using Microsoft.Net.Http.Headers;
|
||||||
|
|
||||||
namespace Jellyfin.Api.Helpers;
|
namespace Jellyfin.Api.Helpers;
|
||||||
@@ -159,6 +158,13 @@ public static class StreamingHelpers
|
|||||||
|
|
||||||
string? containerInternal = Path.GetExtension(state.RequestedUrl);
|
string? containerInternal = Path.GetExtension(state.RequestedUrl);
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(containerInternal)
|
||||||
|
&& (!string.IsNullOrWhiteSpace(streamingRequest.LiveStreamId)
|
||||||
|
|| (mediaSource != null && mediaSource.IsInfiniteStream)))
|
||||||
|
{
|
||||||
|
containerInternal = ".ts";
|
||||||
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(streamingRequest.Container))
|
if (!string.IsNullOrEmpty(streamingRequest.Container))
|
||||||
{
|
{
|
||||||
containerInternal = streamingRequest.Container;
|
containerInternal = streamingRequest.Container;
|
||||||
@@ -415,14 +421,18 @@ public static class StreamingHelpers
|
|||||||
request.Static = string.Equals("true", val, StringComparison.OrdinalIgnoreCase);
|
request.Static = string.Equals("true", val, StringComparison.OrdinalIgnoreCase);
|
||||||
break;
|
break;
|
||||||
case 4:
|
case 4:
|
||||||
if (videoRequest is not null)
|
if (videoRequest is not null && IsValidCodecName(val))
|
||||||
{
|
{
|
||||||
videoRequest.VideoCodec = val;
|
videoRequest.VideoCodec = val;
|
||||||
}
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
case 5:
|
case 5:
|
||||||
|
if (IsValidCodecName(val))
|
||||||
|
{
|
||||||
request.AudioCodec = val;
|
request.AudioCodec = val;
|
||||||
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
case 6:
|
case 6:
|
||||||
if (videoRequest is not null)
|
if (videoRequest is not null)
|
||||||
@@ -476,7 +486,7 @@ public static class StreamingHelpers
|
|||||||
request.StartTimeTicks = long.Parse(val, CultureInfo.InvariantCulture);
|
request.StartTimeTicks = long.Parse(val, CultureInfo.InvariantCulture);
|
||||||
break;
|
break;
|
||||||
case 15:
|
case 15:
|
||||||
if (videoRequest is not null)
|
if (videoRequest is not null && Regex.IsMatch(val, EncodingHelper.LevelValidationRegexStr))
|
||||||
{
|
{
|
||||||
videoRequest.Level = val;
|
videoRequest.Level = val;
|
||||||
}
|
}
|
||||||
@@ -497,7 +507,7 @@ public static class StreamingHelpers
|
|||||||
|
|
||||||
break;
|
break;
|
||||||
case 18:
|
case 18:
|
||||||
if (videoRequest is not null)
|
if (videoRequest is not null && IsValidCodecName(val))
|
||||||
{
|
{
|
||||||
videoRequest.Profile = val;
|
videoRequest.Profile = val;
|
||||||
}
|
}
|
||||||
@@ -556,7 +566,11 @@ public static class StreamingHelpers
|
|||||||
|
|
||||||
break;
|
break;
|
||||||
case 30:
|
case 30:
|
||||||
|
if (IsValidCodecName(val))
|
||||||
|
{
|
||||||
request.SubtitleCodec = val;
|
request.SubtitleCodec = val;
|
||||||
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
case 31:
|
case 31:
|
||||||
if (videoRequest is not null)
|
if (videoRequest is not null)
|
||||||
@@ -579,6 +593,11 @@ public static class StreamingHelpers
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IsValidCodecName(string val)
|
||||||
|
{
|
||||||
|
return EncodingHelper.ContainerValidationRegex().IsMatch(val);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Parses the container into its file extension.
|
/// Parses the container into its file extension.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
namespace Jellyfin.Api.Models.SyncPlayDtos;
|
namespace Jellyfin.Api.Models.SyncPlayDtos;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -17,5 +19,6 @@ public class NewGroupRequestDto
|
|||||||
/// Gets or sets the group name.
|
/// Gets or sets the group name.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <value>The name of the new group.</value>
|
/// <value>The name of the new group.</value>
|
||||||
|
[StringLength(200, ErrorMessage = "Group name must not exceed 200 characters.")]
|
||||||
public string GroupName { get; set; }
|
public string GroupName { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using MediaBrowser.Controller.Authentication;
|
|||||||
using MediaBrowser.Controller.Library;
|
using MediaBrowser.Controller.Library;
|
||||||
using MediaBrowser.Controller.Net;
|
using MediaBrowser.Controller.Net;
|
||||||
using MediaBrowser.Controller.Session;
|
using MediaBrowser.Controller.Session;
|
||||||
|
using MediaBrowser.Model.Dto;
|
||||||
using MediaBrowser.Model.Session;
|
using MediaBrowser.Model.Session;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
@@ -15,7 +16,7 @@ namespace Jellyfin.Api.WebSocketListeners;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Class SessionInfoWebSocketListener.
|
/// Class SessionInfoWebSocketListener.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class SessionInfoWebSocketListener : BasePeriodicWebSocketListener<IEnumerable<SessionInfo>, WebSocketListenerState>
|
public class SessionInfoWebSocketListener : BasePeriodicWebSocketListener<IEnumerable<SessionInfoDto>, WebSocketListenerState>
|
||||||
{
|
{
|
||||||
private readonly ISessionManager _sessionManager;
|
private readonly ISessionManager _sessionManager;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
@@ -52,24 +53,26 @@ public class SessionInfoWebSocketListener : BasePeriodicWebSocketListener<IEnume
|
|||||||
/// Gets the data to send.
|
/// Gets the data to send.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>Task{SystemInfo}.</returns>
|
/// <returns>Task{SystemInfo}.</returns>
|
||||||
protected override Task<IEnumerable<SessionInfo>> GetDataToSend()
|
protected override Task<IEnumerable<SessionInfoDto>> GetDataToSend()
|
||||||
{
|
{
|
||||||
return Task.FromResult(_sessionManager.Sessions);
|
return Task.FromResult(_sessionManager.Sessions.Select(_sessionManager.ToSessionInfoDto));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override Task<IEnumerable<SessionInfo>> GetDataToSendForConnection(IWebSocketConnection connection)
|
protected override Task<IEnumerable<SessionInfoDto>> GetDataToSendForConnection(IWebSocketConnection connection)
|
||||||
{
|
{
|
||||||
|
var sessions = _sessionManager.Sessions;
|
||||||
|
|
||||||
// For non-admin users, filter the sessions to only include their own sessions
|
// For non-admin users, filter the sessions to only include their own sessions
|
||||||
if (connection.AuthorizationInfo?.User is not null &&
|
if (connection.AuthorizationInfo?.User is not null &&
|
||||||
!connection.AuthorizationInfo.IsApiKey &&
|
!connection.AuthorizationInfo.IsApiKey &&
|
||||||
!connection.AuthorizationInfo.User.HasPermission(PermissionKind.IsAdministrator))
|
!connection.AuthorizationInfo.User.HasPermission(PermissionKind.IsAdministrator))
|
||||||
{
|
{
|
||||||
var userId = connection.AuthorizationInfo.User.Id;
|
var userId = connection.AuthorizationInfo.User.Id;
|
||||||
return Task.FromResult(_sessionManager.Sessions.Where(s => s.UserId.Equals(userId) || s.ContainsUser(userId)));
|
sessions = sessions.Where(s => s.UserId.Equals(userId) || s.ContainsUser(userId));
|
||||||
}
|
}
|
||||||
|
|
||||||
return Task.FromResult(_sessionManager.Sessions);
|
return Task.FromResult(sessions.Select(_sessionManager.ToSessionInfoDto));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Authors>Jellyfin Contributors</Authors>
|
<Authors>Jellyfin Contributors</Authors>
|
||||||
<PackageId>Jellyfin.Data</PackageId>
|
<PackageId>Jellyfin.Data</PackageId>
|
||||||
<VersionPrefix>10.11.0</VersionPrefix>
|
<VersionPrefix>10.11.11</VersionPrefix>
|
||||||
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
||||||
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ public static class UserEntityExtensions
|
|||||||
entity.Permissions.Add(new Permission(PermissionKind.EnableSyncTranscoding, true));
|
entity.Permissions.Add(new Permission(PermissionKind.EnableSyncTranscoding, true));
|
||||||
entity.Permissions.Add(new Permission(PermissionKind.EnableAudioPlaybackTranscoding, true));
|
entity.Permissions.Add(new Permission(PermissionKind.EnableAudioPlaybackTranscoding, true));
|
||||||
entity.Permissions.Add(new Permission(PermissionKind.EnableLiveTvAccess, true));
|
entity.Permissions.Add(new Permission(PermissionKind.EnableLiveTvAccess, true));
|
||||||
entity.Permissions.Add(new Permission(PermissionKind.EnableLiveTvManagement, true));
|
entity.Permissions.Add(new Permission(PermissionKind.EnableLiveTvManagement, false));
|
||||||
entity.Permissions.Add(new Permission(PermissionKind.EnableSharedDeviceControl, true));
|
entity.Permissions.Add(new Permission(PermissionKind.EnableSharedDeviceControl, true));
|
||||||
entity.Permissions.Add(new Permission(PermissionKind.EnableVideoPlaybackTranscoding, true));
|
entity.Permissions.Add(new Permission(PermissionKind.EnableVideoPlaybackTranscoding, true));
|
||||||
entity.Permissions.Add(new Permission(PermissionKind.ForceRemoteSourceTranscoding, false));
|
entity.Permissions.Add(new Permission(PermissionKind.ForceRemoteSourceTranscoding, false));
|
||||||
|
|||||||
@@ -118,17 +118,24 @@ public class BackupService : IBackupService
|
|||||||
throw new NotSupportedException($"The loaded archive '{archivePath}' is made for a newer version of Jellyfin ({manifest.ServerVersion}) and cannot be loaded in this version.");
|
throw new NotSupportedException($"The loaded archive '{archivePath}' is made for a newer version of Jellyfin ({manifest.ServerVersion}) and cannot be loaded in this version.");
|
||||||
}
|
}
|
||||||
|
|
||||||
void CopyDirectory(string source, string target)
|
void CopyDirectory(string source, string target, string[]? exclude = null)
|
||||||
{
|
{
|
||||||
var fullSourcePath = NormalizePathSeparator(Path.GetFullPath(source) + Path.DirectorySeparatorChar);
|
var fullSourcePath = NormalizePathSeparator(Path.GetFullPath(source) + Path.DirectorySeparatorChar);
|
||||||
var fullTargetRoot = Path.GetFullPath(target) + Path.DirectorySeparatorChar;
|
var fullTargetRoot = Path.GetFullPath(target) + Path.DirectorySeparatorChar;
|
||||||
|
var excludePaths = exclude?.Select(e => $"{source}/{e}/").ToArray();
|
||||||
foreach (var item in zipArchive.Entries)
|
foreach (var item in zipArchive.Entries)
|
||||||
{
|
{
|
||||||
var sourcePath = NormalizePathSeparator(Path.GetFullPath(item.FullName));
|
var sourcePath = NormalizePathSeparator(Path.GetFullPath(item.FullName));
|
||||||
var targetPath = Path.GetFullPath(Path.Combine(target, Path.GetRelativePath(source, item.FullName)));
|
var targetPath = Path.GetFullPath(Path.Combine(target, Path.GetRelativePath(source, item.FullName)));
|
||||||
|
|
||||||
|
if (excludePaths is not null && excludePaths.Any(e => item.FullName.StartsWith(e, StringComparison.Ordinal)))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (!sourcePath.StartsWith(fullSourcePath, StringComparison.Ordinal)
|
if (!sourcePath.StartsWith(fullSourcePath, StringComparison.Ordinal)
|
||||||
|| !targetPath.StartsWith(fullTargetRoot, StringComparison.Ordinal))
|
|| !targetPath.StartsWith(fullTargetRoot, StringComparison.Ordinal)
|
||||||
|
|| Path.EndsInDirectorySeparator(item.FullName))
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -141,8 +148,10 @@ public class BackupService : IBackupService
|
|||||||
}
|
}
|
||||||
|
|
||||||
CopyDirectory("Config", _applicationPaths.ConfigurationDirectoryPath);
|
CopyDirectory("Config", _applicationPaths.ConfigurationDirectoryPath);
|
||||||
CopyDirectory("Data", _applicationPaths.DataPath);
|
CopyDirectory("Data", _applicationPaths.DataPath, exclude: ["metadata", "metadata-default"]);
|
||||||
CopyDirectory("Root", _applicationPaths.RootFolderPath);
|
CopyDirectory("Root", _applicationPaths.RootFolderPath);
|
||||||
|
CopyDirectory("Data/metadata", _applicationPaths.InternalMetadataPath);
|
||||||
|
CopyDirectory("Data/metadata-default", _applicationPaths.DefaultInternalMetadataPath);
|
||||||
|
|
||||||
if (manifest.Options.Database)
|
if (manifest.Options.Database)
|
||||||
{
|
{
|
||||||
@@ -199,7 +208,7 @@ public class BackupService : IBackupService
|
|||||||
var zipEntry = zipArchive.GetEntry(NormalizePathSeparator(Path.Combine("Database", $"{entityType.Type.Name}.json")));
|
var zipEntry = zipArchive.GetEntry(NormalizePathSeparator(Path.Combine("Database", $"{entityType.Type.Name}.json")));
|
||||||
if (zipEntry is null)
|
if (zipEntry is null)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("No backup of expected table {Table} is present in backup. Continue anyway.", entityType.Type.Name);
|
_logger.LogInformation("No backup of expected table {Table} is present in backup, continuing anyway", entityType.Type.Name);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,7 +232,7 @@ public class BackupService : IBackupService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Could not store entity {Entity} continue anyway.", item);
|
_logger.LogError(ex, "Could not store entity {Entity}, continuing anyway", item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,11 +242,11 @@ public class BackupService : IBackupService
|
|||||||
|
|
||||||
_logger.LogInformation("Try restore Database");
|
_logger.LogInformation("Try restore Database");
|
||||||
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
||||||
_logger.LogInformation("Restored database.");
|
_logger.LogInformation("Restored database");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("Restored Jellyfin system from {Date}.", manifest.DateCreated);
|
_logger.LogInformation("Restored Jellyfin system from {Date}", manifest.DateCreated);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,6 +272,8 @@ public class BackupService : IBackupService
|
|||||||
Options = Map(backupOptions)
|
Options = Map(backupOptions)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
_logger.LogInformation("Running database optimization before backup");
|
||||||
|
|
||||||
await _jellyfinDatabaseProvider.RunScheduledOptimisation(CancellationToken.None).ConfigureAwait(false);
|
await _jellyfinDatabaseProvider.RunScheduledOptimisation(CancellationToken.None).ConfigureAwait(false);
|
||||||
|
|
||||||
var backupFolder = Path.Combine(_applicationPaths.BackupPath);
|
var backupFolder = Path.Combine(_applicationPaths.BackupPath);
|
||||||
@@ -281,16 +292,20 @@ public class BackupService : IBackupService
|
|||||||
}
|
}
|
||||||
|
|
||||||
var backupPath = Path.Combine(backupFolder, $"jellyfin-backup-{manifest.DateCreated.ToLocalTime():yyyyMMddHHmmss}.zip");
|
var backupPath = Path.Combine(backupFolder, $"jellyfin-backup-{manifest.DateCreated.ToLocalTime():yyyyMMddHHmmss}.zip");
|
||||||
_logger.LogInformation("Attempt to create a new backup at {BackupPath}", backupPath);
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Attempting to create a new backup at {BackupPath}", backupPath);
|
||||||
var fileStream = File.OpenWrite(backupPath);
|
var fileStream = File.OpenWrite(backupPath);
|
||||||
await using (fileStream.ConfigureAwait(false))
|
await using (fileStream.ConfigureAwait(false))
|
||||||
using (var zipArchive = new ZipArchive(fileStream, ZipArchiveMode.Create, false))
|
using (var zipArchive = new ZipArchive(fileStream, ZipArchiveMode.Create, false))
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Start backup process.");
|
_logger.LogInformation("Starting backup process");
|
||||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||||
await using (dbContext.ConfigureAwait(false))
|
await using (dbContext.ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
dbContext.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
|
dbContext.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
|
||||||
|
|
||||||
static IAsyncEnumerable<object> GetValues(IQueryable dbSet)
|
static IAsyncEnumerable<object> GetValues(IQueryable dbSet)
|
||||||
{
|
{
|
||||||
var method = dbSet.GetType().GetMethod(nameof(DbSet<object>.AsAsyncEnumerable))!;
|
var method = dbSet.GetType().GetMethod(nameof(DbSet<object>.AsAsyncEnumerable))!;
|
||||||
@@ -302,7 +317,8 @@ public class BackupService : IBackupService
|
|||||||
var historyRepository = dbContext.GetService<IHistoryRepository>();
|
var historyRepository = dbContext.GetService<IHistoryRepository>();
|
||||||
var migrations = await historyRepository.GetAppliedMigrationsAsync().ConfigureAwait(false);
|
var migrations = await historyRepository.GetAppliedMigrationsAsync().ConfigureAwait(false);
|
||||||
|
|
||||||
ICollection<(Type Type, string SourceName, Func<IAsyncEnumerable<object>> ValueFactory)> entityTypes = [
|
ICollection<(Type Type, string SourceName, Func<IAsyncEnumerable<object>> ValueFactory)> entityTypes =
|
||||||
|
[
|
||||||
.. typeof(JellyfinDbContext)
|
.. typeof(JellyfinDbContext)
|
||||||
.GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)
|
.GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)
|
||||||
.Where(e => e.PropertyType.IsAssignableTo(typeof(IQueryable)))
|
.Where(e => e.PropertyType.IsAssignableTo(typeof(IQueryable)))
|
||||||
@@ -335,7 +351,8 @@ public class BackupService : IBackupService
|
|||||||
entities++;
|
entities++;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
JsonSerializer.SerializeToDocument(item, _serializerSettings).WriteTo(jsonSerializer);
|
using var document = JsonSerializer.SerializeToDocument(item, _serializerSettings);
|
||||||
|
document.WriteTo(jsonSerializer);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -348,7 +365,7 @@ public class BackupService : IBackupService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("backup of entity {Table} with {Number} created", entityType.Type.Name, entities);
|
_logger.LogInformation("Backup of entity {Table} with {Number} created", entityType.SourceName, entities);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -394,6 +411,15 @@ public class BackupService : IBackupService
|
|||||||
if (backupOptions.Metadata)
|
if (backupOptions.Metadata)
|
||||||
{
|
{
|
||||||
CopyDirectory(Path.Combine(_applicationPaths.InternalMetadataPath), Path.Combine("Data", "metadata"));
|
CopyDirectory(Path.Combine(_applicationPaths.InternalMetadataPath), Path.Combine("Data", "metadata"));
|
||||||
|
|
||||||
|
// If a custom metadata path is configured, the default location may still contain data.
|
||||||
|
if (!string.Equals(
|
||||||
|
Path.GetFullPath(_applicationPaths.DefaultInternalMetadataPath),
|
||||||
|
Path.GetFullPath(_applicationPaths.InternalMetadataPath),
|
||||||
|
StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
CopyDirectory(Path.Combine(_applicationPaths.DefaultInternalMetadataPath), Path.Combine("Data", "metadata-default"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var manifestStream = zipArchive.CreateEntry(ManifestEntryName).Open();
|
var manifestStream = zipArchive.CreateEntry(ManifestEntryName).Open();
|
||||||
@@ -406,6 +432,24 @@ public class BackupService : IBackupService
|
|||||||
_logger.LogInformation("Backup created");
|
_logger.LogInformation("Backup created");
|
||||||
return Map(manifest, backupPath);
|
return Map(manifest, backupPath);
|
||||||
}
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to create backup, removing {BackupPath}", backupPath);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(backupPath))
|
||||||
|
{
|
||||||
|
File.Delete(backupPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception innerEx)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(innerEx, "Unable to remove failed backup");
|
||||||
|
}
|
||||||
|
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<BackupManifestDto?> GetBackupManifest(string archivePath)
|
public async Task<BackupManifestDto?> GetBackupManifest(string archivePath)
|
||||||
@@ -422,7 +466,7 @@ public class BackupService : IBackupService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Tried to load archive from {Path} but failed.", archivePath);
|
_logger.LogWarning(ex, "Tried to load manifest from archive {Path} but failed", archivePath);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -459,7 +503,7 @@ public class BackupService : IBackupService
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Could not load {BackupArchive} path.", item);
|
_logger.LogWarning(ex, "Tried to load manifest from archive {Path} but failed", item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ using MediaBrowser.Controller.LiveTv;
|
|||||||
using MediaBrowser.Controller.Persistence;
|
using MediaBrowser.Controller.Persistence;
|
||||||
using MediaBrowser.Model.Dto;
|
using MediaBrowser.Model.Dto;
|
||||||
using MediaBrowser.Model.Entities;
|
using MediaBrowser.Model.Entities;
|
||||||
|
using MediaBrowser.Model.Globalization;
|
||||||
using MediaBrowser.Model.LiveTv;
|
using MediaBrowser.Model.LiveTv;
|
||||||
using MediaBrowser.Model.Querying;
|
using MediaBrowser.Model.Querying;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -69,6 +70,7 @@ public sealed class BaseItemRepository
|
|||||||
private readonly IItemTypeLookup _itemTypeLookup;
|
private readonly IItemTypeLookup _itemTypeLookup;
|
||||||
private readonly IServerConfigurationManager _serverConfigurationManager;
|
private readonly IServerConfigurationManager _serverConfigurationManager;
|
||||||
private readonly ILogger<BaseItemRepository> _logger;
|
private readonly ILogger<BaseItemRepository> _logger;
|
||||||
|
private readonly ILocalizationManager _localizationManager;
|
||||||
|
|
||||||
private static readonly IReadOnlyList<ItemValueType> _getAllArtistsValueTypes = [ItemValueType.Artist, ItemValueType.AlbumArtist];
|
private static readonly IReadOnlyList<ItemValueType> _getAllArtistsValueTypes = [ItemValueType.Artist, ItemValueType.AlbumArtist];
|
||||||
private static readonly IReadOnlyList<ItemValueType> _getArtistValueTypes = [ItemValueType.Artist];
|
private static readonly IReadOnlyList<ItemValueType> _getArtistValueTypes = [ItemValueType.Artist];
|
||||||
@@ -85,18 +87,21 @@ public sealed class BaseItemRepository
|
|||||||
/// <param name="itemTypeLookup">The static type lookup.</param>
|
/// <param name="itemTypeLookup">The static type lookup.</param>
|
||||||
/// <param name="serverConfigurationManager">The server Configuration manager.</param>
|
/// <param name="serverConfigurationManager">The server Configuration manager.</param>
|
||||||
/// <param name="logger">System logger.</param>
|
/// <param name="logger">System logger.</param>
|
||||||
|
/// <param name="localizationManager">Localization manager.</param>
|
||||||
public BaseItemRepository(
|
public BaseItemRepository(
|
||||||
IDbContextFactory<JellyfinDbContext> dbProvider,
|
IDbContextFactory<JellyfinDbContext> dbProvider,
|
||||||
IServerApplicationHost appHost,
|
IServerApplicationHost appHost,
|
||||||
IItemTypeLookup itemTypeLookup,
|
IItemTypeLookup itemTypeLookup,
|
||||||
IServerConfigurationManager serverConfigurationManager,
|
IServerConfigurationManager serverConfigurationManager,
|
||||||
ILogger<BaseItemRepository> logger)
|
ILogger<BaseItemRepository> logger,
|
||||||
|
ILocalizationManager localizationManager)
|
||||||
{
|
{
|
||||||
_dbProvider = dbProvider;
|
_dbProvider = dbProvider;
|
||||||
_appHost = appHost;
|
_appHost = appHost;
|
||||||
_itemTypeLookup = itemTypeLookup;
|
_itemTypeLookup = itemTypeLookup;
|
||||||
_serverConfigurationManager = serverConfigurationManager;
|
_serverConfigurationManager = serverConfigurationManager;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_localizationManager = localizationManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -275,6 +280,7 @@ public sealed class BaseItemRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
dbQuery = ApplyQueryPaging(dbQuery, filter);
|
dbQuery = ApplyQueryPaging(dbQuery, filter);
|
||||||
|
dbQuery = ApplyNavigations(dbQuery, filter);
|
||||||
|
|
||||||
result.Items = dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray();
|
result.Items = dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray();
|
||||||
result.StartIndex = filter.StartIndex ?? 0;
|
result.StartIndex = filter.StartIndex ?? 0;
|
||||||
@@ -295,6 +301,26 @@ public sealed class BaseItemRepository
|
|||||||
dbQuery = ApplyGroupingFilter(context, dbQuery, filter);
|
dbQuery = ApplyGroupingFilter(context, dbQuery, filter);
|
||||||
dbQuery = ApplyQueryPaging(dbQuery, filter);
|
dbQuery = ApplyQueryPaging(dbQuery, filter);
|
||||||
|
|
||||||
|
var hasRandomSort = filter.OrderBy.Any(e => e.OrderBy == ItemSortBy.Random);
|
||||||
|
if (hasRandomSort)
|
||||||
|
{
|
||||||
|
var orderedIds = dbQuery.Select(e => e.Id).ToList();
|
||||||
|
if (orderedIds.Count == 0)
|
||||||
|
{
|
||||||
|
return Array.Empty<BaseItemDto>();
|
||||||
|
}
|
||||||
|
|
||||||
|
var itemsById = ApplyNavigations(context.BaseItems.Where(e => orderedIds.Contains(e.Id)), filter)
|
||||||
|
.AsEnumerable()
|
||||||
|
.Select(w => DeserializeBaseItem(w, filter.SkipDeserialization))
|
||||||
|
.Where(dto => dto is not null)
|
||||||
|
.ToDictionary(i => i!.Id);
|
||||||
|
|
||||||
|
return orderedIds.Where(itemsById.ContainsKey).Select(id => itemsById[id]).ToArray()!;
|
||||||
|
}
|
||||||
|
|
||||||
|
dbQuery = ApplyNavigations(dbQuery, filter);
|
||||||
|
|
||||||
return dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray();
|
return dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,6 +363,8 @@ public sealed class BaseItemRepository
|
|||||||
mainquery = ApplyGroupingFilter(context, mainquery, filter);
|
mainquery = ApplyGroupingFilter(context, mainquery, filter);
|
||||||
mainquery = ApplyQueryPaging(mainquery, filter);
|
mainquery = ApplyQueryPaging(mainquery, filter);
|
||||||
|
|
||||||
|
mainquery = ApplyNavigations(mainquery, filter);
|
||||||
|
|
||||||
return mainquery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray();
|
return mainquery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,19 +427,32 @@ public sealed class BaseItemRepository
|
|||||||
dbQuery = dbQuery.Distinct();
|
dbQuery = dbQuery.Distinct();
|
||||||
}
|
}
|
||||||
|
|
||||||
dbQuery = ApplyOrder(dbQuery, filter);
|
dbQuery = ApplyOrder(dbQuery, filter, context);
|
||||||
|
|
||||||
dbQuery = ApplyNavigations(dbQuery, filter);
|
|
||||||
|
|
||||||
return dbQuery;
|
return dbQuery;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IQueryable<BaseItemEntity> ApplyNavigations(IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter)
|
private static IQueryable<BaseItemEntity> ApplyNavigations(IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter)
|
||||||
{
|
{
|
||||||
dbQuery = dbQuery.Include(e => e.TrailerTypes)
|
if (filter.TrailerTypes.Length > 0 || filter.IncludeItemTypes.Contains(BaseItemKind.Trailer))
|
||||||
.Include(e => e.Provider)
|
{
|
||||||
.Include(e => e.LockedFields)
|
dbQuery = dbQuery.Include(e => e.TrailerTypes);
|
||||||
.Include(e => e.UserData);
|
}
|
||||||
|
|
||||||
|
if (filter.DtoOptions.ContainsField(ItemFields.ProviderIds))
|
||||||
|
{
|
||||||
|
dbQuery = dbQuery.Include(e => e.Provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.DtoOptions.ContainsField(ItemFields.Settings))
|
||||||
|
{
|
||||||
|
dbQuery = dbQuery.Include(e => e.LockedFields);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.DtoOptions.EnableUserData)
|
||||||
|
{
|
||||||
|
dbQuery = dbQuery.Include(e => e.UserData);
|
||||||
|
}
|
||||||
|
|
||||||
if (filter.DtoOptions.EnableImages)
|
if (filter.DtoOptions.EnableImages)
|
||||||
{
|
{
|
||||||
@@ -446,6 +487,7 @@ public sealed class BaseItemRepository
|
|||||||
dbQuery = TranslateQuery(dbQuery, context, filter);
|
dbQuery = TranslateQuery(dbQuery, context, filter);
|
||||||
dbQuery = ApplyGroupingFilter(context, dbQuery, filter);
|
dbQuery = ApplyGroupingFilter(context, dbQuery, filter);
|
||||||
dbQuery = ApplyQueryPaging(dbQuery, filter);
|
dbQuery = ApplyQueryPaging(dbQuery, filter);
|
||||||
|
dbQuery = ApplyNavigations(dbQuery, filter);
|
||||||
return dbQuery;
|
return dbQuery;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -599,7 +641,6 @@ public sealed class BaseItemRepository
|
|||||||
|
|
||||||
var ids = tuples.Select(f => f.Item.Id).ToArray();
|
var ids = tuples.Select(f => f.Item.Id).ToArray();
|
||||||
var existingItems = context.BaseItems.Where(e => ids.Contains(e.Id)).Select(f => f.Id).ToArray();
|
var existingItems = context.BaseItems.Where(e => ids.Contains(e.Id)).Select(f => f.Id).ToArray();
|
||||||
var newItems = tuples.Where(e => !existingItems.Contains(e.Item.Id)).ToArray();
|
|
||||||
|
|
||||||
foreach (var item in tuples)
|
foreach (var item in tuples)
|
||||||
{
|
{
|
||||||
@@ -614,25 +655,25 @@ public sealed class BaseItemRepository
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
context.BaseItemProviders.Where(e => e.ItemId == entity.Id).ExecuteDelete();
|
context.BaseItemProviders.Where(e => e.ItemId == entity.Id).ExecuteDelete();
|
||||||
|
context.BaseItemImageInfos.Where(e => e.ItemId == entity.Id).ExecuteDelete();
|
||||||
|
context.BaseItemMetadataFields.Where(e => e.ItemId == entity.Id).ExecuteDelete();
|
||||||
|
|
||||||
|
if (entity.Images is { Count: > 0 })
|
||||||
|
{
|
||||||
|
context.BaseItemImageInfos.AddRange(entity.Images);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entity.LockedFields is { Count: > 0 })
|
||||||
|
{
|
||||||
|
context.BaseItemMetadataFields.AddRange(entity.LockedFields);
|
||||||
|
}
|
||||||
|
|
||||||
context.BaseItems.Attach(entity).State = EntityState.Modified;
|
context.BaseItems.Attach(entity).State = EntityState.Modified;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
context.SaveChanges();
|
context.SaveChanges();
|
||||||
|
|
||||||
foreach (var item in newItems)
|
|
||||||
{
|
|
||||||
// reattach old userData entries
|
|
||||||
var userKeys = item.UserDataKey.ToArray();
|
|
||||||
var retentionDate = (DateTime?)null;
|
|
||||||
context.UserData
|
|
||||||
.Where(e => e.ItemId == PlaceholderId)
|
|
||||||
.Where(e => userKeys.Contains(e.CustomDataKey))
|
|
||||||
.ExecuteUpdate(e => e
|
|
||||||
.SetProperty(f => f.ItemId, item.Item.Id)
|
|
||||||
.SetProperty(f => f.RetentionDate, retentionDate));
|
|
||||||
}
|
|
||||||
|
|
||||||
var itemValueMaps = tuples
|
var itemValueMaps = tuples
|
||||||
.Select(e => (e.Item, Values: GetItemValuesToSave(e.Item, e.InheritedTags)))
|
.Select(e => (e.Item, Values: GetItemValuesToSave(e.Item, e.InheritedTags)))
|
||||||
.ToArray();
|
.ToArray();
|
||||||
@@ -728,6 +769,43 @@ public sealed class BaseItemRepository
|
|||||||
transaction.Commit();
|
transaction.Commit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task ReattachUserDataAsync(BaseItemDto item, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(item);
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
await using (dbContext.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
await using (transaction.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
var userKeys = item.GetUserDataKeys().ToArray();
|
||||||
|
var retentionDate = (DateTime?)null;
|
||||||
|
|
||||||
|
await dbContext.UserData
|
||||||
|
.Where(e => e.ItemId == PlaceholderId)
|
||||||
|
.Where(e => userKeys.Contains(e.CustomDataKey))
|
||||||
|
.ExecuteUpdateAsync(
|
||||||
|
e => e
|
||||||
|
.SetProperty(f => f.ItemId, item.Id)
|
||||||
|
.SetProperty(f => f.RetentionDate, retentionDate),
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Rehydrate the cached userdata
|
||||||
|
item.UserData = await dbContext.UserData
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(e => e.ItemId == item.Id)
|
||||||
|
.ToArrayAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public BaseItemDto? RetrieveItem(Guid id)
|
public BaseItemDto? RetrieveItem(Guid id)
|
||||||
{
|
{
|
||||||
@@ -835,7 +913,7 @@ public sealed class BaseItemRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
dto.ExtraIds = string.IsNullOrWhiteSpace(entity.ExtraIds) ? [] : entity.ExtraIds.Split('|').Select(e => Guid.Parse(e)).ToArray();
|
dto.ExtraIds = string.IsNullOrWhiteSpace(entity.ExtraIds) ? [] : entity.ExtraIds.Split('|').Select(e => Guid.Parse(e)).ToArray();
|
||||||
dto.ProductionLocations = entity.ProductionLocations?.Split('|') ?? [];
|
dto.ProductionLocations = entity.ProductionLocations?.Split('|', StringSplitOptions.RemoveEmptyEntries) ?? [];
|
||||||
dto.Studios = entity.Studios?.Split('|') ?? [];
|
dto.Studios = entity.Studios?.Split('|') ?? [];
|
||||||
dto.Tags = string.IsNullOrWhiteSpace(entity.Tags) ? [] : entity.Tags.Split('|');
|
dto.Tags = string.IsNullOrWhiteSpace(entity.Tags) ? [] : entity.Tags.Split('|');
|
||||||
|
|
||||||
@@ -997,7 +1075,7 @@ public sealed class BaseItemRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
entity.ExtraIds = dto.ExtraIds is not null ? string.Join('|', dto.ExtraIds) : null;
|
entity.ExtraIds = dto.ExtraIds is not null ? string.Join('|', dto.ExtraIds) : null;
|
||||||
entity.ProductionLocations = dto.ProductionLocations is not null ? string.Join('|', dto.ProductionLocations) : null;
|
entity.ProductionLocations = dto.ProductionLocations is not null ? string.Join('|', dto.ProductionLocations.Where(p => !string.IsNullOrWhiteSpace(p))) : null;
|
||||||
entity.Studios = dto.Studios is not null ? string.Join('|', dto.Studios) : null;
|
entity.Studios = dto.Studios is not null ? string.Join('|', dto.Studios) : null;
|
||||||
entity.Tags = dto.Tags is not null ? string.Join('|', dto.Tags) : null;
|
entity.Tags = dto.Tags is not null ? string.Join('|', dto.Tags) : null;
|
||||||
entity.LockedFields = dto.LockedFields is not null ? dto.LockedFields
|
entity.LockedFields = dto.LockedFields is not null ? dto.LockedFields
|
||||||
@@ -1245,7 +1323,7 @@ public sealed class BaseItemRepository
|
|||||||
.AsSingleQuery()
|
.AsSingleQuery()
|
||||||
.Where(e => masterQuery.Contains(e.Id));
|
.Where(e => masterQuery.Contains(e.Id));
|
||||||
|
|
||||||
query = ApplyOrder(query, filter);
|
query = ApplyOrder(query, filter, context);
|
||||||
|
|
||||||
var result = new QueryResult<(BaseItemDto, ItemCounts?)>();
|
var result = new QueryResult<(BaseItemDto, ItemCounts?)>();
|
||||||
if (filter.EnableTotalRecordCount)
|
if (filter.EnableTotalRecordCount)
|
||||||
@@ -1511,51 +1589,58 @@ public sealed class BaseItemRepository
|
|||||||
|| query.IncludeItemTypes.Contains(BaseItemKind.Season);
|
|| query.IncludeItemTypes.Contains(BaseItemKind.Season);
|
||||||
}
|
}
|
||||||
|
|
||||||
private IQueryable<BaseItemEntity> ApplyOrder(IQueryable<BaseItemEntity> query, InternalItemsQuery filter)
|
private IQueryable<BaseItemEntity> ApplyOrder(IQueryable<BaseItemEntity> query, InternalItemsQuery filter, JellyfinDbContext context)
|
||||||
{
|
{
|
||||||
var orderBy = filter.OrderBy;
|
var orderBy = filter.OrderBy.Where(e => e.OrderBy != ItemSortBy.Default).ToArray();
|
||||||
var hasSearch = !string.IsNullOrEmpty(filter.SearchTerm);
|
var hasSearch = !string.IsNullOrEmpty(filter.SearchTerm);
|
||||||
|
|
||||||
if (hasSearch)
|
if (hasSearch)
|
||||||
{
|
{
|
||||||
orderBy = filter.OrderBy = [(ItemSortBy.SortName, SortOrder.Ascending), .. orderBy];
|
orderBy = [(ItemSortBy.SortName, SortOrder.Ascending), .. orderBy];
|
||||||
}
|
}
|
||||||
else if (orderBy.Count == 0)
|
else if (orderBy.Length == 0)
|
||||||
{
|
{
|
||||||
return query.OrderBy(e => e.SortName);
|
return query.OrderBy(e => e.SortName);
|
||||||
}
|
}
|
||||||
|
|
||||||
IOrderedQueryable<BaseItemEntity>? orderedQuery = null;
|
IOrderedQueryable<BaseItemEntity>? orderedQuery = null;
|
||||||
|
|
||||||
|
// When searching, prioritize by match quality: exact match > prefix match > contains
|
||||||
|
if (hasSearch)
|
||||||
|
{
|
||||||
|
orderedQuery = query.OrderBy(OrderMapper.MapSearchRelevanceOrder(filter.SearchTerm!));
|
||||||
|
}
|
||||||
|
|
||||||
var firstOrdering = orderBy.FirstOrDefault();
|
var firstOrdering = orderBy.FirstOrDefault();
|
||||||
if (firstOrdering != default)
|
if (firstOrdering != default)
|
||||||
{
|
{
|
||||||
var expression = OrderMapper.MapOrderByField(firstOrdering.OrderBy, filter);
|
var expression = OrderMapper.MapOrderByField(firstOrdering.OrderBy, filter, context);
|
||||||
if (firstOrdering.SortOrder == SortOrder.Ascending)
|
if (orderedQuery is null)
|
||||||
{
|
{
|
||||||
orderedQuery = query.OrderBy(expression);
|
// No search relevance ordering, start fresh
|
||||||
|
orderedQuery = firstOrdering.SortOrder == SortOrder.Ascending
|
||||||
|
? query.OrderBy(expression)
|
||||||
|
: query.OrderByDescending(expression);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
orderedQuery = query.OrderByDescending(expression);
|
// Search relevance ordering already applied, chain with ThenBy
|
||||||
|
orderedQuery = firstOrdering.SortOrder == SortOrder.Ascending
|
||||||
|
? orderedQuery.ThenBy(expression)
|
||||||
|
: orderedQuery.ThenByDescending(expression);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (firstOrdering.OrderBy is ItemSortBy.Default or ItemSortBy.SortName)
|
if (firstOrdering.OrderBy is ItemSortBy.Default or ItemSortBy.SortName)
|
||||||
{
|
{
|
||||||
if (firstOrdering.SortOrder is SortOrder.Ascending)
|
orderedQuery = firstOrdering.SortOrder is SortOrder.Ascending
|
||||||
{
|
? orderedQuery.ThenBy(e => e.Name)
|
||||||
orderedQuery = orderedQuery.ThenBy(e => e.Name);
|
: orderedQuery.ThenByDescending(e => e.Name);
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
orderedQuery = orderedQuery.ThenByDescending(e => e.Name);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var item in orderBy.Skip(1))
|
foreach (var item in orderBy.Skip(1))
|
||||||
{
|
{
|
||||||
var expression = OrderMapper.MapOrderByField(item.OrderBy, filter);
|
var expression = OrderMapper.MapOrderByField(item.OrderBy, filter, context);
|
||||||
if (item.SortOrder == SortOrder.Ascending)
|
if (item.SortOrder == SortOrder.Ascending)
|
||||||
{
|
{
|
||||||
orderedQuery = orderedQuery!.ThenBy(expression);
|
orderedQuery = orderedQuery!.ThenBy(expression);
|
||||||
@@ -1637,19 +1722,18 @@ public sealed class BaseItemRepository
|
|||||||
var tags = filter.Tags.ToList();
|
var tags = filter.Tags.ToList();
|
||||||
var excludeTags = filter.ExcludeTags.ToList();
|
var excludeTags = filter.ExcludeTags.ToList();
|
||||||
|
|
||||||
if (filter.IsMovie == true)
|
if (filter.IsMovie.HasValue)
|
||||||
{
|
{
|
||||||
if (filter.IncludeItemTypes.Length == 0
|
var shouldIncludeAllMovieTypes = filter.IsMovie.Value
|
||||||
|
&& (filter.IncludeItemTypes.Length == 0
|
||||||
|| filter.IncludeItemTypes.Contains(BaseItemKind.Movie)
|
|| filter.IncludeItemTypes.Contains(BaseItemKind.Movie)
|
||||||
|| filter.IncludeItemTypes.Contains(BaseItemKind.Trailer))
|
|| filter.IncludeItemTypes.Contains(BaseItemKind.Trailer));
|
||||||
|
|
||||||
|
if (!shouldIncludeAllMovieTypes)
|
||||||
{
|
{
|
||||||
baseQuery = baseQuery.Where(e => e.IsMovie);
|
baseQuery = baseQuery.Where(e => e.IsMovie == filter.IsMovie.Value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (filter.IsMovie.HasValue)
|
|
||||||
{
|
|
||||||
baseQuery = baseQuery.Where(e => e.IsMovie == filter.IsMovie);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filter.IsSeries.HasValue)
|
if (filter.IsSeries.HasValue)
|
||||||
{
|
{
|
||||||
@@ -1694,15 +1778,16 @@ public sealed class BaseItemRepository
|
|||||||
|
|
||||||
if (!string.IsNullOrEmpty(filter.SearchTerm))
|
if (!string.IsNullOrEmpty(filter.SearchTerm))
|
||||||
{
|
{
|
||||||
var searchTerm = filter.SearchTerm.ToLower();
|
var cleanedSearchTerm = GetCleanValue(filter.SearchTerm);
|
||||||
if (SearchWildcardTerms.Any(f => searchTerm.Contains(f)))
|
var originalSearchTerm = filter.SearchTerm.ToLower();
|
||||||
|
if (SearchWildcardTerms.Any(f => cleanedSearchTerm.Contains(f)))
|
||||||
{
|
{
|
||||||
searchTerm = $"%{searchTerm.Trim('%')}%";
|
cleanedSearchTerm = $"%{cleanedSearchTerm.Trim('%')}%";
|
||||||
baseQuery = baseQuery.Where(e => EF.Functions.Like(e.CleanName!.ToLower(), searchTerm) || (e.OriginalTitle != null && EF.Functions.Like(e.OriginalTitle.ToLower(), searchTerm)));
|
baseQuery = baseQuery.Where(e => EF.Functions.Like(e.CleanName!, cleanedSearchTerm) || (e.OriginalTitle != null && EF.Functions.Like(e.OriginalTitle.ToLower(), originalSearchTerm)));
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
baseQuery = baseQuery.Where(e => e.CleanName!.ToLower().Contains(searchTerm) || (e.OriginalTitle != null && e.OriginalTitle.ToLower().Contains(searchTerm)));
|
baseQuery = baseQuery.Where(e => e.CleanName!.Contains(cleanedSearchTerm) || (e.OriginalTitle != null && e.OriginalTitle.ToLower().Contains(originalSearchTerm)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1756,7 +1841,8 @@ public sealed class BaseItemRepository
|
|||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(filter.Path))
|
if (!string.IsNullOrWhiteSpace(filter.Path))
|
||||||
{
|
{
|
||||||
baseQuery = baseQuery.Where(e => e.Path == filter.Path);
|
var pathToQuery = GetPathToSave(filter.Path);
|
||||||
|
baseQuery = baseQuery.Where(e => e.Path == pathToQuery);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(filter.PresentationUniqueKey))
|
if (!string.IsNullOrWhiteSpace(filter.PresentationUniqueKey))
|
||||||
@@ -1912,10 +1998,17 @@ public sealed class BaseItemRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(filter.Name))
|
if (!string.IsNullOrWhiteSpace(filter.Name))
|
||||||
|
{
|
||||||
|
if (filter.UseRawName == true)
|
||||||
|
{
|
||||||
|
baseQuery = baseQuery.Where(e => e.Name == filter.Name);
|
||||||
|
}
|
||||||
|
else
|
||||||
{
|
{
|
||||||
var cleanName = GetCleanValue(filter.Name);
|
var cleanName = GetCleanValue(filter.Name);
|
||||||
baseQuery = baseQuery.Where(e => e.CleanName == cleanName);
|
baseQuery = baseQuery.Where(e => e.CleanName == cleanName);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// These are the same, for now
|
// These are the same, for now
|
||||||
var nameContains = filter.NameContains;
|
var nameContains = filter.NameContains;
|
||||||
@@ -1936,19 +2029,20 @@ public sealed class BaseItemRepository
|
|||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(filter.NameStartsWith))
|
if (!string.IsNullOrWhiteSpace(filter.NameStartsWith))
|
||||||
{
|
{
|
||||||
baseQuery = baseQuery.Where(e => e.SortName!.StartsWith(filter.NameStartsWith));
|
var startsWithLower = filter.NameStartsWith.ToLowerInvariant();
|
||||||
|
baseQuery = baseQuery.Where(e => e.SortName!.StartsWith(startsWithLower));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(filter.NameStartsWithOrGreater))
|
if (!string.IsNullOrWhiteSpace(filter.NameStartsWithOrGreater))
|
||||||
{
|
{
|
||||||
// i hate this
|
var startsOrGreaterLower = filter.NameStartsWithOrGreater.ToLowerInvariant();
|
||||||
baseQuery = baseQuery.Where(e => e.SortName!.FirstOrDefault() > filter.NameStartsWithOrGreater[0] || e.Name!.FirstOrDefault() > filter.NameStartsWithOrGreater[0]);
|
baseQuery = baseQuery.Where(e => e.SortName!.CompareTo(startsOrGreaterLower) >= 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(filter.NameLessThan))
|
if (!string.IsNullOrWhiteSpace(filter.NameLessThan))
|
||||||
{
|
{
|
||||||
// i hate this
|
var lessThanLower = filter.NameLessThan.ToLowerInvariant();
|
||||||
baseQuery = baseQuery.Where(e => e.SortName!.FirstOrDefault() < filter.NameLessThan[0] || e.Name!.FirstOrDefault() < filter.NameLessThan[0]);
|
baseQuery = baseQuery.Where(e => e.SortName!.CompareTo(lessThanLower ) < 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter.ImageTypes.Length > 0)
|
if (filter.ImageTypes.Length > 0)
|
||||||
@@ -2046,7 +2140,7 @@ public sealed class BaseItemRepository
|
|||||||
|
|
||||||
if (filter.ExcludeArtistIds.Length > 0)
|
if (filter.ExcludeArtistIds.Length > 0)
|
||||||
{
|
{
|
||||||
baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Artist, filter.ExcludeArtistIds, true);
|
baseQuery = baseQuery.WhereReferencedItemMultipleTypes(context, [ItemValueType.Artist, ItemValueType.AlbumArtist], filter.ExcludeArtistIds, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter.GenreIds.Count > 0)
|
if (filter.GenreIds.Count > 0)
|
||||||
@@ -2207,27 +2301,43 @@ public sealed class BaseItemRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(filter.HasNoAudioTrackWithLanguage))
|
if (!string.IsNullOrWhiteSpace(filter.HasNoAudioTrackWithLanguage))
|
||||||
|
{
|
||||||
|
var lang = _localizationManager.FindLanguageInfo(filter.HasNoAudioTrackWithLanguage);
|
||||||
|
if (lang is not null)
|
||||||
{
|
{
|
||||||
baseQuery = baseQuery
|
baseQuery = baseQuery
|
||||||
.Where(e => !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Audio && f.Language == filter.HasNoAudioTrackWithLanguage));
|
.Where(e => !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Audio && lang.ThreeLetterISOLanguageNames.Contains(f.Language)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(filter.HasNoInternalSubtitleTrackWithLanguage))
|
if (!string.IsNullOrWhiteSpace(filter.HasNoInternalSubtitleTrackWithLanguage))
|
||||||
|
{
|
||||||
|
var lang = _localizationManager.FindLanguageInfo(filter.HasNoInternalSubtitleTrackWithLanguage);
|
||||||
|
if (lang is not null)
|
||||||
{
|
{
|
||||||
baseQuery = baseQuery
|
baseQuery = baseQuery
|
||||||
.Where(e => !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle && !f.IsExternal && f.Language == filter.HasNoInternalSubtitleTrackWithLanguage));
|
.Where(e => !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle && !f.IsExternal && lang.ThreeLetterISOLanguageNames.Contains(f.Language)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(filter.HasNoExternalSubtitleTrackWithLanguage))
|
if (!string.IsNullOrWhiteSpace(filter.HasNoExternalSubtitleTrackWithLanguage))
|
||||||
|
{
|
||||||
|
var lang = _localizationManager.FindLanguageInfo(filter.HasNoExternalSubtitleTrackWithLanguage);
|
||||||
|
if (lang is not null)
|
||||||
{
|
{
|
||||||
baseQuery = baseQuery
|
baseQuery = baseQuery
|
||||||
.Where(e => !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle && f.IsExternal && f.Language == filter.HasNoExternalSubtitleTrackWithLanguage));
|
.Where(e => !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle && f.IsExternal && lang.ThreeLetterISOLanguageNames.Contains(f.Language)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(filter.HasNoSubtitleTrackWithLanguage))
|
if (!string.IsNullOrWhiteSpace(filter.HasNoSubtitleTrackWithLanguage))
|
||||||
|
{
|
||||||
|
var lang = _localizationManager.FindLanguageInfo(filter.HasNoSubtitleTrackWithLanguage);
|
||||||
|
if (lang is not null)
|
||||||
{
|
{
|
||||||
baseQuery = baseQuery
|
baseQuery = baseQuery
|
||||||
.Where(e => !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle && f.Language == filter.HasNoSubtitleTrackWithLanguage));
|
.Where(e => !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle && lang.ThreeLetterISOLanguageNames.Contains(f.Language)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter.HasSubtitles.HasValue)
|
if (filter.HasSubtitles.HasValue)
|
||||||
@@ -2353,17 +2463,23 @@ public sealed class BaseItemRepository
|
|||||||
|
|
||||||
if (filter.HasImdbId.HasValue)
|
if (filter.HasImdbId.HasValue)
|
||||||
{
|
{
|
||||||
baseQuery = baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId == "imdb"));
|
baseQuery = filter.HasImdbId.Value
|
||||||
|
? baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId.ToLower() == MetadataProvider.Imdb.ToString().ToLower()))
|
||||||
|
: baseQuery.Where(e => e.Provider!.All(f => f.ProviderId.ToLower() != MetadataProvider.Imdb.ToString().ToLower()));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter.HasTmdbId.HasValue)
|
if (filter.HasTmdbId.HasValue)
|
||||||
{
|
{
|
||||||
baseQuery = baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId == "tmdb"));
|
baseQuery = filter.HasTmdbId.Value
|
||||||
|
? baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId.ToLower() == MetadataProvider.Tmdb.ToString().ToLower()))
|
||||||
|
: baseQuery.Where(e => e.Provider!.All(f => f.ProviderId.ToLower() != MetadataProvider.Tmdb.ToString().ToLower()));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter.HasTvdbId.HasValue)
|
if (filter.HasTvdbId.HasValue)
|
||||||
{
|
{
|
||||||
baseQuery = baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId == "tvdb"));
|
baseQuery = filter.HasTvdbId.Value
|
||||||
|
? baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId.ToLower() == MetadataProvider.Tvdb.ToString().ToLower()))
|
||||||
|
: baseQuery.Where(e => e.Provider!.All(f => f.ProviderId.ToLower() != MetadataProvider.Tvdb.ToString().ToLower()));
|
||||||
}
|
}
|
||||||
|
|
||||||
var queryTopParentIds = filter.TopParentIds;
|
var queryTopParentIds = filter.TopParentIds;
|
||||||
@@ -2401,40 +2517,24 @@ public sealed class BaseItemRepository
|
|||||||
|
|
||||||
if (filter.ExcludeInheritedTags.Length > 0)
|
if (filter.ExcludeInheritedTags.Length > 0)
|
||||||
{
|
{
|
||||||
baseQuery = baseQuery
|
var excludedTags = filter.ExcludeInheritedTags;
|
||||||
.Where(e => !e.ItemValues!.Where(w => w.ItemValue.Type == ItemValueType.InheritedTags || w.ItemValue.Type == ItemValueType.Tags)
|
baseQuery = baseQuery.Where(e =>
|
||||||
.Any(f => filter.ExcludeInheritedTags.Contains(f.ItemValue.CleanValue)));
|
!e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && excludedTags.Contains(f.ItemValue.CleanValue))
|
||||||
|
&& (!e.SeriesId.HasValue || !context.ItemValuesMap.Any(f => f.ItemId == e.SeriesId.Value && f.ItemValue.Type == ItemValueType.Tags && excludedTags.Contains(f.ItemValue.CleanValue))));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter.IncludeInheritedTags.Length > 0)
|
if (filter.IncludeInheritedTags.Length > 0)
|
||||||
{
|
{
|
||||||
// Episodes do not store inherit tags from their parents in the database, and the tag may be still required by the client.
|
var includeTags = filter.IncludeInheritedTags;
|
||||||
// In addition to the tags for the episodes themselves, we need to manually query its parent (the season)'s tags as well.
|
var isPlaylistOnlyQuery = includeTypes.Length == 1 && includeTypes.FirstOrDefault() == BaseItemKind.Playlist;
|
||||||
if (includeTypes.Length == 1 && includeTypes.FirstOrDefault() is BaseItemKind.Episode)
|
baseQuery = baseQuery.Where(e =>
|
||||||
{
|
e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && includeTags.Contains(f.ItemValue.CleanValue))
|
||||||
baseQuery = baseQuery
|
|
||||||
.Where(e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.InheritedTags || f.ItemValue.Type == ItemValueType.Tags)
|
|
||||||
.Any(f => filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue))
|
|
||||||
||
|
|
||||||
(e.ParentId.HasValue && context.ItemValuesMap.Where(w => w.ItemId == e.ParentId.Value && (w.ItemValue.Type == ItemValueType.InheritedTags || w.ItemValue.Type == ItemValueType.Tags))
|
|
||||||
.Any(f => filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue))));
|
|
||||||
}
|
|
||||||
|
|
||||||
// A playlist should be accessible to its owner regardless of allowed tags.
|
// For seasons and episodes, we also need to check the parent series' tags.
|
||||||
else if (includeTypes.Length == 1 && includeTypes.FirstOrDefault() is BaseItemKind.Playlist)
|
|| (e.SeriesId.HasValue && context.ItemValuesMap.Any(f => f.ItemId == e.SeriesId.Value && f.ItemValue.Type == ItemValueType.Tags && includeTags.Contains(f.ItemValue.CleanValue)))
|
||||||
{
|
|
||||||
baseQuery = baseQuery
|
// A playlist should be accessible to its owner regardless of allowed tags
|
||||||
.Where(e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.InheritedTags || f.ItemValue.Type == ItemValueType.Tags)
|
|| (isPlaylistOnlyQuery && e.Data!.Contains($"OwnerUserId\":\"{filter.User!.Id:N}\"")));
|
||||||
.Any(f => filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue))
|
|
||||||
|| e.Data!.Contains($"OwnerUserId\":\"{filter.User!.Id:N}\""));
|
|
||||||
// d ^^ this is stupid it hate this.
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
baseQuery = baseQuery
|
|
||||||
.Where(e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.InheritedTags || f.ItemValue.Type == ItemValueType.Tags)
|
|
||||||
.Any(f => filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue)));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter.SeriesStatuses.Length > 0)
|
if (filter.SeriesStatuses.Length > 0)
|
||||||
@@ -2588,6 +2688,21 @@ public sealed class BaseItemRepository
|
|||||||
.Where(e => artistNames.Contains(e.Name))
|
.Where(e => artistNames.Contains(e.Name))
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
return artists.GroupBy(e => e.Name).ToDictionary(e => e.Key!, e => e.Select(f => DeserializeBaseItem(f)).Cast<MusicArtist>().ToArray());
|
var lookup = artists
|
||||||
|
.GroupBy(e => e.Name!)
|
||||||
|
.ToDictionary(
|
||||||
|
g => g.Key,
|
||||||
|
g => g.Select(f => DeserializeBaseItem(f)).Where(dto => dto is not null).Cast<MusicArtist>().ToArray());
|
||||||
|
|
||||||
|
var result = new Dictionary<string, MusicArtist[]>(artistNames.Count);
|
||||||
|
foreach (var name in artistNames)
|
||||||
|
{
|
||||||
|
if (lookup.TryGetValue(name, out var artistArray))
|
||||||
|
{
|
||||||
|
result[name] = artistArray;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
|
#pragma warning disable RS0030 // Do not use banned APIs
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Linq.Expressions;
|
using System.Linq.Expressions;
|
||||||
using Jellyfin.Data.Enums;
|
using Jellyfin.Data.Enums;
|
||||||
|
using Jellyfin.Database.Implementations;
|
||||||
using Jellyfin.Database.Implementations.Entities;
|
using Jellyfin.Database.Implementations.Entities;
|
||||||
|
using Jellyfin.Extensions;
|
||||||
using MediaBrowser.Controller.Entities;
|
using MediaBrowser.Controller.Entities;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
@@ -18,40 +22,77 @@ public static class OrderMapper
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="sortBy">Item property to sort by.</param>
|
/// <param name="sortBy">Item property to sort by.</param>
|
||||||
/// <param name="query">Context Query.</param>
|
/// <param name="query">Context Query.</param>
|
||||||
|
/// <param name="jellyfinDbContext">Context.</param>
|
||||||
/// <returns>Func to be executed later for sorting query.</returns>
|
/// <returns>Func to be executed later for sorting query.</returns>
|
||||||
public static Expression<Func<BaseItemEntity, object?>> MapOrderByField(ItemSortBy sortBy, InternalItemsQuery query)
|
public static Expression<Func<BaseItemEntity, object?>> MapOrderByField(ItemSortBy sortBy, InternalItemsQuery query, JellyfinDbContext jellyfinDbContext)
|
||||||
{
|
{
|
||||||
return sortBy switch
|
return (sortBy, query.User) switch
|
||||||
{
|
{
|
||||||
ItemSortBy.AirTime => e => e.SortName, // TODO
|
(ItemSortBy.AirTime, _) => e => e.SortName, // TODO
|
||||||
ItemSortBy.Runtime => e => e.RunTimeTicks,
|
(ItemSortBy.Runtime, _) => e => e.RunTimeTicks,
|
||||||
ItemSortBy.Random => e => EF.Functions.Random(),
|
(ItemSortBy.Random, _) => e => EF.Functions.Random(),
|
||||||
ItemSortBy.DatePlayed => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.LastPlayedDate,
|
(ItemSortBy.DatePlayed, _) => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.LastPlayedDate,
|
||||||
ItemSortBy.PlayCount => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.PlayCount,
|
(ItemSortBy.PlayCount, _) => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.PlayCount,
|
||||||
ItemSortBy.IsFavoriteOrLiked => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.IsFavorite,
|
(ItemSortBy.IsFavoriteOrLiked, _) => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.IsFavorite,
|
||||||
ItemSortBy.IsFolder => e => e.IsFolder,
|
(ItemSortBy.IsFolder, _) => e => e.IsFolder,
|
||||||
ItemSortBy.IsPlayed => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.Played,
|
(ItemSortBy.IsPlayed, _) => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.Played,
|
||||||
ItemSortBy.IsUnplayed => e => !e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.Played,
|
(ItemSortBy.IsUnplayed, _) => e => !e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.Played,
|
||||||
ItemSortBy.DateLastContentAdded => e => e.DateLastMediaAdded,
|
(ItemSortBy.DateLastContentAdded, _) => e => e.DateLastMediaAdded,
|
||||||
ItemSortBy.Artist => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.Artist).Select(f => f.ItemValue.CleanValue).FirstOrDefault(),
|
(ItemSortBy.Artist, _) => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.Artist).Select(f => f.ItemValue.CleanValue).FirstOrDefault(),
|
||||||
ItemSortBy.AlbumArtist => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.AlbumArtist).Select(f => f.ItemValue.CleanValue).FirstOrDefault(),
|
(ItemSortBy.AlbumArtist, _) => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.AlbumArtist).Select(f => f.ItemValue.CleanValue).FirstOrDefault(),
|
||||||
ItemSortBy.Studio => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.Studios).Select(f => f.ItemValue.CleanValue).FirstOrDefault(),
|
(ItemSortBy.Studio, _) => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.Studios).Select(f => f.ItemValue.CleanValue).FirstOrDefault(),
|
||||||
ItemSortBy.OfficialRating => e => e.InheritedParentalRatingValue,
|
(ItemSortBy.OfficialRating, _) => e => e.InheritedParentalRatingValue,
|
||||||
// ItemSortBy.SeriesDatePlayed => "(Select MAX(LastPlayedDate) from TypedBaseItems B" + GetJoinUserDataText(query) + " where Played=1 and B.SeriesPresentationUniqueKey=A.PresentationUniqueKey)",
|
(ItemSortBy.SeriesSortName, _) => e => e.SeriesName,
|
||||||
ItemSortBy.SeriesSortName => e => e.SeriesName,
|
(ItemSortBy.Album, _) => e => e.Album,
|
||||||
|
(ItemSortBy.DateCreated, _) => e => e.DateCreated,
|
||||||
|
(ItemSortBy.PremiereDate, _) => e => (e.PremiereDate ?? (e.ProductionYear.HasValue ? DateTime.MinValue.AddYears(e.ProductionYear.Value - 1) : null)),
|
||||||
|
(ItemSortBy.StartDate, _) => e => e.StartDate,
|
||||||
|
(ItemSortBy.Name, _) => e => e.CleanName,
|
||||||
|
(ItemSortBy.CommunityRating, _) => e => e.CommunityRating,
|
||||||
|
(ItemSortBy.ProductionYear, _) => e => e.ProductionYear,
|
||||||
|
(ItemSortBy.CriticRating, _) => e => e.CriticRating,
|
||||||
|
(ItemSortBy.VideoBitRate, _) => e => e.TotalBitrate,
|
||||||
|
(ItemSortBy.ParentIndexNumber, _) => e => e.ParentIndexNumber,
|
||||||
|
(ItemSortBy.IndexNumber, _) => e => e.IndexNumber,
|
||||||
|
(ItemSortBy.SeriesDatePlayed, not null) => e =>
|
||||||
|
jellyfinDbContext.BaseItems
|
||||||
|
.Where(w => w.SeriesPresentationUniqueKey == e.PresentationUniqueKey)
|
||||||
|
.Join(jellyfinDbContext.UserData.Where(w => w.UserId == query.User.Id && w.Played), f => f.Id, f => f.ItemId, (item, userData) => userData.LastPlayedDate)
|
||||||
|
.Max(f => f),
|
||||||
|
(ItemSortBy.SeriesDatePlayed, null) => e => jellyfinDbContext.BaseItems.Where(w => w.SeriesPresentationUniqueKey == e.PresentationUniqueKey)
|
||||||
|
.Join(jellyfinDbContext.UserData.Where(w => w.Played), f => f.Id, f => f.ItemId, (item, userData) => userData.LastPlayedDate)
|
||||||
|
.Max(f => f),
|
||||||
|
// ItemSortBy.SeriesDatePlayed => e => jellyfinDbContext.UserData
|
||||||
|
// .Where(u => u.Item!.SeriesPresentationUniqueKey == e.PresentationUniqueKey && u.Played)
|
||||||
|
// .Max(f => f.LastPlayedDate),
|
||||||
// ItemSortBy.AiredEpisodeOrder => "AiredEpisodeOrder",
|
// ItemSortBy.AiredEpisodeOrder => "AiredEpisodeOrder",
|
||||||
ItemSortBy.Album => e => e.Album,
|
|
||||||
ItemSortBy.DateCreated => e => e.DateCreated,
|
|
||||||
ItemSortBy.PremiereDate => e => (e.PremiereDate ?? (e.ProductionYear.HasValue ? DateTime.MinValue.AddYears(e.ProductionYear.Value - 1) : null)),
|
|
||||||
ItemSortBy.StartDate => e => e.StartDate,
|
|
||||||
ItemSortBy.Name => e => e.CleanName,
|
|
||||||
ItemSortBy.CommunityRating => e => e.CommunityRating,
|
|
||||||
ItemSortBy.ProductionYear => e => e.ProductionYear,
|
|
||||||
ItemSortBy.CriticRating => e => e.CriticRating,
|
|
||||||
ItemSortBy.VideoBitRate => e => e.TotalBitrate,
|
|
||||||
ItemSortBy.ParentIndexNumber => e => e.ParentIndexNumber,
|
|
||||||
ItemSortBy.IndexNumber => e => e.IndexNumber,
|
|
||||||
_ => e => e.SortName
|
_ => e => e.SortName
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates an expression to order search results by match quality.
|
||||||
|
/// Prioritizes: exact match (0) > prefix match with word boundary (1) > prefix match (2) > contains (3).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="searchTerm">The search term to match against.</param>
|
||||||
|
/// <returns>An expression that returns an integer representing match quality (lower is better).</returns>
|
||||||
|
public static Expression<Func<BaseItemEntity, int>> MapSearchRelevanceOrder(string searchTerm)
|
||||||
|
{
|
||||||
|
var cleanSearchTerm = GetCleanValue(searchTerm);
|
||||||
|
var searchPrefix = cleanSearchTerm + " ";
|
||||||
|
return e =>
|
||||||
|
e.CleanName == cleanSearchTerm ? 0 :
|
||||||
|
e.CleanName!.StartsWith(searchPrefix) ? 1 :
|
||||||
|
e.CleanName!.StartsWith(cleanSearchTerm) ? 2 : 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetCleanValue(string value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.RemoveDiacritics().ToLowerInvariant();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,7 @@ namespace Jellyfin.Server.Implementations.StorageHelpers;
|
|||||||
public static class StorageHelper
|
public static class StorageHelper
|
||||||
{
|
{
|
||||||
private const long TwoGigabyte = 2_147_483_647L;
|
private const long TwoGigabyte = 2_147_483_647L;
|
||||||
private const long FiveHundredAndTwelveMegaByte = 536_870_911L;
|
private static readonly string[] _byteHumanizedSuffixes = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"];
|
||||||
private static readonly string[] _byteHumanizedSuffixes = ["B", "KB", "MB", "GB", "TB", "PB", "EB"];
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Tests the available storage capacity on the jellyfin paths with estimated minimum values.
|
/// Tests the available storage capacity on the jellyfin paths with estimated minimum values.
|
||||||
@@ -24,10 +23,8 @@ public static class StorageHelper
|
|||||||
public static void TestCommonPathsForStorageCapacity(IApplicationPaths applicationPaths, ILogger logger)
|
public static void TestCommonPathsForStorageCapacity(IApplicationPaths applicationPaths, ILogger logger)
|
||||||
{
|
{
|
||||||
TestDataDirectorySize(applicationPaths.DataPath, logger, TwoGigabyte);
|
TestDataDirectorySize(applicationPaths.DataPath, logger, TwoGigabyte);
|
||||||
TestDataDirectorySize(applicationPaths.LogDirectoryPath, logger, FiveHundredAndTwelveMegaByte);
|
|
||||||
TestDataDirectorySize(applicationPaths.CachePath, logger, TwoGigabyte);
|
TestDataDirectorySize(applicationPaths.CachePath, logger, TwoGigabyte);
|
||||||
TestDataDirectorySize(applicationPaths.ProgramDataPath, logger, TwoGigabyte);
|
TestDataDirectorySize(applicationPaths.ProgramDataPath, logger, TwoGigabyte);
|
||||||
TestDataDirectorySize(applicationPaths.TempDirectory, logger, TwoGigabyte);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -77,7 +74,7 @@ public static class StorageHelper
|
|||||||
var drive = new DriveInfo(path);
|
var drive = new DriveInfo(path);
|
||||||
if (threshold != -1 && drive.AvailableFreeSpace < threshold)
|
if (threshold != -1 && drive.AvailableFreeSpace < threshold)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException($"The path `{path}` has insufficient free space. Required: at least {HumanizeStorageSize(threshold)}.");
|
throw new InvalidOperationException($"The path `{path}` has insufficient free space. Available: {HumanizeStorageSize(drive.AvailableFreeSpace)}, Required: {HumanizeStorageSize(threshold)}.");
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
|
|||||||
@@ -254,10 +254,10 @@ public class TrickplayManager : ITrickplayManager
|
|||||||
}
|
}
|
||||||
|
|
||||||
// We support video backdrops, but we should not generate trickplay images for them
|
// We support video backdrops, but we should not generate trickplay images for them
|
||||||
var parentDirectory = Directory.GetParent(mediaPath);
|
var parentDirectory = Directory.GetParent(video.Path);
|
||||||
if (parentDirectory is not null && string.Equals(parentDirectory.Name, "backdrops", StringComparison.OrdinalIgnoreCase))
|
if (parentDirectory is not null && string.Equals(parentDirectory.Name, "backdrops", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
_logger.LogDebug("Ignoring backdrop media found at {Path} for item {ItemID}", mediaPath, video.Id);
|
_logger.LogDebug("Ignoring backdrop media found at {Path} for item {ItemID}", video.Path, video.Id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
@@ -73,7 +74,7 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
var resetUser = userManager.GetUserByName(spr.UserName)
|
var resetUser = userManager.GetUserByName(spr.UserName)
|
||||||
?? throw new ResourceNotFoundException($"User with a username of {spr.UserName} not found");
|
?? throw new ResourceNotFoundException($"User with a username of {spr.UserName} not found");
|
||||||
|
|
||||||
await userManager.ChangePassword(resetUser, pin).ConfigureAwait(false);
|
await userManager.ChangePassword(resetUser.Id, pin).ConfigureAwait(false);
|
||||||
usersReset.Add(resetUser.Username);
|
usersReset.Add(resetUser.Username);
|
||||||
File.Delete(resetFile);
|
File.Delete(resetFile);
|
||||||
}
|
}
|
||||||
@@ -92,33 +93,38 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<ForgotPasswordResult> StartForgotPasswordProcess(User user, bool isInNetwork)
|
public async Task<ForgotPasswordResult> StartForgotPasswordProcess(User? user, string enteredUsername, bool isInNetwork)
|
||||||
|
{
|
||||||
|
DateTime expireTime = DateTime.UtcNow.AddMinutes(30);
|
||||||
|
var usernameHash = enteredUsername.ToUpperInvariant().GetMD5().ToString("N", CultureInfo.InvariantCulture);
|
||||||
|
var pinFile = _passwordResetFileBase + usernameHash + ".json";
|
||||||
|
|
||||||
|
if (user is not null && isInNetwork)
|
||||||
{
|
{
|
||||||
byte[] bytes = new byte[4];
|
byte[] bytes = new byte[4];
|
||||||
RandomNumberGenerator.Fill(bytes);
|
RandomNumberGenerator.Fill(bytes);
|
||||||
string pin = BitConverter.ToString(bytes);
|
string pin = BitConverter.ToString(bytes);
|
||||||
|
|
||||||
DateTime expireTime = DateTime.UtcNow.AddMinutes(30);
|
|
||||||
string filePath = _passwordResetFileBase + user.Id + ".json";
|
|
||||||
SerializablePasswordReset spr = new SerializablePasswordReset
|
SerializablePasswordReset spr = new SerializablePasswordReset
|
||||||
{
|
{
|
||||||
ExpirationDate = expireTime,
|
ExpirationDate = expireTime,
|
||||||
Pin = pin,
|
Pin = pin,
|
||||||
PinFile = filePath,
|
PinFile = pinFile,
|
||||||
UserName = user.Username
|
UserName = user.Username
|
||||||
};
|
};
|
||||||
|
|
||||||
FileStream fileStream = AsyncFile.Create(filePath);
|
FileStream fileStream = AsyncFile.Create(pinFile);
|
||||||
await using (fileStream.ConfigureAwait(false))
|
await using (fileStream.ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
await JsonSerializer.SerializeAsync(fileStream, spr).ConfigureAwait(false);
|
await JsonSerializer.SerializeAsync(fileStream, spr).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return new ForgotPasswordResult
|
return new ForgotPasswordResult
|
||||||
{
|
{
|
||||||
Action = ForgotPasswordAction.PinCode,
|
Action = ForgotPasswordAction.PinCode,
|
||||||
PinExpirationDate = expireTime,
|
PinExpirationDate = expireTime,
|
||||||
PinFile = filePath
|
PinFile = pinFile
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
#pragma warning disable CA1307
|
#pragma warning disable RS0030 // Do not use banned APIs
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Concurrent;
|
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using AsyncKeyedLock;
|
||||||
using Jellyfin.Data;
|
using Jellyfin.Data;
|
||||||
using Jellyfin.Data.Enums;
|
using Jellyfin.Data.Enums;
|
||||||
using Jellyfin.Data.Events;
|
using Jellyfin.Data.Events;
|
||||||
@@ -35,7 +36,7 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Manages the creation and retrieval of <see cref="User"/> instances.
|
/// Manages the creation and retrieval of <see cref="User"/> instances.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class UserManager : IUserManager
|
public partial class UserManager : IUserManager, IDisposable
|
||||||
{
|
{
|
||||||
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
|
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
|
||||||
private readonly IEventManager _eventManager;
|
private readonly IEventManager _eventManager;
|
||||||
@@ -50,7 +51,7 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
private readonly DefaultPasswordResetProvider _defaultPasswordResetProvider;
|
private readonly DefaultPasswordResetProvider _defaultPasswordResetProvider;
|
||||||
private readonly IServerConfigurationManager _serverConfigurationManager;
|
private readonly IServerConfigurationManager _serverConfigurationManager;
|
||||||
|
|
||||||
private readonly IDictionary<Guid, User> _users;
|
private readonly LockHelper _userLock = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="UserManager"/> class.
|
/// Initializes a new instance of the <see cref="UserManager"/> class.
|
||||||
@@ -89,29 +90,28 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
_invalidAuthProvider = _authenticationProviders.OfType<InvalidAuthProvider>().First();
|
_invalidAuthProvider = _authenticationProviders.OfType<InvalidAuthProvider>().First();
|
||||||
_defaultAuthenticationProvider = _authenticationProviders.OfType<DefaultAuthenticationProvider>().First();
|
_defaultAuthenticationProvider = _authenticationProviders.OfType<DefaultAuthenticationProvider>().First();
|
||||||
_defaultPasswordResetProvider = _passwordResetProviders.OfType<DefaultPasswordResetProvider>().First();
|
_defaultPasswordResetProvider = _passwordResetProviders.OfType<DefaultPasswordResetProvider>().First();
|
||||||
|
|
||||||
_users = new ConcurrentDictionary<Guid, User>();
|
|
||||||
using var dbContext = _dbProvider.CreateDbContext();
|
|
||||||
foreach (var user in dbContext.Users
|
|
||||||
.AsSplitQuery()
|
|
||||||
.Include(user => user.Permissions)
|
|
||||||
.Include(user => user.Preferences)
|
|
||||||
.Include(user => user.AccessSchedules)
|
|
||||||
.Include(user => user.ProfileImage)
|
|
||||||
.AsEnumerable())
|
|
||||||
{
|
|
||||||
_users.Add(user.Id, user);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public event EventHandler<GenericEventArgs<User>>? OnUserUpdated;
|
public event EventHandler<GenericEventArgs<User>>? OnUserUpdated;
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public IEnumerable<User> Users => _users.Values;
|
public IEnumerable<User> GetUsers()
|
||||||
|
{
|
||||||
|
using var dbContext = _dbProvider.CreateDbContext();
|
||||||
|
return UserQuery(dbContext)
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public IEnumerable<Guid> UsersIds => _users.Keys;
|
public IEnumerable<Guid> GetUsersIds()
|
||||||
|
{
|
||||||
|
using var dbContext = _dbProvider.CreateDbContext();
|
||||||
|
return dbContext.Users
|
||||||
|
.AsNoTracking()
|
||||||
|
.Select(user => user.Id)
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
// This is some regex that matches only on unicode "word" characters, as well as -, _ and @
|
// This is some regex that matches only on unicode "word" characters, as well as -, _ and @
|
||||||
// In theory this will cut out most if not all 'control' characters which should help minimize any weirdness
|
// In theory this will cut out most if not all 'control' characters which should help minimize any weirdness
|
||||||
@@ -127,8 +127,28 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
throw new ArgumentException("Guid can't be empty", nameof(id));
|
throw new ArgumentException("Guid can't be empty", nameof(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
_users.TryGetValue(id, out var user);
|
using var dbContext = _dbProvider.CreateDbContext();
|
||||||
return user;
|
return UserQuery(dbContext)
|
||||||
|
.FirstOrDefault(user => user.Id == id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IQueryable<User> UserQuery(JellyfinDbContext dbContext)
|
||||||
|
{
|
||||||
|
return dbContext.Users
|
||||||
|
.AsSingleQuery()
|
||||||
|
.Include(user => user.Permissions)
|
||||||
|
.Include(user => user.Preferences)
|
||||||
|
.Include(user => user.AccessSchedules)
|
||||||
|
.Include(user => user.ProfileImage)
|
||||||
|
.AsNoTracking();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public User? GetFirstUser()
|
||||||
|
{
|
||||||
|
using var dbContext = _dbProvider.CreateDbContext();
|
||||||
|
return UserQuery(dbContext)
|
||||||
|
.FirstOrDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
@@ -139,29 +159,32 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
throw new ArgumentException("Invalid username", nameof(name));
|
throw new ArgumentException("Invalid username", nameof(name));
|
||||||
}
|
}
|
||||||
|
|
||||||
return _users.Values.FirstOrDefault(u => string.Equals(u.Username, name, StringComparison.OrdinalIgnoreCase));
|
using var dbContext = _dbProvider.CreateDbContext();
|
||||||
|
#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
|
||||||
|
return UserQuery(dbContext)
|
||||||
|
.FirstOrDefault(u => u.NormalizedUsername == name.ToUpperInvariant());
|
||||||
|
#pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task RenameUser(User user, string newName)
|
public async Task RenameUser(Guid userId, string oldName, string newName)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(user);
|
|
||||||
|
|
||||||
ThrowIfInvalidUsername(newName);
|
ThrowIfInvalidUsername(newName);
|
||||||
|
|
||||||
if (user.Username.Equals(newName, StringComparison.OrdinalIgnoreCase))
|
if (oldName.Equals(newName, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
throw new ArgumentException("The new and old names must be different.");
|
throw new ArgumentException("The new and old names must be different.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
User user = null!; // user is never actually null where its used afterwards so we can just ignore.
|
||||||
|
using (await _userLock.LockAsync(userId).ConfigureAwait(false))
|
||||||
|
{
|
||||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||||
await using (dbContext.ConfigureAwait(false))
|
await using (dbContext.ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
|
#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
|
||||||
#pragma warning disable CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture
|
|
||||||
#pragma warning disable CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings
|
|
||||||
if (await dbContext.Users
|
if (await dbContext.Users
|
||||||
.AnyAsync(u => u.Username.ToUpper() == newName.ToUpper() && !u.Id.Equals(user.Id))
|
.AnyAsync(u => u.NormalizedUsername == newName.ToUpperInvariant() && u.Id != userId)
|
||||||
.ConfigureAwait(false))
|
.ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
throw new ArgumentException(string.Format(
|
throw new ArgumentException(string.Format(
|
||||||
@@ -169,13 +192,19 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
"A user with the name '{0}' already exists.",
|
"A user with the name '{0}' already exists.",
|
||||||
newName));
|
newName));
|
||||||
}
|
}
|
||||||
#pragma warning restore CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings
|
|
||||||
#pragma warning restore CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture
|
|
||||||
#pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
|
#pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
|
||||||
|
|
||||||
|
user = await UserQuery(dbContext)
|
||||||
|
.AsTracking()
|
||||||
|
.FirstOrDefaultAsync(u => u.Id == userId)
|
||||||
|
.ConfigureAwait(false)
|
||||||
|
?? throw new ResourceNotFoundException(nameof(userId));
|
||||||
|
|
||||||
user.Username = newName;
|
user.Username = newName;
|
||||||
|
user.NormalizedUsername = newName.ToUpperInvariant();
|
||||||
await UpdateUserInternalAsync(dbContext, user).ConfigureAwait(false);
|
await UpdateUserInternalAsync(dbContext, user).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var eventArgs = new UserUpdatedEventArgs(user);
|
var eventArgs = new UserUpdatedEventArgs(user);
|
||||||
await _eventManager.PublishAsync(eventArgs).ConfigureAwait(false);
|
await _eventManager.PublishAsync(eventArgs).ConfigureAwait(false);
|
||||||
@@ -184,11 +213,61 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task UpdateUserAsync(User user)
|
public async Task UpdateUserAsync(User user)
|
||||||
|
{
|
||||||
|
using (await _userLock.LockAsync(user.Id).ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||||
await using (dbContext.ConfigureAwait(false))
|
await using (dbContext.ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
await UpdateUserInternalAsync(dbContext, user).ConfigureAwait(false);
|
// TODO: this is a bit of a hack. Because the user entity can be created in another context, it is maybe tracked elsewhere and navigation properties do not easily move between context. Solution is to use proper DTOs instead.
|
||||||
|
var dbUser = await UserQuery(dbContext)
|
||||||
|
.AsTracking()
|
||||||
|
.FirstOrDefaultAsync(u => u.Id == user.Id)
|
||||||
|
.ConfigureAwait(false)
|
||||||
|
?? throw new ResourceNotFoundException(nameof(user.Id));
|
||||||
|
|
||||||
|
dbContext.Entry(dbUser).CurrentValues.SetValues(user);
|
||||||
|
dbUser.Permissions.Clear();
|
||||||
|
foreach (var permission in user.Permissions)
|
||||||
|
{
|
||||||
|
dbUser.Permissions.Add(new Permission(permission.Kind, permission.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
dbUser.Preferences.Clear();
|
||||||
|
foreach (var preference in user.Preferences)
|
||||||
|
{
|
||||||
|
dbUser.Preferences.Add(new Preference(preference.Kind, preference.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
dbUser.AccessSchedules.Clear();
|
||||||
|
foreach (var accessSchedule in user.AccessSchedules)
|
||||||
|
{
|
||||||
|
dbUser.AccessSchedules.Add(new AccessSchedule(accessSchedule.DayOfWeek, accessSchedule.StartHour, accessSchedule.EndHour, dbUser.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.ProfileImage is null)
|
||||||
|
{
|
||||||
|
if (dbUser.ProfileImage is not null)
|
||||||
|
{
|
||||||
|
dbContext.Remove(dbUser.ProfileImage);
|
||||||
|
dbUser.ProfileImage = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (dbUser.ProfileImage is null)
|
||||||
|
{
|
||||||
|
dbUser.ProfileImage = new Jellyfin.Database.Implementations.Entities.ImageInfo(user.ProfileImage.Path)
|
||||||
|
{
|
||||||
|
LastModified = user.ProfileImage.LastModified
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
dbUser.ProfileImage.Path = user.ProfileImage.Path;
|
||||||
|
dbUser.ProfileImage.LastModified = user.ProfileImage.LastModified;
|
||||||
|
}
|
||||||
|
|
||||||
|
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,23 +297,26 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
{
|
{
|
||||||
ThrowIfInvalidUsername(name);
|
ThrowIfInvalidUsername(name);
|
||||||
|
|
||||||
if (Users.Any(u => u.Username.Equals(name, StringComparison.OrdinalIgnoreCase)))
|
User newUser;
|
||||||
|
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||||
|
await using (dbContext.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
|
||||||
|
if (await dbContext.Users
|
||||||
|
.AnyAsync(u => u.NormalizedUsername == name.ToUpperInvariant())
|
||||||
|
.ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
throw new ArgumentException(string.Format(
|
throw new ArgumentException(string.Format(
|
||||||
CultureInfo.InvariantCulture,
|
CultureInfo.InvariantCulture,
|
||||||
"A user with the name '{0}' already exists.",
|
"A user with the name '{0}' already exists.",
|
||||||
name));
|
name));
|
||||||
}
|
}
|
||||||
|
#pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
|
||||||
|
|
||||||
User newUser;
|
|
||||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
|
||||||
await using (dbContext.ConfigureAwait(false))
|
|
||||||
{
|
|
||||||
newUser = await CreateUserInternalAsync(name, dbContext).ConfigureAwait(false);
|
newUser = await CreateUserInternalAsync(name, dbContext).ConfigureAwait(false);
|
||||||
|
|
||||||
dbContext.Users.Add(newUser);
|
dbContext.Users.Add(newUser);
|
||||||
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
||||||
_users.Add(newUser.Id, newUser);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await _eventManager.PublishAsync(new UserCreatedEventArgs(newUser)).ConfigureAwait(false);
|
await _eventManager.PublishAsync(new UserCreatedEventArgs(newUser)).ConfigureAwait(false);
|
||||||
@@ -245,12 +327,24 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task DeleteUserAsync(Guid userId)
|
public async Task DeleteUserAsync(Guid userId)
|
||||||
{
|
{
|
||||||
if (!_users.TryGetValue(userId, out var user))
|
User? user;
|
||||||
|
using (await _userLock.LockAsync(userId).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||||
|
await using (dbContext.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
user = await dbContext.Users
|
||||||
|
.Include(u => u.Permissions)
|
||||||
|
.FirstOrDefaultAsync(u => u.Id.Equals(userId))
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (user is null)
|
||||||
{
|
{
|
||||||
throw new ResourceNotFoundException(nameof(userId));
|
throw new ResourceNotFoundException(nameof(userId));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_users.Count == 1)
|
var userCount = await dbContext.Users.CountAsync().ConfigureAwait(false);
|
||||||
|
if (userCount == 1)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException(string.Format(
|
throw new InvalidOperationException(string.Format(
|
||||||
CultureInfo.InvariantCulture,
|
CultureInfo.InvariantCulture,
|
||||||
@@ -259,7 +353,9 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (user.HasPermission(PermissionKind.IsAdministrator)
|
if (user.HasPermission(PermissionKind.IsAdministrator)
|
||||||
&& Users.Count(i => i.HasPermission(PermissionKind.IsAdministrator)) == 1)
|
&& await dbContext.Users
|
||||||
|
.CountAsync(i => i.Permissions.Any(p => p.Kind == PermissionKind.IsAdministrator && p.Value))
|
||||||
|
.ConfigureAwait(false) == 1)
|
||||||
{
|
{
|
||||||
throw new ArgumentException(
|
throw new ArgumentException(
|
||||||
string.Format(
|
string.Format(
|
||||||
@@ -269,38 +365,46 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
nameof(userId));
|
nameof(userId));
|
||||||
}
|
}
|
||||||
|
|
||||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
|
||||||
await using (dbContext.ConfigureAwait(false))
|
|
||||||
{
|
|
||||||
dbContext.Users.Attach(user);
|
|
||||||
dbContext.Users.Remove(user);
|
dbContext.Users.Remove(user);
|
||||||
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
_users.Remove(userId);
|
|
||||||
|
|
||||||
await _eventManager.PublishAsync(new UserDeletedEventArgs(user)).ConfigureAwait(false);
|
await _eventManager.PublishAsync(new UserDeletedEventArgs(user)).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public Task ResetPassword(User user)
|
public Task ResetPassword(Guid userId)
|
||||||
{
|
{
|
||||||
return ChangePassword(user, string.Empty);
|
return ChangePassword(userId, string.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task ChangePassword(User user, string newPassword)
|
public async Task ChangePassword(Guid userId, string newPassword)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(user);
|
User dbUser = null!;
|
||||||
if (user.HasPermission(PermissionKind.IsAdministrator) && string.IsNullOrWhiteSpace(newPassword))
|
using (await _userLock.LockAsync(userId).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||||
|
await using (dbContext.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
dbUser = await UserQuery(dbContext)
|
||||||
|
.AsTracking()
|
||||||
|
.FirstOrDefaultAsync(u => u.Id == userId)
|
||||||
|
.ConfigureAwait(false)
|
||||||
|
?? throw new ResourceNotFoundException(nameof(userId));
|
||||||
|
|
||||||
|
if (dbUser.HasPermission(PermissionKind.IsAdministrator) && string.IsNullOrWhiteSpace(newPassword))
|
||||||
{
|
{
|
||||||
throw new ArgumentException("Admin user passwords must not be empty", nameof(newPassword));
|
throw new ArgumentException("Admin user passwords must not be empty", nameof(newPassword));
|
||||||
}
|
}
|
||||||
|
|
||||||
await GetAuthenticationProvider(user).ChangePassword(user, newPassword).ConfigureAwait(false);
|
await GetAuthenticationProvider(dbUser).ChangePassword(dbUser, newPassword).ConfigureAwait(false);
|
||||||
await UpdateUserAsync(user).ConfigureAwait(false);
|
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await _eventManager.PublishAsync(new UserPasswordChangedEventArgs(user)).ConfigureAwait(false);
|
await _eventManager.PublishAsync(new UserPasswordChangedEventArgs(dbUser)).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
@@ -403,11 +507,31 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
throw new ArgumentNullException(nameof(username));
|
throw new ArgumentNullException(nameof(username));
|
||||||
}
|
}
|
||||||
|
|
||||||
var user = Users.FirstOrDefault(i => string.Equals(username, i.Username, StringComparison.OrdinalIgnoreCase));
|
bool success;
|
||||||
|
var user = GetUserByName(username);
|
||||||
|
using (await _userLock.LockAsync(user?.Id ?? Guid.Empty).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
using var dbContext = _dbProvider.CreateDbContext();
|
||||||
|
|
||||||
|
// Reload the user now that we hold the lock so the RowVersion is current.
|
||||||
|
// GetUserByName uses AsNoTracking and the snapshot may be stale if another
|
||||||
|
// write (e.g. a concurrent login) incremented RowVersion after our initial load.
|
||||||
|
if (user is not null)
|
||||||
|
{
|
||||||
|
user = await UserQuery(dbContext).FirstOrDefaultAsync(e => e.Id == user.Id).ConfigureAwait(false) ?? user;
|
||||||
|
}
|
||||||
|
|
||||||
var authResult = await AuthenticateLocalUser(username, password, user)
|
var authResult = await AuthenticateLocalUser(username, password, user)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
var authenticationProvider = authResult.AuthenticationProvider;
|
var authenticationProvider = authResult.AuthenticationProvider;
|
||||||
var success = authResult.Success;
|
success = authResult.Success;
|
||||||
|
|
||||||
|
if (success && user is not null)
|
||||||
|
{
|
||||||
|
// refresh the user if the auth provider might have updated it in the auth method.
|
||||||
|
// this is a hack, this needs removal once the LDAP plugin uses the correct interface to get the user we hand in here and update that one instead.
|
||||||
|
user = await UserQuery(dbContext).FirstOrDefaultAsync(e => e.Id == user.Id).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
if (user is null)
|
if (user is null)
|
||||||
{
|
{
|
||||||
@@ -422,11 +546,16 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
|
|
||||||
// Search the database for the user again
|
// Search the database for the user again
|
||||||
// the authentication provider might have created it
|
// the authentication provider might have created it
|
||||||
user = Users.FirstOrDefault(i => string.Equals(username, i.Username, StringComparison.OrdinalIgnoreCase));
|
#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
|
||||||
|
user = await UserQuery(dbContext)
|
||||||
|
.FirstOrDefaultAsync(e => e.NormalizedUsername == username.ToUpperInvariant()).ConfigureAwait(false);
|
||||||
|
|
||||||
if (authenticationProvider is IHasNewUserPolicy hasNewUserPolicy && user is not null)
|
if (authenticationProvider is IHasNewUserPolicy hasNewUserPolicy && user is not null)
|
||||||
{
|
{
|
||||||
await UpdatePolicyAsync(user.Id, hasNewUserPolicy.GetNewUserPolicy()).ConfigureAwait(false);
|
await UpdatePolicyAsync(user.Id, hasNewUserPolicy.GetNewUserPolicy()).ConfigureAwait(false);
|
||||||
|
user = await UserQuery(dbContext)
|
||||||
|
.FirstOrDefaultAsync(e => e.NormalizedUsername == username.ToUpperInvariant()).ConfigureAwait(false);
|
||||||
|
#pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -437,8 +566,10 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
|
|
||||||
if (providerId is not null && !string.Equals(providerId, user.AuthenticationProviderId, StringComparison.OrdinalIgnoreCase))
|
if (providerId is not null && !string.Equals(providerId, user.AuthenticationProviderId, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
user.AuthenticationProviderId = providerId;
|
await dbContext.Users
|
||||||
await UpdateUserAsync(user).ConfigureAwait(false);
|
.Where(e => e.Id == user.Id)
|
||||||
|
.ExecuteUpdateAsync(e => e.SetProperty(f => f.AuthenticationProviderId, providerId))
|
||||||
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -485,21 +616,48 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
{
|
{
|
||||||
if (isUserSession)
|
if (isUserSession)
|
||||||
{
|
{
|
||||||
user.LastActivityDate = user.LastLoginDate = DateTime.UtcNow;
|
var date = DateTime.UtcNow;
|
||||||
|
await dbContext.Users
|
||||||
|
.Where(e => e.Id == user.Id)
|
||||||
|
.ExecuteUpdateAsync(e => e
|
||||||
|
.SetProperty(f => f.LastActivityDate, date)
|
||||||
|
.SetProperty(f => f.LastLoginDate, date))
|
||||||
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
user.InvalidLoginAttemptCount = 0;
|
await dbContext.Users
|
||||||
await UpdateUserAsync(user).ConfigureAwait(false);
|
.Where(e => e.Id == user.Id)
|
||||||
|
.ExecuteUpdateAsync(e => e.SetProperty(f => f.InvalidLoginAttemptCount, 0))
|
||||||
|
.ConfigureAwait(false);
|
||||||
_logger.LogInformation("Authentication request for {UserName} has succeeded.", user.Username);
|
_logger.LogInformation("Authentication request for {UserName} has succeeded.", user.Username);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
await IncrementInvalidLoginAttemptCount(user).ConfigureAwait(false);
|
user.InvalidLoginAttemptCount++;
|
||||||
|
int? maxInvalidLogins = user.LoginAttemptsBeforeLockout;
|
||||||
|
if (maxInvalidLogins.HasValue && user.InvalidLoginAttemptCount >= maxInvalidLogins)
|
||||||
|
{
|
||||||
|
user.SetPermission(PermissionKind.IsDisabled, true);
|
||||||
|
await dbContext.SaveChangesAsync()
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
await _eventManager.PublishAsync(new UserLockedOutEventArgs(user)).ConfigureAwait(false);
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Disabling user {Username} due to {Attempts} unsuccessful login attempts.",
|
||||||
|
user.Username,
|
||||||
|
user.InvalidLoginAttemptCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
await dbContext.Users
|
||||||
|
.Where(e => e.Id == user.Id)
|
||||||
|
.ExecuteUpdateAsync(e => e.SetProperty(f => f.InvalidLoginAttemptCount, f => f.InvalidLoginAttemptCount + 1))
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"Authentication request for {UserName} has been denied (IP: {IP}).",
|
"Authentication request for {UserName} has been denied (IP: {IP}).",
|
||||||
user.Username,
|
user.Username,
|
||||||
remoteEndPoint);
|
remoteEndPoint);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return success ? user : null;
|
return success ? user : null;
|
||||||
}
|
}
|
||||||
@@ -508,23 +666,18 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
public async Task<ForgotPasswordResult> StartForgotPasswordProcess(string enteredUsername, bool isInNetwork)
|
public async Task<ForgotPasswordResult> StartForgotPasswordProcess(string enteredUsername, bool isInNetwork)
|
||||||
{
|
{
|
||||||
var user = string.IsNullOrWhiteSpace(enteredUsername) ? null : GetUserByName(enteredUsername);
|
var user = string.IsNullOrWhiteSpace(enteredUsername) ? null : GetUserByName(enteredUsername);
|
||||||
|
var passwordResetProvider = GetPasswordResetProvider(user);
|
||||||
|
|
||||||
|
var result = await passwordResetProvider
|
||||||
|
.StartForgotPasswordProcess(user, enteredUsername, isInNetwork)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
if (user is not null && isInNetwork)
|
if (user is not null && isInNetwork)
|
||||||
{
|
{
|
||||||
var passwordResetProvider = GetPasswordResetProvider(user);
|
|
||||||
var result = await passwordResetProvider
|
|
||||||
.StartForgotPasswordProcess(user, isInNetwork)
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
|
|
||||||
await UpdateUserAsync(user).ConfigureAwait(false);
|
await UpdateUserAsync(user).ConfigureAwait(false);
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return new ForgotPasswordResult
|
return result;
|
||||||
{
|
|
||||||
Action = ForgotPasswordAction.InNetworkRequired,
|
|
||||||
PinFile = string.Empty
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
@@ -547,7 +700,10 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
public async Task InitializeAsync()
|
public async Task InitializeAsync()
|
||||||
{
|
{
|
||||||
// TODO: Refactor the startup wizard so that it doesn't require a user to already exist.
|
// TODO: Refactor the startup wizard so that it doesn't require a user to already exist.
|
||||||
if (_users.Any())
|
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||||
|
await using (dbContext.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
if (await dbContext.Users.AnyAsync().ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -560,9 +716,6 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
|
|
||||||
_logger.LogWarning("No users, creating one with username {UserName}", defaultName);
|
_logger.LogWarning("No users, creating one with username {UserName}", defaultName);
|
||||||
|
|
||||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
|
||||||
await using (dbContext.ConfigureAwait(false))
|
|
||||||
{
|
|
||||||
var newUser = await CreateUserInternalAsync(defaultName, dbContext).ConfigureAwait(false);
|
var newUser = await CreateUserInternalAsync(defaultName, dbContext).ConfigureAwait(false);
|
||||||
newUser.SetPermission(PermissionKind.IsAdministrator, true);
|
newUser.SetPermission(PermissionKind.IsAdministrator, true);
|
||||||
newUser.SetPermission(PermissionKind.EnableContentDeletion, true);
|
newUser.SetPermission(PermissionKind.EnableContentDeletion, true);
|
||||||
@@ -570,7 +723,6 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
|
|
||||||
dbContext.Users.Add(newUser);
|
dbContext.Users.Add(newUser);
|
||||||
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
||||||
_users.Add(newUser.Id, newUser);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -606,15 +758,14 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task UpdateConfigurationAsync(Guid userId, UserConfiguration config)
|
public async Task UpdateConfigurationAsync(Guid userId, UserConfiguration config)
|
||||||
|
{
|
||||||
|
using (await _userLock.LockAsync(userId).ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||||
await using (dbContext.ConfigureAwait(false))
|
await using (dbContext.ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
var user = dbContext.Users
|
var user = UserQuery(dbContext)
|
||||||
.Include(u => u.Permissions)
|
.AsTracking()
|
||||||
.Include(u => u.Preferences)
|
|
||||||
.Include(u => u.AccessSchedules)
|
|
||||||
.Include(u => u.ProfileImage)
|
|
||||||
.FirstOrDefault(u => u.Id.Equals(userId))
|
.FirstOrDefault(u => u.Id.Equals(userId))
|
||||||
?? throw new ArgumentException("No user exists with given Id!");
|
?? throw new ArgumentException("No user exists with given Id!");
|
||||||
|
|
||||||
@@ -643,22 +794,21 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
user.SetPreference(PreferenceKind.LatestItemExcludes, config.LatestItemsExcludes);
|
user.SetPreference(PreferenceKind.LatestItemExcludes, config.LatestItemsExcludes);
|
||||||
|
|
||||||
dbContext.Update(user);
|
dbContext.Update(user);
|
||||||
_users[user.Id] = user;
|
|
||||||
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task UpdatePolicyAsync(Guid userId, UserPolicy policy)
|
public async Task UpdatePolicyAsync(Guid userId, UserPolicy policy)
|
||||||
|
{
|
||||||
|
using (await _userLock.LockAsync(userId).ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||||
await using (dbContext.ConfigureAwait(false))
|
await using (dbContext.ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
var user = dbContext.Users
|
var user = UserQuery(dbContext)
|
||||||
.Include(u => u.Permissions)
|
.AsTracking()
|
||||||
.Include(u => u.Preferences)
|
|
||||||
.Include(u => u.AccessSchedules)
|
|
||||||
.Include(u => u.ProfileImage)
|
|
||||||
.FirstOrDefault(u => u.Id.Equals(userId))
|
.FirstOrDefault(u => u.Id.Equals(userId))
|
||||||
?? throw new ArgumentException("No user exists with given Id!");
|
?? throw new ArgumentException("No user exists with given Id!");
|
||||||
|
|
||||||
@@ -721,10 +871,10 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
user.SetPreference(PreferenceKind.EnableContentDeletionFromFolders, policy.EnableContentDeletionFromFolders);
|
user.SetPreference(PreferenceKind.EnableContentDeletionFromFolders, policy.EnableContentDeletionFromFolders);
|
||||||
|
|
||||||
dbContext.Update(user);
|
dbContext.Update(user);
|
||||||
_users[user.Id] = user;
|
|
||||||
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task ClearProfileImageAsync(User user)
|
public async Task ClearProfileImageAsync(User user)
|
||||||
@@ -734,6 +884,8 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
using (await _userLock.LockAsync(user.Id).ConfigureAwait(false))
|
||||||
|
{
|
||||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||||
await using (dbContext.ConfigureAwait(false))
|
await using (dbContext.ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
@@ -742,7 +894,7 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
}
|
}
|
||||||
|
|
||||||
user.ProfileImage = null;
|
user.ProfileImage = null;
|
||||||
_users[user.Id] = user;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static void ThrowIfInvalidUsername(string name)
|
internal static void ThrowIfInvalidUsername(string name)
|
||||||
@@ -760,8 +912,13 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
return GetAuthenticationProviders(user)[0];
|
return GetAuthenticationProviders(user)[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
private IPasswordResetProvider GetPasswordResetProvider(User user)
|
private IPasswordResetProvider GetPasswordResetProvider(User? user)
|
||||||
{
|
{
|
||||||
|
if (user is null)
|
||||||
|
{
|
||||||
|
return _defaultPasswordResetProvider;
|
||||||
|
}
|
||||||
|
|
||||||
return GetPasswordResetProviders(user)[0];
|
return GetPasswordResetProviders(user)[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -869,29 +1026,95 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task IncrementInvalidLoginAttemptCount(User user)
|
|
||||||
{
|
|
||||||
user.InvalidLoginAttemptCount++;
|
|
||||||
int? maxInvalidLogins = user.LoginAttemptsBeforeLockout;
|
|
||||||
if (maxInvalidLogins.HasValue && user.InvalidLoginAttemptCount >= maxInvalidLogins)
|
|
||||||
{
|
|
||||||
user.SetPermission(PermissionKind.IsDisabled, true);
|
|
||||||
await _eventManager.PublishAsync(new UserLockedOutEventArgs(user)).ConfigureAwait(false);
|
|
||||||
_logger.LogWarning(
|
|
||||||
"Disabling user {Username} due to {Attempts} unsuccessful login attempts.",
|
|
||||||
user.Username,
|
|
||||||
user.InvalidLoginAttemptCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
await UpdateUserAsync(user).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task UpdateUserInternalAsync(JellyfinDbContext dbContext, User user)
|
private async Task UpdateUserInternalAsync(JellyfinDbContext dbContext, User user)
|
||||||
{
|
{
|
||||||
dbContext.Users.Attach(user);
|
dbContext.Users.Attach(user);
|
||||||
dbContext.Entry(user).State = EntityState.Modified;
|
dbContext.Entry(user).State = EntityState.Modified;
|
||||||
_users[user.Id] = user;
|
|
||||||
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Dispose(true);
|
||||||
|
GC.SuppressFinalize(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Disposes all members of this class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="disposing">Defines if the class has been cleaned up by a dispose or finalizer.</param>
|
||||||
|
protected virtual void Dispose(bool disposing)
|
||||||
|
{
|
||||||
|
if (disposing)
|
||||||
|
{
|
||||||
|
_userLock.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class LockHelper : IDisposable
|
||||||
|
{
|
||||||
|
private readonly AsyncKeyedLocker<Guid> _userLock = new();
|
||||||
|
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
public static AsyncLocal<int> IsNestedLock { get; set; } = new();
|
||||||
|
|
||||||
|
public bool ShouldLock()
|
||||||
|
{
|
||||||
|
return IsNestedLock.Value == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask<IDisposable> LockAsync(Guid key)
|
||||||
|
{
|
||||||
|
ThrowIfDisposed();
|
||||||
|
var isNested = LockHelper.IsNestedLock.Value != 0;
|
||||||
|
LockHelper.IsNestedLock.Value = LockHelper.IsNestedLock.Value + 1;
|
||||||
|
if (isNested)
|
||||||
|
{
|
||||||
|
return new ValueTask<IDisposable>(new LockHandle { Parent = null });
|
||||||
|
}
|
||||||
|
|
||||||
|
return AcquireLockAsync(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ValueTask<IDisposable> AcquireLockAsync(Guid key)
|
||||||
|
{
|
||||||
|
var lockHandle = await _userLock.LockAsync(key, true).ConfigureAwait(false);
|
||||||
|
return new LockHandle { Parent = lockHandle };
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_disposed = true;
|
||||||
|
_userLock.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ThrowIfDisposed()
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class LockHandle : IDisposable
|
||||||
|
{
|
||||||
|
public required IDisposable? Parent { get; init; }
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Parent?.Dispose();
|
||||||
|
LockHelper.IsNestedLock.Value = LockHelper.IsNestedLock.Value - 1;
|
||||||
|
|
||||||
|
if (LockHelper.IsNestedLock.Value < 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Mismatched locking detected. Threads internal NestedLock is less then 0 which should not be possible.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,9 +33,11 @@ using Microsoft.AspNetCore.Builder;
|
|||||||
using Microsoft.AspNetCore.Cors.Infrastructure;
|
using Microsoft.AspNetCore.Cors.Infrastructure;
|
||||||
using Microsoft.AspNetCore.HttpOverrides;
|
using Microsoft.AspNetCore.HttpOverrides;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||||
using Microsoft.OpenApi.Any;
|
using Microsoft.OpenApi.Any;
|
||||||
using Microsoft.OpenApi.Interfaces;
|
using Microsoft.OpenApi.Interfaces;
|
||||||
using Microsoft.OpenApi.Models;
|
using Microsoft.OpenApi.Models;
|
||||||
|
using Swashbuckle.AspNetCore.Swagger;
|
||||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||||
using AuthenticationSchemes = Jellyfin.Api.Constants.AuthenticationSchemes;
|
using AuthenticationSchemes = Jellyfin.Api.Constants.AuthenticationSchemes;
|
||||||
|
|
||||||
@@ -259,7 +261,8 @@ namespace Jellyfin.Server.Extensions
|
|||||||
c.OperationFilter<FileRequestFilter>();
|
c.OperationFilter<FileRequestFilter>();
|
||||||
c.OperationFilter<ParameterObsoleteFilter>();
|
c.OperationFilter<ParameterObsoleteFilter>();
|
||||||
c.DocumentFilter<AdditionalModelFilter>();
|
c.DocumentFilter<AdditionalModelFilter>();
|
||||||
});
|
})
|
||||||
|
.Replace(ServiceDescriptor.Transient<ISwaggerProvider, CachingOpenApiProvider>());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void AddPolicy(this AuthorizationOptions authorizationOptions, string policyName, IAuthorizationRequirement authorizationRequirement)
|
private static void AddPolicy(this AuthorizationOptions authorizationOptions, string policyName, IAuthorizationRequirement authorizationRequirement)
|
||||||
|
|||||||
89
Jellyfin.Server/Filters/CachingOpenApiProvider.cs
Normal file
89
Jellyfin.Server/Filters/CachingOpenApiProvider.cs
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using Microsoft.AspNetCore.Mvc.ApiExplorer;
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Microsoft.OpenApi.Models;
|
||||||
|
using Swashbuckle.AspNetCore.Swagger;
|
||||||
|
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||||
|
|
||||||
|
namespace Jellyfin.Server.Filters;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OpenApi provider with caching.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class CachingOpenApiProvider : ISwaggerProvider
|
||||||
|
{
|
||||||
|
private const string CacheKey = "openapi.json";
|
||||||
|
|
||||||
|
private static readonly MemoryCacheEntryOptions _cacheOptions = new() { SlidingExpiration = TimeSpan.FromMinutes(5) };
|
||||||
|
private static readonly SemaphoreSlim _lock = new(1, 1);
|
||||||
|
private static readonly TimeSpan _lockTimeout = TimeSpan.FromSeconds(1);
|
||||||
|
|
||||||
|
private readonly IMemoryCache _memoryCache;
|
||||||
|
private readonly SwaggerGenerator _swaggerGenerator;
|
||||||
|
private readonly SwaggerGeneratorOptions _swaggerGeneratorOptions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="CachingOpenApiProvider"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="optionsAccessor">The options accessor.</param>
|
||||||
|
/// <param name="apiDescriptionsProvider">The api descriptions provider.</param>
|
||||||
|
/// <param name="schemaGenerator">The schema generator.</param>
|
||||||
|
/// <param name="memoryCache">The memory cache.</param>
|
||||||
|
public CachingOpenApiProvider(
|
||||||
|
IOptions<SwaggerGeneratorOptions> optionsAccessor,
|
||||||
|
IApiDescriptionGroupCollectionProvider apiDescriptionsProvider,
|
||||||
|
ISchemaGenerator schemaGenerator,
|
||||||
|
IMemoryCache memoryCache)
|
||||||
|
{
|
||||||
|
_swaggerGeneratorOptions = optionsAccessor.Value;
|
||||||
|
_swaggerGenerator = new SwaggerGenerator(_swaggerGeneratorOptions, apiDescriptionsProvider, schemaGenerator);
|
||||||
|
_memoryCache = memoryCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public OpenApiDocument GetSwagger(string documentName, string? host = null, string? basePath = null)
|
||||||
|
{
|
||||||
|
if (_memoryCache.TryGetValue(CacheKey, out OpenApiDocument? openApiDocument) && openApiDocument is not null)
|
||||||
|
{
|
||||||
|
return AdjustDocument(openApiDocument, host, basePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
var acquired = _lock.Wait(_lockTimeout);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_memoryCache.TryGetValue(CacheKey, out openApiDocument) && openApiDocument is not null)
|
||||||
|
{
|
||||||
|
return AdjustDocument(openApiDocument, host, basePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!acquired)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("OpenApi document is generating");
|
||||||
|
}
|
||||||
|
|
||||||
|
openApiDocument = _swaggerGenerator.GetSwagger(documentName);
|
||||||
|
_memoryCache.Set(CacheKey, openApiDocument, _cacheOptions);
|
||||||
|
return AdjustDocument(openApiDocument, host, basePath);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (acquired)
|
||||||
|
{
|
||||||
|
_lock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private OpenApiDocument AdjustDocument(OpenApiDocument document, string? host, string? basePath)
|
||||||
|
{
|
||||||
|
document.Servers = _swaggerGeneratorOptions.Servers.Count != 0
|
||||||
|
? _swaggerGeneratorOptions.Servers
|
||||||
|
: string.IsNullOrEmpty(host) && string.IsNullOrEmpty(basePath)
|
||||||
|
? []
|
||||||
|
: [new OpenApiServer { Url = $"{host}{basePath}" }];
|
||||||
|
|
||||||
|
return document;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
// The MIT License (MIT)
|
|
||||||
//
|
|
||||||
// Copyright (c) .NET Foundation and Contributors
|
|
||||||
//
|
|
||||||
// All rights reserved.
|
|
||||||
//
|
|
||||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
// of this software and associated documentation files (the "Software"), to deal
|
|
||||||
// in the Software without restriction, including without limitation the rights
|
|
||||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
// copies of the Software, and to permit persons to whom the Software is
|
|
||||||
// furnished to do so, subject to the following conditions:
|
|
||||||
//
|
|
||||||
// The above copyright notice and this permission notice shall be included in all
|
|
||||||
// copies or substantial portions of the Software.
|
|
||||||
//
|
|
||||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
// SOFTWARE.
|
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.IO;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Microsoft.AspNetCore.Http;
|
|
||||||
using Microsoft.AspNetCore.Http.Extensions;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.AspNetCore.Mvc.Infrastructure;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Microsoft.Net.Http.Headers;
|
|
||||||
|
|
||||||
namespace Jellyfin.Server.Infrastructure
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public class SymlinkFollowingPhysicalFileResultExecutor : PhysicalFileResultExecutor
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Initializes a new instance of the <see cref="SymlinkFollowingPhysicalFileResultExecutor"/> class.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="loggerFactory">An instance of the <see cref="ILoggerFactory"/> interface.</param>
|
|
||||||
public SymlinkFollowingPhysicalFileResultExecutor(ILoggerFactory loggerFactory) : base(loggerFactory)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override FileMetadata GetFileInfo(string path)
|
|
||||||
{
|
|
||||||
var fileInfo = new FileInfo(path);
|
|
||||||
var length = fileInfo.Length;
|
|
||||||
// This may or may not be fixed in .NET 6, but looks like it will not https://github.com/dotnet/aspnetcore/issues/34371
|
|
||||||
if ((fileInfo.Attributes & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint)
|
|
||||||
{
|
|
||||||
using var fileHandle = File.OpenHandle(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
|
||||||
length = RandomAccess.GetLength(fileHandle);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new FileMetadata
|
|
||||||
{
|
|
||||||
Exists = fileInfo.Exists,
|
|
||||||
Length = length,
|
|
||||||
LastModified = fileInfo.LastWriteTimeUtc
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override async Task WriteFileAsync(ActionContext context, PhysicalFileResult result, RangeItemHeaderValue? range, long rangeLength)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(context);
|
|
||||||
ArgumentNullException.ThrowIfNull(result);
|
|
||||||
|
|
||||||
if (range is not null && rangeLength == 0)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// It's a bit of wasted IO to perform this check again, but non-symlinks shouldn't use this code
|
|
||||||
if (!IsSymLink(result.FileName))
|
|
||||||
{
|
|
||||||
await base.WriteFileAsync(context, result, range, rangeLength).ConfigureAwait(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var response = context.HttpContext.Response;
|
|
||||||
|
|
||||||
if (range is not null)
|
|
||||||
{
|
|
||||||
await SendFileAsync(
|
|
||||||
result.FileName,
|
|
||||||
response,
|
|
||||||
offset: range.From ?? 0L,
|
|
||||||
count: rangeLength).ConfigureAwait(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await SendFileAsync(
|
|
||||||
result.FileName,
|
|
||||||
response,
|
|
||||||
offset: 0,
|
|
||||||
count: null).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SendFileAsync(string filePath, HttpResponse response, long offset, long? count, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var fileInfo = GetFileInfo(filePath);
|
|
||||||
if (offset < 0 || offset > fileInfo.Length)
|
|
||||||
{
|
|
||||||
throw new ArgumentOutOfRangeException(nameof(offset), offset, string.Empty);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (count.HasValue
|
|
||||||
&& (count.Value < 0 || count.Value > fileInfo.Length - offset))
|
|
||||||
{
|
|
||||||
throw new ArgumentOutOfRangeException(nameof(count), count, string.Empty);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copied from SendFileFallback.SendFileAsync
|
|
||||||
const int BufferSize = 1024 * 16;
|
|
||||||
|
|
||||||
var useRequestAborted = !cancellationToken.CanBeCanceled;
|
|
||||||
var localCancel = useRequestAborted ? response.HttpContext.RequestAborted : cancellationToken;
|
|
||||||
|
|
||||||
var fileStream = new FileStream(
|
|
||||||
filePath,
|
|
||||||
FileMode.Open,
|
|
||||||
FileAccess.Read,
|
|
||||||
FileShare.ReadWrite,
|
|
||||||
bufferSize: BufferSize,
|
|
||||||
options: FileOptions.Asynchronous | FileOptions.SequentialScan);
|
|
||||||
await using (fileStream.ConfigureAwait(false))
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
localCancel.ThrowIfCancellationRequested();
|
|
||||||
fileStream.Seek(offset, SeekOrigin.Begin);
|
|
||||||
await StreamCopyOperation
|
|
||||||
.CopyToAsync(fileStream, response.Body, count, BufferSize, localCancel)
|
|
||||||
.ConfigureAwait(true);
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException) when (useRequestAborted)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool IsSymLink(string path) => (File.GetAttributes(path) & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -78,7 +78,7 @@
|
|||||||
<None Update="wwwroot\api-docs\swagger\custom.css">
|
<None Update="wwwroot\api-docs\swagger\custom.css">
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
</None>
|
</None>
|
||||||
<None Update="wwwroot\api-docs\banner-dark.svg">
|
<None Update="wwwroot\api-docs\jellyfin.svg">
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
</None>
|
</None>
|
||||||
<None Update="ServerSetupApp/index.mstemplate.html">
|
<None Update="ServerSetupApp/index.mstemplate.html">
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Database.Implementations;
|
||||||
|
using MediaBrowser.Controller.Configuration;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jellyfin.Server.Migrations.Routines;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Part 2 Migration for NormalisedUsername.
|
||||||
|
/// </summary>
|
||||||
|
[JellyfinMigration("2026-05-22T09:23:04", nameof(UpdateNormalizedUsername), Stage = Stages.JellyfinMigrationStageTypes.CoreInitialisation)]
|
||||||
|
#pragma warning disable SA1649 // File name should match first type name
|
||||||
|
public class UpdateNormalizedUsername : IAsyncMigrationRoutine
|
||||||
|
#pragma warning restore SA1649 // File name should match first type name
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<JellyfinDbContext> _contextFactory;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="UpdateNormalizedUsername"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="contextFactory">Db Context factory.</param>
|
||||||
|
public UpdateNormalizedUsername(IDbContextFactory<JellyfinDbContext> contextFactory)
|
||||||
|
{
|
||||||
|
_contextFactory = contextFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task PerformAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var dbContext = await _contextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
await using (dbContext.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
var users = await dbContext.Users.ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
foreach (var user in users)
|
||||||
|
{
|
||||||
|
user.NormalizedUsername = user.Username.ToUpperInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Server.ServerSetupApp;
|
||||||
|
using MediaBrowser.Controller.Entities;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
|
using MediaBrowser.Model.Globalization;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Jellyfin.Server.Migrations.Routines;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Migration to fix broken library subtitle download languages.
|
||||||
|
/// </summary>
|
||||||
|
[JellyfinMigration("2026-02-06T20:00:00", nameof(FixLibrarySubtitleDownloadLanguages))]
|
||||||
|
internal class FixLibrarySubtitleDownloadLanguages : IAsyncMigrationRoutine
|
||||||
|
{
|
||||||
|
private readonly ILocalizationManager _localizationManager;
|
||||||
|
private readonly ILibraryManager _libraryManager;
|
||||||
|
private readonly ILogger _logger;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="FixLibrarySubtitleDownloadLanguages"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="localizationManager">The Localization manager.</param>
|
||||||
|
/// <param name="startupLogger">The startup logger for Startup UI integration.</param>
|
||||||
|
/// <param name="libraryManager">The Library manager.</param>
|
||||||
|
/// <param name="logger">The logger.</param>
|
||||||
|
public FixLibrarySubtitleDownloadLanguages(
|
||||||
|
ILocalizationManager localizationManager,
|
||||||
|
IStartupLogger<FixLibrarySubtitleDownloadLanguages> startupLogger,
|
||||||
|
ILibraryManager libraryManager,
|
||||||
|
ILogger<FixLibrarySubtitleDownloadLanguages> logger)
|
||||||
|
{
|
||||||
|
_localizationManager = localizationManager;
|
||||||
|
_libraryManager = libraryManager;
|
||||||
|
_logger = startupLogger.With(logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task PerformAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Starting to fix library subtitle download languages.");
|
||||||
|
|
||||||
|
var virtualFolders = _libraryManager.GetVirtualFolders(false);
|
||||||
|
|
||||||
|
foreach (var virtualFolder in virtualFolders)
|
||||||
|
{
|
||||||
|
var options = virtualFolder.LibraryOptions;
|
||||||
|
if (options?.SubtitleDownloadLanguages is null || options.SubtitleDownloadLanguages.Length == 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Some virtual folders don't have a proper item id.
|
||||||
|
if (!Guid.TryParse(virtualFolder.ItemId, out var folderId))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var collectionFolder = _libraryManager.GetItemById<CollectionFolder>(folderId);
|
||||||
|
if (collectionFolder is null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Could not find collection folder for virtual folder '{LibraryName}' with id '{FolderId}'. Skipping.", virtualFolder.Name, folderId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var fixedLanguages = new List<string>();
|
||||||
|
|
||||||
|
foreach (var language in options.SubtitleDownloadLanguages)
|
||||||
|
{
|
||||||
|
var foundLanguage = _localizationManager.FindLanguageInfo(language)?.ThreeLetterISOLanguageName;
|
||||||
|
if (foundLanguage is not null)
|
||||||
|
{
|
||||||
|
// Converted ISO 639-2/B to T (ger to deu)
|
||||||
|
if (!string.Equals(foundLanguage, language, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Converted '{Language}' to '{ResolvedLanguage}' in library '{LibraryName}'.", language, foundLanguage, virtualFolder.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fixedLanguages.Contains(foundLanguage, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Language '{Language}' already exists for library '{LibraryName}'. Skipping duplicate.", foundLanguage, virtualFolder.Name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
fixedLanguages.Add(foundLanguage);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Could not resolve language '{Language}' in library '{LibraryName}'. Skipping.", language, virtualFolder.Name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
options.SubtitleDownloadLanguages = [.. fixedLanguages];
|
||||||
|
collectionFolder.UpdateLibraryOptions(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Library subtitle download languages fixed.");
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,9 +55,25 @@ namespace Jellyfin.Server.Migrations.Routines
|
|||||||
};
|
};
|
||||||
|
|
||||||
var dataPath = _paths.DataPath;
|
var dataPath = _paths.DataPath;
|
||||||
using (var connection = new SqliteConnection($"Filename={Path.Combine(dataPath, DbFilename)}"))
|
var activityLogPath = Path.Combine(dataPath, DbFilename);
|
||||||
|
if (!File.Exists(activityLogPath))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("{ActivityLogDb} doesn't exist, nothing to migrate", activityLogPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var connection = new SqliteConnection($"Filename={activityLogPath}"))
|
||||||
{
|
{
|
||||||
connection.Open();
|
connection.Open();
|
||||||
|
var tableQuery = connection.Query("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='ActivityLog';");
|
||||||
|
foreach (var row in tableQuery)
|
||||||
|
{
|
||||||
|
if (row.GetInt32(0) == 0)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Table 'ActivityLog' doesn't exist in {ActivityLogPath}, nothing to migrate", activityLogPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
using var userDbConnection = new SqliteConnection($"Filename={Path.Combine(dataPath, "users.db")}");
|
using var userDbConnection = new SqliteConnection($"Filename={Path.Combine(dataPath, "users.db")}");
|
||||||
userDbConnection.Open();
|
userDbConnection.Open();
|
||||||
|
|||||||
@@ -50,9 +50,28 @@ namespace Jellyfin.Server.Migrations.Routines
|
|||||||
public void Perform()
|
public void Perform()
|
||||||
{
|
{
|
||||||
var dataPath = _appPaths.DataPath;
|
var dataPath = _appPaths.DataPath;
|
||||||
using (var connection = new SqliteConnection($"Filename={Path.Combine(dataPath, DbFilename)}"))
|
var dbFilePath = Path.Combine(dataPath, DbFilename);
|
||||||
|
|
||||||
|
if (!File.Exists(dbFilePath))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("{Path} doesn't exist, nothing to migrate", dbFilePath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var connection = new SqliteConnection($"Filename={dbFilePath}"))
|
||||||
{
|
{
|
||||||
connection.Open();
|
connection.Open();
|
||||||
|
|
||||||
|
var tableQuery = connection.Query("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='Tokens';");
|
||||||
|
foreach (var row in tableQuery)
|
||||||
|
{
|
||||||
|
if (row.GetInt32(0) == 0)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Table 'Tokens' doesn't exist in {Path}, nothing to migrate", dbFilePath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
using var dbContext = _dbProvider.CreateDbContext();
|
using var dbContext = _dbProvider.CreateDbContext();
|
||||||
|
|
||||||
var authenticatedDevices = connection.Query("SELECT * FROM Tokens");
|
var authenticatedDevices = connection.Query("SELECT * FROM Tokens");
|
||||||
|
|||||||
@@ -78,9 +78,27 @@ namespace Jellyfin.Server.Migrations.Routines
|
|||||||
var displayPrefs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
var displayPrefs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
var customDisplayPrefs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
var customDisplayPrefs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
var dbFilePath = Path.Combine(_paths.DataPath, DbFilename);
|
var dbFilePath = Path.Combine(_paths.DataPath, DbFilename);
|
||||||
|
|
||||||
|
if (!File.Exists(dbFilePath))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("{Path} doesn't exist, nothing to migrate", dbFilePath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
using (var connection = new SqliteConnection($"Filename={dbFilePath}"))
|
using (var connection = new SqliteConnection($"Filename={dbFilePath}"))
|
||||||
{
|
{
|
||||||
connection.Open();
|
connection.Open();
|
||||||
|
|
||||||
|
var tableQuery = connection.Query("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='userdisplaypreferences';");
|
||||||
|
foreach (var row in tableQuery)
|
||||||
|
{
|
||||||
|
if (row.GetInt32(0) == 0)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Table 'userdisplaypreferences' doesn't exist in {Path}, nothing to migrate", dbFilePath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
using var dbContext = _provider.CreateDbContext();
|
using var dbContext = _provider.CreateDbContext();
|
||||||
|
|
||||||
var results = connection.Query("SELECT * FROM userdisplaypreferences");
|
var results = connection.Query("SELECT * FROM userdisplaypreferences");
|
||||||
|
|||||||
@@ -122,6 +122,16 @@ public class MigrateKeyframeData : IDatabaseMigrationRoutine
|
|||||||
{
|
{
|
||||||
lastWriteTimeUtc = File.GetLastWriteTimeUtc(filePath);
|
lastWriteTimeUtc = File.GetLastWriteTimeUtc(filePath);
|
||||||
}
|
}
|
||||||
|
catch (ArgumentOutOfRangeException e)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Skipping {Path}: {Exception}", filePath, e.Message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException e)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Skipping {Path}: {Exception}", filePath, e.Message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
catch (IOException e)
|
catch (IOException e)
|
||||||
{
|
{
|
||||||
_logger.LogDebug("Skipping {Path}: {Exception}", filePath, e.Message);
|
_logger.LogDebug("Skipping {Path}: {Exception}", filePath, e.Message);
|
||||||
@@ -135,15 +145,22 @@ public class MigrateKeyframeData : IDatabaseMigrationRoutine
|
|||||||
return Path.Join(keyframeCachePath, prefix, filename);
|
return Path.Join(keyframeCachePath, prefix, filename);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool TryReadFromCache(string? cachePath, [NotNullWhen(true)] out MediaEncoding.Keyframes.KeyframeData? cachedResult)
|
private bool TryReadFromCache(string? cachePath, [NotNullWhen(true)] out MediaEncoding.Keyframes.KeyframeData? cachedResult)
|
||||||
{
|
{
|
||||||
if (File.Exists(cachePath))
|
if (File.Exists(cachePath))
|
||||||
|
{
|
||||||
|
try
|
||||||
{
|
{
|
||||||
var bytes = File.ReadAllBytes(cachePath);
|
var bytes = File.ReadAllBytes(cachePath);
|
||||||
cachedResult = JsonSerializer.Deserialize<MediaEncoding.Keyframes.KeyframeData>(bytes, _jsonOptions);
|
cachedResult = JsonSerializer.Deserialize<MediaEncoding.Keyframes.KeyframeData>(bytes, _jsonOptions);
|
||||||
|
|
||||||
return cachedResult is not null;
|
return cachedResult is not null;
|
||||||
}
|
}
|
||||||
|
catch (JsonException jsonException)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(jsonException, "Failed to read {Path}", cachePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
cachedResult = null;
|
cachedResult = null;
|
||||||
|
|
||||||
|
|||||||
@@ -383,8 +383,6 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
baseItemIds.Clear();
|
|
||||||
|
|
||||||
foreach (var item in peopleCache)
|
foreach (var item in peopleCache)
|
||||||
{
|
{
|
||||||
operation.JellyfinDbContext.Peoples.Add(item.Value.Person);
|
operation.JellyfinDbContext.Peoples.Add(item.Value.Person);
|
||||||
@@ -466,6 +464,16 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
|
|||||||
|
|
||||||
SqliteConnection.ClearAllPools();
|
SqliteConnection.ClearAllPools();
|
||||||
|
|
||||||
|
using (var checkpointConnection = new SqliteConnection($"Filename={libraryDbPath}"))
|
||||||
|
{
|
||||||
|
checkpointConnection.Open();
|
||||||
|
using var cmd = checkpointConnection.CreateCommand();
|
||||||
|
cmd.CommandText = "PRAGMA wal_checkpoint(TRUNCATE);";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
SqliteConnection.ClearAllPools();
|
||||||
|
|
||||||
_logger.LogInformation("Move {0} to {1}.", libraryDbPath, libraryDbPath + ".old");
|
_logger.LogInformation("Move {0} to {1}.", libraryDbPath, libraryDbPath + ".old");
|
||||||
File.Move(libraryDbPath, libraryDbPath + ".old", true);
|
File.Move(libraryDbPath, libraryDbPath + ".old", true);
|
||||||
}
|
}
|
||||||
@@ -1165,7 +1173,9 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
|
|||||||
Item = null!,
|
Item = null!,
|
||||||
ProviderId = e[0],
|
ProviderId = e[0],
|
||||||
ProviderValue = string.Join('|', e.Skip(1))
|
ProviderValue = string.Join('|', e.Skip(1))
|
||||||
}).ToArray();
|
})
|
||||||
|
.DistinctBy(e => e.ProviderId)
|
||||||
|
.ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reader.TryGetString(index++, out var imageInfos))
|
if (reader.TryGetString(index++, out var imageInfos))
|
||||||
|
|||||||
@@ -57,11 +57,28 @@ public class MigrateUserDb : IMigrationRoutine
|
|||||||
public void Perform()
|
public void Perform()
|
||||||
{
|
{
|
||||||
var dataPath = _paths.DataPath;
|
var dataPath = _paths.DataPath;
|
||||||
|
var userDbPath = Path.Combine(dataPath, DbFilename);
|
||||||
|
if (!File.Exists(userDbPath))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("{UserDbPath} doesn't exist, nothing to migrate", userDbPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
_logger.LogInformation("Migrating the user database may take a while, do not stop Jellyfin.");
|
_logger.LogInformation("Migrating the user database may take a while, do not stop Jellyfin.");
|
||||||
|
|
||||||
using (var connection = new SqliteConnection($"Filename={Path.Combine(dataPath, DbFilename)}"))
|
using (var connection = new SqliteConnection($"Filename={userDbPath}"))
|
||||||
{
|
{
|
||||||
connection.Open();
|
connection.Open();
|
||||||
|
var tableQuery = connection.Query("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='LocalUsersv2';");
|
||||||
|
foreach (var row in tableQuery)
|
||||||
|
{
|
||||||
|
if (row.GetInt32(0) == 0)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Table 'LocalUsersv2' doesn't exist in {UserDbPath}, nothing to migrate", userDbPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
using var dbContext = _provider.CreateDbContext();
|
using var dbContext = _provider.CreateDbContext();
|
||||||
|
|
||||||
var queryResult = connection.Query("SELECT * FROM LocalUsersv2");
|
var queryResult = connection.Query("SELECT * FROM LocalUsersv2");
|
||||||
|
|||||||
@@ -181,7 +181,9 @@ public class MoveExtractedFiles : IAsyncMigrationRoutine
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var newAttachmentPath = _pathManager.GetAttachmentPath(itemIdString, attachment.Filename ?? attachmentIndex.ToString(CultureInfo.InvariantCulture));
|
var attachmentIndexName = attachmentIndex.ToString(CultureInfo.InvariantCulture);
|
||||||
|
var newAttachmentPath = _pathManager.GetAttachmentPath(itemIdString, attachment.Filename ?? attachmentIndexName)
|
||||||
|
?? _pathManager.GetAttachmentPath(itemIdString, attachmentIndexName)!;
|
||||||
if (File.Exists(newAttachmentPath))
|
if (File.Exists(newAttachmentPath))
|
||||||
{
|
{
|
||||||
File.Delete(oldAttachmentPath);
|
File.Delete(oldAttachmentPath);
|
||||||
@@ -224,6 +226,18 @@ public class MoveExtractedFiles : IAsyncMigrationRoutine
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
catch (UnauthorizedAccessException e)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Skipping subtitle at index {Index} for {Path}: {Exception}", attachmentStreamIndex, mediaPath, e.Message);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch (ArgumentOutOfRangeException e)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Skipping attachment at index {Index} for {Path}: {Exception}", attachmentStreamIndex, mediaPath, e.Message);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
filename = (mediaPath + attachmentStreamIndex.ToString(CultureInfo.InvariantCulture) + "_" + date.Value.Ticks.ToString(CultureInfo.InvariantCulture)).GetMD5().ToString("D", CultureInfo.InvariantCulture);
|
filename = (mediaPath + attachmentStreamIndex.ToString(CultureInfo.InvariantCulture) + "_" + date.Value.Ticks.ToString(CultureInfo.InvariantCulture)).GetMD5().ToString("D", CultureInfo.InvariantCulture);
|
||||||
}
|
}
|
||||||
@@ -263,6 +277,18 @@ public class MoveExtractedFiles : IAsyncMigrationRoutine
|
|||||||
{
|
{
|
||||||
date = File.GetLastWriteTimeUtc(path);
|
date = File.GetLastWriteTimeUtc(path);
|
||||||
}
|
}
|
||||||
|
catch (ArgumentOutOfRangeException e)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Skipping subtitle at index {Index} for {Path}: {Exception}", streamIndex, path, e.Message);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException e)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Skipping subtitle at index {Index} for {Path}: {Exception}", streamIndex, path, e.Message);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
catch (IOException e)
|
catch (IOException e)
|
||||||
{
|
{
|
||||||
_logger.LogDebug("Skipping subtitle at index {Index} for {Path}: {Exception}", streamIndex, path, e.Message);
|
_logger.LogDebug("Skipping subtitle at index {Index} for {Path}: {Exception}", streamIndex, path, e.Message);
|
||||||
|
|||||||
@@ -184,6 +184,12 @@ namespace Jellyfin.Server
|
|||||||
.AddSingleton<IServiceCollection>(e))
|
.AddSingleton<IServiceCollection>(e))
|
||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Initialize the transcode path marker so we avoid starting Jellyfin in a broken state.
|
||||||
|
* This should really be a part of IApplicationPaths but this path is configured differently.
|
||||||
|
*/
|
||||||
|
_ = appHost.ConfigurationManager.GetTranscodePath();
|
||||||
|
|
||||||
// Re-use the host service provider in the app host since ASP.NET doesn't allow a custom service collection.
|
// Re-use the host service provider in the app host since ASP.NET doesn't allow a custom service collection.
|
||||||
appHost.ServiceProvider = _jellyfinHost.Services;
|
appHost.ServiceProvider = _jellyfinHost.Services;
|
||||||
PrepareDatabaseProvider(appHost.ServiceProvider);
|
PrepareDatabaseProvider(appHost.ServiceProvider);
|
||||||
|
|||||||
@@ -250,6 +250,7 @@ public sealed class SetupServer : IDisposable
|
|||||||
{ "isInReportingMode", _isUnhealthy },
|
{ "isInReportingMode", _isUnhealthy },
|
||||||
{ "retryValue", retryAfterValue },
|
{ "retryValue", retryAfterValue },
|
||||||
{ "logs", startupLogEntries },
|
{ "logs", startupLogEntries },
|
||||||
|
{ "networkManagerReady", networkManager is not null },
|
||||||
{ "localNetworkRequest", networkManager is not null && context.Connection.RemoteIpAddress is not null && networkManager.IsInLocalNetwork(context.Connection.RemoteIpAddress) }
|
{ "localNetworkRequest", networkManager is not null && context.Connection.RemoteIpAddress is not null && networkManager.IsInLocalNetwork(context.Connection.RemoteIpAddress) }
|
||||||
},
|
},
|
||||||
new ByteCounterStream(context.Response.BodyWriter.AsStream(), IODefaults.FileStreamBufferSize, true, _startupUiRenderer.ParserOptions))
|
new ByteCounterStream(context.Response.BodyWriter.AsStream(), IODefaults.FileStreamBufferSize, true, _startupUiRenderer.ParserOptions))
|
||||||
|
|||||||
@@ -213,7 +213,12 @@
|
|||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
{{#ELSE}}
|
{{#ELSE}}
|
||||||
|
{{#IF networkManagerReady}}
|
||||||
<p>Please visit this page from your local network to view detailed startup logs.</p>
|
<p>Please visit this page from your local network to view detailed startup logs.</p>
|
||||||
|
{{#ELSE}}
|
||||||
|
<p>Initializing network settings. Please wait.</p>
|
||||||
|
{{/ELSE}}
|
||||||
|
{{/IF}}
|
||||||
{{/ELSE}}
|
{{/ELSE}}
|
||||||
{{/IF}}
|
{{/IF}}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,15 +16,12 @@ using Jellyfin.Networking.HappyEyeballs;
|
|||||||
using Jellyfin.Server.Extensions;
|
using Jellyfin.Server.Extensions;
|
||||||
using Jellyfin.Server.HealthChecks;
|
using Jellyfin.Server.HealthChecks;
|
||||||
using Jellyfin.Server.Implementations.Extensions;
|
using Jellyfin.Server.Implementations.Extensions;
|
||||||
using Jellyfin.Server.Infrastructure;
|
|
||||||
using MediaBrowser.Common.Net;
|
using MediaBrowser.Common.Net;
|
||||||
using MediaBrowser.Controller.Configuration;
|
using MediaBrowser.Controller.Configuration;
|
||||||
using MediaBrowser.Controller.Extensions;
|
using MediaBrowser.Controller.Extensions;
|
||||||
using MediaBrowser.XbmcMetadata;
|
using MediaBrowser.XbmcMetadata;
|
||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
using Microsoft.AspNetCore.Hosting;
|
using Microsoft.AspNetCore.Hosting;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.AspNetCore.Mvc.Infrastructure;
|
|
||||||
using Microsoft.AspNetCore.StaticFiles;
|
using Microsoft.AspNetCore.StaticFiles;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
@@ -69,8 +66,6 @@ namespace Jellyfin.Server
|
|||||||
options.HttpsPort = _serverApplicationHost.HttpsPort;
|
options.HttpsPort = _serverApplicationHost.HttpsPort;
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO remove once this is fixed upstream https://github.com/dotnet/aspnetcore/issues/34371
|
|
||||||
services.AddSingleton<IActionResultExecutor<PhysicalFileResult>, SymlinkFollowingPhysicalFileResultExecutor>();
|
|
||||||
services.AddJellyfinApi(_serverApplicationHost.GetApiPluginAssemblies(), _serverConfigurationManager.GetNetworkConfiguration());
|
services.AddJellyfinApi(_serverApplicationHost.GetApiPluginAssemblies(), _serverConfigurationManager.GetNetworkConfiguration());
|
||||||
services.AddJellyfinDbContext(_serverApplicationHost.ConfigurationManager, _configuration);
|
services.AddJellyfinDbContext(_serverApplicationHost.ConfigurationManager, _configuration);
|
||||||
services.AddJellyfinApiSwagger();
|
services.AddJellyfinApiSwagger();
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!-- ***** BEGIN LICENSE BLOCK *****
|
|
||||||
- Part of the Jellyfin project (https://jellyfin.media)
|
|
||||||
-
|
|
||||||
- All copyright belongs to the Jellyfin contributors; a full list can
|
|
||||||
- be found in the file CONTRIBUTORS.md
|
|
||||||
-
|
|
||||||
- This work is licensed under the Creative Commons Attribution-ShareAlike 4.0 International License.
|
|
||||||
- To view a copy of this license, visit http://creativecommons.org/licenses/by-sa/4.0/.
|
|
||||||
- ***** END LICENSE BLOCK ***** -->
|
|
||||||
<svg id="banner-dark" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1536 512">
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="linear-gradient" x1="110.25" y1="213.3" x2="496.14" y2="436.09" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop offset="0" stop-color="#aa5cc3"/>
|
|
||||||
<stop offset="1" stop-color="#00a4dc"/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<title>banner-dark</title>
|
|
||||||
<g id="banner-dark">
|
|
||||||
<g id="banner-dark-icon">
|
|
||||||
<path id="inner-shape" d="M261.42,201.62c-20.44,0-86.24,119.29-76.2,139.43s142.48,19.92,152.4,0S281.86,201.63,261.42,201.62Z" fill="url(#linear-gradient)"/>
|
|
||||||
<path id="outer-shape" d="M261.42,23.3C199.83,23.3,1.57,382.73,31.8,443.43s429.34,60,459.24,0S323,23.3,261.42,23.3ZM411.9,390.76c-19.59,39.33-281.08,39.77-300.9,0S221.1,115.48,261.45,115.48,431.49,351.42,411.9,390.76Z" fill="url(#linear-gradient)"/>
|
|
||||||
</g>
|
|
||||||
<g id="jellyfin-light-outlines" style="isolation:isolate" transform="translate(43.8)">
|
|
||||||
<path d="M556.64,350.75a67,67,0,0,1-22.87-27.47,8.91,8.91,0,0,1-1.49-4.75,7.42,7.42,0,0,1,2.83-5.94,9.25,9.25,0,0,1,6.09-2.38c3.16,0,5.94,1.69,8.31,5.05a48.09,48.09,0,0,0,16.34,20.34,40.59,40.59,0,0,0,24,7.58q20.51,0,33.27-12.62t12.77-33.12V159a8.44,8.44,0,0,1,2.67-6.39,9.56,9.56,0,0,1,6.83-2.52,9,9,0,0,1,6.68,2.52,8.7,8.7,0,0,1,2.53,6.39v138.4a64.7,64.7,0,0,1-8.32,32.67,59,59,0,0,1-23,22.72Q608.62,361,589.9,361A57.21,57.21,0,0,1,556.64,350.75Z" fill="#fff"/>
|
|
||||||
<path d="M831.66,279.47a8.77,8.77,0,0,1-6.24,2.53H713.16q0,17.82,7.27,31.92a54.91,54.91,0,0,0,20.79,22.28q13.51,8.18,31.93,8.17a54,54,0,0,0,25.54-5.94,52.7,52.7,0,0,0,18.12-15.15,10,10,0,0,1,6.24-2.67,8.14,8.14,0,0,1,7.72,7.72,8.81,8.81,0,0,1-3,6.24,74.7,74.7,0,0,1-23.91,19A65.56,65.56,0,0,1,773.45,361q-22.87,0-40.4-9.8a69.51,69.51,0,0,1-27.32-27.48q-9.79-17.66-9.8-40.83,0-24.36,9.65-42.62t25.69-27.92a65.2,65.2,0,0,1,34.16-9.65A70,70,0,0,1,798.84,211a65.78,65.78,0,0,1,25.39,24.36q9.81,16,10.1,38A8.07,8.07,0,0,1,831.66,279.47ZM733.5,231.8Q718.8,243.68,714.64,266H815.92v-2.38A46.91,46.91,0,0,0,807,240.27a48.47,48.47,0,0,0-18.56-15.15,54,54,0,0,0-23-5.2Q748.2,219.92,733.5,231.8Z" fill="#fff"/>
|
|
||||||
<path d="M888.24,355.5a8.92,8.92,0,0,1-15.3-6.38v-202a8.91,8.91,0,1,1,17.82,0v202A8.65,8.65,0,0,1,888.24,355.5Z" fill="#fff"/>
|
|
||||||
<path d="M956.55,355.5a8.92,8.92,0,0,1-15.3-6.38v-202a8.91,8.91,0,1,1,17.82,0v202A8.65,8.65,0,0,1,956.55,355.5Z" fill="#fff"/>
|
|
||||||
<path d="M1122.86,206.11a8.7,8.7,0,0,1,2.53,6.39v131q0,23.44-9.21,40.09a61.58,61.58,0,0,1-25.54,25.25q-16.34,8.61-36.83,8.61a96.73,96.73,0,0,1-23.31-2.68,61.72,61.72,0,0,1-18-7.12q-6.24-3.87-6.24-8.62a17.94,17.94,0,0,1,.6-3,8.06,8.06,0,0,1,3-4.45,7.49,7.49,0,0,1,4.45-1.49,7.91,7.91,0,0,1,3.56.89q19,10.39,36.24,10.4,24.65,0,39.06-15.44t14.4-42.18V333.38a54.37,54.37,0,0,1-21.38,20,62.55,62.55,0,0,1-30.3,7.58q-25.83,0-39.2-15.45t-13.37-41.87V212.5a8.91,8.91,0,1,1,17.82,0V301q0,21.39,9.36,32.38t29.25,11a48,48,0,0,0,23.32-6.09,49.88,49.88,0,0,0,17.82-16,37.44,37.44,0,0,0,6.68-21.24V212.5a9,9,0,0,1,15.29-6.39Z" fill="#fff"/>
|
|
||||||
<path d="M1210.18,161.41q-5.21,6.24-5.2,17.23v30.59h33.27a8.19,8.19,0,0,1,5.79,2.38,8.26,8.26,0,0,1,0,11.88,8.22,8.22,0,0,1-5.79,2.37H1205V349.12a8.91,8.91,0,1,1-17.82,0V225.86h-21.68a7.83,7.83,0,0,1-5.94-2.52,8.21,8.21,0,0,1-2.37-5.79,8,8,0,0,1,2.37-6.09,8.33,8.33,0,0,1,5.94-2.23h21.68V178.64q0-18.7,10.84-29t29-10.24a46.1,46.1,0,0,1,15.45,2.52q7.13,2.53,7.12,8.17a8.07,8.07,0,0,1-2.37,5.94,7.37,7.37,0,0,1-5.35,2.37,18.81,18.81,0,0,1-6.53-1.48,42,42,0,0,0-10.4-1.78Q1215.37,155.18,1210.18,161.41ZM1276,180.87c-2.19-1.88-3.27-4.61-3.27-8.17v-3q0-5.34,3.41-8.17t9.36-2.82q11.88,0,11.88,11v3c0,3.56-1,6.29-3.12,8.17s-5.1,2.82-9.06,2.82S1278.14,182.75,1276,180.87Zm15.59,174.63a8.92,8.92,0,0,1-15.3-6.38V212.5a8.91,8.91,0,1,1,17.82,0V349.12A8.65,8.65,0,0,1,1291.56,355.5Z" fill="#fff"/>
|
|
||||||
<path d="M1452.53,218.88q12.92,16.2,12.92,42.92v87.32a8.4,8.4,0,0,1-2.67,6.38,8.8,8.8,0,0,1-6.24,2.53,8.64,8.64,0,0,1-8.91-8.91V262.69q0-19.31-9.65-31.33t-29.85-12a53.28,53.28,0,0,0-42.77,21.83,36.24,36.24,0,0,0-7.13,21.53v86.43a8.91,8.91,0,1,1-17.82,0V216.06a8.91,8.91,0,1,1,17.82,0V232.4q8-12.77,23-21.24A61.84,61.84,0,0,1,1412,202.7Q1439.61,202.7,1452.53,218.88Z" fill="#fff"/>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 4.7 KiB |
26
Jellyfin.Server/wwwroot/api-docs/jellyfin.svg
Normal file
26
Jellyfin.Server/wwwroot/api-docs/jellyfin.svg
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="251" height="72" fill="none" viewBox="0 0 251 72">
|
||||||
|
<g clip-path="url(#a)">
|
||||||
|
<path fill="url(#b)"
|
||||||
|
d="M24.212 49.158C22.66 46.042 32.838 27.588 36 27.588c3.167.002 13.323 18.488 11.788 21.57-1.534 3.082-22.025 3.116-23.576 0" />
|
||||||
|
<path fill="url(#c)" fill-rule="evenodd"
|
||||||
|
d="M.482 64.995C-4.195 55.605 26.477 0 36 0c9.533 0 40.153 55.713 35.527 64.995s-66.368 9.39-71.045 0m12.254-8.148c3.064 6.152 43.518 6.084 46.548 0 3.03-6.086-17.032-42.586-23.275-42.586S9.671 50.694 12.736 56.847"
|
||||||
|
clip-rule="evenodd" />
|
||||||
|
<path fill="#fff"
|
||||||
|
d="M225.22 56c-.28 0-.42 0-.527-.055a.5.5 0 0 1-.219-.218c-.054-.107-.054-.247-.054-.527V26.8c0-.28 0-.42.054-.527a.5.5 0 0 1 .219-.219c.107-.054.247-.054.527-.054h5.183c.28 0 .42 0 .527.054a.5.5 0 0 1 .218.219c.055.107.055.247.055.527v2.895a7.9 7.9 0 0 1 3.419-3.254q2.261-1.103 5.074-1.103 3.308 0 5.845 1.434a10.1 10.1 0 0 1 4.026 4.026q1.434 2.536 1.434 5.9V55.2c0 .28 0 .42-.055.527a.5.5 0 0 1-.218.218c-.107.055-.247.055-.527.055h-5.625c-.28 0-.42 0-.527-.055a.5.5 0 0 1-.218-.218c-.055-.107-.055-.247-.055-.527V38.408q0-2.978-1.709-4.688-1.654-1.764-4.357-1.764-2.702 0-4.412 1.764-1.654 1.766-1.654 4.688V55.2c0 .28 0 .42-.054.527a.5.5 0 0 1-.219.218c-.107.055-.247.055-.527.055zm-11.54-33.363c-.28 0-.42 0-.527-.055a.5.5 0 0 1-.218-.218c-.055-.107-.055-.247-.055-.527v-6.121c0-.28 0-.42.055-.527a.5.5 0 0 1 .218-.219c.107-.054.247-.054.527-.054h5.624c.28 0 .42 0 .527.054a.5.5 0 0 1 .219.219c.054.107.054.247.054.527v6.12c0 .28 0 .42-.054.528a.5.5 0 0 1-.219.218c-.107.055-.247.055-.527.055zm0 33.363c-.28 0-.42 0-.527-.054a.5.5 0 0 1-.218-.219c-.055-.107-.055-.247-.055-.527V26.8c0-.28 0-.42.055-.527a.5.5 0 0 1 .218-.218c.107-.055.247-.055.527-.055h5.624c.28 0 .42 0 .527.055a.5.5 0 0 1 .219.218c.054.107.054.247.054.527v28.4c0 .28 0 .42-.054.527a.5.5 0 0 1-.219.219c-.107.054-.247.054-.527.054zm-16.712-.054c.107.054.247.054.527.054h5.625c.28 0 .42 0 .526-.054a.5.5 0 0 0 .219-.219c.055-.107.055-.247.055-.527V32.452h5.872c.28 0 .42 0 .527-.054a.5.5 0 0 0 .219-.219c.054-.107.054-.247.054-.527V26.8c0-.28 0-.42-.054-.527a.5.5 0 0 0-.219-.218c-.107-.055-.247-.055-.527-.055h-5.872v-.992q0-2.261 1.323-3.31 1.379-1.102 3.75-1.102.454 0 .939.044c.345.031.518.047.634-.004a.48.48 0 0 0 .241-.22c.061-.111.061-.274.061-.6V15.39c0-.304 0-.457-.061-.589a.7.7 0 0 0-.248-.284c-.122-.078-.261-.097-.537-.136a14.5 14.5 0 0 0-1.966-.126q-5.184 0-8.273 2.812t-3.088 7.942V26H186.53c-.3 0-.451 0-.58.05a.75.75 0 0 0-.296.205c-.091.104-.143.244-.248.526l-7.43 19.9-7.483-19.903c-.105-.28-.158-.42-.249-.524a.75.75 0 0 0-.296-.205c-.129-.049-.279-.049-.578-.049h-5.769c-.394 0-.591 0-.717.083a.5.5 0 0 0-.213.314c-.031.147.041.33.186.697L174.281 56l-.661 1.6q-.883 1.874-2.041 3.033-1.103 1.158-3.584 1.158-.883 0-1.875-.166a13 13 0 0 1-.73-.1c-.389-.066-.584-.099-.709-.053a.47.47 0 0 0-.26.22c-.066.116-.066.298-.066.663v4.329c0 .243 0 .365.045.481a.7.7 0 0 0 .189.266c.095.081.194.116.392.185q.684.24 1.47.351 1.158.22 2.371.22 4.246 0 7.059-2.426 2.867-2.37 4.577-6.728l10.517-26.58h5.72V55.2c0 .28 0 .42.055.527a.5.5 0 0 0 .218.219M154.363 56c-.28 0-.42 0-.527-.054a.5.5 0 0 1-.219-.219c-.054-.107-.054-.247-.054-.527V15.054c0-.28 0-.42.054-.527a.5.5 0 0 1 .219-.219c.107-.054.247-.054.527-.054h5.624c.28 0 .42 0 .527.054a.5.5 0 0 1 .218.219c.055.107.055.247.055.527V55.2c0 .28 0 .42-.055.527a.5.5 0 0 1-.218.219c-.107.054-.247.054-.527.054zm-11.621 0c-.28 0-.42 0-.527-.054a.5.5 0 0 1-.219-.219c-.054-.107-.054-.247-.054-.527V15.054c0-.28 0-.42.054-.527a.5.5 0 0 1 .219-.219c.107-.054.247-.054.527-.054h5.624c.28 0 .42 0 .527.054a.5.5 0 0 1 .219.219c.054.107.054.247.054.527V55.2c0 .28 0 .42-.054.527a.5.5 0 0 1-.219.219c-.107.054-.247.054-.527.054zm-18.132.662q-4.632-.001-8.107-2.096a14.6 14.6 0 0 1-5.404-5.68q-1.93-3.585-1.93-7.942 0-4.522 1.93-7.996 1.985-3.53 5.349-5.57 3.42-2.04 7.61-2.04 4.688 0 7.942 2.04 3.253 1.986 4.963 5.294 1.71 3.309 1.709 7.335 0 .828-.11 1.654-.031.45-.12.841c-.037.165-.055.247-.115.33a.55.55 0 0 1-.208.168c-.095.04-.194.04-.393.04h-21.057q.33 3.309 2.537 5.294 2.205 1.986 5.459 1.985 2.482 0 4.191-1.047a8.2 8.2 0 0 0 2.206-1.986c.241-.316.362-.474.484-.542a.6.6 0 0 1 .352-.083c.139.006.296.083.608.236l4.269 2.094c.239.118.359.176.431.275a.52.52 0 0 1 .098.298c0 .122-.058.231-.172.45q-1.432 2.742-4.526 4.607-3.419 2.04-7.996 2.04m-.552-25.368q-2.702 0-4.687 1.654-1.93 1.6-2.537 4.577h14.118q-.22-2.757-2.151-4.466-1.875-1.765-4.743-1.765M90.801 56c-.28 0-.42 0-.527-.054a.5.5 0 0 1-.218-.218C90 55.62 90 55.48 90 55.2v-5.294c0-.28 0-.42.055-.527a.5.5 0 0 1 .218-.218c.107-.055.247-.055.527-.055h1.572q2.646 0 4.19-1.489 1.6-1.545 1.6-4.08V15.715c0-.28 0-.42.055-.527a.5.5 0 0 1 .218-.219c.107-.054.247-.054.527-.054h5.956c.28 0 .42 0 .527.054a.5.5 0 0 1 .218.219c.055.107.055.247.055.527v27.546q0 3.804-1.655 6.672-1.599 2.868-4.632 4.467-2.979 1.6-7.06 1.6z" />
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="b" x1="12" x2="71.999" y1="30.001" y2="63.002"
|
||||||
|
gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#aa5cc3" />
|
||||||
|
<stop offset="1" stop-color="#00a4dc" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="c" x1="12" x2="71.999" y1="29.999" y2="63.001"
|
||||||
|
gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#aa5cc3" />
|
||||||
|
<stop offset="1" stop-color="#00a4dc" />
|
||||||
|
</linearGradient>
|
||||||
|
<clipPath id="a">
|
||||||
|
<path fill="#fff" d="M0 0h251v72H0z" />
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.3 KiB |
@@ -4,12 +4,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.topbar-wrapper .link:after {
|
.topbar-wrapper .link:after {
|
||||||
content: url(../banner-dark.svg);
|
content: '';
|
||||||
display: block;
|
display: block;
|
||||||
-moz-box-sizing: border-box;
|
background-image: url(../jellyfin.svg);
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: contain;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
max-width: 100%;
|
width: 220px;
|
||||||
max-height: 100%;
|
height: 40px;
|
||||||
width: 150px;
|
|
||||||
}
|
}
|
||||||
/* end logo */
|
/* end logo */
|
||||||
|
|||||||
@@ -103,11 +103,11 @@ namespace MediaBrowser.Common.Configuration
|
|||||||
void MakeSanityCheckOrThrow();
|
void MakeSanityCheckOrThrow();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Checks and creates the given path and adds it with a marker file if non existant.
|
/// Checks and creates the given path and adds it with a marker file if non existent.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="path">The path to check.</param>
|
/// <param name="path">The path to check.</param>
|
||||||
/// <param name="markerName">The common marker file name.</param>
|
/// <param name="markerName">The common marker file name.</param>
|
||||||
/// <param name="recursive">Check for other settings paths recursivly.</param>
|
/// <param name="recursive">Check for other settings paths recursively.</param>
|
||||||
void CreateAndCheckMarker(string path, string markerName, bool recursive = false);
|
void CreateAndCheckMarker(string path, string markerName, bool recursive = false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Authors>Jellyfin Contributors</Authors>
|
<Authors>Jellyfin Contributors</Authors>
|
||||||
<PackageId>Jellyfin.Common</PackageId>
|
<PackageId>Jellyfin.Common</PackageId>
|
||||||
<VersionPrefix>10.11.0</VersionPrefix>
|
<VersionPrefix>10.11.11</VersionPrefix>
|
||||||
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
||||||
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
#nullable disable
|
|
||||||
|
|
||||||
#pragma warning disable CS1591
|
#pragma warning disable CS1591
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
@@ -15,11 +13,12 @@ namespace MediaBrowser.Controller.Authentication
|
|||||||
|
|
||||||
bool IsEnabled { get; }
|
bool IsEnabled { get; }
|
||||||
|
|
||||||
Task<ForgotPasswordResult> StartForgotPasswordProcess(User user, bool isInNetwork);
|
Task<ForgotPasswordResult> StartForgotPasswordProcess(User? user, string enteredUsername, bool isInNetwork);
|
||||||
|
|
||||||
Task<PinRedeemResult> RedeemPasswordResetPin(string pin);
|
Task<PinRedeemResult> RedeemPasswordResetPin(string pin);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
public class PasswordPinCreationResult
|
public class PasswordPinCreationResult
|
||||||
{
|
{
|
||||||
public string PinFile { get; set; }
|
public string PinFile { get; set; }
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Extensions;
|
||||||
|
|
||||||
namespace MediaBrowser.Controller.ClientEvent
|
namespace MediaBrowser.Controller.ClientEvent
|
||||||
{
|
{
|
||||||
@@ -21,8 +22,15 @@ namespace MediaBrowser.Controller.ClientEvent
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<string> WriteDocumentAsync(string clientName, string clientVersion, Stream fileContents)
|
public async Task<string> WriteDocumentAsync(string clientName, string clientVersion, Stream fileContents)
|
||||||
{
|
{
|
||||||
var fileName = $"upload_{clientName}_{clientVersion}_{DateTime.UtcNow:yyyyMMddHHmmss}_{Guid.NewGuid():N}.log";
|
var safeClientName = PathHelper.GetSafeLeafFileName(clientName) ?? "unknown-client";
|
||||||
|
var safeClientVersion = PathHelper.GetSafeLeafFileName(clientVersion) ?? "unknown-version";
|
||||||
|
var fileName = $"upload_{safeClientName}_{safeClientVersion}_{DateTime.UtcNow:yyyyMMddHHmmss}_{Guid.NewGuid():N}.log";
|
||||||
var logFilePath = Path.Combine(_applicationPaths.LogDirectoryPath, fileName);
|
var logFilePath = Path.Combine(_applicationPaths.LogDirectoryPath, fileName);
|
||||||
|
if (!PathHelper.IsContainedIn(_applicationPaths.LogDirectoryPath, logFilePath))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Path resolved to filename not in log directory");
|
||||||
|
}
|
||||||
|
|
||||||
var fileStream = new FileStream(logFilePath, FileMode.CreateNew, FileAccess.Write, FileShare.None);
|
var fileStream = new FileStream(logFilePath, FileMode.CreateNew, FileAccess.Write, FileShare.None);
|
||||||
await using (fileStream.ConfigureAwait(false))
|
await using (fileStream.ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ using MediaBrowser.Controller.Configuration;
|
|||||||
using MediaBrowser.Controller.Dto;
|
using MediaBrowser.Controller.Dto;
|
||||||
using MediaBrowser.Controller.Entities.Audio;
|
using MediaBrowser.Controller.Entities.Audio;
|
||||||
using MediaBrowser.Controller.Entities.TV;
|
using MediaBrowser.Controller.Entities.TV;
|
||||||
|
using MediaBrowser.Controller.IO;
|
||||||
using MediaBrowser.Controller.Library;
|
using MediaBrowser.Controller.Library;
|
||||||
using MediaBrowser.Controller.MediaSegments;
|
using MediaBrowser.Controller.MediaSegments;
|
||||||
using MediaBrowser.Controller.Persistence;
|
using MediaBrowser.Controller.Persistence;
|
||||||
@@ -1127,6 +1128,15 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
|
|
||||||
var protocol = item.PathProtocol;
|
var protocol = item.PathProtocol;
|
||||||
|
|
||||||
|
// Resolve the item path so everywhere we use the media source it will always point to
|
||||||
|
// the correct path even if symlinks are in use. Calling ResolveLinkTarget on a non-link
|
||||||
|
// path will return null, so it's safe to check for all paths.
|
||||||
|
var itemPath = item.Path;
|
||||||
|
if (protocol is MediaProtocol.File && FileSystemHelper.ResolveLinkTarget(itemPath, returnFinalTarget: true) is { Exists: true } linkInfo)
|
||||||
|
{
|
||||||
|
itemPath = linkInfo.FullName;
|
||||||
|
}
|
||||||
|
|
||||||
var info = new MediaSourceInfo
|
var info = new MediaSourceInfo
|
||||||
{
|
{
|
||||||
Id = item.Id.ToString("N", CultureInfo.InvariantCulture),
|
Id = item.Id.ToString("N", CultureInfo.InvariantCulture),
|
||||||
@@ -1134,7 +1144,7 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
MediaStreams = MediaSourceManager.GetMediaStreams(item.Id),
|
MediaStreams = MediaSourceManager.GetMediaStreams(item.Id),
|
||||||
MediaAttachments = MediaSourceManager.GetMediaAttachments(item.Id),
|
MediaAttachments = MediaSourceManager.GetMediaAttachments(item.Id),
|
||||||
Name = GetMediaSourceName(item),
|
Name = GetMediaSourceName(item),
|
||||||
Path = enablePathSubstitution ? GetMappedPath(item, item.Path, protocol) : item.Path,
|
Path = enablePathSubstitution ? GetMappedPath(item, itemPath, protocol) : itemPath,
|
||||||
RunTimeTicks = item.RunTimeTicks,
|
RunTimeTicks = item.RunTimeTicks,
|
||||||
Container = item.Container,
|
Container = item.Container,
|
||||||
Size = item.Size,
|
Size = item.Size,
|
||||||
@@ -1162,11 +1172,18 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
info.Video3DFormat = video.Video3DFormat;
|
info.Video3DFormat = video.Video3DFormat;
|
||||||
info.Timestamp = video.Timestamp;
|
info.Timestamp = video.Timestamp;
|
||||||
|
|
||||||
if (video.IsShortcut)
|
if (video.IsShortcut && !string.IsNullOrEmpty(video.ShortcutPath))
|
||||||
|
{
|
||||||
|
var shortcutProtocol = MediaSourceManager.GetPathProtocol(video.ShortcutPath);
|
||||||
|
|
||||||
|
// Only allow remote shortcut paths — local file paths in .strm files
|
||||||
|
// could be used to read arbitrary files from the server.
|
||||||
|
if (shortcutProtocol != MediaProtocol.File)
|
||||||
{
|
{
|
||||||
info.IsRemote = true;
|
info.IsRemote = true;
|
||||||
info.Path = video.ShortcutPath;
|
info.Path = video.ShortcutPath;
|
||||||
info.Protocol = MediaSourceManager.GetPathProtocol(info.Path);
|
info.Protocol = shortcutProtocol;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(info.Container))
|
if (string.IsNullOrEmpty(info.Container))
|
||||||
@@ -1610,12 +1627,17 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
return isAllowed;
|
return isAllowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (maxAllowedSubRating is not null)
|
if (!maxAllowedRating.HasValue)
|
||||||
{
|
{
|
||||||
return (ratingScore.SubScore ?? 0) <= maxAllowedSubRating && ratingScore.Score <= maxAllowedRating.Value;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return !maxAllowedRating.HasValue || ratingScore.Score <= maxAllowedRating.Value;
|
if (ratingScore.Score != maxAllowedRating.Value)
|
||||||
|
{
|
||||||
|
return ratingScore.Score < maxAllowedRating.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !maxAllowedSubRating.HasValue || (ratingScore.SubScore ?? 0) <= maxAllowedSubRating.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
public ParentalRatingScore GetParentalRatingScore()
|
public ParentalRatingScore GetParentalRatingScore()
|
||||||
@@ -2038,6 +2060,9 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
public virtual async Task UpdateToRepositoryAsync(ItemUpdateType updateReason, CancellationToken cancellationToken)
|
public virtual async Task UpdateToRepositoryAsync(ItemUpdateType updateReason, CancellationToken cancellationToken)
|
||||||
=> await LibraryManager.UpdateItemAsync(this, GetParent(), updateReason, cancellationToken).ConfigureAwait(false);
|
=> await LibraryManager.UpdateItemAsync(this, GetParent(), updateReason, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
public async Task ReattachUserDataAsync(CancellationToken cancellationToken) =>
|
||||||
|
await LibraryManager.ReattachUserDataAsync(this, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Validates that images within the item are still on the filesystem.
|
/// Validates that images within the item are still on the filesystem.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -452,6 +452,7 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
// That's all the new and changed ones - now see if any have been removed and need cleanup
|
// That's all the new and changed ones - now see if any have been removed and need cleanup
|
||||||
var itemsRemoved = currentChildren.Values.Except(validChildren).ToList();
|
var itemsRemoved = currentChildren.Values.Except(validChildren).ToList();
|
||||||
var shouldRemove = !IsRoot || allowRemoveRoot;
|
var shouldRemove = !IsRoot || allowRemoveRoot;
|
||||||
|
var actuallyRemoved = new List<BaseItem>();
|
||||||
// If it's an AggregateFolder, don't remove
|
// If it's an AggregateFolder, don't remove
|
||||||
if (shouldRemove && itemsRemoved.Count > 0)
|
if (shouldRemove && itemsRemoved.Count > 0)
|
||||||
{
|
{
|
||||||
@@ -467,6 +468,7 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
{
|
{
|
||||||
Logger.LogDebug("Removed item: {Path}", item.Path);
|
Logger.LogDebug("Removed item: {Path}", item.Path);
|
||||||
|
|
||||||
|
actuallyRemoved.Add(item);
|
||||||
item.SetParent(null);
|
item.SetParent(null);
|
||||||
LibraryManager.DeleteItem(item, new DeleteOptions { DeleteFileLocation = false }, this, false);
|
LibraryManager.DeleteItem(item, new DeleteOptions { DeleteFileLocation = false }, this, false);
|
||||||
}
|
}
|
||||||
@@ -477,6 +479,20 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
{
|
{
|
||||||
LibraryManager.CreateItems(newItems, this, cancellationToken);
|
LibraryManager.CreateItems(newItems, this, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// After removing items, reattach any detached user data to remaining children
|
||||||
|
// that share the same user data keys (eg. same episode replaced with a new file).
|
||||||
|
if (actuallyRemoved.Count > 0)
|
||||||
|
{
|
||||||
|
var removedKeys = actuallyRemoved.SelectMany(i => i.GetUserDataKeys()).ToHashSet();
|
||||||
|
foreach (var child in validChildren)
|
||||||
|
{
|
||||||
|
if (child.GetUserDataKeys().Any(removedKeys.Contains))
|
||||||
|
{
|
||||||
|
await child.ReattachUserDataAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -715,14 +731,21 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
items = GetRecursiveChildren(user, query, out totalCount);
|
// Save pagination params before clearing them to prevent pagination from happening
|
||||||
|
// before sorting. PostFilterAndSort will apply pagination after sorting.
|
||||||
|
var limit = query.Limit;
|
||||||
|
var startIndex = query.StartIndex;
|
||||||
query.Limit = null;
|
query.Limit = null;
|
||||||
query.StartIndex = null; // override these here as they have already been applied
|
query.StartIndex = null;
|
||||||
|
|
||||||
|
items = GetRecursiveChildren(user, query, out totalCount);
|
||||||
|
|
||||||
|
// Restore pagination params so PostFilterAndSort can apply them after sorting
|
||||||
|
query.Limit = limit;
|
||||||
|
query.StartIndex = startIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
var result = PostFilterAndSort(items, query);
|
return PostFilterAndSort(items, query);
|
||||||
result.TotalRecordCount = totalCount;
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this is not UserRootFolder
|
if (this is not UserRootFolder
|
||||||
@@ -980,25 +1003,19 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
// need to pass this param to the children.
|
// need to pass this param to the children.
|
||||||
|
// Note: Don't pass Limit/StartIndex here as pagination should happen after sorting in PostFilterAndSort
|
||||||
var childQuery = new InternalItemsQuery
|
var childQuery = new InternalItemsQuery
|
||||||
{
|
{
|
||||||
DisplayAlbumFolders = query.DisplayAlbumFolders,
|
DisplayAlbumFolders = query.DisplayAlbumFolders,
|
||||||
Limit = query.Limit,
|
|
||||||
StartIndex = query.StartIndex,
|
|
||||||
NameStartsWith = query.NameStartsWith,
|
NameStartsWith = query.NameStartsWith,
|
||||||
NameStartsWithOrGreater = query.NameStartsWithOrGreater,
|
NameStartsWithOrGreater = query.NameStartsWithOrGreater,
|
||||||
NameLessThan = query.NameLessThan
|
NameLessThan = query.NameLessThan
|
||||||
};
|
};
|
||||||
|
|
||||||
items = GetChildren(user, true, out totalItemCount, childQuery).Where(filter);
|
items = GetChildren(user, true, out totalItemCount, childQuery).Where(filter);
|
||||||
|
|
||||||
query.Limit = null;
|
|
||||||
query.StartIndex = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var result = PostFilterAndSort(items, query);
|
return PostFilterAndSort(items, query);
|
||||||
result.TotalRecordCount = totalItemCount;
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected QueryResult<BaseItem> PostFilterAndSort(IEnumerable<BaseItem> items, InternalItemsQuery query)
|
protected QueryResult<BaseItem> PostFilterAndSort(IEnumerable<BaseItem> items, InternalItemsQuery query)
|
||||||
@@ -1034,7 +1051,15 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
items = UserViewBuilder.FilterForAdjacency(items.ToList(), query.AdjacentTo.Value);
|
items = UserViewBuilder.FilterForAdjacency(items.ToList(), query.AdjacentTo.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
return UserViewBuilder.SortAndPage(items, null, query, LibraryManager);
|
var filteredItems = items as IReadOnlyList<BaseItem> ?? items.ToList();
|
||||||
|
var result = UserViewBuilder.SortAndPage(filteredItems, null, query, LibraryManager);
|
||||||
|
|
||||||
|
if (query.EnableTotalRecordCount)
|
||||||
|
{
|
||||||
|
result.TotalRecordCount = filteredItems.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IEnumerable<BaseItem> CollapseBoxSetItemsIfNeeded(
|
private static IEnumerable<BaseItem> CollapseBoxSetItemsIfNeeded(
|
||||||
@@ -1047,14 +1072,51 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(items);
|
ArgumentNullException.ThrowIfNull(items);
|
||||||
|
|
||||||
if (CollapseBoxSetItems(query, queryParent, user, configurationManager))
|
if (!CollapseBoxSetItems(query, queryParent, user, configurationManager))
|
||||||
{
|
{
|
||||||
items = collectionManager.CollapseItemsWithinBoxSets(items, user);
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var config = configurationManager.Configuration;
|
||||||
|
|
||||||
|
bool collapseMovies = config.EnableGroupingMoviesIntoCollections;
|
||||||
|
bool collapseSeries = config.EnableGroupingShowsIntoCollections;
|
||||||
|
|
||||||
|
if (user is null || (collapseMovies && collapseSeries))
|
||||||
|
{
|
||||||
|
return collectionManager.CollapseItemsWithinBoxSets(items, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!collapseMovies && !collapseSeries)
|
||||||
|
{
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var collapsibleItems = new List<BaseItem>();
|
||||||
|
var remainingItems = new List<BaseItem>();
|
||||||
|
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
if ((collapseMovies && item is Movie) || (collapseSeries && item is Series))
|
||||||
|
{
|
||||||
|
collapsibleItems.Add(item);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
remainingItems.Add(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (collapsibleItems.Count == 0)
|
||||||
|
{
|
||||||
|
return remainingItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
var collapsedItems = collectionManager.CollapseItemsWithinBoxSets(collapsibleItems, user);
|
||||||
|
|
||||||
|
return collapsedItems.Concat(remainingItems);
|
||||||
|
}
|
||||||
|
|
||||||
private static bool CollapseBoxSetItems(
|
private static bool CollapseBoxSetItems(
|
||||||
InternalItemsQuery query,
|
InternalItemsQuery query,
|
||||||
BaseItem queryParent,
|
BaseItem queryParent,
|
||||||
@@ -1083,24 +1145,26 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
}
|
}
|
||||||
|
|
||||||
var param = query.CollapseBoxSetItems;
|
var param = query.CollapseBoxSetItems;
|
||||||
|
if (param.HasValue)
|
||||||
if (!param.HasValue)
|
|
||||||
{
|
{
|
||||||
if (user is not null && query.IncludeItemTypes.Any(type =>
|
return param.Value && AllowBoxSetCollapsing(query);
|
||||||
(type == BaseItemKind.Movie && !configurationManager.Configuration.EnableGroupingMoviesIntoCollections) ||
|
|
||||||
(type == BaseItemKind.Series && !configurationManager.Configuration.EnableGroupingShowsIntoCollections)))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (query.IncludeItemTypes.Length == 0
|
var config = configurationManager.Configuration;
|
||||||
|| query.IncludeItemTypes.Any(type => type == BaseItemKind.Movie || type == BaseItemKind.Series))
|
|
||||||
|
bool queryHasMovies = query.IncludeItemTypes.Length == 0 || query.IncludeItemTypes.Contains(BaseItemKind.Movie);
|
||||||
|
bool queryHasSeries = query.IncludeItemTypes.Length == 0 || query.IncludeItemTypes.Contains(BaseItemKind.Series);
|
||||||
|
|
||||||
|
bool collapseMovies = config.EnableGroupingMoviesIntoCollections;
|
||||||
|
bool collapseSeries = config.EnableGroupingShowsIntoCollections;
|
||||||
|
|
||||||
|
if (user is not null)
|
||||||
{
|
{
|
||||||
param = true;
|
bool canCollapse = (queryHasMovies && collapseMovies) || (queryHasSeries && collapseSeries);
|
||||||
}
|
return canCollapse && AllowBoxSetCollapsing(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
return param.HasValue && param.Value && AllowBoxSetCollapsing(query);
|
return (queryHasMovies || queryHasSeries) && AllowBoxSetCollapsing(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool AllowBoxSetCollapsing(InternalItemsQuery request)
|
private static bool AllowBoxSetCollapsing(InternalItemsQuery request)
|
||||||
@@ -1358,13 +1422,6 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
.Where(e => query is null || UserViewBuilder.FilterItem(e, query))
|
.Where(e => query is null || UserViewBuilder.FilterItem(e, query))
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
if (this is BoxSet && (query.OrderBy is null || query.OrderBy.Count == 0))
|
|
||||||
{
|
|
||||||
realChildren = realChildren
|
|
||||||
.OrderBy(e => e.ProductionYear ?? int.MaxValue)
|
|
||||||
.ToArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
var childCount = realChildren.Length;
|
var childCount = realChildren.Length;
|
||||||
if (result.Count < limit)
|
if (result.Count < limit)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -125,6 +125,8 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
|
|
||||||
public string? Name { get; set; }
|
public string? Name { get; set; }
|
||||||
|
|
||||||
|
public bool? UseRawName { get; set; }
|
||||||
|
|
||||||
public string? Person { get; set; }
|
public string? Person { get; set; }
|
||||||
|
|
||||||
public Guid[] PersonIds { get; set; }
|
public Guid[] PersonIds { get; set; }
|
||||||
|
|||||||
@@ -136,6 +136,12 @@ namespace MediaBrowser.Controller.Entities.Movies
|
|||||||
return Sort(children, user).ToArray();
|
return Sort(children, user).ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public override IReadOnlyList<BaseItem> GetChildren(User user, bool includeLinkedChildren, out int totalItemCount, InternalItemsQuery query = null)
|
||||||
|
{
|
||||||
|
var children = base.GetChildren(user, includeLinkedChildren, out totalItemCount, query);
|
||||||
|
return Sort(children, user).ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
public override IReadOnlyList<BaseItem> GetRecursiveChildren(User user, InternalItemsQuery query, out int totalCount)
|
public override IReadOnlyList<BaseItem> GetRecursiveChildren(User user, InternalItemsQuery query, out int totalCount)
|
||||||
{
|
{
|
||||||
var children = base.GetRecursiveChildren(user, query, out totalCount);
|
var children = base.GetRecursiveChildren(user, query, out totalCount);
|
||||||
|
|||||||
@@ -201,12 +201,17 @@ namespace MediaBrowser.Controller.Entities.TV
|
|||||||
|
|
||||||
public List<BaseItem> GetEpisodes(Series series, User user, IEnumerable<Episode> allSeriesEpisodes, DtoOptions options, bool shouldIncludeMissingEpisodes)
|
public List<BaseItem> GetEpisodes(Series series, User user, IEnumerable<Episode> allSeriesEpisodes, DtoOptions options, bool shouldIncludeMissingEpisodes)
|
||||||
{
|
{
|
||||||
|
if (series is null)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
return series.GetSeasonEpisodes(this, user, allSeriesEpisodes, options, shouldIncludeMissingEpisodes);
|
return series.GetSeasonEpisodes(this, user, allSeriesEpisodes, options, shouldIncludeMissingEpisodes);
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<BaseItem> GetEpisodes()
|
public List<BaseItem> GetEpisodes()
|
||||||
{
|
{
|
||||||
return Series.GetSeasonEpisodes(this, null, null, new DtoOptions(true), true);
|
return GetEpisodes(Series, null, null, new DtoOptions(true), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public override List<BaseItem> GetChildren(User user, bool includeLinkedChildren, InternalItemsQuery query)
|
public override List<BaseItem> GetChildren(User user, bool includeLinkedChildren, InternalItemsQuery query)
|
||||||
|
|||||||
@@ -214,7 +214,7 @@ namespace MediaBrowser.Controller.Entities.TV
|
|||||||
query.AncestorWithPresentationUniqueKey = null;
|
query.AncestorWithPresentationUniqueKey = null;
|
||||||
query.SeriesPresentationUniqueKey = seriesKey;
|
query.SeriesPresentationUniqueKey = seriesKey;
|
||||||
query.IncludeItemTypes = new[] { BaseItemKind.Season };
|
query.IncludeItemTypes = new[] { BaseItemKind.Season };
|
||||||
query.OrderBy = new[] { (ItemSortBy.IndexNumber, SortOrder.Ascending) };
|
query.OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) };
|
||||||
|
|
||||||
if (user is not null && !user.DisplayMissingEpisodes)
|
if (user is not null && !user.DisplayMissingEpisodes)
|
||||||
{
|
{
|
||||||
@@ -247,6 +247,10 @@ namespace MediaBrowser.Controller.Entities.TV
|
|||||||
|
|
||||||
query.AncestorWithPresentationUniqueKey = null;
|
query.AncestorWithPresentationUniqueKey = null;
|
||||||
query.SeriesPresentationUniqueKey = seriesKey;
|
query.SeriesPresentationUniqueKey = seriesKey;
|
||||||
|
if (query.OrderBy.Count == 0)
|
||||||
|
{
|
||||||
|
query.OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) };
|
||||||
|
}
|
||||||
|
|
||||||
if (query.IncludeItemTypes.Length == 0)
|
if (query.IncludeItemTypes.Length == 0)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using MediaBrowser.Model.IO;
|
using MediaBrowser.Model.IO;
|
||||||
@@ -61,4 +62,108 @@ public static class FileSystemHelper
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves a single link hop for the specified path.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Returns <c>null</c> if the path is not a symbolic link or the filesystem does not support link resolution (e.g., exFAT).
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="path">The file path to resolve.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// A <see cref="FileInfo"/> representing the next link target if the path is a link; otherwise, <c>null</c>.
|
||||||
|
/// </returns>
|
||||||
|
private static FileInfo? Resolve(string path)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return File.ResolveLinkTarget(path, returnFinalTarget: false) as FileInfo;
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
// Filesystem doesn't support links (e.g., exFAT).
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the target of the specified file link.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This helper exists because of this upstream runtime issue; https://github.com/dotnet/runtime/issues/92128.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="linkPath">The path of the file link.</param>
|
||||||
|
/// <param name="returnFinalTarget">true to follow links to the final target; false to return the immediate next link.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// A <see cref="FileInfo"/> if the <paramref name="linkPath"/> is a link, regardless of if the target exists; otherwise, <c>null</c>.
|
||||||
|
/// </returns>
|
||||||
|
public static FileInfo? ResolveLinkTarget(string linkPath, bool returnFinalTarget = false)
|
||||||
|
{
|
||||||
|
// Check if the file exists so the native resolve handler won't throw at us.
|
||||||
|
if (!File.Exists(linkPath))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!returnFinalTarget)
|
||||||
|
{
|
||||||
|
return Resolve(linkPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
var targetInfo = Resolve(linkPath);
|
||||||
|
if (targetInfo is null || !targetInfo.Exists)
|
||||||
|
{
|
||||||
|
return targetInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentPath = targetInfo.FullName;
|
||||||
|
var visited = new HashSet<string>(StringComparer.Ordinal) { linkPath, currentPath };
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var linkInfo = Resolve(currentPath);
|
||||||
|
if (linkInfo is null)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
var targetPath = linkInfo.FullName;
|
||||||
|
|
||||||
|
// If an infinite loop is detected, return the file info for the
|
||||||
|
// first link in the loop we encountered.
|
||||||
|
if (!visited.Add(targetPath))
|
||||||
|
{
|
||||||
|
return new FileInfo(targetPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
targetInfo = linkInfo;
|
||||||
|
currentPath = targetPath;
|
||||||
|
|
||||||
|
// Exit if the target doesn't exist, so the native resolve handler won't throw at us.
|
||||||
|
if (!targetInfo.Exists)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return targetInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the target of the specified file link.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This helper exists because of this upstream runtime issue; https://github.com/dotnet/runtime/issues/92128.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="fileInfo">The file info of the file link.</param>
|
||||||
|
/// <param name="returnFinalTarget">true to follow links to the final target; false to return the immediate next link.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// A <see cref="FileInfo"/> if the <paramref name="fileInfo"/> is a link, regardless of if the target exists; otherwise, <c>null</c>.
|
||||||
|
/// </returns>
|
||||||
|
public static FileInfo? ResolveLinkTarget(FileInfo fileInfo, bool returnFinalTarget = false)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(fileInfo);
|
||||||
|
|
||||||
|
return ResolveLinkTarget(fileInfo.FullName, returnFinalTarget);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ public interface IPathManager
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="mediaSourceId">The media source id.</param>
|
/// <param name="mediaSourceId">The media source id.</param>
|
||||||
/// <param name="fileName">The attachmentFileName index.</param>
|
/// <param name="fileName">The attachmentFileName index.</param>
|
||||||
/// <returns>The absolute path.</returns>
|
/// <returns>The absolute path, or <c>null</c> if <paramref name="fileName"/> cannot be reduced to a safe leaf name.</returns>
|
||||||
public string GetAttachmentPath(string mediaSourceId, string fileName);
|
public string? GetAttachmentPath(string mediaSourceId, string fileName);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the path to the attachment folder.
|
/// Gets the path to the attachment folder.
|
||||||
|
|||||||
@@ -281,6 +281,14 @@ namespace MediaBrowser.Controller.Library
|
|||||||
/// <returns>Returns a Task that can be awaited.</returns>
|
/// <returns>Returns a Task that can be awaited.</returns>
|
||||||
Task UpdateItemAsync(BaseItem item, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken);
|
Task UpdateItemAsync(BaseItem item, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reattaches the user data to the item.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="item">The item.</param>
|
||||||
|
/// <param name="cancellationToken">The cancellation token.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous reattachment operation.</returns>
|
||||||
|
Task ReattachUserDataAsync(BaseItem item, CancellationToken cancellationToken);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Retrieves the item.
|
/// Retrieves the item.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -652,5 +660,12 @@ namespace MediaBrowser.Controller.Library
|
|||||||
/// This exists so plugins can trigger a library scan.
|
/// This exists so plugins can trigger a library scan.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
void QueueLibraryScan();
|
void QueueLibraryScan();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Add mblink file for a media path.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="virtualFolderPath">The path to the virtualfolder.</param>
|
||||||
|
/// <param name="pathInfo">The new virtualfolder.</param>
|
||||||
|
public void CreateShortcut(string virtualFolderPath, MediaPathInfo pathInfo);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,14 +24,14 @@ namespace MediaBrowser.Controller.Library
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the users.
|
/// Gets the users.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <value>The users.</value>
|
/// <returns>The users.</returns>
|
||||||
IEnumerable<User> Users { get; }
|
IEnumerable<User> GetUsers();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the user ids.
|
/// Gets the user ids.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <value>The users ids.</value>
|
/// <returns>The users ids.</returns>
|
||||||
IEnumerable<Guid> UsersIds { get; }
|
IEnumerable<Guid> GetUsersIds();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes the user manager and ensures that a user exists.
|
/// Initializes the user manager and ensures that a user exists.
|
||||||
@@ -47,6 +47,12 @@ namespace MediaBrowser.Controller.Library
|
|||||||
/// <exception cref="ArgumentException"><c>id</c> is an empty Guid.</exception>
|
/// <exception cref="ArgumentException"><c>id</c> is an empty Guid.</exception>
|
||||||
User? GetUserById(Guid id);
|
User? GetUserById(Guid id);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the first available user.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>The first user, or <c>null</c> if no users exist.</returns>
|
||||||
|
User? GetFirstUser();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the name of the user by.
|
/// Gets the name of the user by.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -57,12 +63,13 @@ namespace MediaBrowser.Controller.Library
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Renames the user.
|
/// Renames the user.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="user">The user.</param>
|
/// <param name="userId">The UserId to change.</param>
|
||||||
|
/// <param name="oldName">The old Username.</param>
|
||||||
/// <param name="newName">The new name.</param>
|
/// <param name="newName">The new name.</param>
|
||||||
/// <returns>Task.</returns>
|
/// <returns>Task.</returns>
|
||||||
/// <exception cref="ArgumentNullException">If user is <c>null</c>.</exception>
|
/// <exception cref="ArgumentNullException">If user is <c>null</c>.</exception>
|
||||||
/// <exception cref="ArgumentException">If the provided user doesn't exist.</exception>
|
/// <exception cref="ArgumentException">If the provided user doesn't exist.</exception>
|
||||||
Task RenameUser(User user, string newName);
|
Task RenameUser(Guid userId, string oldName, string newName);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Updates the user.
|
/// Updates the user.
|
||||||
@@ -92,17 +99,17 @@ namespace MediaBrowser.Controller.Library
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Resets the password.
|
/// Resets the password.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="user">The user.</param>
|
/// <param name="userId">The users Id.</param>
|
||||||
/// <returns>Task.</returns>
|
/// <returns>Task.</returns>
|
||||||
Task ResetPassword(User user);
|
Task ResetPassword(Guid userId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Changes the password.
|
/// Changes the password.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="user">The user.</param>
|
/// <param name="userId">The users id.</param>
|
||||||
/// <param name="newPassword">New password to use.</param>
|
/// <param name="newPassword">New password to use.</param>
|
||||||
/// <returns>Awaitable task.</returns>
|
/// <returns>Awaitable task.</returns>
|
||||||
Task ChangePassword(User user, string newPassword);
|
Task ChangePassword(Guid userId, string newPassword);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the user dto.
|
/// Gets the user dto.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using System.Collections.Generic;
|
|||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
|
using System.Threading.Channels;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using MediaBrowser.Controller.Configuration;
|
using MediaBrowser.Controller.Configuration;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
@@ -29,7 +30,7 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private readonly Lock _taskLock = new();
|
private readonly Lock _taskLock = new();
|
||||||
|
|
||||||
private readonly BlockingCollection<TaskQueueItem> _tasks = new();
|
private readonly Channel<TaskQueueItem> _tasks = Channel.CreateUnbounded<TaskQueueItem>();
|
||||||
|
|
||||||
private volatile int _workCounter;
|
private volatile int _workCounter;
|
||||||
private Task? _cleanupTask;
|
private Task? _cleanupTask;
|
||||||
@@ -77,7 +78,7 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr
|
|||||||
|
|
||||||
lock (_taskLock)
|
lock (_taskLock)
|
||||||
{
|
{
|
||||||
if (_tasks.Count > 0 || _workCounter > 0)
|
if (_tasks.Reader.Count > 0 || _workCounter > 0)
|
||||||
{
|
{
|
||||||
_logger.LogDebug("Delay cleanup task, operations still running.");
|
_logger.LogDebug("Delay cleanup task, operations still running.");
|
||||||
// tasks are still there so its still in use. Reschedule cleanup task.
|
// tasks are still there so its still in use. Reschedule cleanup task.
|
||||||
@@ -144,9 +145,9 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr
|
|||||||
_deadlockDetector.Value = stopToken.TaskStop;
|
_deadlockDetector.Value = stopToken.TaskStop;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
foreach (var item in _tasks.GetConsumingEnumerable(stopToken.GlobalStop.Token))
|
while (!stopToken.GlobalStop.Token.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
stopToken.GlobalStop.Token.ThrowIfCancellationRequested();
|
var item = await _tasks.Reader.ReadAsync(stopToken.GlobalStop.Token).ConfigureAwait(false);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var newWorkerLimit = Interlocked.Increment(ref _workCounter) > 0;
|
var newWorkerLimit = Interlocked.Increment(ref _workCounter) > 0;
|
||||||
@@ -242,7 +243,7 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr
|
|||||||
};
|
};
|
||||||
}).ToArray();
|
}).ToArray();
|
||||||
|
|
||||||
if (ShouldForceSequentialOperation())
|
if (ShouldForceSequentialOperation() || _deadlockDetector.Value is not null)
|
||||||
{
|
{
|
||||||
_logger.LogDebug("Process sequentially.");
|
_logger.LogDebug("Process sequentially.");
|
||||||
try
|
try
|
||||||
@@ -264,36 +265,15 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr
|
|||||||
for (var i = 0; i < workItems.Length; i++)
|
for (var i = 0; i < workItems.Length; i++)
|
||||||
{
|
{
|
||||||
var item = workItems[i]!;
|
var item = workItems[i]!;
|
||||||
_tasks.Add(item, CancellationToken.None);
|
await _tasks.Writer.WriteAsync(item, CancellationToken.None).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_deadlockDetector.Value is not null)
|
|
||||||
{
|
|
||||||
_logger.LogDebug("Nested invocation detected, process in-place.");
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// we are in a nested loop. There is no reason to spawn a task here as that would just lead to deadlocks and no additional concurrency is achieved
|
|
||||||
while (workItems.Any(e => !e.Done.Task.IsCompleted) && _tasks.TryTake(out var item, 200, _deadlockDetector.Value.Token))
|
|
||||||
{
|
|
||||||
await ProcessItem(item).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException) when (_deadlockDetector.Value.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
// operation is cancelled. Do nothing.
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogDebug("process in-place done.");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Worker();
|
Worker();
|
||||||
_logger.LogDebug("Wait for {NoWorkers} to complete.", workItems.Length);
|
_logger.LogDebug("Wait for {NoWorkers} to complete.", workItems.Length);
|
||||||
await Task.WhenAll([.. workItems.Select(f => f.Done.Task)]).ConfigureAwait(false);
|
await Task.WhenAll([.. workItems.Select(f => f.Done.Task)]).ConfigureAwait(false);
|
||||||
_logger.LogDebug("{NoWorkers} completed.", workItems.Length);
|
_logger.LogDebug("{NoWorkers} completed.", workItems.Length);
|
||||||
ScheduleTaskCleanup();
|
ScheduleTaskCleanup();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
@@ -304,13 +284,12 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr
|
|||||||
}
|
}
|
||||||
|
|
||||||
_disposed = true;
|
_disposed = true;
|
||||||
_tasks.CompleteAdding();
|
_tasks.Writer.Complete();
|
||||||
foreach (var item in _taskRunners)
|
foreach (var item in _taskRunners)
|
||||||
{
|
{
|
||||||
await item.Key.CancelAsync().ConfigureAwait(false);
|
await item.Key.CancelAsync().ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
_tasks.Dispose();
|
|
||||||
if (_cleanupTask is not null)
|
if (_cleanupTask is not null)
|
||||||
{
|
{
|
||||||
await _cleanupTask.ConfigureAwait(false);
|
await _cleanupTask.ConfigureAwait(false);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Authors>Jellyfin Contributors</Authors>
|
<Authors>Jellyfin Contributors</Authors>
|
||||||
<PackageId>Jellyfin.Controller</PackageId>
|
<PackageId>Jellyfin.Controller</PackageId>
|
||||||
<VersionPrefix>10.11.0</VersionPrefix>
|
<VersionPrefix>10.11.11</VersionPrefix>
|
||||||
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
||||||
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -33,18 +33,18 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
public partial class EncodingHelper
|
public partial class EncodingHelper
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The codec validation regex.
|
/// The codec validation regex string.
|
||||||
/// This regular expression matches strings that consist of alphanumeric characters, hyphens,
|
/// This regular expression matches strings that consist of alphanumeric characters, hyphens,
|
||||||
/// periods, underscores, commas, and vertical bars, with a length between 0 and 40 characters.
|
/// periods, underscores, commas, and vertical bars, with a length between 0 and 40 characters.
|
||||||
/// This should matches all common valid codecs.
|
/// This should matches all common valid codecs.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const string ContainerValidationRegex = @"^[a-zA-Z0-9\-\._,|]{0,40}$";
|
public const string ContainerValidationRegexStr = @"^[a-zA-Z0-9\-\._,|]{0,40}$";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The level validation regex.
|
/// The level validation regex string.
|
||||||
/// This regular expression matches strings representing a double.
|
/// This regular expression matches strings representing a double.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const string LevelValidationRegex = @"-?[0-9]+(?:\.[0-9]+)?";
|
public const string LevelValidationRegexStr = @"-?[0-9]+(?:\.[0-9]+)?";
|
||||||
|
|
||||||
private const string _defaultMjpegEncoder = "mjpeg";
|
private const string _defaultMjpegEncoder = "mjpeg";
|
||||||
|
|
||||||
@@ -85,8 +85,7 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
private readonly Version _minFFmpegVaapiDeviceVendorId = new Version(7, 0, 1);
|
private readonly Version _minFFmpegVaapiDeviceVendorId = new Version(7, 0, 1);
|
||||||
private readonly Version _minFFmpegQsvVppScaleModeOption = new Version(6, 0);
|
private readonly Version _minFFmpegQsvVppScaleModeOption = new Version(6, 0);
|
||||||
private readonly Version _minFFmpegRkmppHevcDecDoviRpu = new Version(7, 1, 1);
|
private readonly Version _minFFmpegRkmppHevcDecDoviRpu = new Version(7, 1, 1);
|
||||||
|
private readonly Version _minFFmpegReadrateCatchupOption = new Version(8, 0);
|
||||||
private static readonly Regex _containerValidationRegex = new(ContainerValidationRegex, RegexOptions.Compiled);
|
|
||||||
|
|
||||||
private static readonly string[] _videoProfilesH264 =
|
private static readonly string[] _videoProfilesH264 =
|
||||||
[
|
[
|
||||||
@@ -180,6 +179,15 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
RemoveHdr10Plus,
|
RemoveHdr10Plus,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The codec validation regex.
|
||||||
|
/// This regular expression matches strings that consist of alphanumeric characters, hyphens,
|
||||||
|
/// periods, underscores, commas, and vertical bars, with a length between 0 and 40 characters.
|
||||||
|
/// This should matches all common valid codecs.
|
||||||
|
/// </summary>
|
||||||
|
[GeneratedRegex(@"^[a-zA-Z0-9\-\._,|]{0,40}$")]
|
||||||
|
public static partial Regex ContainerValidationRegex();
|
||||||
|
|
||||||
[GeneratedRegex(@"\s+")]
|
[GeneratedRegex(@"\s+")]
|
||||||
private static partial Regex WhiteSpaceRegex();
|
private static partial Regex WhiteSpaceRegex();
|
||||||
|
|
||||||
@@ -405,7 +413,9 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
}
|
}
|
||||||
|
|
||||||
return state.VideoStream.VideoRange == VideoRange.HDR
|
return state.VideoStream.VideoRange == VideoRange.HDR
|
||||||
&& IsDoviWithHdr10Bl(state.VideoStream);
|
&& (state.VideoStream.VideoRangeType == VideoRangeType.HDR10
|
||||||
|
|| IsHdr10Plus(state.VideoStream)
|
||||||
|
|| IsDoviWithHdr10Bl(state.VideoStream));
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool IsVideoToolboxTonemapAvailable(EncodingJobInfo state, EncodingOptions options)
|
private bool IsVideoToolboxTonemapAvailable(EncodingJobInfo state, EncodingOptions options)
|
||||||
@@ -420,8 +430,10 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
// Certain DV profile 5 video works in Safari with direct playing, but the VideoToolBox does not produce correct mapping results with transcoding.
|
// Certain DV profile 5 video works in Safari with direct playing, but the VideoToolBox does not produce correct mapping results with transcoding.
|
||||||
// All other HDR formats working.
|
// All other HDR formats working.
|
||||||
return state.VideoStream.VideoRange == VideoRange.HDR
|
return state.VideoStream.VideoRange == VideoRange.HDR
|
||||||
&& (IsDoviWithHdr10Bl(state.VideoStream)
|
&& (state.VideoStream.VideoRangeType == VideoRangeType.HDR10
|
||||||
|| state.VideoStream.VideoRangeType is VideoRangeType.HLG);
|
|| IsHdr10Plus(state.VideoStream)
|
||||||
|
|| IsDoviWithHdr10Bl(state.VideoStream)
|
||||||
|
|| state.VideoStream.VideoRangeType == VideoRangeType.HLG);
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool IsVideoStreamHevcRext(EncodingJobInfo state)
|
private bool IsVideoStreamHevcRext(EncodingJobInfo state)
|
||||||
@@ -476,7 +488,7 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
return GetMjpegEncoder(state, encodingOptions);
|
return GetMjpegEncoder(state, encodingOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_containerValidationRegex.IsMatch(codec))
|
if (ContainerValidationRegex().IsMatch(codec))
|
||||||
{
|
{
|
||||||
return codec.ToLowerInvariant();
|
return codec.ToLowerInvariant();
|
||||||
}
|
}
|
||||||
@@ -517,7 +529,7 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
|
|
||||||
public static string GetInputFormat(string container)
|
public static string GetInputFormat(string container)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(container) || !_containerValidationRegex.IsMatch(container))
|
if (string.IsNullOrEmpty(container) || !ContainerValidationRegex().IsMatch(container))
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -735,7 +747,7 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
{
|
{
|
||||||
var codec = state.OutputAudioCodec;
|
var codec = state.OutputAudioCodec;
|
||||||
|
|
||||||
if (!_containerValidationRegex.IsMatch(codec))
|
if (!ContainerValidationRegex().IsMatch(codec))
|
||||||
{
|
{
|
||||||
codec = "aac";
|
codec = "aac";
|
||||||
}
|
}
|
||||||
@@ -1267,6 +1279,20 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use analyzeduration also for subtitle streams to improve resolution detection with streams inside MKS files
|
||||||
|
var analyzeDurationArgument = GetFfmpegAnalyzeDurationArg(state);
|
||||||
|
if (!string.IsNullOrEmpty(analyzeDurationArgument))
|
||||||
|
{
|
||||||
|
arg.Append(' ').Append(analyzeDurationArgument);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply probesize, too, if configured
|
||||||
|
var ffmpegProbeSizeArgument = GetFfmpegProbesizeArg();
|
||||||
|
if (!string.IsNullOrEmpty(ffmpegProbeSizeArgument))
|
||||||
|
{
|
||||||
|
arg.Append(' ').Append(ffmpegProbeSizeArgument);
|
||||||
|
}
|
||||||
|
|
||||||
// Also seek the external subtitles stream.
|
// Also seek the external subtitles stream.
|
||||||
var seekSubParam = GetFastSeekCommandLineParameter(state, options, segmentContainer);
|
var seekSubParam = GetFastSeekCommandLineParameter(state, options, segmentContainer);
|
||||||
if (!string.IsNullOrEmpty(seekSubParam))
|
if (!string.IsNullOrEmpty(seekSubParam))
|
||||||
@@ -1279,7 +1305,7 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
arg.Append(canvasArgs);
|
arg.Append(canvasArgs);
|
||||||
}
|
}
|
||||||
|
|
||||||
arg.Append(" -i file:\"").Append(subtitlePath).Append('\"');
|
arg.Append(" -i file:\"").Append(subtitlePath.Replace("\"", "\\\"", StringComparison.Ordinal)).Append('\"');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.AudioStream is not null && state.AudioStream.IsExternal)
|
if (state.AudioStream is not null && state.AudioStream.IsExternal)
|
||||||
@@ -1291,7 +1317,7 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
arg.Append(' ').Append(seekAudioParam);
|
arg.Append(' ').Append(seekAudioParam);
|
||||||
}
|
}
|
||||||
|
|
||||||
arg.Append(" -i \"").Append(state.AudioStream.Path).Append('"');
|
arg.Append(" -i \"").Append(state.AudioStream.Path.Replace("\"", "\\\"", StringComparison.Ordinal)).Append('"');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Disable auto inserted SW scaler for HW decoders in case of changed resolution.
|
// Disable auto inserted SW scaler for HW decoders in case of changed resolution.
|
||||||
@@ -1552,14 +1578,15 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
|
|
||||||
int bitrate = state.OutputVideoBitrate.Value;
|
int bitrate = state.OutputVideoBitrate.Value;
|
||||||
|
|
||||||
// Bit rate under 1000k is not allowed in h264_qsv
|
// Bit rate under 1000k is not allowed in h264_qsv.
|
||||||
if (string.Equals(videoCodec, "h264_qsv", StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(videoCodec, "h264_qsv", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
bitrate = Math.Max(bitrate, 1000);
|
bitrate = Math.Max(bitrate, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Currently use the same buffer size for all encoders
|
// Currently use the same buffer size for all non-QSV encoders.
|
||||||
int bufsize = bitrate * 2;
|
// Use long arithmetic to prevent int32 overflow for very high bitrate values.
|
||||||
|
int bufsize = (int)Math.Min((long)bitrate * 2, int.MaxValue);
|
||||||
|
|
||||||
if (string.Equals(videoCodec, "libsvtav1", StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(videoCodec, "libsvtav1", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
@@ -1587,16 +1614,33 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
mbbrcOpt = " -mbbrc 1";
|
mbbrcOpt = " -mbbrc 1";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Some less powerful H.264 HW decoders require strict CPB size
|
||||||
|
// So bufsize optimizations should not be applied to them
|
||||||
|
int factor = 2;
|
||||||
|
var codec = state.ActualOutputVideoCodec;
|
||||||
|
var level = state.GetRequestedLevel(codec);
|
||||||
|
if (string.Equals(codec, "h264", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& double.TryParse(level, CultureInfo.InvariantCulture, out double requestedLevel)
|
||||||
|
&& requestedLevel < 51)
|
||||||
|
{
|
||||||
|
factor = 1;
|
||||||
|
}
|
||||||
|
|
||||||
// Set (maxrate == bitrate + 1) to trigger VBR for better bitrate allocation
|
// Set (maxrate == bitrate + 1) to trigger VBR for better bitrate allocation
|
||||||
// Set (rc_init_occupancy == 2 * bitrate) and (bufsize == 4 * bitrate) to deal with drastic scene changes
|
// Set (rc_init_occupancy == 2 * bitrate) and (bufsize == 4 * bitrate) to deal with drastic scene changes
|
||||||
return FormattableString.Invariant($"{mbbrcOpt} -b:v {bitrate} -maxrate {bitrate + 1} -rc_init_occupancy {bitrate * 2} -bufsize {bitrate * 4}");
|
// Use long arithmetic and clamp to int.MaxValue to prevent int32 overflow
|
||||||
|
// (e.g. bitrate * 4 wraps to a negative value for bitrates above ~537 million)
|
||||||
|
int qsvMaxrate = (int)Math.Min((long)bitrate + 1, int.MaxValue);
|
||||||
|
int qsvInitOcc = (int)Math.Min((long)bitrate * 1 * factor, int.MaxValue);
|
||||||
|
int qsvBufsize = (int)Math.Min((long)bitrate * 2 * factor, int.MaxValue);
|
||||||
|
|
||||||
|
return FormattableString.Invariant($"{mbbrcOpt} -b:v {bitrate} -maxrate {qsvMaxrate} -rc_init_occupancy {qsvInitOcc} -bufsize {qsvBufsize}");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.Equals(videoCodec, "h264_amf", StringComparison.OrdinalIgnoreCase)
|
if (string.Equals(videoCodec, "h264_amf", StringComparison.OrdinalIgnoreCase)
|
||||||
|| string.Equals(videoCodec, "hevc_amf", StringComparison.OrdinalIgnoreCase)
|
|| string.Equals(videoCodec, "hevc_amf", StringComparison.OrdinalIgnoreCase))
|
||||||
|| string.Equals(videoCodec, "av1_amf", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
{
|
||||||
// Override the too high default qmin 18 in transcoding preset
|
// Override the too high default qmin 18 in transcoding preset in legacy h26x_amf
|
||||||
return FormattableString.Invariant($" -rc cbr -qmin 0 -qmax 32 -b:v {bitrate} -maxrate {bitrate} -bufsize {bufsize}");
|
return FormattableString.Invariant($" -rc cbr -qmin 0 -qmax 32 -b:v {bitrate} -maxrate {bitrate} -bufsize {bufsize}");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1768,8 +1812,11 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
|
|
||||||
public static string NormalizeTranscodingLevel(EncodingJobInfo state, string level)
|
public static string NormalizeTranscodingLevel(EncodingJobInfo state, string level)
|
||||||
{
|
{
|
||||||
if (double.TryParse(level, CultureInfo.InvariantCulture, out double requestLevel))
|
if (!double.TryParse(level, CultureInfo.InvariantCulture, out double requestLevel))
|
||||||
{
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (string.Equals(state.ActualOutputVideoCodec, "av1", StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(state.ActualOutputVideoCodec, "av1", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
// Transcode to level 5.3 (15) and lower for maximum compatibility.
|
// Transcode to level 5.3 (15) and lower for maximum compatibility.
|
||||||
@@ -1801,7 +1848,6 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
return "51";
|
return "51";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return level;
|
return level;
|
||||||
}
|
}
|
||||||
@@ -2189,12 +2235,10 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var level = state.GetRequestedLevel(targetVideoCodec);
|
var level = NormalizeTranscodingLevel(state, state.GetRequestedLevel(targetVideoCodec));
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(level))
|
if (!string.IsNullOrEmpty(level))
|
||||||
{
|
{
|
||||||
level = NormalizeTranscodingLevel(state, level);
|
|
||||||
|
|
||||||
// libx264, QSV, AMF can adjust the given level to match the output.
|
// libx264, QSV, AMF can adjust the given level to match the output.
|
||||||
if (string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase)
|
if (string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase)
|
||||||
|| string.Equals(videoEncoder, "libx264", StringComparison.OrdinalIgnoreCase))
|
|| string.Equals(videoEncoder, "libx264", StringComparison.OrdinalIgnoreCase))
|
||||||
@@ -2378,6 +2422,13 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
var requestHasSDR = requestedRangeTypes.Contains(VideoRangeType.SDR.ToString(), StringComparison.OrdinalIgnoreCase);
|
var requestHasSDR = requestedRangeTypes.Contains(VideoRangeType.SDR.ToString(), StringComparison.OrdinalIgnoreCase);
|
||||||
var requestHasDOVI = requestedRangeTypes.Contains(VideoRangeType.DOVI.ToString(), StringComparison.OrdinalIgnoreCase);
|
var requestHasDOVI = requestedRangeTypes.Contains(VideoRangeType.DOVI.ToString(), StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
// If SDR is the only supported range, we should not copy any of the HDR streams.
|
||||||
|
// All the following copy check assumes at least one HDR format is supported.
|
||||||
|
if (requestedRangeTypes.Length == 1 && requestHasSDR && videoStream.VideoRangeType != VideoRangeType.SDR)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// If the client does not support DOVI and the video stream is DOVI without fallback, we should not copy it.
|
// If the client does not support DOVI and the video stream is DOVI without fallback, we should not copy it.
|
||||||
if (!requestHasDOVI && videoStream.VideoRangeType == VideoRangeType.DOVI)
|
if (!requestHasDOVI && videoStream.VideoRangeType == VideoRangeType.DOVI)
|
||||||
{
|
{
|
||||||
@@ -2390,8 +2441,8 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
|| (requestHasSDR && videoStream.VideoRangeType == VideoRangeType.DOVIWithSDR)
|
|| (requestHasSDR && videoStream.VideoRangeType == VideoRangeType.DOVIWithSDR)
|
||||||
|| (requestHasHDR10 && videoStream.VideoRangeType == VideoRangeType.HDR10Plus)))
|
|| (requestHasHDR10 && videoStream.VideoRangeType == VideoRangeType.HDR10Plus)))
|
||||||
{
|
{
|
||||||
// If the video stream is in a static HDR format, don't allow copy if the client does not support HDR10 or HLG.
|
// If the video stream is in HDR10+ or a static HDR format, don't allow copy if the client does not support HDR10 or HLG.
|
||||||
if (videoStream.VideoRangeType is VideoRangeType.HDR10 or VideoRangeType.HLG)
|
if (videoStream.VideoRangeType is VideoRangeType.HDR10Plus or VideoRangeType.HDR10 or VideoRangeType.HLG)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -5942,28 +5993,37 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
|
|
||||||
var isFullAfbcPipeline = isEncoderSupportAfbc && isDrmInDrmOut && !doOclTonemap;
|
var isFullAfbcPipeline = isEncoderSupportAfbc && isDrmInDrmOut && !doOclTonemap;
|
||||||
var swapOutputWandH = doRkVppTranspose && swapWAndH;
|
var swapOutputWandH = doRkVppTranspose && swapWAndH;
|
||||||
var outFormat = doOclTonemap ? "p010" : (isMjpegEncoder ? "bgra" : "nv12"); // RGA only support full range in rgb fmts
|
var outFormat = doOclTonemap ? "p010" : "nv12";
|
||||||
var hwScaleFilter = GetHwScaleFilter("vpp", "rkrga", outFormat, swapOutputWandH, swpInW, swpInH, reqW, reqH, reqMaxW, reqMaxH);
|
var hwScaleFilter = GetHwScaleFilter("vpp", "rkrga", outFormat, swapOutputWandH, swpInW, swpInH, reqW, reqH, reqMaxW, reqMaxH);
|
||||||
var doScaling = GetHwScaleFilter("vpp", "rkrga", string.Empty, swapOutputWandH, swpInW, swpInH, reqW, reqH, reqMaxW, reqMaxH);
|
var doScaling = !string.IsNullOrEmpty(GetHwScaleFilter("vpp", "rkrga", string.Empty, swapOutputWandH, swpInW, swpInH, reqW, reqH, reqMaxW, reqMaxH));
|
||||||
|
|
||||||
if (!hasSubs
|
if (!hasSubs
|
||||||
|| doRkVppTranspose
|
|| doRkVppTranspose
|
||||||
|| !isFullAfbcPipeline
|
|| !isFullAfbcPipeline
|
||||||
|| !string.IsNullOrEmpty(doScaling))
|
|| doScaling)
|
||||||
{
|
{
|
||||||
|
var isScaleRatioSupported = IsScaleRatioSupported(inW, inH, reqW, reqH, reqMaxW, reqMaxH, 8.0f);
|
||||||
|
|
||||||
// RGA3 hardware only support (1/8 ~ 8) scaling in each blit operation,
|
// RGA3 hardware only support (1/8 ~ 8) scaling in each blit operation,
|
||||||
// but in Trickplay there's a case: (3840/320 == 12), enable 2pass for it
|
// but in Trickplay there's a case: (3840/320 == 12), enable 2pass for it
|
||||||
if (!string.IsNullOrEmpty(doScaling)
|
if (doScaling && !isScaleRatioSupported)
|
||||||
&& !IsScaleRatioSupported(inW, inH, reqW, reqH, reqMaxW, reqMaxH, 8.0f))
|
|
||||||
{
|
{
|
||||||
// Vendor provided BSP kernel has an RGA driver bug that causes the output to be corrupted for P010 format.
|
// Vendor provided BSP kernel has an RGA driver bug that causes the output to be corrupted for P010 format.
|
||||||
// Use NV15 instead of P010 to avoid the issue.
|
// Use NV15 instead of P010 to avoid the issue.
|
||||||
// SDR inputs are using BGRA formats already which is not affected.
|
// SDR inputs are using BGRA formats already which is not affected.
|
||||||
var intermediateFormat = string.Equals(outFormat, "p010", StringComparison.OrdinalIgnoreCase) ? "nv15" : outFormat;
|
var intermediateFormat = doOclTonemap ? "nv15" : (isMjpegEncoder ? "bgra" : outFormat);
|
||||||
var hwScaleFilterFirstPass = $"scale_rkrga=w=iw/7.9:h=ih/7.9:format={intermediateFormat}:force_original_aspect_ratio=increase:force_divisible_by=4:afbc=1";
|
var hwScaleFilterFirstPass = $"scale_rkrga=w=iw/7.9:h=ih/7.9:format={intermediateFormat}:force_original_aspect_ratio=increase:force_divisible_by=4:afbc=1";
|
||||||
mainFilters.Add(hwScaleFilterFirstPass);
|
mainFilters.Add(hwScaleFilterFirstPass);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The RKMPP MJPEG encoder on some newer chip models no longer supports RGB input.
|
||||||
|
// Use 2pass here to enable RGA output of full-range YUV in the 2nd pass.
|
||||||
|
if (isMjpegEncoder && !doOclTonemap && ((doScaling && isScaleRatioSupported) || !doScaling))
|
||||||
|
{
|
||||||
|
var hwScaleFilterFirstPass = "vpp_rkrga=format=bgra:afbc=1";
|
||||||
|
mainFilters.Add(hwScaleFilterFirstPass);
|
||||||
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(hwScaleFilter) && doRkVppTranspose)
|
if (!string.IsNullOrEmpty(hwScaleFilter) && doRkVppTranspose)
|
||||||
{
|
{
|
||||||
hwScaleFilter += $":transpose={transposeDir}";
|
hwScaleFilter += $":transpose={transposeDir}";
|
||||||
@@ -6343,6 +6403,19 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Block unsupported H.264 Hi422P and Hi444PP profiles, which can be encoded with 4:2:0 pixel format
|
||||||
|
if (string.Equals(videoStream.Codec, "h264", StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& ((videoStream.Profile?.Contains("4:2:2", StringComparison.OrdinalIgnoreCase) ?? false)
|
||||||
|
|| (videoStream.Profile?.Contains("4:4:4", StringComparison.OrdinalIgnoreCase) ?? false)))
|
||||||
|
{
|
||||||
|
// VideoToolbox on Apple Silicon has H.264 Hi444PP and theoretically also has Hi422P
|
||||||
|
if (!(hardwareAccelerationType == HardwareAccelerationType.videotoolbox
|
||||||
|
&& RuntimeInformation.OSArchitecture.Equals(Architecture.Arm64)))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var decoder = hardwareAccelerationType switch
|
var decoder = hardwareAccelerationType switch
|
||||||
{
|
{
|
||||||
HardwareAccelerationType.vaapi => GetVaapiVidDecoder(state, options, videoStream, bitDepth),
|
HardwareAccelerationType.vaapi => GetVaapiVidDecoder(state, options, videoStream, bitDepth),
|
||||||
@@ -7023,8 +7096,8 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
|
|
||||||
if (string.Equals(videoStream.Codec, "av1", StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(videoStream.Codec, "av1", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
var accelType = GetHwaccelType(state, options, "av1", bitDepth, hwSurface);
|
// there's an issue about AV1 AFBC on RK3588, disable it for now until it's fixed upstream
|
||||||
return accelType + ((!string.IsNullOrEmpty(accelType) && isAfbcSupported) ? " -afbc rga" : string.Empty);
|
return GetHwaccelType(state, options, "av1", bitDepth, hwSurface);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7087,9 +7160,8 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public string GetInputModifier(EncodingJobInfo state, EncodingOptions encodingOptions, string segmentContainer)
|
private string GetFfmpegAnalyzeDurationArg(EncodingJobInfo state)
|
||||||
{
|
{
|
||||||
var inputModifier = string.Empty;
|
|
||||||
var analyzeDurationArgument = string.Empty;
|
var analyzeDurationArgument = string.Empty;
|
||||||
|
|
||||||
// Apply -analyzeduration as per the environment variable,
|
// Apply -analyzeduration as per the environment variable,
|
||||||
@@ -7105,6 +7177,26 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
analyzeDurationArgument = "-analyzeduration " + ffmpegAnalyzeDuration;
|
analyzeDurationArgument = "-analyzeduration " + ffmpegAnalyzeDuration;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return analyzeDurationArgument;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string GetFfmpegProbesizeArg()
|
||||||
|
{
|
||||||
|
var ffmpegProbeSize = _config.GetFFmpegProbeSize();
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(ffmpegProbeSize))
|
||||||
|
{
|
||||||
|
return $"-probesize {ffmpegProbeSize}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string GetInputModifier(EncodingJobInfo state, EncodingOptions encodingOptions, string segmentContainer)
|
||||||
|
{
|
||||||
|
var inputModifier = string.Empty;
|
||||||
|
var analyzeDurationArgument = GetFfmpegAnalyzeDurationArg(state);
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(analyzeDurationArgument))
|
if (!string.IsNullOrEmpty(analyzeDurationArgument))
|
||||||
{
|
{
|
||||||
inputModifier += " " + analyzeDurationArgument;
|
inputModifier += " " + analyzeDurationArgument;
|
||||||
@@ -7113,11 +7205,11 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
inputModifier = inputModifier.Trim();
|
inputModifier = inputModifier.Trim();
|
||||||
|
|
||||||
// Apply -probesize if configured
|
// Apply -probesize if configured
|
||||||
var ffmpegProbeSize = _config.GetFFmpegProbeSize();
|
var ffmpegProbeSizeArgument = GetFfmpegProbesizeArg();
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(ffmpegProbeSize))
|
if (!string.IsNullOrEmpty(ffmpegProbeSizeArgument))
|
||||||
{
|
{
|
||||||
inputModifier += $" -probesize {ffmpegProbeSize}";
|
inputModifier += " " + ffmpegProbeSizeArgument;
|
||||||
}
|
}
|
||||||
|
|
||||||
var userAgentParam = GetUserAgentParam(state);
|
var userAgentParam = GetUserAgentParam(state);
|
||||||
@@ -7157,8 +7249,10 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
inputModifier += GetVideoSyncOption(state.InputVideoSync, _mediaEncoder.EncoderVersion);
|
inputModifier += GetVideoSyncOption(state.InputVideoSync, _mediaEncoder.EncoderVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int readrate = 0;
|
||||||
if (state.ReadInputAtNativeFramerate && state.InputProtocol != MediaProtocol.Rtsp)
|
if (state.ReadInputAtNativeFramerate && state.InputProtocol != MediaProtocol.Rtsp)
|
||||||
{
|
{
|
||||||
|
readrate = 1;
|
||||||
inputModifier += " -re";
|
inputModifier += " -re";
|
||||||
}
|
}
|
||||||
else if (encodingOptions.EnableSegmentDeletion
|
else if (encodingOptions.EnableSegmentDeletion
|
||||||
@@ -7169,7 +7263,15 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
{
|
{
|
||||||
// Set an input read rate limit 10x for using SegmentDeletion with stream-copy
|
// Set an input read rate limit 10x for using SegmentDeletion with stream-copy
|
||||||
// to prevent ffmpeg from exiting prematurely (due to fast drive)
|
// to prevent ffmpeg from exiting prematurely (due to fast drive)
|
||||||
inputModifier += " -readrate 10";
|
readrate = 10;
|
||||||
|
inputModifier += $" -readrate {readrate}";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set a larger catchup value to revert to the old behavior,
|
||||||
|
// otherwise, remuxing might stall due to this new option
|
||||||
|
if (readrate > 0 && _mediaEncoder.EncoderVersion >= _minFFmpegReadrateCatchupOption)
|
||||||
|
{
|
||||||
|
inputModifier += $" -readrate_catchup {readrate * 100}";
|
||||||
}
|
}
|
||||||
|
|
||||||
var flags = new List<string>();
|
var flags = new List<string>();
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user