mirror of
https://github.com/jellyfin/jellyfin.git
synced 2026-07-16 05:43:54 +03:00
Compare commits
290 Commits
standards-
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8771b52ec | ||
|
|
e3d610c3c5 | ||
|
|
486ffaa6dd | ||
|
|
0fe9c1ce91 | ||
|
|
b6882c86dc | ||
|
|
1a9bd2417a | ||
|
|
0d2f1b26b3 | ||
|
|
bc59e56ef5 | ||
|
|
cfeaef6180 | ||
|
|
ebab369eac | ||
|
|
0584a102ee | ||
|
|
2aae53bc15 | ||
|
|
911044b415 | ||
|
|
b77def6883 | ||
|
|
5c48935fa8 | ||
|
|
e2d9d592bc | ||
|
|
305160d2e4 | ||
|
|
933f7eea1b | ||
|
|
ed06fd5139 | ||
|
|
8b826d981b | ||
|
|
8bf1710e07 | ||
|
|
5e42941d3b | ||
|
|
a1255bda6a | ||
|
|
9a2fdb3573 | ||
|
|
53aafcd38e | ||
|
|
d73e65172a | ||
|
|
974038e9b0 | ||
|
|
f3fbe5575a | ||
|
|
860cb75d58 | ||
|
|
e31f168e9b | ||
|
|
7f5537ca47 | ||
|
|
a03072b617 | ||
|
|
31d05dbdd6 | ||
|
|
d4faf7bd72 | ||
|
|
6e728b009f | ||
|
|
efd3814a7e | ||
|
|
b391cd5e9e | ||
|
|
8433773fad | ||
|
|
381bf18161 | ||
|
|
f8ffccae7f | ||
|
|
2528bc1032 | ||
|
|
4b5f5d6ca3 | ||
|
|
e2eaacd239 | ||
|
|
77d4ab9ea9 | ||
|
|
43a152359e | ||
|
|
482cf4b8c3 | ||
|
|
2b4945217a | ||
|
|
ccc1712d10 | ||
|
|
820e7b91da | ||
|
|
08ba3717ea | ||
|
|
8f3eb3205d | ||
|
|
379c58a48d | ||
|
|
28b43838dd | ||
|
|
38f1d9749e | ||
|
|
9cc25d133d | ||
|
|
8e1b69b37f | ||
|
|
a43fd188dc | ||
|
|
f011529388 | ||
|
|
8ffb54603a | ||
|
|
d3ee1e84b1 | ||
|
|
1035f6a101 | ||
|
|
ef6f342a54 | ||
|
|
a888257c82 | ||
|
|
d55f808423 | ||
|
|
dc92e3b0e4 | ||
|
|
feef2403c4 | ||
|
|
1b6342e217 | ||
|
|
62e6cf0196 | ||
|
|
8c6ee890cb | ||
|
|
eee26e6fee | ||
|
|
fb07067f8f | ||
|
|
a83920c5a7 | ||
|
|
75d71cb73c | ||
|
|
c158418e0b | ||
|
|
cbef19c313 | ||
|
|
fc13a7ca7d | ||
|
|
ff36b1b417 | ||
|
|
9ec19b8244 | ||
|
|
ed5e868a6b | ||
|
|
58de9b7a99 | ||
|
|
aa037c748a | ||
|
|
1efdad3443 | ||
|
|
f2ed842b4b | ||
|
|
70b4589382 | ||
|
|
c2cb18a9d1 | ||
|
|
f398b6d08b | ||
|
|
fa07a3abe8 | ||
|
|
b9db4566a7 | ||
|
|
d71b17fcc7 | ||
|
|
dff84c8490 | ||
|
|
1947296edd | ||
|
|
31070e8208 | ||
|
|
2c98ad99db | ||
|
|
e26f4a1005 | ||
|
|
e41f415594 | ||
|
|
d090c59939 | ||
|
|
da515e94b1 | ||
|
|
987744529a | ||
|
|
917244ab1d | ||
|
|
af82aceadb | ||
|
|
7f2cd5cf57 | ||
|
|
2feb588db3 | ||
|
|
58e9e3423a | ||
|
|
da2b994fff | ||
|
|
7f7e4dfa40 | ||
|
|
c257fd5004 | ||
|
|
0046adda29 | ||
|
|
b60c535c84 | ||
|
|
069eb40ebf | ||
|
|
4e80648fd3 | ||
|
|
f08a3f9fd9 | ||
|
|
083f9d291a | ||
|
|
e4383493a9 | ||
|
|
ce58e4400e | ||
|
|
3741d71965 | ||
|
|
11f642594d | ||
|
|
8d15529df7 | ||
|
|
310a47c1d4 | ||
|
|
0fb042b740 | ||
|
|
24886d4849 | ||
|
|
e75161c557 | ||
|
|
c6c72f30ec | ||
|
|
528593efbf | ||
|
|
308981cc0d | ||
|
|
bebb7ce803 | ||
|
|
1a6f019cfd | ||
|
|
751b763838 | ||
|
|
64e02c0e28 | ||
|
|
49f8a96360 | ||
|
|
364f1e12c0 | ||
|
|
ada11f5692 | ||
|
|
5036bf7db0 | ||
|
|
1c4dea4b2c | ||
|
|
1ea525a408 | ||
|
|
e525fc7c4b | ||
|
|
3307406ac8 | ||
|
|
e86b502cbc | ||
|
|
4c228eaf63 | ||
|
|
1176c2d329 | ||
|
|
b9271eb199 | ||
|
|
e2433e2c79 | ||
|
|
0022508889 | ||
|
|
a9a02719ab | ||
|
|
d50205cc9f | ||
|
|
e4edce9a70 | ||
|
|
ac92da233b | ||
|
|
f6bb086415 | ||
|
|
9375f31bd3 | ||
|
|
a0862a4cb5 | ||
|
|
2d8ab1e2ec | ||
|
|
068bbb7981 | ||
|
|
f9644f24d2 | ||
|
|
8d0003533e | ||
|
|
1dd5a85080 | ||
|
|
f5c3e2c65a | ||
|
|
f4bab458a2 | ||
|
|
8028e1d59d | ||
|
|
1a9ed49083 | ||
|
|
ab988d0e73 | ||
|
|
aa3fc60b2e | ||
|
|
d8f8dbabcb | ||
|
|
5df25cf688 | ||
|
|
047519c61a | ||
|
|
e198c430ae | ||
|
|
3c11329256 | ||
|
|
3d80da6cfa | ||
|
|
db89b49752 | ||
|
|
21efb55db6 | ||
|
|
a9dc8f6f74 | ||
|
|
23f8ec93ab | ||
|
|
a5706c2fa6 | ||
|
|
d0a8445f76 | ||
|
|
7e3f758bee | ||
|
|
8e2c5607ef | ||
|
|
4278b34fe6 | ||
|
|
0aeee8233b | ||
|
|
58508e60a4 | ||
|
|
2ede3c1342 | ||
|
|
dd42a121c4 | ||
|
|
5ac14f0688 | ||
|
|
d8acea21fb | ||
|
|
0ef94347c0 | ||
|
|
12f718e7bb | ||
|
|
5920d8dc3c | ||
|
|
95de28cdda | ||
|
|
0874a26131 | ||
|
|
25016babc1 | ||
|
|
eb38f462ad | ||
|
|
c693047467 | ||
|
|
894ee38f68 | ||
|
|
97fd210cd3 | ||
|
|
1a786f26c1 | ||
|
|
007515eb73 | ||
|
|
0a0060c9ca | ||
|
|
8b002e3fca | ||
|
|
f5cf68e979 | ||
|
|
f6ce2cedd9 | ||
|
|
e71914e993 | ||
|
|
fbe522592a | ||
|
|
59974490cc | ||
|
|
dee63ef3f1 | ||
|
|
2392e32779 | ||
|
|
6c931dcdda | ||
|
|
f584759638 | ||
|
|
fe1accc0e7 | ||
|
|
fe1d8d8840 | ||
|
|
a53a533cc4 | ||
|
|
4725722b1c | ||
|
|
9ea3f45886 | ||
|
|
e64cc73f88 | ||
|
|
63990d6a2d | ||
|
|
09723bd123 | ||
|
|
c242533f4e | ||
|
|
507998a4e3 | ||
|
|
4459147788 | ||
|
|
003f01a99a | ||
|
|
f56670bdce | ||
|
|
d8d386e88a | ||
|
|
b4d970ae38 | ||
|
|
fa0e1b6e9a | ||
|
|
d873964297 | ||
|
|
6ccdaad0a4 | ||
|
|
ec43ea156e | ||
|
|
cbf284d229 | ||
|
|
8aaea6ea52 | ||
|
|
3114c0a9b8 | ||
|
|
c9f9f30baf | ||
|
|
0ed27bad65 | ||
|
|
c8da0abf0f | ||
|
|
a96824f257 | ||
|
|
1a2db53710 | ||
|
|
f4db44ffb3 | ||
|
|
b639166c0a | ||
|
|
efb0336369 | ||
|
|
5f13afa1ce | ||
|
|
45e40d3b33 | ||
|
|
53c1c4982a | ||
|
|
21c0a35edf | ||
|
|
1b80da0c3d | ||
|
|
47f2b3b6d0 | ||
|
|
857b99ce61 | ||
|
|
cf88058099 | ||
|
|
5ee9e79da2 | ||
|
|
5ed7798c36 | ||
|
|
b71b4cc26f | ||
|
|
26a149a970 | ||
|
|
5104497331 | ||
|
|
7185257da5 | ||
|
|
d4c962f6e4 | ||
|
|
52cf8d1ba4 | ||
|
|
081f0ef4a0 | ||
|
|
cc5fb3f1ee | ||
|
|
d69de6ccc4 | ||
|
|
9ab7cc0fe9 | ||
|
|
285fc1b9f6 | ||
|
|
5ce7170813 | ||
|
|
bdb8250300 | ||
|
|
a2bab98c23 | ||
|
|
a479e145dc | ||
|
|
9f350171c6 | ||
|
|
a05bde53d4 | ||
|
|
cb9d6e9884 | ||
|
|
1175846120 | ||
|
|
e627c723e2 | ||
|
|
372c1681d8 | ||
|
|
3d8bcf1ffd | ||
|
|
ea8f6c51fd | ||
|
|
d71194aa8c | ||
|
|
9e794e80c2 | ||
|
|
8f7c54ee5e | ||
|
|
65710a4e4f | ||
|
|
df751af194 | ||
|
|
5e82b61bab | ||
|
|
ea7000a4d6 | ||
|
|
07a802d8fa | ||
|
|
8ceb8c23ce | ||
|
|
a0d1e05696 | ||
|
|
88602ce905 | ||
|
|
4cb0385745 | ||
|
|
438d992c8b | ||
|
|
1adf441f1c | ||
|
|
490bf347cb | ||
|
|
fd6e48603b | ||
|
|
b09c9655fd | ||
|
|
790220ef6b | ||
|
|
c08b1a4595 | ||
|
|
622b60064d | ||
|
|
91b2b7fc3d | ||
|
|
0b4854c5ef | ||
|
|
d6a1c8413c |
@@ -3,7 +3,7 @@
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"dotnet-ef": {
|
||||
"version": "10.0.8",
|
||||
"version": "10.0.10",
|
||||
"commands": [
|
||||
"dotnet-ef"
|
||||
]
|
||||
|
||||
1
.github/ISSUE_TEMPLATE/issue report.yml
vendored
1
.github/ISSUE_TEMPLATE/issue report.yml
vendored
@@ -87,6 +87,7 @@ body:
|
||||
label: Jellyfin Server version
|
||||
description: What version of Jellyfin are you using?
|
||||
options:
|
||||
- 10.11.11
|
||||
- 10.11.10
|
||||
- 10.11.9
|
||||
- 10.11.8
|
||||
|
||||
6
.github/pull_request_template.md
vendored
6
.github/pull_request_template.md
vendored
@@ -1,11 +1,15 @@
|
||||
<!--
|
||||
Ensure your title is short, descriptive, and in the imperative mood (Fix X, Change Y, instead of Fixed X, Changed Y).
|
||||
For a good inspiration of what to write in commit messages and PRs please review https://chris.beams.io/posts/git-commit/ and our documentation.
|
||||
For a good inspiration of what to write in commit messages and PRs please review https://chris.beams.io/posts/git-commit/ and our https://jellyfin.org/docs/general/contributing/issues/ page.
|
||||
-->
|
||||
|
||||
**Changes**
|
||||
<!-- Describe your changes here in 1-5 sentences. -->
|
||||
|
||||
**Code assistance**
|
||||
<!-- If code assistance was used, describe how it contributed
|
||||
e.g., code generated by LLM, explanation of code base, debugging guidance. -->
|
||||
|
||||
**Issues**
|
||||
<!-- Tag any issues that this PR solves here.
|
||||
ex. Fixes # -->
|
||||
|
||||
10
.github/workflows/ci-codeql-analysis.yml
vendored
10
.github/workflows/ci-codeql-analysis.yml
vendored
@@ -24,21 +24,21 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
|
||||
uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: +security-extended
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
uses: github/codeql-action/autobuild@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
||||
|
||||
8
.github/workflows/ci-compat.yml
vendored
8
.github/workflows/ci-compat.yml
vendored
@@ -11,13 +11,13 @@ jobs:
|
||||
permissions: read-all
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
|
||||
uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
@@ -40,14 +40,14 @@ jobs:
|
||||
permissions: read-all
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
|
||||
uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
|
||||
4
.github/workflows/ci-format.yml
vendored
4
.github/workflows/ci-format.yml
vendored
@@ -15,9 +15,9 @@ jobs:
|
||||
format-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
|
||||
- uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
|
||||
with:
|
||||
dotnet-version: ${{ env.SDK_VERSION }}
|
||||
|
||||
|
||||
4
.github/workflows/ci-tests.yml
vendored
4
.github/workflows/ci-tests.yml
vendored
@@ -20,9 +20,9 @@ jobs:
|
||||
|
||||
runs-on: "${{ matrix.os }}"
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
|
||||
- uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
|
||||
with:
|
||||
dotnet-version: ${{ env.SDK_VERSION }}
|
||||
|
||||
|
||||
6
.github/workflows/commands.yml
vendored
6
.github/workflows/commands.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
||||
reactions: '+1'
|
||||
|
||||
- name: Checkout the latest code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
fetch-depth: 0
|
||||
@@ -40,12 +40,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: pull in script
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
repository: jellyfin/jellyfin-triage-script
|
||||
|
||||
- name: install python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
with:
|
||||
python-version: '3.14'
|
||||
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
|
||||
if: ${{ contains(github.repository, 'jellyfin/') }}
|
||||
steps:
|
||||
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
- uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0
|
||||
with:
|
||||
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
ascending: true
|
||||
|
||||
4
.github/workflows/issue-template-check.yml
vendored
4
.github/workflows/issue-template-check.yml
vendored
@@ -10,12 +10,12 @@ jobs:
|
||||
issues: write
|
||||
steps:
|
||||
- name: pull in script
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
repository: jellyfin/jellyfin-triage-script
|
||||
|
||||
- name: install python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
with:
|
||||
python-version: '3.14'
|
||||
cache: 'pip'
|
||||
|
||||
4
.github/workflows/openapi-generate.yml
vendored
4
.github/workflows/openapi-generate.yml
vendored
@@ -22,13 +22,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ inputs.ref }}
|
||||
repository: ${{ inputs.repository }}
|
||||
|
||||
- name: Configure .NET
|
||||
uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
|
||||
uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
|
||||
2
.github/workflows/openapi-pull-request.yml
vendored
2
.github/workflows/openapi-pull-request.yml
vendored
@@ -10,7 +10,7 @@ jobs:
|
||||
base_ref: ${{ steps.ancestor.outputs.base_ref }}
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
|
||||
11
.github/workflows/pull-request-conflict.yml
vendored
11
.github/workflows/pull-request-conflict.yml
vendored
@@ -5,18 +5,19 @@ on:
|
||||
branches:
|
||||
- master
|
||||
pull_request_target:
|
||||
issue_comment:
|
||||
types: [synchronize]
|
||||
|
||||
permissions: {}
|
||||
jobs:
|
||||
label:
|
||||
name: Labeling
|
||||
main:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.repository == 'jellyfin/jellyfin' && github.event.issue.pull_request }}
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
if: ${{ github.repository == 'jellyfin/jellyfin' }}
|
||||
steps:
|
||||
- name: Apply label
|
||||
uses: eps1lon/actions-label-merge-conflict@0273be72a0bbd58fcd71d0d6c02c209b50d1e5e1 # v3.1.0
|
||||
if: ${{ github.event_name == 'push' || github.event_name == 'pull_request_target'}}
|
||||
with:
|
||||
dirtyLabel: 'merge conflict'
|
||||
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
|
||||
if: ${{ contains(github.repository, 'jellyfin/') }}
|
||||
steps:
|
||||
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
- uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0
|
||||
with:
|
||||
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
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
|
||||
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ env.TAG_BRANCH }}
|
||||
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
NEXT_VERSION: ${{ github.event.inputs.NEXT_VERSION }}
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ env.TAG_BRANCH }}
|
||||
|
||||
|
||||
@@ -90,6 +90,7 @@
|
||||
- [mark-monteiro](https://github.com/mark-monteiro)
|
||||
- [MarkCiliaVincenti](https://github.com/MarkCiliaVincenti)
|
||||
- [Martin Reuter](https://github.com/reuterma24)
|
||||
- [Matt Teahan](https://github.com/matt-teahan)
|
||||
- [Matt07211](https://github.com/Matt07211)
|
||||
- [Matthew Jones](https://github.com/matthew-jones-uk)
|
||||
- [Maxr1998](https://github.com/Maxr1998)
|
||||
|
||||
@@ -26,28 +26,28 @@
|
||||
<PackageVersion Include="libse" Version="4.0.12" />
|
||||
<PackageVersion Include="LrcParser" Version="2025.623.0" />
|
||||
<PackageVersion Include="MetaBrainz.MusicBrainz" Version="8.0.1" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.8" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.8" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="4.14.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="5.3.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.3.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="5.3.0" />
|
||||
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.8" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.8" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.8" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.8" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.8" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.8" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.8" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.8" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.8" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.8" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.8" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.8" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.8" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.8" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.6.0" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="5.6.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="5.6.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.6.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="5.6.0" />
|
||||
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
|
||||
<PackageVersion Include="MimeTypes" Version="2.5.2" />
|
||||
<PackageVersion Include="Morestachio" Version="5.0.1.670" />
|
||||
<PackageVersion Include="Moq" Version="4.18.4" />
|
||||
@@ -57,26 +57,27 @@
|
||||
<PackageVersion Include="prometheus-net.AspNetCore" Version="8.2.1" />
|
||||
<PackageVersion Include="prometheus-net.DotNetRuntime" Version="4.4.1" />
|
||||
<PackageVersion Include="prometheus-net" Version="8.2.1" />
|
||||
<PackageVersion Include="Polly" Version="8.6.6" />
|
||||
<PackageVersion Include="Polly" Version="8.7.0" />
|
||||
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||
<PackageVersion Include="Serilog.Enrichers.Thread" Version="4.0.0" />
|
||||
<PackageVersion Include="Serilog.Expressions" Version="5.0.0" />
|
||||
<PackageVersion Include="Serilog.Settings.Configuration" Version="10.0.0" />
|
||||
<PackageVersion Include="Serilog.Settings.Configuration" Version="10.0.1" />
|
||||
<PackageVersion Include="Serilog.Sinks.Async" Version="2.1.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.Graylog" Version="3.1.1" />
|
||||
<PackageVersion Include="SerilogAnalyzer" Version="0.15.0" />
|
||||
<PackageVersion Include="SharpFuzz" Version="2.2.0" />
|
||||
<PackageVersion Include="SharpCompress" Version="0.49.1" />
|
||||
<PackageVersion Include="SharpFuzz" Version="2.3.0" />
|
||||
<PackageVersion Include="SkiaSharp" Version="3.119.4" />
|
||||
<PackageVersion Include="SkiaSharp.HarfBuzz" Version="3.119.4" />
|
||||
<PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="3.119.4" />
|
||||
<PackageVersion Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" />
|
||||
<PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" />
|
||||
<PackageVersion Include="Svg.Skia" Version="3.7.0" />
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="10.2.0" />
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.0" />
|
||||
<PackageVersion Include="System.Text.Json" Version="10.0.8" />
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="10.2.3" />
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.3" />
|
||||
<PackageVersion Include="System.Text.Json" Version="10.0.10" />
|
||||
<PackageVersion Include="TagLibSharp" Version="2.3.0" />
|
||||
<PackageVersion Include="z440.atl.core" Version="7.15.3" />
|
||||
<PackageVersion Include="TMDbLib" Version="3.0.0" />
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Emby.Naming.Book
|
||||
@@ -5,7 +6,7 @@ namespace Emby.Naming.Book
|
||||
/// <summary>
|
||||
/// Helper class to retrieve basic metadata from a book filename.
|
||||
/// </summary>
|
||||
public static class BookFileNameParser
|
||||
public static partial class BookFileNameParser
|
||||
{
|
||||
private const string NameMatchGroup = "name";
|
||||
private const string IndexMatchGroup = "index";
|
||||
@@ -15,14 +16,17 @@ namespace Emby.Naming.Book
|
||||
private static readonly Regex[] _nameMatches =
|
||||
[
|
||||
// seriesName (seriesYear) #index (of count) (year) where only seriesName and index are required
|
||||
new Regex(@"^(?<seriesName>.+?)((\s\((?<seriesYear>[0-9]{4})\))?)\s#(?<index>[0-9]+)((\s\(of\s(?<count>[0-9]+)\))?)((\s\((?<year>[0-9]{4})\))?)$"),
|
||||
new Regex(@"^(?<name>.+?)\s\((?<seriesName>.+?),\s#(?<index>[0-9]+)\)((\s\((?<year>[0-9]{4})\))?)$"),
|
||||
new Regex(@"^(?<index>[0-9]+)\s\-\s(?<name>.+?)((\s\((?<year>[0-9]{4})\))?)$"),
|
||||
new Regex(@"^(?<seriesName>.+?)((\s\((?<seriesYear>[0-9]{4})\))?)\s#(?<index>[0-9]+)(?:\.0)?((\s\(of\s(?<count>[0-9]+)\))?)((\s\((?<year>[0-9]{4})\))?)$"),
|
||||
new Regex(@"^(?<name>.+?)\s\((?<seriesName>.+?),\s#(?<index>[0-9]+)\)(?:\.0)?((\s\((?<year>[0-9]{4})\))?)$"),
|
||||
new Regex(@"^(?<index>[0-9]+)(?:\.0)?\s\-\s(?<name>.+?)((\s\((?<year>[0-9]{4})\))?)$"),
|
||||
new Regex(@"(?<name>.*)\((?<year>[0-9]{4})\)"),
|
||||
// last resort matches the whole string as the name
|
||||
new Regex(@"(?<name>.*)")
|
||||
];
|
||||
|
||||
[GeneratedRegex(@"^(?<name>.+?)(\sv(?<volume>[0-9]+))?(\sc(?<chapter>[0-9]+))?$")]
|
||||
private static partial Regex ComicRegex();
|
||||
|
||||
/// <summary>
|
||||
/// Parse a filename name to retrieve the book name, series name, index, and year.
|
||||
/// </summary>
|
||||
@@ -48,7 +52,22 @@ namespace Emby.Naming.Book
|
||||
|
||||
if (match.Groups.TryGetValue(NameMatchGroup, out Group? nameGroup) && nameGroup.Success)
|
||||
{
|
||||
result.Name = nameGroup.Value.Trim();
|
||||
var comicMatch = ComicRegex().Match(nameGroup.Value.Trim());
|
||||
|
||||
if (comicMatch.Success)
|
||||
{
|
||||
if (comicMatch.Groups.TryGetValue("volume", out Group? volumeGroup) && volumeGroup.Success && int.TryParse(volumeGroup.ValueSpan, out var volume))
|
||||
{
|
||||
result.ParentIndex = volume;
|
||||
}
|
||||
|
||||
if (comicMatch.Groups.TryGetValue("chapter", out Group? chapterGroup) && chapterGroup.Success && int.TryParse(chapterGroup.ValueSpan, out var chapter))
|
||||
{
|
||||
result.Index = chapter;
|
||||
}
|
||||
}
|
||||
|
||||
result.Name = nameGroup.ValueSpan.Trim().ToString();
|
||||
}
|
||||
|
||||
if (match.Groups.TryGetValue(IndexMatchGroup, out Group? indexGroup) && indexGroup.Success && int.TryParse(indexGroup.Value, out var index))
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
using System;
|
||||
|
||||
namespace Emby.Naming.Book
|
||||
{
|
||||
/// <summary>
|
||||
@@ -14,6 +12,7 @@ namespace Emby.Naming.Book
|
||||
{
|
||||
Name = null;
|
||||
Index = null;
|
||||
ParentIndex = null;
|
||||
Year = null;
|
||||
SeriesName = null;
|
||||
}
|
||||
@@ -28,6 +27,11 @@ namespace Emby.Naming.Book
|
||||
/// </summary>
|
||||
public int? Index { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the parent index number.
|
||||
/// </summary>
|
||||
public int? ParentIndex { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the publication year.
|
||||
/// </summary>
|
||||
|
||||
@@ -125,7 +125,7 @@ namespace Emby.Naming.TV
|
||||
result.Success = true;
|
||||
}
|
||||
}
|
||||
else if (DateTime.TryParse(match.Groups[0].ValueSpan, out date))
|
||||
else if (DateTime.TryParse(match.Groups[0].ValueSpan, CultureInfo.InvariantCulture, out date))
|
||||
{
|
||||
result.Year = date.Year;
|
||||
result.Month = date.Month;
|
||||
|
||||
@@ -25,5 +25,11 @@ namespace Emby.Naming.TV
|
||||
/// </summary>
|
||||
/// <value>The name of the series.</value>
|
||||
public string? Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the year of the series.
|
||||
/// </summary>
|
||||
/// <value>The year of the series.</value>
|
||||
public int? Year { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ namespace Emby.Naming.TV
|
||||
/// Regex that matches titles with year in parentheses. Captures the title (which may be
|
||||
/// numeric) before the year, i.e. turns "1923 (2022)" into "1923".
|
||||
/// </summary>
|
||||
[GeneratedRegex(@"(?<title>.+?)\s*\(\d{4}\)")]
|
||||
[GeneratedRegex(@"(?<title>.+?)\s*\((?<year>[0-9]{4})\)")]
|
||||
private static partial Regex TitleWithYearRegex();
|
||||
|
||||
/// <summary>
|
||||
@@ -43,7 +43,8 @@ namespace Emby.Naming.TV
|
||||
seriesName = titleWithYearMatch.Groups["title"].Value.Trim();
|
||||
return new SeriesInfo(path)
|
||||
{
|
||||
Name = seriesName
|
||||
Name = seriesName,
|
||||
Year = int.TryParse(titleWithYearMatch.Groups["year"].ValueSpan, out var year) ? year : null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ using Emby.Server.Implementations.Dto;
|
||||
using Emby.Server.Implementations.HttpServer.Security;
|
||||
using Emby.Server.Implementations.IO;
|
||||
using Emby.Server.Implementations.Library;
|
||||
using Emby.Server.Implementations.Library.Search;
|
||||
using Emby.Server.Implementations.Library.SimilarItems;
|
||||
using Emby.Server.Implementations.Localization;
|
||||
using Emby.Server.Implementations.Playlists;
|
||||
@@ -92,6 +93,9 @@ using MediaBrowser.Model.Net;
|
||||
using MediaBrowser.Model.Serialization;
|
||||
using MediaBrowser.Model.System;
|
||||
using MediaBrowser.Model.Tasks;
|
||||
using MediaBrowser.Providers.Books;
|
||||
using MediaBrowser.Providers.Books.ComicBookInfo;
|
||||
using MediaBrowser.Providers.Books.ComicInfo;
|
||||
using MediaBrowser.Providers.Lyric;
|
||||
using MediaBrowser.Providers.Manager;
|
||||
using MediaBrowser.Providers.Plugins.ListenBrainz;
|
||||
@@ -495,6 +499,14 @@ namespace Emby.Server.Implementations
|
||||
serviceCollection.AddSingleton<ListenBrainzLabsClient>();
|
||||
serviceCollection.AddSingleton<ListenBrainzSimilarArtistProvider>();
|
||||
|
||||
// register the generic local metadata provider for comic files
|
||||
serviceCollection.AddSingleton<ComicProvider>();
|
||||
|
||||
// register the actual implementations of the local metadata provider for comic files
|
||||
serviceCollection.AddSingleton<IComicProvider, ComicBookInfoProvider>();
|
||||
serviceCollection.AddSingleton<IComicProvider, ExternalComicInfoProvider>();
|
||||
serviceCollection.AddSingleton<IComicProvider, InternalComicInfoProvider>();
|
||||
|
||||
serviceCollection.AddSingleton(NetManager);
|
||||
|
||||
serviceCollection.AddSingleton<ITaskManager, TaskManager>();
|
||||
@@ -539,6 +551,7 @@ namespace Emby.Server.Implementations
|
||||
serviceCollection.AddTransient(provider => new Lazy<ILibraryMonitor>(provider.GetRequiredService<ILibraryMonitor>));
|
||||
serviceCollection.AddTransient(provider => new Lazy<IProviderManager>(provider.GetRequiredService<IProviderManager>));
|
||||
serviceCollection.AddTransient(provider => new Lazy<IUserViewManager>(provider.GetRequiredService<IUserViewManager>));
|
||||
serviceCollection.AddTransient(provider => new Lazy<IExternalDataManager>(provider.GetRequiredService<IExternalDataManager>));
|
||||
serviceCollection.AddSingleton<ILibraryManager, LibraryManager>();
|
||||
serviceCollection.AddSingleton<NamingOptions>();
|
||||
serviceCollection.AddSingleton<VideoListResolver>();
|
||||
@@ -550,7 +563,8 @@ namespace Emby.Server.Implementations
|
||||
|
||||
serviceCollection.AddSingleton<ISimilarItemsManager, SimilarItemsManager>();
|
||||
|
||||
serviceCollection.AddSingleton<ISearchEngine, SearchEngine>();
|
||||
serviceCollection.AddSingleton<ISearchManager, SearchManager>();
|
||||
serviceCollection.AddSingleton<ISearchProvider, SqlSearchProvider>();
|
||||
|
||||
serviceCollection.AddSingleton<IWebSocketManager, WebSocketManager>();
|
||||
|
||||
@@ -709,6 +723,7 @@ namespace Emby.Server.Implementations
|
||||
Resolve<IMediaSourceManager>().AddParts(GetExports<IMediaSourceProvider>());
|
||||
|
||||
Resolve<ISimilarItemsManager>().AddParts(GetExports<ISimilarItemsProvider>());
|
||||
Resolve<ISearchManager>().AddParts(GetExports<ISearchProvider>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -71,6 +71,8 @@ namespace Emby.Server.Implementations.Dto
|
||||
{
|
||||
BaseItemKind.Person, [
|
||||
BaseItemKind.Audio,
|
||||
BaseItemKind.AudioBook,
|
||||
BaseItemKind.Book,
|
||||
BaseItemKind.Episode,
|
||||
BaseItemKind.Movie,
|
||||
BaseItemKind.LiveTvProgram,
|
||||
@@ -167,9 +169,13 @@ namespace Emby.Server.Implementations.Dto
|
||||
|
||||
// Batch-fetch user data for all items
|
||||
Dictionary<Guid, UserItemData>? userDataBatch = null;
|
||||
IReadOnlyDictionary<Guid, VersionResumeData>? resumeDataBatch = null;
|
||||
if (user is not null && options.EnableUserData)
|
||||
{
|
||||
userDataBatch = _userDataRepository.GetUserDataBatch(accessibleItems, user);
|
||||
|
||||
// For items with alternate versions, the most recently played version drives resume.
|
||||
resumeDataBatch = _userDataRepository.GetResumeUserDataBatch(accessibleItems, user);
|
||||
}
|
||||
|
||||
// Pre-compute collection folders once to avoid N+1 queries in CanDelete
|
||||
@@ -248,7 +254,8 @@ namespace Emby.Server.Implementations.Dto
|
||||
allCollectionFolders,
|
||||
childCountBatch,
|
||||
playedCountBatch,
|
||||
artistsBatch);
|
||||
artistsBatch,
|
||||
resumeDataBatch?.GetValueOrDefault(item.Id));
|
||||
|
||||
if (item is LiveTvChannel tvChannel)
|
||||
{
|
||||
@@ -309,7 +316,8 @@ namespace Emby.Server.Implementations.Dto
|
||||
List<Folder>? allCollectionFolders = null,
|
||||
Dictionary<Guid, int>? childCountBatch = null,
|
||||
Dictionary<Guid, (int Played, int Total)>? playedCountBatch = null,
|
||||
IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null)
|
||||
IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null,
|
||||
VersionResumeData? resumeData = null)
|
||||
{
|
||||
var dto = new BaseItemDto
|
||||
{
|
||||
@@ -353,7 +361,8 @@ namespace Emby.Server.Implementations.Dto
|
||||
options,
|
||||
userData,
|
||||
childCountBatch,
|
||||
playedCountBatch);
|
||||
playedCountBatch,
|
||||
resumeData);
|
||||
}
|
||||
|
||||
if (item is IHasMediaSources
|
||||
@@ -369,7 +378,7 @@ namespace Emby.Server.Implementations.Dto
|
||||
AttachStudios(dto, item);
|
||||
}
|
||||
|
||||
AttachBasicFields(dto, item, owner, options, artistsBatch);
|
||||
AttachBasicFields(dto, item, owner, options, artistsBatch, user);
|
||||
|
||||
if (options.ContainsField(ItemFields.CanDelete))
|
||||
{
|
||||
@@ -538,7 +547,8 @@ namespace Emby.Server.Implementations.Dto
|
||||
DtoOptions options,
|
||||
UserItemData? userData = null,
|
||||
Dictionary<Guid, int>? childCountBatch = null,
|
||||
Dictionary<Guid, (int Played, int Total)>? playedCountBatch = null)
|
||||
Dictionary<Guid, (int Played, int Total)>? playedCountBatch = null,
|
||||
VersionResumeData? resumeData = null)
|
||||
{
|
||||
if (item.IsFolder)
|
||||
{
|
||||
@@ -600,6 +610,9 @@ namespace Emby.Server.Implementations.Dto
|
||||
// Use pre-fetched user data
|
||||
dto.UserData = GetUserItemDataDto(userData, item.Id);
|
||||
item.FillUserDataDtoValues(dto.UserData, userData, dto, user, options);
|
||||
|
||||
// For items with alternate versions, the most recently played version drives resume.
|
||||
resumeData?.ApplyTo(dto.UserData);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -943,7 +956,8 @@ namespace Emby.Server.Implementations.Dto
|
||||
/// <param name="owner">The owner.</param>
|
||||
/// <param name="options">The options.</param>
|
||||
/// <param name="artistsBatch">Optional pre-fetched artist lookup shared across a batch of items.</param>
|
||||
private void AttachBasicFields(BaseItemDto dto, BaseItem item, BaseItem? owner, DtoOptions options, IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null)
|
||||
/// <param name="user">The user, for per-user values such as the accessible media source count.</param>
|
||||
private void AttachBasicFields(BaseItemDto dto, BaseItem item, BaseItem? owner, DtoOptions options, IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null, User? user = null)
|
||||
{
|
||||
if (options.ContainsField(ItemFields.DateCreated))
|
||||
{
|
||||
@@ -1257,7 +1271,12 @@ namespace Emby.Server.Implementations.Dto
|
||||
|
||||
if (options.ContainsField(ItemFields.MediaSourceCount))
|
||||
{
|
||||
var mediaSourceCount = video.MediaSourceCount;
|
||||
// Match the per-user filtering of the media sources: versions the user cannot
|
||||
// access are not selectable, so they must not count towards the badge either.
|
||||
var mediaSourceCount = user is null
|
||||
|| (!video.PrimaryVersionId.HasValue && video.LinkedAlternateVersions.Length == 0 && !video.HasLocalAlternateVersions)
|
||||
? video.MediaSourceCount
|
||||
: video.GetAllVersions().Count(v => v.Id.Equals(video.Id) || v.IsVisibleStandalone(user));
|
||||
if (mediaSourceCount != 1)
|
||||
{
|
||||
dto.MediaSourceCount = mediaSourceCount;
|
||||
@@ -1539,6 +1558,21 @@ namespace Emby.Server.Implementations.Dto
|
||||
|
||||
private void AddInheritedImages(BaseItemDto dto, BaseItem item, DtoOptions options, BaseItem? owner)
|
||||
{
|
||||
if (item is UserView { ViewType: CollectionType.playlists } playlistsView
|
||||
&& options.GetImageLimit(ImageType.Primary) > 0
|
||||
&& !playlistsView.DisplayParentId.IsEmpty())
|
||||
{
|
||||
var displayParent = _libraryManager.GetItemById(playlistsView.DisplayParentId);
|
||||
var displayParentPrimaryImage = displayParent?.GetImageInfo(ImageType.Primary, 0);
|
||||
|
||||
if (displayParentPrimaryImage is not null)
|
||||
{
|
||||
dto.ImageTags?.Remove(ImageType.Primary);
|
||||
dto.ParentPrimaryImageItemId = displayParent!.Id;
|
||||
dto.ParentPrimaryImageTag = GetTagAndFillBlurhash(dto, displayParent, displayParentPrimaryImage);
|
||||
}
|
||||
}
|
||||
|
||||
if (!item.SupportsInheritedParentImages)
|
||||
{
|
||||
return;
|
||||
|
||||
@@ -127,8 +127,12 @@ namespace Emby.Server.Implementations.HttpServer
|
||||
{
|
||||
receiveResult = await _socket.ReceiveAsync(memory, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (WebSocketException ex)
|
||||
catch (Exception ex) when (ex is WebSocketException or ObjectDisposedException or OperationCanceledException)
|
||||
{
|
||||
// ObjectDisposedException/OperationCanceledException: the socket was torn
|
||||
// down underneath us (e.g. by the keep-alive watchdog after the connection
|
||||
// was declared lost). Fall through so Closed is still raised and the
|
||||
// session can release this connection.
|
||||
_logger.LogWarning("WS {IP} error receiving data: {Message}", RemoteEndPoint, ex.Message);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -691,7 +691,7 @@ namespace Emby.Server.Implementations.IO
|
||||
}
|
||||
catch (Exception ex) when (ex is UnauthorizedAccessException or DirectoryNotFoundException or SecurityException)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to enumerate path {Path}", path);
|
||||
_logger.LogWarning("Failed to enumerate path \"{Path}\": {Message}", path, ex.Message);
|
||||
return Enumerable.Empty<string>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using Jellyfin.Api.Extensions;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations.Enums;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
@@ -14,7 +15,6 @@ using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.IO;
|
||||
using MediaBrowser.Model.Querying;
|
||||
|
||||
namespace Emby.Server.Implementations.Images
|
||||
{
|
||||
@@ -28,38 +28,7 @@ namespace Emby.Server.Implementations.Images
|
||||
{
|
||||
var view = (CollectionFolder)item;
|
||||
var viewType = view.CollectionType;
|
||||
|
||||
BaseItemKind[] includeItemTypes;
|
||||
|
||||
switch (viewType)
|
||||
{
|
||||
case CollectionType.movies:
|
||||
includeItemTypes = new[] { BaseItemKind.Movie };
|
||||
break;
|
||||
case CollectionType.tvshows:
|
||||
includeItemTypes = new[] { BaseItemKind.Series };
|
||||
break;
|
||||
case CollectionType.music:
|
||||
includeItemTypes = new[] { BaseItemKind.MusicArtist }; // Music albums usually don't have dedicated backdrops, so use artist instead
|
||||
break;
|
||||
case CollectionType.musicvideos:
|
||||
includeItemTypes = new[] { BaseItemKind.MusicVideo };
|
||||
break;
|
||||
case CollectionType.books:
|
||||
includeItemTypes = new[] { BaseItemKind.Book, BaseItemKind.AudioBook };
|
||||
break;
|
||||
case CollectionType.boxsets:
|
||||
includeItemTypes = new[] { BaseItemKind.BoxSet };
|
||||
break;
|
||||
case CollectionType.homevideos:
|
||||
case CollectionType.photos:
|
||||
includeItemTypes = new[] { BaseItemKind.Video, BaseItemKind.Photo };
|
||||
break;
|
||||
default:
|
||||
includeItemTypes = new[] { BaseItemKind.Video, BaseItemKind.Audio, BaseItemKind.Photo, BaseItemKind.Movie, BaseItemKind.Series };
|
||||
break;
|
||||
}
|
||||
|
||||
var includeItemTypes = DtoExtensions.GetBaseItemKindsForCollectionType(viewType);
|
||||
var recursive = viewType != CollectionType.playlists;
|
||||
|
||||
return view.GetItemList(new InternalItemsQuery
|
||||
@@ -67,12 +36,9 @@ namespace Emby.Server.Implementations.Images
|
||||
CollapseBoxSetItems = false,
|
||||
Recursive = recursive,
|
||||
DtoOptions = new DtoOptions(false),
|
||||
ImageTypes = new[] { ImageType.Primary },
|
||||
ImageTypes = [ImageType.Primary],
|
||||
Limit = 8,
|
||||
OrderBy = new[]
|
||||
{
|
||||
(ItemSortBy.Random, SortOrder.Ascending)
|
||||
},
|
||||
OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)],
|
||||
IncludeItemTypes = includeItemTypes
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Controller.Chapters;
|
||||
@@ -52,26 +51,33 @@ public class ExternalDataManager : IExternalDataManager
|
||||
/// <inheritdoc/>
|
||||
public async Task DeleteExternalItemDataAsync(BaseItem item, CancellationToken cancellationToken)
|
||||
{
|
||||
var validPaths = _pathManager.GetExtractedDataPaths(item).Where(Directory.Exists).ToList();
|
||||
var itemId = item.Id;
|
||||
if (validPaths.Count > 0)
|
||||
{
|
||||
foreach (var path in validPaths)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(path, true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning("Unable to prune external item data at {Path}: {Exception}", path, ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
DeleteExternalItemFiles(item);
|
||||
|
||||
var itemId = item.Id;
|
||||
await _keyframeManager.DeleteKeyframeDataAsync(itemId, cancellationToken).ConfigureAwait(false);
|
||||
await _mediaSegmentManager.DeleteSegmentsAsync(itemId, cancellationToken).ConfigureAwait(false);
|
||||
await _trickplayManager.DeleteTrickplayDataAsync(itemId, cancellationToken).ConfigureAwait(false);
|
||||
await _chapterManager.DeleteChapterDataAsync(itemId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void DeleteExternalItemFiles(BaseItem item)
|
||||
{
|
||||
foreach (var path in _pathManager.GetExtractedDataPaths(item))
|
||||
{
|
||||
if (!Directory.Exists(path))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Directory.Delete(path, true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning("Unable to prune external item data at {Path}: {Exception}", path, ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +89,7 @@ namespace Emby.Server.Implementations.Library
|
||||
private readonly FastConcurrentLru<Guid, BaseItem> _cache;
|
||||
private readonly DotIgnoreIgnoreRule _dotIgnoreIgnoreRule;
|
||||
private readonly IMediaStreamRepository _mediaStreamRepository;
|
||||
private readonly Lazy<IExternalDataManager> _externalDataManagerFactory;
|
||||
|
||||
/// <summary>
|
||||
/// The _root folder sync lock.
|
||||
@@ -132,6 +133,7 @@ namespace Emby.Server.Implementations.Library
|
||||
/// <param name="pathManager">The path manager.</param>
|
||||
/// <param name="dotIgnoreIgnoreRule">The .ignore rule handler.</param>
|
||||
/// <param name="mediaStreamRepository">The media stream repository.</param>
|
||||
/// <param name="externalDataManagerFactory">The external data manager (lazy, to break the DI cycle through ChapterManager).</param>
|
||||
public LibraryManager(
|
||||
IServerApplicationHost appHost,
|
||||
ILoggerFactory loggerFactory,
|
||||
@@ -155,7 +157,8 @@ namespace Emby.Server.Implementations.Library
|
||||
IPeopleRepository peopleRepository,
|
||||
IPathManager pathManager,
|
||||
DotIgnoreIgnoreRule dotIgnoreIgnoreRule,
|
||||
IMediaStreamRepository mediaStreamRepository)
|
||||
IMediaStreamRepository mediaStreamRepository,
|
||||
Lazy<IExternalDataManager> externalDataManagerFactory)
|
||||
{
|
||||
_appHost = appHost;
|
||||
_logger = loggerFactory.CreateLogger<LibraryManager>();
|
||||
@@ -186,6 +189,7 @@ namespace Emby.Server.Implementations.Library
|
||||
_configurationManager.ConfigurationUpdated += ConfigurationUpdated;
|
||||
|
||||
_mediaStreamRepository = mediaStreamRepository;
|
||||
_externalDataManagerFactory = externalDataManagerFactory;
|
||||
|
||||
RecordConfigurationValues(_configurationManager.Configuration);
|
||||
}
|
||||
@@ -396,6 +400,12 @@ namespace Emby.Server.Implementations.Library
|
||||
}
|
||||
}
|
||||
|
||||
var externalDataManager = _externalDataManagerFactory.Value;
|
||||
foreach (var (item, _, _) in pathMaps)
|
||||
{
|
||||
externalDataManager.DeleteExternalItemFiles(item);
|
||||
}
|
||||
|
||||
_persistenceService.DeleteItem([.. pathMaps.Select(f => f.Item.Id)]);
|
||||
}
|
||||
|
||||
@@ -576,6 +586,13 @@ namespace Emby.Server.Implementations.Library
|
||||
|
||||
item.SetParent(null);
|
||||
|
||||
var externalDataManager = _externalDataManagerFactory.Value;
|
||||
externalDataManager.DeleteExternalItemFiles(item);
|
||||
foreach (var child in children)
|
||||
{
|
||||
externalDataManager.DeleteExternalItemFiles(child);
|
||||
}
|
||||
|
||||
_persistenceService.DeleteItem([item.Id, .. children.Select(f => f.Id)]);
|
||||
_cache.TryRemove(item.Id, out _);
|
||||
foreach (var child in children)
|
||||
@@ -1987,7 +2004,8 @@ namespace Emby.Server.Implementations.Library
|
||||
query.TopParentIds.Length == 0 &&
|
||||
string.IsNullOrEmpty(query.AncestorWithPresentationUniqueKey) &&
|
||||
string.IsNullOrEmpty(query.SeriesPresentationUniqueKey) &&
|
||||
query.ItemIds.Length == 0)
|
||||
query.ItemIds.Length == 0 &&
|
||||
query.OwnerIds.Length == 0)
|
||||
{
|
||||
var userViews = UserViewManager.GetUserViews(new UserViewQuery
|
||||
{
|
||||
@@ -2432,8 +2450,14 @@ namespace Emby.Server.Implementations.Library
|
||||
var outdated = forceUpdate
|
||||
? item.ImageInfos.Where(i => i.Path is not null).ToArray()
|
||||
: item.ImageInfos.Where(ImageNeedsRefresh).ToArray();
|
||||
// Skip image processing if current or live tv source
|
||||
if (outdated.Length == 0 || item.SourceType != SourceType.Library)
|
||||
|
||||
var parentItem = item.GetParent();
|
||||
var isLiveTvShow = item.SourceType != SourceType.Library &&
|
||||
parentItem is not null &&
|
||||
parentItem.SourceType != SourceType.Library; // not a channel
|
||||
|
||||
// Skip image processing if current or live tv show
|
||||
if (outdated.Length == 0 || isLiveTvShow)
|
||||
{
|
||||
RegisterItem(item);
|
||||
return;
|
||||
|
||||
@@ -229,7 +229,11 @@ namespace Emby.Server.Implementations.Library
|
||||
list.Add(source);
|
||||
}
|
||||
|
||||
return SortMediaSources(list).ToArray();
|
||||
var preferredId = mediaSources.Count > 0 && Guid.TryParse(mediaSources[0].Id, out var topSourceId)
|
||||
? topSourceId
|
||||
: item.Id;
|
||||
|
||||
return SortMediaSources(list, preferredId).ToArray();
|
||||
}
|
||||
|
||||
/// <inheritdoc />>
|
||||
@@ -386,6 +390,12 @@ namespace Emby.Server.Implementations.Library
|
||||
|
||||
if (user is not null)
|
||||
{
|
||||
sources = sources
|
||||
.Where(source => !Guid.TryParse(source.Id, out var sourceId)
|
||||
|| sourceId.Equals(item.Id)
|
||||
|| _libraryManager.GetItemById<BaseItem>(sourceId, user) is not null)
|
||||
.ToArray();
|
||||
|
||||
foreach (var source in sources)
|
||||
{
|
||||
SetDefaultAudioAndSubtitleStreamIndices(item, source, user);
|
||||
@@ -400,6 +410,69 @@ namespace Emby.Server.Implementations.Library
|
||||
source.SupportsDirectStream = user.HasPermission(PermissionKind.EnablePlaybackRemuxing);
|
||||
}
|
||||
}
|
||||
|
||||
sources = SetAlternateVersionResumeStates(item, sources, user);
|
||||
}
|
||||
|
||||
return sources;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Populates each source's own playback position for the user and, when the queried item is a
|
||||
/// primary, moves the most recently played version to the front so that resuming without an
|
||||
/// explicit source selection plays the version that was last watched. A directly queried
|
||||
/// alternate version keeps its own source first.
|
||||
/// </summary>
|
||||
/// <param name="item">The queried item.</param>
|
||||
/// <param name="sources">The item's media sources.</param>
|
||||
/// <param name="user">The user.</param>
|
||||
/// <returns>The media sources, reordered when a version drives resume.</returns>
|
||||
private IReadOnlyList<MediaSourceInfo> SetAlternateVersionResumeStates(BaseItem item, IReadOnlyList<MediaSourceInfo> sources, User user)
|
||||
{
|
||||
// For a video, multiple sources means alternate versions.
|
||||
if (item is not Video video || sources.Count < 2)
|
||||
{
|
||||
return sources;
|
||||
}
|
||||
|
||||
var versions = video.GetAllVersions();
|
||||
if (versions.Count < 2)
|
||||
{
|
||||
return sources;
|
||||
}
|
||||
|
||||
var userDataByVersion = _userDataManager.GetUserDataBatch(versions, user);
|
||||
var dataBySourceId = new Dictionary<string, UserItemData>(versions.Count, StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var version in versions)
|
||||
{
|
||||
if (userDataByVersion.TryGetValue(version.Id, out var data))
|
||||
{
|
||||
dataBySourceId[version.Id.ToString("N", CultureInfo.InvariantCulture)] = data;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var source in sources)
|
||||
{
|
||||
if (source.Id is not null
|
||||
&& dataBySourceId.TryGetValue(source.Id, out var data)
|
||||
&& data.PlaybackPositionTicks > 0)
|
||||
{
|
||||
source.PlaybackPositionTicks = data.PlaybackPositionTicks;
|
||||
}
|
||||
}
|
||||
|
||||
// Reorder only for a resumable (in-progress) version;
|
||||
// a completed version has no position to resume, so it must not be pulled to the front here.
|
||||
var resumeSource = VersionPlaybackSelector.SelectMostRecentlyPlayed(
|
||||
sources,
|
||||
source => source.Id is not null ? dataBySourceId.GetValueOrDefault(source.Id) : null,
|
||||
data => data.PlaybackPositionTicks > 0);
|
||||
|
||||
if (resumeSource is not null && !video.PrimaryVersionId.HasValue && !ReferenceEquals(sources[0], resumeSource))
|
||||
{
|
||||
var reordered = new List<MediaSourceInfo>(sources.Count) { resumeSource };
|
||||
reordered.AddRange(sources.Where(s => !ReferenceEquals(s, resumeSource)));
|
||||
return reordered;
|
||||
}
|
||||
|
||||
return sources;
|
||||
@@ -540,24 +613,32 @@ namespace Emby.Server.Implementations.Library
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<MediaSourceInfo> SortMediaSources(IEnumerable<MediaSourceInfo> sources)
|
||||
private static IEnumerable<MediaSourceInfo> SortMediaSources(IEnumerable<MediaSourceInfo> sources, Guid preferredItemId = default)
|
||||
{
|
||||
return sources.OrderBy(i =>
|
||||
{
|
||||
if (i.VideoType.HasValue && i.VideoType.Value == VideoType.VideoFile)
|
||||
// The source belonging to the queried item sorts first so it stays the default that gets played.
|
||||
var preferredId = preferredItemId.IsEmpty()
|
||||
? null
|
||||
: preferredItemId.ToString("N", CultureInfo.InvariantCulture);
|
||||
|
||||
return sources
|
||||
.OrderByDescending(i => preferredId is not null && string.Equals(i.Id, preferredId, StringComparison.OrdinalIgnoreCase))
|
||||
.ThenBy(i =>
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
if (i.VideoType.HasValue && i.VideoType.Value == VideoType.VideoFile)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return 1;
|
||||
}).ThenBy(i => i.Video3DFormat.HasValue ? 1 : 0)
|
||||
.ThenByDescending(i =>
|
||||
{
|
||||
var stream = i.VideoStream;
|
||||
return 1;
|
||||
})
|
||||
.ThenBy(i => i.Video3DFormat.HasValue ? 1 : 0)
|
||||
.ThenByDescending(i =>
|
||||
{
|
||||
var stream = i.VideoStream;
|
||||
|
||||
return stream?.Width ?? 0;
|
||||
})
|
||||
.Where(i => i.Type != MediaSourceType.Placeholder);
|
||||
return stream?.Width ?? 0;
|
||||
})
|
||||
.Where(i => i.Type != MediaSourceType.Placeholder);
|
||||
}
|
||||
|
||||
public async Task<Tuple<LiveStreamResponse, IDirectStreamProvider>> OpenLiveStreamInternal(LiveStreamRequest request, CancellationToken cancellationToken)
|
||||
|
||||
@@ -121,7 +121,11 @@ public class PathManager : IPathManager
|
||||
}
|
||||
|
||||
paths.Add(GetTrickplayDirectory(item, false));
|
||||
paths.Add(GetTrickplayDirectory(item, true));
|
||||
if (!string.IsNullOrEmpty(item.Path))
|
||||
{
|
||||
paths.Add(GetTrickplayDirectory(item, true));
|
||||
}
|
||||
|
||||
paths.Add(GetChapterImageFolderPath(item));
|
||||
|
||||
return paths;
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
#nullable disable
|
||||
|
||||
#pragma warning disable CS1591
|
||||
|
||||
using System;
|
||||
@@ -18,7 +16,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Books
|
||||
{
|
||||
private readonly string[] _validExtensions = { ".azw", ".azw3", ".cb7", ".cbr", ".cbt", ".cbz", ".epub", ".mobi", ".pdf" };
|
||||
|
||||
protected override Book Resolve(ItemResolveArgs args)
|
||||
protected override Book? Resolve(ItemResolveArgs args)
|
||||
{
|
||||
var collectionType = args.GetCollectionType();
|
||||
|
||||
@@ -47,13 +45,14 @@ namespace Emby.Server.Implementations.Library.Resolvers.Books
|
||||
Path = args.Path,
|
||||
Name = result.Name ?? string.Empty,
|
||||
IndexNumber = result.Index,
|
||||
ParentIndexNumber = result.ParentIndex,
|
||||
ProductionYear = result.Year,
|
||||
SeriesName = result.SeriesName ?? Path.GetFileName(Path.GetDirectoryName(args.Path)),
|
||||
IsInMixedFolder = true,
|
||||
};
|
||||
}
|
||||
|
||||
private Book GetBook(ItemResolveArgs args)
|
||||
private Book? GetBook(ItemResolveArgs args)
|
||||
{
|
||||
var bookFiles = args.FileSystemChildren.Where(f =>
|
||||
{
|
||||
@@ -78,6 +77,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Books
|
||||
Path = bookFiles[0].FullName,
|
||||
Name = result.Name ?? string.Empty,
|
||||
IndexNumber = result.Index,
|
||||
ParentIndexNumber = result.ParentIndex,
|
||||
ProductionYear = result.Year,
|
||||
SeriesName = result.SeriesName ?? string.Empty,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#nullable disable
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Jellyfin.Data.Enums;
|
||||
@@ -46,7 +47,16 @@ namespace Emby.Server.Implementations.Library.Resolvers
|
||||
}
|
||||
|
||||
// It's a directory-based playlist if the directory contains a playlist file
|
||||
var filePaths = Directory.EnumerateFiles(args.Path, "*", new EnumerationOptions { IgnoreInaccessible = true });
|
||||
IEnumerable<string> filePaths;
|
||||
try
|
||||
{
|
||||
filePaths = Directory.EnumerateFiles(args.Path, "*", new EnumerationOptions { IgnoreInaccessible = true });
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (filePaths.Any(f => f.EndsWith(PlaylistXmlSaver.DefaultPlaylistFilename, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return new Playlist
|
||||
|
||||
@@ -57,6 +57,11 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
|
||||
return null;
|
||||
}
|
||||
|
||||
if (args.Parent is not null && args.Parent.IsRoot)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var seriesInfo = Naming.TV.SeriesResolver.Resolve(_namingOptions, args.Path);
|
||||
|
||||
var collectionType = args.GetCollectionType();
|
||||
@@ -69,7 +74,8 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
|
||||
return new Series
|
||||
{
|
||||
Path = args.Path,
|
||||
Name = seriesInfo.Name
|
||||
Name = seriesInfo.Name,
|
||||
ProductionYear = seriesInfo.Year
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
458
Emby.Server.Implementations/Library/Search/SearchManager.cs
Normal file
458
Emby.Server.Implementations/Library/Search/SearchManager.cs
Normal file
@@ -0,0 +1,458 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Controller.Dto;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Persistence;
|
||||
using MediaBrowser.Model.Querying;
|
||||
using MediaBrowser.Model.Search;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Emby.Server.Implementations.Library.Search;
|
||||
|
||||
/// <summary>
|
||||
/// Manages search providers and orchestrates search operations.
|
||||
/// </summary>
|
||||
public class SearchManager : ISearchManager
|
||||
{
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
|
||||
private readonly IItemQueryHelpers _queryHelpers;
|
||||
private readonly ILogger<SearchManager> _logger;
|
||||
private IExternalSearchProvider[] _externalProviders = [];
|
||||
private IInternalSearchProvider[] _internalProviders = [];
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SearchManager"/> class.
|
||||
/// </summary>
|
||||
/// <param name="libraryManager">The library manager.</param>
|
||||
/// <param name="userManager">The user manager.</param>
|
||||
/// <param name="dbProvider">The database context factory.</param>
|
||||
/// <param name="queryHelpers">The shared item query helpers.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public SearchManager(
|
||||
ILibraryManager libraryManager,
|
||||
IUserManager userManager,
|
||||
IDbContextFactory<JellyfinDbContext> dbProvider,
|
||||
IItemQueryHelpers queryHelpers,
|
||||
ILogger<SearchManager> logger)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
_userManager = userManager;
|
||||
_dbProvider = dbProvider;
|
||||
_queryHelpers = queryHelpers;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void AddParts(IEnumerable<ISearchProvider> providers)
|
||||
{
|
||||
var allProviders = providers.OrderBy(p => p.Priority).ToArray();
|
||||
|
||||
_externalProviders = allProviders.OfType<IExternalSearchProvider>().ToArray();
|
||||
_internalProviders = allProviders.OfType<IInternalSearchProvider>().ToArray();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Registered {ExternalCount} external search providers: {ExternalProviders}. Fallback providers: {FallbackProviders}",
|
||||
_externalProviders.Length,
|
||||
string.Join(", ", _externalProviders.Select(p => $"{p.Name} (priority {p.Priority})")),
|
||||
string.Join(", ", _internalProviders.Select(p => $"{p.Name} (priority {p.Priority})")));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IReadOnlyList<ISearchProvider> GetProviders()
|
||||
{
|
||||
return [.. _externalProviders, .. _internalProviders];
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IReadOnlyList<SearchResult>> GetSearchResultsAsync(
|
||||
SearchProviderQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(query.SearchTerm);
|
||||
|
||||
var searchTerm = query.SearchTerm.Trim().RemoveDiacritics();
|
||||
|
||||
var externalTask = CollectFromProvidersAsync(_externalProviders, query, searchTerm, cancellationToken);
|
||||
var internalTask = _internalProviders.Length > 0
|
||||
? CollectFromProvidersAsync(_internalProviders, query, searchTerm, cancellationToken)
|
||||
: Task.FromResult<IReadOnlyList<SearchResult>>([]);
|
||||
|
||||
await Task.WhenAll(externalTask, internalTask).ConfigureAwait(false);
|
||||
|
||||
var externalResults = await externalTask.ConfigureAwait(false);
|
||||
var fromExternal = externalResults.Count > 0;
|
||||
IReadOnlyList<SearchResult> results;
|
||||
if (fromExternal)
|
||||
{
|
||||
results = externalResults;
|
||||
}
|
||||
else
|
||||
{
|
||||
results = await internalTask.ConfigureAwait(false);
|
||||
if (_internalProviders.Length > 0)
|
||||
{
|
||||
_logger.LogDebug("No results from external providers, using internal provider results");
|
||||
}
|
||||
}
|
||||
|
||||
// Internal providers apply user-access filtering inline in their queries. External
|
||||
// providers don't know about user permissions, so they may return IDs from hidden
|
||||
// libraries or items the user is otherwise blocked from. Run the post-filter only
|
||||
// when results came from externals to close that gap. The Items controller's second
|
||||
// roundtrip via folder.GetItems applies most of these again, but it does not restrict
|
||||
// by TopParentIds when ItemIds is set.
|
||||
if (fromExternal && results.Count > 0 && query.UserId.HasValue && !query.UserId.Value.IsEmpty())
|
||||
{
|
||||
var user = _userManager.GetUserById(query.UserId.Value);
|
||||
if (user is not null)
|
||||
{
|
||||
results = await FilterByUserAccessAsync(results, user, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<SearchResult>> FilterByUserAccessAsync(
|
||||
IReadOnlyList<SearchResult> candidates,
|
||||
User user,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// SetUser populates parental rating + blocked/allowed tags. ConfigureUserAccess populates
|
||||
// TopParentIds for the user's accessible libraries — we call it before assigning ItemIds
|
||||
// because LibraryManager.AddUserToQuery skips TopParentIds when ItemIds is non-empty.
|
||||
var accessFilter = new InternalItemsQuery(user);
|
||||
_libraryManager.ConfigureUserAccess(accessFilter, user);
|
||||
|
||||
Guid[] candidateIds = [.. candidates.Select(c => c.ItemId)];
|
||||
|
||||
var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
var baseQuery = dbContext.BaseItems
|
||||
.AsNoTracking()
|
||||
.WhereOneOrMany(candidateIds, e => e.Id);
|
||||
|
||||
baseQuery = _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, accessFilter);
|
||||
|
||||
var allowedCount = await baseQuery.CountAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (allowedCount == candidates.Count)
|
||||
{
|
||||
return candidates;
|
||||
}
|
||||
|
||||
var allowedIds = await baseQuery
|
||||
.Select(e => e.Id)
|
||||
.ToHashSetAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var filtered = candidates.Where(c => allowedIds.Contains(c.ItemId)).ToList();
|
||||
if (filtered.Count < candidates.Count)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Dropped {Dropped} of {Total} search candidates due to user access filtering",
|
||||
candidates.Count - filtered.Count,
|
||||
candidates.Count);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<QueryResult<SearchHintInfo>> GetSearchHintsAsync(SearchQuery query, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(query.SearchTerm);
|
||||
|
||||
var providerQuery = BuildProviderQuery(query);
|
||||
var candidates = await GetSearchResultsAsync(providerQuery, cancellationToken).ConfigureAwait(false);
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
return new QueryResult<SearchHintInfo>();
|
||||
}
|
||||
|
||||
var candidateScores = BuildScoreLookup(candidates);
|
||||
var user = query.UserId.IsEmpty() ? null : _userManager.GetUserById(query.UserId);
|
||||
|
||||
var excludeItemTypes = BuildExcludeItemTypes(query);
|
||||
var includeItemTypes = BuildIncludeItemTypes(query);
|
||||
|
||||
var internalQuery = new InternalItemsQuery(user)
|
||||
{
|
||||
ItemIds = candidateScores.Keys.ToArray(),
|
||||
ExcludeItemTypes = excludeItemTypes.ToArray(),
|
||||
IncludeItemTypes = includeItemTypes.Count > 0 ? includeItemTypes.ToArray() : [],
|
||||
MediaTypes = query.MediaTypes.ToArray(),
|
||||
IncludeItemsByName = !query.ParentId.HasValue,
|
||||
ParentId = query.ParentId ?? Guid.Empty,
|
||||
Recursive = true,
|
||||
IsKids = query.IsKids,
|
||||
IsMovie = query.IsMovie,
|
||||
IsNews = query.IsNews,
|
||||
IsSeries = query.IsSeries,
|
||||
IsSports = query.IsSports,
|
||||
DtoOptions = new DtoOptions
|
||||
{
|
||||
Fields =
|
||||
[
|
||||
ItemFields.AirTime,
|
||||
ItemFields.DateCreated,
|
||||
ItemFields.ChannelInfo,
|
||||
ItemFields.ParentId
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
// MusicArtist items are "ItemsByName" entities - virtual items that aggregate content by artist name
|
||||
// rather than being stored as regular library items. They require special handling:
|
||||
// 1. Convert ParentId to AncestorIds (to filter by library folder)
|
||||
// 2. Set IncludeItemsByName = true (to include these virtual items in results)
|
||||
// 3. Clear IncludeItemTypes (GetAllArtists handles type filtering internally)
|
||||
// 4. Use GetAllArtists() instead of GetItemList() to query the artist index
|
||||
IReadOnlyList<BaseItem> items;
|
||||
if (internalQuery.IncludeItemTypes.Length == 1 && internalQuery.IncludeItemTypes[0] == BaseItemKind.MusicArtist)
|
||||
{
|
||||
if (!internalQuery.ParentId.IsEmpty())
|
||||
{
|
||||
internalQuery.AncestorIds = [internalQuery.ParentId];
|
||||
internalQuery.ParentId = Guid.Empty;
|
||||
}
|
||||
|
||||
internalQuery.IncludeItemsByName = true;
|
||||
internalQuery.IncludeItemTypes = [];
|
||||
items = _libraryManager.GetAllArtists(internalQuery).Items.Select(i => i.Item).ToList();
|
||||
}
|
||||
else
|
||||
{
|
||||
items = _libraryManager.GetItemList(internalQuery);
|
||||
}
|
||||
|
||||
var orderedResults = items
|
||||
.Select(item => new SearchHintInfo { Item = item })
|
||||
.OrderByDescending(hint => candidateScores.GetValueOrDefault(hint.Item.Id, 0f))
|
||||
.ToList();
|
||||
|
||||
var totalCount = orderedResults.Count;
|
||||
|
||||
if (query.StartIndex.HasValue)
|
||||
{
|
||||
orderedResults = orderedResults.Skip(query.StartIndex.Value).ToList();
|
||||
}
|
||||
|
||||
if (query.Limit.HasValue)
|
||||
{
|
||||
orderedResults = orderedResults.Take(query.Limit.Value).ToList();
|
||||
}
|
||||
|
||||
return new QueryResult<SearchHintInfo>(query.StartIndex, totalCount, orderedResults);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<SearchResult>> CollectFromProvidersAsync(
|
||||
IEnumerable<ISearchProvider> providers,
|
||||
SearchProviderQuery providerQuery,
|
||||
string searchTerm,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var requestedLimit = providerQuery.Limit ?? 100;
|
||||
var applicable = providers.Where(p => p.CanSearch(providerQuery)).ToArray();
|
||||
if (applicable.Length == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var perProvider = await Task.WhenAll(
|
||||
applicable.Select(p => CollectFromProviderAsync(p, providerQuery, searchTerm, requestedLimit, cancellationToken)))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var bestScores = new Dictionary<Guid, float>();
|
||||
foreach (var providerResults in perProvider)
|
||||
{
|
||||
foreach (var result in providerResults)
|
||||
{
|
||||
UpdateBestScore(bestScores, result);
|
||||
}
|
||||
}
|
||||
|
||||
return bestScores
|
||||
.Select(kvp => new SearchResult(kvp.Key, kvp.Value))
|
||||
.OrderByDescending(r => r.Score)
|
||||
.Take(requestedLimit)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<SearchResult>> CollectFromProviderAsync(
|
||||
ISearchProvider provider,
|
||||
SearchProviderQuery providerQuery,
|
||||
string searchTerm,
|
||||
int requestedLimit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var results = provider is IExternalSearchProvider externalProvider
|
||||
? await CollectFromExternalProviderAsync(externalProvider, providerQuery, requestedLimit, cancellationToken).ConfigureAwait(false)
|
||||
: await provider.SearchAsync(providerQuery, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Provider {Provider} returned {Count} candidates for search term '{SearchTerm}'",
|
||||
provider.Name,
|
||||
results.Count,
|
||||
searchTerm);
|
||||
return results;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Search provider {Provider} failed for term '{SearchTerm}'", provider.Name, searchTerm);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<SearchResult>> CollectFromExternalProviderAsync(
|
||||
IExternalSearchProvider provider,
|
||||
SearchProviderQuery providerQuery,
|
||||
int requestedLimit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var results = new List<SearchResult>();
|
||||
await foreach (var result in provider.SearchAsync(providerQuery, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
results.Add(result);
|
||||
if (results.Count >= requestedLimit)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static void UpdateBestScore(Dictionary<Guid, float> bestScores, SearchResult result)
|
||||
{
|
||||
if (!bestScores.TryGetValue(result.ItemId, out var existingScore) || result.Score > existingScore)
|
||||
{
|
||||
bestScores[result.ItemId] = result.Score;
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<Guid, float> BuildScoreLookup(IReadOnlyList<SearchResult> results)
|
||||
{
|
||||
var lookup = new Dictionary<Guid, float>(results.Count);
|
||||
foreach (var result in results)
|
||||
{
|
||||
lookup[result.ItemId] = result.Score;
|
||||
}
|
||||
|
||||
return lookup;
|
||||
}
|
||||
|
||||
private static SearchProviderQuery BuildProviderQuery(SearchQuery query)
|
||||
{
|
||||
var excludeItemTypes = BuildExcludeItemTypes(query);
|
||||
var includeItemTypes = BuildIncludeItemTypes(query);
|
||||
|
||||
// Remove any excluded types from includes
|
||||
if (includeItemTypes.Count > 0 && excludeItemTypes.Count > 0)
|
||||
{
|
||||
includeItemTypes.RemoveAll(excludeItemTypes.Contains);
|
||||
}
|
||||
|
||||
return new SearchProviderQuery
|
||||
{
|
||||
SearchTerm = query.SearchTerm,
|
||||
UserId = query.UserId.IsEmpty() ? null : query.UserId,
|
||||
IncludeItemTypes = includeItemTypes.ToArray(),
|
||||
ExcludeItemTypes = excludeItemTypes.ToArray(),
|
||||
MediaTypes = query.MediaTypes.ToArray(),
|
||||
Limit = query.Limit,
|
||||
ParentId = query.ParentId
|
||||
};
|
||||
}
|
||||
|
||||
private static List<BaseItemKind> BuildExcludeItemTypes(SearchQuery query)
|
||||
{
|
||||
var excludeItemTypes = query.ExcludeItemTypes.ToList();
|
||||
|
||||
excludeItemTypes.Add(BaseItemKind.Year);
|
||||
excludeItemTypes.Add(BaseItemKind.Folder);
|
||||
excludeItemTypes.Add(BaseItemKind.CollectionFolder);
|
||||
|
||||
if (!query.IncludeGenres)
|
||||
{
|
||||
AddIfMissing(excludeItemTypes, BaseItemKind.Genre);
|
||||
AddIfMissing(excludeItemTypes, BaseItemKind.MusicGenre);
|
||||
}
|
||||
|
||||
if (!query.IncludePeople)
|
||||
{
|
||||
AddIfMissing(excludeItemTypes, BaseItemKind.Person);
|
||||
}
|
||||
|
||||
if (!query.IncludeStudios)
|
||||
{
|
||||
AddIfMissing(excludeItemTypes, BaseItemKind.Studio);
|
||||
}
|
||||
|
||||
if (!query.IncludeArtists)
|
||||
{
|
||||
AddIfMissing(excludeItemTypes, BaseItemKind.MusicArtist);
|
||||
}
|
||||
|
||||
return excludeItemTypes;
|
||||
}
|
||||
|
||||
private static List<BaseItemKind> BuildIncludeItemTypes(SearchQuery query)
|
||||
{
|
||||
var includeItemTypes = query.IncludeItemTypes.ToList();
|
||||
if (query.IncludeMedia)
|
||||
{
|
||||
return includeItemTypes;
|
||||
}
|
||||
|
||||
if (query.IncludeGenres && IsEmptyOrContains(includeItemTypes, BaseItemKind.Genre))
|
||||
{
|
||||
AddIfMissing(includeItemTypes, BaseItemKind.Genre);
|
||||
AddIfMissing(includeItemTypes, BaseItemKind.MusicGenre);
|
||||
}
|
||||
|
||||
if (query.IncludePeople && IsEmptyOrContains(includeItemTypes, BaseItemKind.Person))
|
||||
{
|
||||
AddIfMissing(includeItemTypes, BaseItemKind.Person);
|
||||
}
|
||||
|
||||
if (query.IncludeStudios && IsEmptyOrContains(includeItemTypes, BaseItemKind.Studio))
|
||||
{
|
||||
AddIfMissing(includeItemTypes, BaseItemKind.Studio);
|
||||
}
|
||||
|
||||
if (query.IncludeArtists && IsEmptyOrContains(includeItemTypes, BaseItemKind.MusicArtist))
|
||||
{
|
||||
AddIfMissing(includeItemTypes, BaseItemKind.MusicArtist);
|
||||
}
|
||||
|
||||
return includeItemTypes;
|
||||
}
|
||||
|
||||
private static bool IsEmptyOrContains(List<BaseItemKind> list, BaseItemKind value)
|
||||
=> list.Count == 0 || list.Contains(value);
|
||||
|
||||
private static void AddIfMissing(List<BaseItemKind> list, BaseItemKind value)
|
||||
{
|
||||
if (!list.Contains(value))
|
||||
{
|
||||
list.Add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
230
Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs
Normal file
230
Emby.Server.Implementations/Library/Search/SqlSearchProvider.cs
Normal file
@@ -0,0 +1,230 @@
|
||||
#pragma warning disable RS0030 // Do not use banned APIs
|
||||
#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Persistence;
|
||||
using MediaBrowser.Model.Configuration;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Emby.Server.Implementations.Library.Search;
|
||||
|
||||
/// <summary>
|
||||
/// Built-in SQL-based search provider that queries the library database directly.
|
||||
/// </summary>
|
||||
public class SqlSearchProvider : IInternalSearchProvider
|
||||
{
|
||||
private const int DefaultSearchLimit = 100;
|
||||
private const float ExactMatchScore = 100f;
|
||||
private const float PrefixMatchScore = 80f;
|
||||
private const float WordPrefixMatchScore = 75f;
|
||||
private const float ContainsMatchScore = 50f;
|
||||
|
||||
private static readonly Guid _placeholderId = Guid.Parse("00000000-0000-0000-0000-000000000001");
|
||||
|
||||
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
|
||||
private readonly IItemTypeLookup _itemTypeLookup;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly IItemQueryHelpers _queryHelpers;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SqlSearchProvider"/> class.
|
||||
/// </summary>
|
||||
/// <param name="dbProvider">The database context factory.</param>
|
||||
/// <param name="itemTypeLookup">The item type lookup.</param>
|
||||
/// <param name="libraryManager">The library manager.</param>
|
||||
/// <param name="userManager">The user manager.</param>
|
||||
/// <param name="queryHelpers">The shared item query helpers.</param>
|
||||
public SqlSearchProvider(
|
||||
IDbContextFactory<JellyfinDbContext> dbProvider,
|
||||
IItemTypeLookup itemTypeLookup,
|
||||
ILibraryManager libraryManager,
|
||||
IUserManager userManager,
|
||||
IItemQueryHelpers queryHelpers)
|
||||
{
|
||||
_dbProvider = dbProvider;
|
||||
_itemTypeLookup = itemTypeLookup;
|
||||
_libraryManager = libraryManager;
|
||||
_userManager = userManager;
|
||||
_queryHelpers = queryHelpers;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string Name => "Database";
|
||||
|
||||
/// <inheritdoc/>
|
||||
public MetadataPluginType Type => MetadataPluginType.SearchProvider;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public int Priority => 100; // Low priority - runs as fallback
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool CanSearch(SearchProviderQuery query)
|
||||
{
|
||||
// SQL search can always handle any query
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IReadOnlyList<SearchResult>> SearchAsync(SearchProviderQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(query.SearchTerm);
|
||||
|
||||
var rawSearchTerm = query.SearchTerm.Trim().RemoveDiacritics();
|
||||
if (string.IsNullOrEmpty(rawSearchTerm))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var cleanSearchTerm = rawSearchTerm.GetCleanValue();
|
||||
if (string.IsNullOrEmpty(cleanSearchTerm))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var cleanPrefix = cleanSearchTerm + " ";
|
||||
// OriginalTitle is stored mixed-case and isn't pre-normalized like CleanName,
|
||||
// so match it via a case-insensitive LIKE rather than a per-row case conversion
|
||||
// that may not translate to SQL on every provider.
|
||||
var likeOriginal = $"%{rawSearchTerm}%";
|
||||
var limit = query.Limit ?? DefaultSearchLimit;
|
||||
|
||||
var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
// Lightweight projection: select only what's needed to score and identify items.
|
||||
var dbQuery = dbContext.BaseItems
|
||||
.AsNoTracking()
|
||||
.Where(e => e.Id != _placeholderId)
|
||||
.Where(e => !e.IsVirtualItem)
|
||||
.Where(e => e.CleanName!.Contains(cleanSearchTerm)
|
||||
|| (e.OriginalTitle != null && EF.Functions.Like(e.OriginalTitle, likeOriginal)));
|
||||
|
||||
dbQuery = ApplyTypeFilter(dbQuery, query.IncludeItemTypes, query.ExcludeItemTypes);
|
||||
dbQuery = ApplyMediaTypeFilter(dbQuery, query.MediaTypes);
|
||||
dbQuery = ApplyParentFilter(dbQuery, query.ParentId);
|
||||
dbQuery = ApplyUserAccessFilter(dbContext, dbQuery, query.UserId);
|
||||
|
||||
// Compute the score in SQL: the ternary translates to a CASE WHEN. CleanName is
|
||||
// the pre-normalized (lowercase, diacritic-stripped) form, so we score against it
|
||||
// directly without any per-row case conversion. Items that match only via
|
||||
// OriginalTitle fall through to the Contains tier.
|
||||
// Tie-break by Id for deterministic ordering so the explicit OrderBy + Take
|
||||
// satisfies EF Core's row-limiting-with-OrderBy requirement.
|
||||
var scored = dbQuery.Select(e => new
|
||||
{
|
||||
e.Id,
|
||||
Score =
|
||||
(e.CleanName == cleanSearchTerm) ? ExactMatchScore
|
||||
: e.CleanName!.StartsWith(cleanSearchTerm) ? PrefixMatchScore
|
||||
: e.CleanName!.Contains(cleanPrefix) ? WordPrefixMatchScore
|
||||
: ContainsMatchScore
|
||||
});
|
||||
|
||||
return await scored
|
||||
.OrderByDescending(x => x.Score)
|
||||
.ThenBy(x => x.Id)
|
||||
.Take(limit)
|
||||
.Select(x => new SearchResult(x.Id, x.Score))
|
||||
.ToArrayAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private IQueryable<BaseItemEntity> ApplyTypeFilter(
|
||||
IQueryable<BaseItemEntity> query,
|
||||
BaseItemKind[] includeItemTypes,
|
||||
BaseItemKind[] excludeItemTypes)
|
||||
{
|
||||
if (includeItemTypes.Length > 0)
|
||||
{
|
||||
var includeTypeNames = MapKindsToTypeNames(includeItemTypes);
|
||||
if (includeTypeNames.Count > 0)
|
||||
{
|
||||
query = query.Where(e => includeTypeNames.Contains(e.Type));
|
||||
}
|
||||
}
|
||||
else if (excludeItemTypes.Length > 0)
|
||||
{
|
||||
var excludeTypeNames = MapKindsToTypeNames(excludeItemTypes);
|
||||
if (excludeTypeNames.Count > 0)
|
||||
{
|
||||
query = query.Where(e => !excludeTypeNames.Contains(e.Type));
|
||||
}
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
private static IQueryable<BaseItemEntity> ApplyMediaTypeFilter(
|
||||
IQueryable<BaseItemEntity> query,
|
||||
MediaType[] mediaTypes)
|
||||
{
|
||||
if (mediaTypes.Length == 0)
|
||||
{
|
||||
return query;
|
||||
}
|
||||
|
||||
var mediaTypeNames = mediaTypes.Select(m => m.ToString()).ToArray();
|
||||
return query.Where(e => e.MediaType != null && mediaTypeNames.Contains(e.MediaType));
|
||||
}
|
||||
|
||||
private static IQueryable<BaseItemEntity> ApplyParentFilter(
|
||||
IQueryable<BaseItemEntity> query,
|
||||
Guid? parentId)
|
||||
{
|
||||
if (!parentId.HasValue || parentId.Value.IsEmpty())
|
||||
{
|
||||
return query;
|
||||
}
|
||||
|
||||
var pid = parentId.Value;
|
||||
return query.Where(e => e.ParentId == pid || e.Parents!.Any(p => p.ParentItemId == pid));
|
||||
}
|
||||
|
||||
private IQueryable<BaseItemEntity> ApplyUserAccessFilter(
|
||||
JellyfinDbContext dbContext,
|
||||
IQueryable<BaseItemEntity> query,
|
||||
Guid? userId)
|
||||
{
|
||||
if (!userId.HasValue || userId.Value.IsEmpty())
|
||||
{
|
||||
return query;
|
||||
}
|
||||
|
||||
var user = _userManager.GetUserById(userId.Value);
|
||||
if (user is null)
|
||||
{
|
||||
return query;
|
||||
}
|
||||
|
||||
var accessFilter = new InternalItemsQuery(user);
|
||||
_libraryManager.ConfigureUserAccess(accessFilter, user);
|
||||
return _queryHelpers.ApplyAccessFiltering(dbContext, query, accessFilter);
|
||||
}
|
||||
|
||||
private List<string> MapKindsToTypeNames(BaseItemKind[] kinds)
|
||||
{
|
||||
var list = new List<string>(kinds.Length);
|
||||
foreach (var kind in kinds)
|
||||
{
|
||||
if (_itemTypeLookup.BaseItemKindNames.TryGetValue(kind, out var name) && name is not null)
|
||||
{
|
||||
list.Add(name);
|
||||
}
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
#pragma warning disable CS1591
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Database.Implementations.Enums;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Controller.Dto;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.Querying;
|
||||
using MediaBrowser.Model.Search;
|
||||
|
||||
namespace Emby.Server.Implementations.Library
|
||||
{
|
||||
public class SearchEngine : ISearchEngine
|
||||
{
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IUserManager _userManager;
|
||||
|
||||
public SearchEngine(ILibraryManager libraryManager, IUserManager userManager)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
_userManager = userManager;
|
||||
}
|
||||
|
||||
public QueryResult<SearchHintInfo> GetSearchHints(SearchQuery query)
|
||||
{
|
||||
User? user = null;
|
||||
if (!query.UserId.IsEmpty())
|
||||
{
|
||||
user = _userManager.GetUserById(query.UserId);
|
||||
}
|
||||
|
||||
var results = GetSearchHints(query, user);
|
||||
var totalRecordCount = results.Count;
|
||||
|
||||
if (query.StartIndex.HasValue)
|
||||
{
|
||||
results = results.GetRange(query.StartIndex.Value, totalRecordCount - query.StartIndex.Value);
|
||||
}
|
||||
|
||||
if (query.Limit.HasValue && query.Limit.Value > 0)
|
||||
{
|
||||
results = results.GetRange(0, Math.Min(query.Limit.Value, results.Count));
|
||||
}
|
||||
|
||||
return new QueryResult<SearchHintInfo>(
|
||||
query.StartIndex,
|
||||
totalRecordCount,
|
||||
results);
|
||||
}
|
||||
|
||||
private static void AddIfMissing(List<BaseItemKind> list, BaseItemKind value)
|
||||
{
|
||||
if (!list.Contains(value))
|
||||
{
|
||||
list.Add(value);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the search hints.
|
||||
/// </summary>
|
||||
/// <param name="query">The query.</param>
|
||||
/// <param name="user">The user.</param>
|
||||
/// <returns>IEnumerable{SearchHintResult}.</returns>
|
||||
/// <exception cref="ArgumentException"><c>query.SearchTerm</c> is <c>null</c> or empty.</exception>
|
||||
private List<SearchHintInfo> GetSearchHints(SearchQuery query, User? user)
|
||||
{
|
||||
var searchTerm = query.SearchTerm;
|
||||
|
||||
ArgumentException.ThrowIfNullOrEmpty(searchTerm);
|
||||
|
||||
searchTerm = searchTerm.Trim().RemoveDiacritics();
|
||||
|
||||
var excludeItemTypes = query.ExcludeItemTypes.ToList();
|
||||
var includeItemTypes = query.IncludeItemTypes.ToList();
|
||||
|
||||
excludeItemTypes.Add(BaseItemKind.Year);
|
||||
excludeItemTypes.Add(BaseItemKind.Folder);
|
||||
|
||||
if (query.IncludeGenres && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.Genre)))
|
||||
{
|
||||
if (!query.IncludeMedia)
|
||||
{
|
||||
AddIfMissing(includeItemTypes, BaseItemKind.Genre);
|
||||
AddIfMissing(includeItemTypes, BaseItemKind.MusicGenre);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
AddIfMissing(excludeItemTypes, BaseItemKind.Genre);
|
||||
AddIfMissing(excludeItemTypes, BaseItemKind.MusicGenre);
|
||||
}
|
||||
|
||||
if (query.IncludePeople && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.Person)))
|
||||
{
|
||||
if (!query.IncludeMedia)
|
||||
{
|
||||
AddIfMissing(includeItemTypes, BaseItemKind.Person);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
AddIfMissing(excludeItemTypes, BaseItemKind.Person);
|
||||
}
|
||||
|
||||
if (query.IncludeStudios && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.Studio)))
|
||||
{
|
||||
if (!query.IncludeMedia)
|
||||
{
|
||||
AddIfMissing(includeItemTypes, BaseItemKind.Studio);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
AddIfMissing(excludeItemTypes, BaseItemKind.Studio);
|
||||
}
|
||||
|
||||
if (query.IncludeArtists && (includeItemTypes.Count == 0 || includeItemTypes.Contains(BaseItemKind.MusicArtist)))
|
||||
{
|
||||
if (!query.IncludeMedia)
|
||||
{
|
||||
AddIfMissing(includeItemTypes, BaseItemKind.MusicArtist);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
AddIfMissing(excludeItemTypes, BaseItemKind.MusicArtist);
|
||||
}
|
||||
|
||||
AddIfMissing(excludeItemTypes, BaseItemKind.CollectionFolder);
|
||||
AddIfMissing(excludeItemTypes, BaseItemKind.Folder);
|
||||
var mediaTypes = query.MediaTypes.ToList();
|
||||
|
||||
if (includeItemTypes.Count > 0)
|
||||
{
|
||||
excludeItemTypes.Clear();
|
||||
mediaTypes.Clear();
|
||||
}
|
||||
|
||||
var searchQuery = new InternalItemsQuery(user)
|
||||
{
|
||||
SearchTerm = searchTerm,
|
||||
ExcludeItemTypes = excludeItemTypes.ToArray(),
|
||||
IncludeItemTypes = includeItemTypes.ToArray(),
|
||||
Limit = query.Limit,
|
||||
IncludeItemsByName = !query.ParentId.HasValue,
|
||||
ParentId = query.ParentId ?? Guid.Empty,
|
||||
OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) },
|
||||
Recursive = true,
|
||||
|
||||
IsKids = query.IsKids,
|
||||
IsMovie = query.IsMovie,
|
||||
IsNews = query.IsNews,
|
||||
IsSeries = query.IsSeries,
|
||||
IsSports = query.IsSports,
|
||||
MediaTypes = mediaTypes.ToArray(),
|
||||
|
||||
DtoOptions = new DtoOptions
|
||||
{
|
||||
Fields = new ItemFields[]
|
||||
{
|
||||
ItemFields.AirTime,
|
||||
ItemFields.DateCreated,
|
||||
ItemFields.ChannelInfo,
|
||||
ItemFields.ParentId
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
IReadOnlyList<BaseItem> mediaItems;
|
||||
|
||||
if (searchQuery.IncludeItemTypes.Length == 1 && searchQuery.IncludeItemTypes[0] == BaseItemKind.MusicArtist)
|
||||
{
|
||||
if (!searchQuery.ParentId.IsEmpty())
|
||||
{
|
||||
searchQuery.AncestorIds = [searchQuery.ParentId];
|
||||
searchQuery.ParentId = Guid.Empty;
|
||||
}
|
||||
|
||||
searchQuery.IncludeItemsByName = true;
|
||||
searchQuery.IncludeItemTypes = Array.Empty<BaseItemKind>();
|
||||
mediaItems = _libraryManager.GetAllArtists(searchQuery).Items.Select(i => i.Item).ToList();
|
||||
}
|
||||
else
|
||||
{
|
||||
mediaItems = _libraryManager.GetItemList(searchQuery);
|
||||
}
|
||||
|
||||
return mediaItems.Select(i => new SearchHintInfo
|
||||
{
|
||||
Item = i
|
||||
}).ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -247,6 +247,103 @@ namespace Emby.Server.Implementations.Library
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public VersionResumeData? GetResumeUserData(User user, BaseItem item)
|
||||
{
|
||||
return GetResumeUserDataBatch([item], user).GetValueOrDefault(item.Id);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyDictionary<Guid, VersionResumeData> GetResumeUserDataBatch(IReadOnlyList<BaseItem> items, User user)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(user);
|
||||
|
||||
var result = new Dictionary<Guid, VersionResumeData>();
|
||||
|
||||
// Candidate primaries: a directly queried version (PrimaryVersionId set) keeps its own data.
|
||||
// Linked alternates are already known in memory; only the local-alternate existence check
|
||||
// would otherwise hit the database (one query per item via Video.HasLocalAlternateVersions),
|
||||
// so collect those ids and resolve them all in a single query below.
|
||||
List<Video>? candidates = null;
|
||||
List<Guid>? localProbeIds = null;
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (item is not Video video || video.PrimaryVersionId.HasValue)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
(candidates ??= []).Add(video);
|
||||
|
||||
if (video.LinkedAlternateVersions.Length == 0)
|
||||
{
|
||||
(localProbeIds ??= []).Add(video.Id);
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates is null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
HashSet<Guid>? withLocalAlternates = null;
|
||||
if (localProbeIds is not null)
|
||||
{
|
||||
using var dbContext = _repository.CreateDbContext();
|
||||
withLocalAlternates = dbContext.LinkedChildren
|
||||
.Where(lc => lc.ChildType == Jellyfin.Database.Implementations.Entities.LinkedChildType.LocalAlternateVersion
|
||||
&& localProbeIds.Contains(lc.ParentId))
|
||||
.Select(lc => lc.ParentId)
|
||||
.Distinct()
|
||||
.ToHashSet();
|
||||
}
|
||||
|
||||
List<(Guid PrimaryId, IReadOnlyList<Video> Versions)>? versionGroups = null;
|
||||
List<BaseItem>? allVersions = null;
|
||||
|
||||
foreach (var video in candidates)
|
||||
{
|
||||
// Only items that actually have alternate versions aggregate over them.
|
||||
if (video.LinkedAlternateVersions.Length == 0
|
||||
&& (withLocalAlternates is null || !withLocalAlternates.Contains(video.Id)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var versions = video.GetAllVersions();
|
||||
if (versions.Count < 2)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
(versionGroups ??= []).Add((video.Id, versions));
|
||||
(allVersions ??= []).AddRange(versions);
|
||||
}
|
||||
|
||||
if (versionGroups is null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
var userDataByVersion = GetUserDataBatch(allVersions!.DistinctBy(i => i.Id).ToList(), user);
|
||||
|
||||
foreach (var (primaryId, versions) in versionGroups)
|
||||
{
|
||||
// Consider both in-progress and completed versions so a finished alternate still marks the primary as played.
|
||||
var resumeVersion = VersionPlaybackSelector.SelectMostRecentlyPlayed(
|
||||
versions,
|
||||
version => userDataByVersion.GetValueOrDefault(version.Id),
|
||||
data => data.PlaybackPositionTicks > 0 || data.Played);
|
||||
|
||||
if (resumeVersion is not null)
|
||||
{
|
||||
result[primaryId] = new VersionResumeData(resumeVersion.Id, userDataByVersion[resumeVersion.Id]);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the internal key.
|
||||
/// </summary>
|
||||
@@ -281,6 +378,10 @@ namespace Emby.Server.Implementations.Library
|
||||
var dto = GetUserItemDataDto(userData, item.Id);
|
||||
|
||||
item.FillUserDataDtoValues(dto, userData, itemDto, user, options);
|
||||
|
||||
// For an item with alternate versions, surface the most recently played version's resume point.
|
||||
GetResumeUserData(user, item)?.ApplyTo(dto);
|
||||
|
||||
return dto;
|
||||
}
|
||||
|
||||
@@ -385,5 +486,41 @@ namespace Emby.Server.Implementations.Library
|
||||
|
||||
return playedToCompletion;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ResetPlaybackStreamSelections(User user, BaseItem item)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(user);
|
||||
ArgumentNullException.ThrowIfNull(item);
|
||||
|
||||
using var dbContext = _repository.CreateDbContext();
|
||||
var rows = dbContext.UserData
|
||||
.Where(e => e.ItemId == item.Id && e.UserId == user.Id
|
||||
&& (e.AudioStreamIndex != null || e.SubtitleStreamIndex != null))
|
||||
.ToList();
|
||||
|
||||
if (rows.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
row.AudioStreamIndex = null;
|
||||
row.SubtitleStreamIndex = null;
|
||||
}
|
||||
|
||||
dbContext.SaveChanges();
|
||||
|
||||
var cacheKey = GetCacheKey(user.InternalId, item.Id);
|
||||
if (_cache.TryGet(cacheKey, out var cached))
|
||||
{
|
||||
cached.AudioStreamIndex = null;
|
||||
cached.SubtitleStreamIndex = null;
|
||||
_cache.AddOrUpdate(cacheKey, cached);
|
||||
}
|
||||
|
||||
item.UserData = dbContext.UserData.Where(e => e.ItemId == item.Id).AsNoTracking().ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
19
Emby.Server.Implementations/Localization/Core/az.json
Normal file
19
Emby.Server.Implementations/Localization/Core/az.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"Books": "Kitablar",
|
||||
"HomeVideos": "Ev Videoları",
|
||||
"Latest": "Ən son",
|
||||
"MixedContent": "Qarışıq məzmun",
|
||||
"Movies": "Filmlər",
|
||||
"Music": "Musiqi",
|
||||
"MusicVideos": "Musiqi Videoları",
|
||||
"NameSeasonUnknown": "Mövsüm Naməlum",
|
||||
"NewVersionIsAvailable": "Jellyfin Serverin yeni versiyası yükləmək üçün əlçatandır.",
|
||||
"NotificationOptionApplicationUpdateAvailable": "Tətbiq yeniləməsi mövcuddur",
|
||||
"NotificationOptionApplicationUpdateInstalled": "Tətbiq yeniləməsi quraşdırılıb",
|
||||
"NotificationOptionAudioPlayback": "Audio oxutma başladı",
|
||||
"NotificationOptionAudioPlaybackStopped": "Audio oxutma dayandırıldı",
|
||||
"NotificationOptionCameraImageUploaded": "Kamera şəkli yükləndi",
|
||||
"NotificationOptionInstallationFailed": "Quraşdırma uğursuzluğu",
|
||||
"NotificationOptionNewLibraryContent": "Yeni məzmun əlavə edildi",
|
||||
"NotificationOptionPluginError": "Plugin uğursuzluğu"
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
"Favorites": "Favoritter",
|
||||
"Folders": "Mapper",
|
||||
"Genres": "Genrer",
|
||||
"HeaderContinueWatching": "Fortsæt afspilning",
|
||||
"HeaderContinueWatching": "Fortsæt med at se",
|
||||
"HeaderFavoriteEpisodes": "Yndlingsafsnit",
|
||||
"HeaderFavoriteShows": "Yndlingsserier",
|
||||
"HeaderLiveTV": "Live-TV",
|
||||
@@ -65,7 +65,7 @@
|
||||
"TaskDownloadMissingSubtitlesDescription": "Søger på internettet efter manglende undertekster baseret på metadata-konfigurationen.",
|
||||
"TaskDownloadMissingSubtitles": "Hent manglende undertekster",
|
||||
"TaskUpdatePluginsDescription": "Henter og installerer opdateringer for plugins, som er konfigurerede til at blive opdateret automatisk.",
|
||||
"TaskUpdatePlugins": "Opdater plugins",
|
||||
"TaskUpdatePlugins": "Opdatér plugins",
|
||||
"TaskCleanLogsDescription": "Sletter log-filer som er mere end {0} dage gamle.",
|
||||
"TaskCleanLogs": "Ryd log-mappe",
|
||||
"TaskRefreshLibraryDescription": "Scanner dit mediebibliotek for nye filer og opdateret metadata.",
|
||||
@@ -79,10 +79,10 @@
|
||||
"TaskRefreshChapterImages": "Udtræk kapitelbilleder",
|
||||
"TaskRefreshChapterImagesDescription": "Laver miniaturebilleder for videoer, der har kapitler.",
|
||||
"TaskRefreshChannelsDescription": "Opdaterer information for internetkanaler.",
|
||||
"TaskRefreshChannels": "Opdater kanaler",
|
||||
"TaskRefreshChannels": "Opdatér kanaler",
|
||||
"TaskCleanTranscodeDescription": "Fjerner omkodningsfiler, som er mere end 1 dag gamle.",
|
||||
"TaskCleanTranscode": "Tøm omkodningsmappen",
|
||||
"TaskRefreshPeople": "Opdater personer",
|
||||
"TaskRefreshPeople": "Opdatér personer",
|
||||
"TaskRefreshPeopleDescription": "Opdaterer metadata for skuespillere og instruktører i dit mediebibliotek.",
|
||||
"TaskCleanActivityLogDescription": "Sletter linjer i aktivitetsloggen ældre end den konfigurerede alder.",
|
||||
"TaskCleanActivityLog": "Ryd aktivitetslog",
|
||||
@@ -90,7 +90,7 @@
|
||||
"Forced": "Tvunget",
|
||||
"Default": "Standard",
|
||||
"TaskOptimizeDatabaseDescription": "Komprimerer databasen for at frigøre plads. Denne handling køres efter at have scannet mediebiblioteket, eller efter at have lavet ændringer til databasen.",
|
||||
"TaskOptimizeDatabase": "Optimer database",
|
||||
"TaskOptimizeDatabase": "Optimér database",
|
||||
"TaskKeyframeExtractorDescription": "Udtrækker rammer fra videofiler for at lave mere præcise HLS-playlister. Denne opgave kan tage lang tid.",
|
||||
"TaskKeyframeExtractor": "Udtræk nøglerammer",
|
||||
"External": "Ekstern",
|
||||
@@ -99,7 +99,7 @@
|
||||
"TaskRefreshTrickplayImagesDescription": "Laver trickplay-billeder for videoer i aktiverede biblioteker.",
|
||||
"TaskAudioNormalizationDescription": "Skanner filer for data vedrørende lydnormalisering.",
|
||||
"TaskAudioNormalization": "Lydnormalisering",
|
||||
"TaskDownloadMissingLyricsDescription": "Søger på internettet efter manglende sangtekster baseret på metadata-konfigurationen",
|
||||
"TaskDownloadMissingLyricsDescription": "Download sangtekster",
|
||||
"TaskDownloadMissingLyrics": "Hent manglende sangtekster",
|
||||
"TaskExtractMediaSegments": "Scan for mediesegmenter",
|
||||
"TaskMoveTrickplayImages": "Migrer billedelokationer for trickplay-billeder",
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
"ScheduledTaskFailedWithName": "{0} αποτυχία",
|
||||
"Shows": "Σειρές",
|
||||
"StartupEmbyServerIsLoading": "Ο διακομιστής Jellyfin φορτώνει. Περιμένετε λίγο και δοκιμάστε ξανά.",
|
||||
"SubtitleDownloadFailureFromForItem": "Αποτυχίες μεταφόρτωσης υποτίτλων από {0} για {1}",
|
||||
"SubtitleDownloadFailureFromForItem": "Αποτυχία λήψης υποτίτλων από {0} για {1}",
|
||||
"TvShows": "Τηλεοπτικές Σειρές",
|
||||
"UserCreatedWithName": "Ο χρήστης {0} δημιουργήθηκε",
|
||||
"UserDeletedWithName": "Ο χρήστης {0} έχει διαγραφεί",
|
||||
@@ -106,5 +106,7 @@
|
||||
"TaskExtractMediaSegments": "Σάρωση τμημάτων πολυμέσων",
|
||||
"TaskExtractMediaSegmentsDescription": "Εξάγει ή βρίσκει τμήματα πολυμέσων από επεκτάσεις που χρησιμοποιούν το MediaSegment.",
|
||||
"CleanupUserDataTaskDescription": "Καθαρίζει όλα τα δεδομένα χρήστη (κατάσταση παρακολούθησης, κατάσταση αγαπημένων κ.λπ.) από πολυμέσα που δεν υπάρχουν πλέον για τουλάχιστον 90 ημέρες.",
|
||||
"CleanupUserDataTask": "Εργασία εκκαθάρισης δεδομένων χρήστη"
|
||||
"CleanupUserDataTask": "Εργασία εκκαθάρισης δεδομένων χρήστη",
|
||||
"LyricDownloadFailureFromForItem": "Αποτυχία λήψης στίχων από {0} για {1}",
|
||||
"Original": "Πρωτότυπο"
|
||||
}
|
||||
|
||||
@@ -106,5 +106,7 @@
|
||||
"TaskMoveTrickplayImages": "Migrate Trickplay Image Location",
|
||||
"TaskMoveTrickplayImagesDescription": "Moves existing trickplay files according to the library settings.",
|
||||
"CleanupUserDataTask": "User data cleanup task",
|
||||
"CleanupUserDataTaskDescription": "Cleans all user data (Watch state, favourite status etc) from media that is no longer present for at least 90 days."
|
||||
"CleanupUserDataTaskDescription": "Cleans all user data (Watch state, favourite status etc) from media that is no longer present for at least 90 days.",
|
||||
"LyricDownloadFailureFromForItem": "Lyrics failed to download from {0} for {1}",
|
||||
"Original": "Original"
|
||||
}
|
||||
|
||||
@@ -106,5 +106,7 @@
|
||||
"TaskMoveTrickplayImagesDescription": "Mueve archivos existentes de trickplay de acuerdo a la configuración de la biblioteca.",
|
||||
"TaskMoveTrickplayImages": "Migrar Ubicación de Imagen de Trickplay",
|
||||
"CleanupUserDataTaskDescription": "Limpia todos los datos del usuario (estado de visualización, estado de los favoritos, etc.) que no están presentes en la biblioteca por al menos 90 días.",
|
||||
"CleanupUserDataTask": "Tarea de limpieza de datos de usuarios"
|
||||
"CleanupUserDataTask": "Tarea de limpieza de datos de usuarios",
|
||||
"LyricDownloadFailureFromForItem": "No se pudo descargar la letra desde {0} para {1}",
|
||||
"Original": "Original"
|
||||
}
|
||||
|
||||
@@ -106,5 +106,6 @@
|
||||
"TaskRefreshTrickplayImages": "Xerar miniaturas de previsualización",
|
||||
"TaskAudioNormalizationDescription": "Escanea ficheiros á procura de datos de normalización de volume.",
|
||||
"CleanupUserDataTask": "Tarefa de limpeza de datos dos usuarios",
|
||||
"CleanupUserDataTaskDescription": "Limpa todos os datos do usuario (estado de visualización, de favorito etc.) dos medios ausentes polo menos 90 días."
|
||||
"CleanupUserDataTaskDescription": "Limpa todos os datos do usuario (estado de visualización, de favorito etc.) dos medios ausentes polo menos 90 días.",
|
||||
"Original": "Orixinal"
|
||||
}
|
||||
|
||||
@@ -106,5 +106,7 @@
|
||||
"TaskExtractMediaSegmentsDescription": "מחלץ חלקי מדיה מתוספים המאפשרים זאת.",
|
||||
"TaskMoveTrickplayImagesDescription": "הזזת קבצי Trickplay קיימים בהתאם להגדרות הספרייה.",
|
||||
"CleanupUserDataTaskDescription": "ניקוי כל המידע של המשתמש (מצב צפייה, מועדפים וכו) ממדיה שאינה קיימת מעל 90 יום.",
|
||||
"CleanupUserDataTask": "משימת ניקוי מידע משתמש"
|
||||
"CleanupUserDataTask": "משימת ניקוי מידע משתמש",
|
||||
"LyricDownloadFailureFromForItem": "הורדת המילים מ-{0} עבור {1} נכשלה",
|
||||
"Original": "מקור"
|
||||
}
|
||||
|
||||
@@ -106,5 +106,7 @@
|
||||
"TaskExtractMediaSegments": "Scan Segmen media",
|
||||
"TaskMoveTrickplayImages": "Migrasikan Lokasi Gambar Trickplay",
|
||||
"TaskDownloadMissingLyrics": "Unduh Lirik yang Hilang",
|
||||
"CleanupUserDataTask": "Tugas Pembersihan Data Pengguna"
|
||||
"CleanupUserDataTask": "Tugas Pembersihan Data Pengguna",
|
||||
"LyricDownloadFailureFromForItem": "Lirik gagal di download dari {0} untuk {1}",
|
||||
"Original": "Asli"
|
||||
}
|
||||
|
||||
@@ -103,5 +103,8 @@
|
||||
"TaskDownloadMissingLyrics": "Sækja söngtexta sem vantar",
|
||||
"TaskExtractMediaSegments": "Skönnun efnishluta",
|
||||
"CleanupUserDataTask": "Hreinsun notendagagna",
|
||||
"CleanupUserDataTaskDescription": "Hreinsar öll notendagögn (spilunarstöðu, uppáhöld o.s.frv.) um gögn sem hafa ekki verið til staðar í að lámarki 90 daga."
|
||||
"CleanupUserDataTaskDescription": "Hreinsar öll notendagögn (spilunarstöðu, uppáhöld o.s.frv.) um gögn sem hafa ekki verið til staðar í að lámarki 90 daga.",
|
||||
"LyricDownloadFailureFromForItem": "Ekki tókst að niðurhala texta frá {0} fyrir {1}",
|
||||
"Original": "Upprunaleg",
|
||||
"TaskExtractMediaSegmentsDescription": "Sækir myndbúta úr viðbótum þar sem MediaSegment er virkt."
|
||||
}
|
||||
|
||||
@@ -106,5 +106,7 @@
|
||||
"TaskDownloadMissingLyrics": "失われた歌詞をダウンロード",
|
||||
"TaskExtractMediaSegmentsDescription": "MediaSegment 対応プラグインからメディア セグメントを抽出または取得します。",
|
||||
"CleanupUserDataTask": "ユーザーデータのクリーンアップタスク",
|
||||
"CleanupUserDataTaskDescription": "90日以上存在しないメディアに対して、視聴状態やお気に入り状態などのユーザーデータをすべて削除します。"
|
||||
"CleanupUserDataTaskDescription": "90日以上存在しないメディアに対して、視聴状態やお気に入り状態などのユーザーデータをすべて削除します。",
|
||||
"LyricDownloadFailureFromForItem": "歌詞",
|
||||
"Original": "オリジナル"
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
"NotificationOptionInstallationFailed": "ಸ್ಥಾಪನ ವೈಫಲ್ಯ",
|
||||
"NotificationOptionNewLibraryContent": "ಹೊಸ ವಿಷಯವನ್ನು ಒಳಗೊಂಡಿದೆ",
|
||||
"NotificationOptionPluginError": "ಪ್ಲಗಿನ್ ವೈಫಲ್ಯ",
|
||||
"NotificationOptionPluginInstalled": "ಪ್ಲಗಿನ್ ವೈಫಲ್ಯ",
|
||||
"NotificationOptionPluginInstalled": "ಪ್ಲಗಿನ್ ಸ್ಥಾಪಿಸಲಾಗಿದೆ",
|
||||
"NotificationOptionPluginUpdateInstalled": "ಪ್ಲಗಿನ್ ನವೀಕರಣವನ್ನು ಸ್ಥಾಪಿಸಲಾಗಿದೆ",
|
||||
"NotificationOptionServerRestartRequired": "ಸರ್ವರ್ ಮರುಪ್ರಾರಂಭದ ಅಗತ್ಯವಿದೆ",
|
||||
"NotificationOptionTaskFailed": "ನಿಗದಿತ ಕಾರ್ಯ ವೈಫಲ್ಯ",
|
||||
|
||||
@@ -106,5 +106,7 @@
|
||||
"TaskDownloadMissingLyrics": "누락된 가사 다운로드",
|
||||
"TaskDownloadMissingLyricsDescription": "가사 다운로드",
|
||||
"CleanupUserDataTask": "사용자 데이터 정리 작업",
|
||||
"CleanupUserDataTaskDescription": "최소 90일 이상 존재하지 않는 미디어에 대한 사용자 데이터(시청 상태, 즐겨찾기 등)를 정리합니다."
|
||||
"CleanupUserDataTaskDescription": "최소 90일 이상 존재하지 않는 미디어에 대한 사용자 데이터(시청 상태, 즐겨찾기 등)를 정리합니다.",
|
||||
"LyricDownloadFailureFromForItem": "{1}에 대한 가사를 {0}에서 다운로드하지 못했습니다",
|
||||
"Original": "원본"
|
||||
}
|
||||
|
||||
@@ -107,5 +107,6 @@
|
||||
"TaskMoveTrickplayImagesDescription": "Move os ficheiros trickplay existentes de acordo com as definições da mediateca.",
|
||||
"CleanupUserDataTaskDescription": "Apaga todos os dados de utilizador (estados de reprodução, favoritos, etc) de arquivos média não presentes há 90 dias ou mais.",
|
||||
"CleanupUserDataTask": "Limpeza de dados de utilizador",
|
||||
"Original": "Original"
|
||||
"Original": "Original",
|
||||
"LyricDownloadFailureFromForItem": "Erro ao descarregar letras de {0} para {1}"
|
||||
}
|
||||
|
||||
@@ -1 +1,15 @@
|
||||
{}
|
||||
{
|
||||
"AppDeviceValues": "ඇප්: {0}, උපාංග: {1}",
|
||||
"Artists": "කලාකරුවන්",
|
||||
"AuthenticationSucceededWithUserName": "{0} සාර්ථකව තහවුරු කරන ලදී",
|
||||
"Books": "පොත්",
|
||||
"ChapterNameValue": "{0} වෙනි පරිච්ඡේදය",
|
||||
"Collections": "සංහිතා",
|
||||
"Default": "පෙරනිමි",
|
||||
"External": "බාහිර",
|
||||
"FailedLoginAttemptWithUserName": "{0} වෙතින් සිදුකළ පිවිසීමේ උත්සාහය අසාර්ථක විය",
|
||||
"Favorites": "ප්රියතමයන්",
|
||||
"Folders": "ෆෝල්ඩර",
|
||||
"Forced": "නියමිත",
|
||||
"Genres": "ප්රභේද"
|
||||
}
|
||||
|
||||
@@ -106,5 +106,7 @@
|
||||
"TaskDownloadMissingLyrics": "Stiahnuť chýbajúce texty piesní",
|
||||
"TaskDownloadMissingLyricsDescription": "Stiahne texty pre piesne",
|
||||
"CleanupUserDataTask": "Prečistiť používateľské dáta",
|
||||
"CleanupUserDataTaskDescription": "Vyčistí všetky dáta používateľa (stav sledovania, stav obľúbených atď.) z médií, ktoré už neexistujú aspoň 90 dní."
|
||||
"CleanupUserDataTaskDescription": "Vyčistí všetky dáta používateľa (stav sledovania, stav obľúbených atď.) z médií, ktoré už neexistujú aspoň 90 dní.",
|
||||
"LyricDownloadFailureFromForItem": "Text piesne sa nepodarilo stiahnuť z {0} pre {1}",
|
||||
"Original": "Originál"
|
||||
}
|
||||
|
||||
@@ -106,5 +106,7 @@
|
||||
"TaskAudioNormalization": "Normalizacija zvoka",
|
||||
"TaskAudioNormalizationDescription": "Pregled datotek za podatke o normalizaciji zvoka.",
|
||||
"CleanupUserDataTask": "Čiščenje uporabniških podatkov",
|
||||
"CleanupUserDataTaskDescription": "Izbriše vse uporabniške podatke (stanje ogleda, priljubljene itd.) za vsebine, ki že več kot 90 dni niso na voljo."
|
||||
"CleanupUserDataTaskDescription": "Izbriše vse uporabniške podatke (stanje ogleda, priljubljene itd.) za vsebine, ki že več kot 90 dni niso na voljo.",
|
||||
"LyricDownloadFailureFromForItem": "Besedila ni bilo mogoče prenesti iz {0} za {1}",
|
||||
"Original": "Original"
|
||||
}
|
||||
|
||||
@@ -106,5 +106,7 @@
|
||||
"TaskAudioNormalization": "Normalizimi i audios",
|
||||
"TaskAudioNormalizationDescription": "Skannon skedarët për të dhëna të normalizimit të audios.",
|
||||
"CleanupUserDataTaskDescription": "Pastron të gjitha të dhënat e përdorueseve (gjendja e shikimit, statusi i të preferuarave etj.) nga mediat që nuk janë më të pranishme për të paktën 90 ditë.",
|
||||
"CleanupUserDataTask": "Veprim për pastrimin të dhënave të përdorueseve"
|
||||
"CleanupUserDataTask": "Veprim për pastrimin të dhënave të përdorueseve",
|
||||
"LyricDownloadFailureFromForItem": "Teksti i këngës nuk arriti të shkarkohej nga {0} për {1}",
|
||||
"Original": "Origjinal"
|
||||
}
|
||||
|
||||
@@ -106,5 +106,7 @@
|
||||
"CleanupUserDataTask": "Задатак чишћења корисничких података",
|
||||
"CleanupUserDataTaskDescription": "Чисти све корисничке податке (напредак гледања, ознаке за омиљено...) медија који нису доступни 90 дана или дуже.",
|
||||
"TaskMoveTrickplayImages": "Промени локацију сличица за визуелно премотавање",
|
||||
"TaskDownloadMissingLyricsDescription": "Преузми стихове песама"
|
||||
"TaskDownloadMissingLyricsDescription": "Преузми стихове песама",
|
||||
"LyricDownloadFailureFromForItem": "Није успело преузимање стихова са {0} за {1}",
|
||||
"Original": "Изворно"
|
||||
}
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
{}
|
||||
{
|
||||
"Artists": "Wasanii",
|
||||
"Books": "Vitabu",
|
||||
"Collections": "Mikusanyiko"
|
||||
}
|
||||
|
||||
@@ -77,8 +77,8 @@
|
||||
"TaskCleanLogsDescription": "自動刪走超過 {0} 日嘅紀錄檔。",
|
||||
"TaskCleanLogs": "清理日誌資料夾",
|
||||
"TaskRefreshLibrary": "掃描媒體櫃",
|
||||
"TaskRefreshChapterImagesDescription": "幫有章節嘅影片整返啲章節縮圖。",
|
||||
"TaskRefreshChapterImages": "擷取章節圖片",
|
||||
"TaskRefreshChapterImagesDescription": "幫有章節嘅影片整返啲章節預覽圖。",
|
||||
"TaskRefreshChapterImages": "擷取章節圖像",
|
||||
"TaskCleanCacheDescription": "刪走系統已經唔再需要嘅快取檔案。",
|
||||
"TaskCleanCache": "清理快取(Cache)資料夾",
|
||||
"TasksChannelsCategory": "網路頻道",
|
||||
@@ -106,5 +106,7 @@
|
||||
"TaskMoveTrickplayImagesDescription": "根據媒體櫃設定,將現有嘅 Trickplay(快轉預覽)檔案搬去對應位置。",
|
||||
"TaskMoveTrickplayImages": "搬移快轉預覽圖嘅位置",
|
||||
"CleanupUserDataTask": "清理使用者資料嘅任務",
|
||||
"CleanupUserDataTaskDescription": "清理已消失至少 90 日嘅媒體用家數據(包括觀看狀態、心水狀態等)。"
|
||||
"CleanupUserDataTaskDescription": "清理已消失至少 90 日嘅媒體用家數據(包括觀看狀態、心水狀態等)。",
|
||||
"LyricDownloadFailureFromForItem": "冇辦法從 {0} 下載 {1} 嘅歌詞",
|
||||
"Original": "原始"
|
||||
}
|
||||
|
||||
@@ -356,6 +356,27 @@ namespace Emby.Server.Implementations.Localization
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(rating);
|
||||
|
||||
// Some providers may list multiple ratings separated by '/' (e.g. "SE:15 / SE:15+ / SE:Från 15 år").
|
||||
// Try each one in order and use the first that resolves.
|
||||
var ratingValues = rating.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
foreach (var ratingValue in ratingValues)
|
||||
{
|
||||
var score = GetSingleRatingScore(ratingValue, countryCode);
|
||||
if (score is not null)
|
||||
{
|
||||
return score;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a single rating value to a score.
|
||||
/// </summary>
|
||||
private ParentalRatingScore? GetSingleRatingScore(string rating, string? countryCode)
|
||||
{
|
||||
// Handle unrated content
|
||||
if (_unratedValues.Contains(rating.AsSpan(), StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
@@ -364,7 +385,7 @@ namespace Emby.Server.Implementations.Localization
|
||||
|
||||
// Convert ints directly
|
||||
// This may override some of the locale specific age ratings (but those always map to the same age)
|
||||
if (int.TryParse(rating, out var ratingAge))
|
||||
if (TryParseRatingAsScore(rating, out var ratingAge))
|
||||
{
|
||||
return new(ratingAge, null);
|
||||
}
|
||||
@@ -466,6 +487,13 @@ namespace Emby.Server.Implementations.Localization
|
||||
return true;
|
||||
}
|
||||
|
||||
// If it's not a recognized rating string, fall back to using the number as the score
|
||||
if (TryParseRatingAsScore(ratingPart, out var numericScore))
|
||||
{
|
||||
result = new ParentalRatingScore(numericScore, null);
|
||||
return true;
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"Rating '{Rating}' not found in the '{CountryCode}' rating system, treating as unrated",
|
||||
rating,
|
||||
@@ -480,6 +508,18 @@ namespace Emby.Server.Implementations.Localization
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to parse a rating as a number, allowing an optional trailing '+' (e.g. "16" or "18+").
|
||||
/// </summary>
|
||||
/// <param name="ratingValue">Rating value to parse.</param>
|
||||
/// <param name="score">Parsed score.</param>
|
||||
/// <returns>Returns true if parsing was successful.</returns>
|
||||
private static bool TryParseRatingAsScore(ReadOnlySpan<char> ratingValue, out int score)
|
||||
{
|
||||
var trimmed = ratingValue.TrimEnd('+');
|
||||
return int.TryParse(trimmed, out score);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GetLocalizedString(string phrase)
|
||||
{
|
||||
@@ -566,11 +606,15 @@ namespace Emby.Server.Implementations.Localization
|
||||
|
||||
private static string GetResourceFilename(string culture)
|
||||
{
|
||||
var parts = culture.Split('-');
|
||||
// Region codes may use a '-' (BCP-47, e.g. "pt-BR") or '_' (e.g. "es_419", "ar_SA") separator.
|
||||
// Normalize the casing (lower-case language, upper-case region) while preserving the separator
|
||||
// so the result matches the embedded resource file name, which is case-sensitive.
|
||||
var separatorIndex = culture.IndexOfAny(['-', '_']);
|
||||
|
||||
if (parts.Length == 2)
|
||||
if (separatorIndex > 0)
|
||||
{
|
||||
culture = parts[0].ToLowerInvariant() + "-" + parts[1].ToUpperInvariant();
|
||||
var separator = culture[separatorIndex];
|
||||
culture = culture[..separatorIndex].ToLowerInvariant() + separator + culture[(separatorIndex + 1)..].ToUpperInvariant();
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -10,21 +10,42 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"ratingStrings": ["K12"],
|
||||
"ratingStrings": ["K12", "K-12", "12"],
|
||||
"ratingScore": {
|
||||
"score": 12,
|
||||
"subScore": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"ratingStrings": ["K13", "K-13", "13"],
|
||||
"ratingScore": {
|
||||
"score": 13,
|
||||
"subScore": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"ratingStrings": ["K15"],
|
||||
"ratingStrings": ["K15", "K-15", "15"],
|
||||
"ratingScore": {
|
||||
"score": 15,
|
||||
"subScore": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"ratingStrings": ["K18"],
|
||||
"ratingStrings": ["K16", "K-16", "16"],
|
||||
"ratingScore": {
|
||||
"score": 16,
|
||||
"subScore": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"ratingStrings": ["K17", "K-17", "17"],
|
||||
"ratingScore": {
|
||||
"score": 17,
|
||||
"subScore": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"ratingStrings": ["K18", "K-18", "18", "18+"],
|
||||
"ratingScore": {
|
||||
"score": 18,
|
||||
"subScore": null
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"ratingStrings": ["7"],
|
||||
"ratingStrings": ["7", "7+", "7 År", "Från 7 år"],
|
||||
"ratingScore": {
|
||||
"score": 7,
|
||||
"subScore": null
|
||||
@@ -31,7 +31,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"ratingStrings": ["11"],
|
||||
"ratingStrings": ["11", "11+", "11 År", "Från 11 år"],
|
||||
"ratingScore": {
|
||||
"score": 11,
|
||||
"subScore": null
|
||||
@@ -45,11 +45,18 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"ratingStrings": ["15"],
|
||||
"ratingStrings": ["15", "15+", "15 År", "Från 15 år"],
|
||||
"ratingScore": {
|
||||
"score": 15,
|
||||
"subScore": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"ratingStrings": ["18", "18+", "Barnförbjuden", "Bfj"],
|
||||
"ratingScore": {
|
||||
"score": 18,
|
||||
"subScore": null
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -92,7 +92,8 @@ public class ChapterImagesTask : IScheduledTask
|
||||
EnableImages = false
|
||||
},
|
||||
SourceTypes = [SourceType.Library],
|
||||
IsVirtualItem = false
|
||||
IsVirtualItem = false,
|
||||
IncludeOwnedItems = true
|
||||
})
|
||||
.OfType<Video>()
|
||||
.ToList();
|
||||
|
||||
@@ -68,6 +68,7 @@ public class MediaSegmentExtractionTask : IScheduledTask
|
||||
DtoOptions = new DtoOptions(true),
|
||||
SourceTypes = [SourceType.Library],
|
||||
Recursive = true,
|
||||
IncludeOwnedItems = true,
|
||||
Limit = pagesize
|
||||
};
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.Globalization;
|
||||
using MediaBrowser.Model.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -17,6 +18,7 @@ public class OptimizeDatabaseTask : IScheduledTask, IConfigurableScheduledTask
|
||||
private readonly ILogger<OptimizeDatabaseTask> _logger;
|
||||
private readonly ILocalizationManager _localization;
|
||||
private readonly IJellyfinDatabaseProvider _jellyfinDatabaseProvider;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="OptimizeDatabaseTask" /> class.
|
||||
@@ -24,14 +26,17 @@ public class OptimizeDatabaseTask : IScheduledTask, IConfigurableScheduledTask
|
||||
/// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
|
||||
/// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
|
||||
/// <param name="jellyfinDatabaseProvider">Instance of the JellyfinDatabaseProvider that can be used for provider specific operations.</param>
|
||||
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
|
||||
public OptimizeDatabaseTask(
|
||||
ILogger<OptimizeDatabaseTask> logger,
|
||||
ILocalizationManager localization,
|
||||
IJellyfinDatabaseProvider jellyfinDatabaseProvider)
|
||||
IJellyfinDatabaseProvider jellyfinDatabaseProvider,
|
||||
ILibraryManager libraryManager)
|
||||
{
|
||||
_logger = logger;
|
||||
_localization = localization;
|
||||
_jellyfinDatabaseProvider = jellyfinDatabaseProvider;
|
||||
_libraryManager = libraryManager;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -68,6 +73,15 @@ public class OptimizeDatabaseTask : IScheduledTask, IConfigurableScheduledTask
|
||||
/// <inheritdoc />
|
||||
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
||||
{
|
||||
// Vacuuming/checkpointing requires an exclusive lock on the database. Running it while a library scan is in
|
||||
// progress causes both operations to contend for the database and can stall the scan, so defer optimization
|
||||
// until no scan is running. The task will run again on its next trigger.
|
||||
if (_libraryManager.IsScanRunning)
|
||||
{
|
||||
_logger.LogInformation("Skipping database optimization because a library scan is currently running.");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Optimizing and vacuuming jellyfin.db...");
|
||||
|
||||
try
|
||||
|
||||
@@ -4,11 +4,18 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Persistence;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Model.Globalization;
|
||||
using MediaBrowser.Model.IO;
|
||||
using MediaBrowser.Model.Tasks;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Emby.Server.Implementations.ScheduledTasks.Tasks;
|
||||
|
||||
@@ -20,6 +27,9 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly ILocalizationManager _localization;
|
||||
private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory;
|
||||
private readonly IFileSystem _fileSystem;
|
||||
private readonly ILogger<PeopleValidationTask> _logger;
|
||||
private readonly IItemTypeLookup _itemTypeLookup;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PeopleValidationTask" /> class.
|
||||
@@ -27,11 +37,23 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
|
||||
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
|
||||
/// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
|
||||
/// <param name="dbContextFactory">Instance of the <see cref="IDbContextFactory{TContext}"/> interface.</param>
|
||||
public PeopleValidationTask(ILibraryManager libraryManager, ILocalizationManager localization, IDbContextFactory<JellyfinDbContext> dbContextFactory)
|
||||
/// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
|
||||
/// <param name="logger">Instance of the <see cref="ILogger{PeopleValidationTask}"/> interface.</param>
|
||||
/// <param name="itemTypeLookup">Instance of the <see cref="IItemTypeLookup"/> interface.</param>
|
||||
public PeopleValidationTask(
|
||||
ILibraryManager libraryManager,
|
||||
ILocalizationManager localization,
|
||||
IDbContextFactory<JellyfinDbContext> dbContextFactory,
|
||||
IFileSystem fileSystem,
|
||||
ILogger<PeopleValidationTask> logger,
|
||||
IItemTypeLookup itemTypeLookup)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
_localization = localization;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_fileSystem = fileSystem;
|
||||
_logger = logger;
|
||||
_itemTypeLookup = itemTypeLookup;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -71,13 +93,19 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
|
||||
/// <inheritdoc />
|
||||
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
||||
{
|
||||
IProgress<double> subProgress = new Progress<double>((val) => progress.Report(val / 2));
|
||||
await _libraryManager.ValidatePeopleAsync(subProgress, cancellationToken).ConfigureAwait(false);
|
||||
// People validation performs heavy database writes that contend with an active library scan.
|
||||
// Defer it until the scan has finished; the task will run again on its next trigger.
|
||||
if (_libraryManager.IsScanRunning)
|
||||
{
|
||||
_logger.LogInformation("Skipping people validation because a library scan is currently running.");
|
||||
return;
|
||||
}
|
||||
|
||||
subProgress = new Progress<double>((val) => progress.Report((val / 2) + 50));
|
||||
// Phase 1: Deduplicate and remove orphaned people (0-33%)
|
||||
var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using (context.ConfigureAwait(false))
|
||||
{
|
||||
IProgress<double> subProgress = new Progress<double>((val) => progress.Report(val / 3));
|
||||
var dupQuery = context.Peoples
|
||||
.GroupBy(e => new { e.Name, e.PersonType })
|
||||
.Where(e => e.Count() > 1)
|
||||
@@ -123,7 +151,113 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
|
||||
ArrayPool<Guid[]>.Shared.Return(buffer);
|
||||
}
|
||||
|
||||
var peopleToDelete = await context.Peoples
|
||||
.Where(p => !context.PeopleBaseItemMap.Any(m => m.PeopleId.Equals(p.Id)))
|
||||
.ExecuteDeleteAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
_logger.LogInformation("Removed {Count} orphaned people.", peopleToDelete);
|
||||
|
||||
subProgress.Report(100);
|
||||
}
|
||||
|
||||
// Phase 2: Validate people (33-66%). Runs after orphaned PeopleBaseItemMap entries are
|
||||
// cleaned up above, so dead people are removed in a single pass instead of requiring a second run.
|
||||
IProgress<double> validateProgress = new Progress<double>((val) => progress.Report((val / 3) + 33));
|
||||
await _libraryManager.ValidatePeopleAsync(validateProgress, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Phase 3: Refresh images for people missing them (66-100%)
|
||||
IProgress<double> refreshProgress = new Progress<double>((val) => progress.Report((val / 3) + 66));
|
||||
await RefreshPeopleImagesAsync(refreshProgress, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
progress.Report(100);
|
||||
}
|
||||
|
||||
private async Task RefreshPeopleImagesAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
||||
{
|
||||
var thirtyDaysAgo = DateTime.UtcNow.AddDays(-30);
|
||||
var personTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Person];
|
||||
|
||||
var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using (context.ConfigureAwait(false))
|
||||
{
|
||||
const int PartitionSize = 100;
|
||||
|
||||
var numPeople = await context.BaseItems
|
||||
.AsNoTracking()
|
||||
.Where(b => b.Type == personTypeName)
|
||||
.Where(b => b.DateLastRefreshed == null || b.DateLastRefreshed < thirtyDaysAgo)
|
||||
.Where(b =>
|
||||
!b.Images!.Any(i => i.ImageType == ImageInfoImageType.Primary) ||
|
||||
string.IsNullOrEmpty(b.Overview))
|
||||
.CountAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug("Found {Count} people needing image/overview refresh", numPeople);
|
||||
|
||||
if (numPeople == 0)
|
||||
{
|
||||
progress.Report(100);
|
||||
return;
|
||||
}
|
||||
|
||||
var numComplete = 0;
|
||||
var numRefreshed = 0;
|
||||
|
||||
await foreach (var entry in context.BaseItems
|
||||
.AsNoTracking()
|
||||
.Where(b => b.Type == personTypeName)
|
||||
.Where(b => b.DateLastRefreshed == null || b.DateLastRefreshed < thirtyDaysAgo)
|
||||
.Where(b =>
|
||||
!b.Images!.Any(i => i.ImageType == ImageInfoImageType.Primary) ||
|
||||
string.IsNullOrEmpty(b.Overview))
|
||||
.OrderBy(b => b.Id)
|
||||
.WithPartitionProgress(partition => _logger.LogDebug("Processing people partition {Partition}", partition))
|
||||
.PartitionEagerAsync(PartitionSize, cancellationToken)
|
||||
.WithCancellation(cancellationToken)
|
||||
.ConfigureAwait(false))
|
||||
{
|
||||
if (await RefreshPersonAsync(entry.Id, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
numRefreshed++;
|
||||
}
|
||||
|
||||
numComplete++;
|
||||
progress.Report(100.0 * numComplete / numPeople);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Refreshed metadata for {Count} people missing images or overview", numRefreshed);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> RefreshPersonAsync(Guid personId, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_libraryManager.GetItemById(personId) is not Person item)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var hasImage = item.HasImage(MediaBrowser.Model.Entities.ImageType.Primary);
|
||||
var hasOverview = !string.IsNullOrEmpty(item.Overview);
|
||||
|
||||
var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem))
|
||||
{
|
||||
ImageRefreshMode = hasImage ? MetadataRefreshMode.ValidationOnly : MetadataRefreshMode.Default,
|
||||
MetadataRefreshMode = hasOverview ? MetadataRefreshMode.ValidationOnly : MetadataRefreshMode.Default
|
||||
};
|
||||
|
||||
await item.RefreshMetadata(options, cancellationToken).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error refreshing images for person {PersonId}", personId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,6 +343,10 @@ namespace Emby.Server.Implementations.Session
|
||||
_activeLiveStreamSessions.TryRemove(liveStreamId, out _);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
liveStreamNeedsToBeClosed = true;
|
||||
}
|
||||
|
||||
if (liveStreamNeedsToBeClosed)
|
||||
{
|
||||
@@ -725,6 +729,31 @@ namespace Emby.Server.Implementations.Session
|
||||
return item;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the item whose user data (playback position, played status) should be updated
|
||||
/// for a playback report. When an alternate version is played the client reports the displayed
|
||||
/// item as <c>ItemId</c> and the played version as <c>MediaSourceId</c>.
|
||||
/// </summary>
|
||||
/// <param name="libraryItem">The now playing (displayed) item.</param>
|
||||
/// <param name="mediaSourceId">The reported media source id.</param>
|
||||
/// <returns>The item to track progress against.</returns>
|
||||
private BaseItem GetProgressItem(BaseItem libraryItem, string mediaSourceId)
|
||||
{
|
||||
if (libraryItem is Video libraryVideo
|
||||
&& !string.IsNullOrEmpty(mediaSourceId)
|
||||
&& Guid.TryParse(mediaSourceId, out var mediaSourceItemId)
|
||||
&& !mediaSourceItemId.Equals(libraryVideo.Id))
|
||||
{
|
||||
var versionItem = libraryVideo.GetAlternateVersion(mediaSourceItemId);
|
||||
if (versionItem is not null)
|
||||
{
|
||||
return versionItem;
|
||||
}
|
||||
}
|
||||
|
||||
return libraryItem;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Used to report that playback has started for an item.
|
||||
/// </summary>
|
||||
@@ -756,9 +785,10 @@ namespace Emby.Server.Implementations.Session
|
||||
|
||||
if (libraryItem is not null)
|
||||
{
|
||||
var progressItem = GetProgressItem(libraryItem, info.MediaSourceId);
|
||||
foreach (var user in users)
|
||||
{
|
||||
OnPlaybackStart(user, libraryItem);
|
||||
OnPlaybackStart(user, progressItem);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -890,9 +920,10 @@ namespace Emby.Server.Implementations.Session
|
||||
// only update saved user data on actual check-ins, not automated ones
|
||||
if (libraryItem is not null && !isAutomated)
|
||||
{
|
||||
var progressItem = GetProgressItem(libraryItem, info.MediaSourceId);
|
||||
foreach (var user in users)
|
||||
{
|
||||
OnPlaybackProgress(user, libraryItem, info);
|
||||
OnPlaybackProgress(user, progressItem, info);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -952,6 +983,20 @@ namespace Emby.Server.Implementations.Session
|
||||
if (changed)
|
||||
{
|
||||
_userDataManager.SaveUserData(user, item, data, UserDataSaveReason.PlaybackProgress, CancellationToken.None);
|
||||
|
||||
// A completed version marks every alternate version played and clears their resume points, so the
|
||||
// whole movie leaves Continue Watching and reads as watched everywhere. (Per-version resume positions
|
||||
// only persist while nothing has been completed yet.)
|
||||
if (data.Played == true && item is Video playedVideo)
|
||||
{
|
||||
playedVideo.PropagatePlayedState(user, true);
|
||||
}
|
||||
}
|
||||
|
||||
if ((!user.RememberAudioSelections && info.AudioStreamIndex.HasValue)
|
||||
|| (!user.RememberSubtitleSelections && info.SubtitleStreamIndex.HasValue))
|
||||
{
|
||||
_userDataManager.ResetPlaybackStreamSelections(user, item);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1083,9 +1128,10 @@ namespace Emby.Server.Implementations.Session
|
||||
|
||||
if (libraryItem is not null)
|
||||
{
|
||||
var progressItem = GetProgressItem(libraryItem, info.MediaSourceId);
|
||||
foreach (var user in users)
|
||||
{
|
||||
playedToCompletion = OnPlaybackStopped(user, libraryItem, info.PositionTicks, info.Failed);
|
||||
playedToCompletion = OnPlaybackStopped(user, progressItem, info.PositionTicks, info.Failed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1138,6 +1184,14 @@ namespace Emby.Server.Implementations.Session
|
||||
|
||||
_userDataManager.SaveUserData(user, item, data, UserDataSaveReason.PlaybackFinished, CancellationToken.None);
|
||||
|
||||
// A completed version marks every alternate version played and clears their resume points, so the
|
||||
// whole movie leaves Continue Watching and reads as watched everywhere. (Per-version resume positions
|
||||
// only persist while nothing has been completed yet.)
|
||||
if (data.Played == true && item is Video playedVideo)
|
||||
{
|
||||
playedVideo.PropagatePlayedState(user, true);
|
||||
}
|
||||
|
||||
return playedToCompletion;
|
||||
}
|
||||
|
||||
|
||||
@@ -246,8 +246,21 @@ namespace Emby.Server.Implementations.Session
|
||||
_logger.LogInformation("Lost {0} WebSockets.", lost.Count);
|
||||
foreach (var webSocket in lost)
|
||||
{
|
||||
// TODO: handle session relative to the lost webSocket
|
||||
RemoveWebSocket(webSocket);
|
||||
|
||||
// The connection stopped answering keep-alives, so a close frame will
|
||||
// never arrive and the pending receive loop would hang forever, keeping
|
||||
// the session (and e.g. its SyncPlay group membership) alive. Disposing
|
||||
// the connection aborts the receive loop, which raises Closed and lets
|
||||
// the session end normally.
|
||||
try
|
||||
{
|
||||
webSocket.Dispose();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogWarning(exception, "Error disposing lost WebSocket from {RemoteEndPoint}.", webSocket.RemoteEndPoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,7 +206,8 @@ namespace Emby.Server.Implementations.SyncPlay
|
||||
foreach (var itemId in queue)
|
||||
{
|
||||
var item = _libraryManager.GetItemById(itemId);
|
||||
if (!item.IsVisibleStandalone(user))
|
||||
|
||||
if (item is null || !item.IsVisibleStandalone(user))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using Jellyfin.Data;
|
||||
using Jellyfin.Data.Enums;
|
||||
@@ -136,11 +137,15 @@ namespace Emby.Server.Implementations.TV
|
||||
|
||||
if (nextEpisode is not null)
|
||||
{
|
||||
// The last played date and the version that was actually played live on the version item's user data
|
||||
// The played state propagated to the sibling versions carries no date
|
||||
var (playedVersion, lastPlayedDate) = GetMostRecentlyPlayedVersion(result.LastWatched, user);
|
||||
nextEpisode = GetPreferredVersion(nextEpisode, result.LastWatched, playedVersion);
|
||||
|
||||
DateTime lastWatchedDate = DateTime.MinValue;
|
||||
if (result.LastWatched is not null)
|
||||
{
|
||||
var userData = _userDataManager.GetUserData(user, result.LastWatched);
|
||||
lastWatchedDate = userData?.LastPlayedDate ?? DateTime.MinValue.AddDays(1);
|
||||
lastWatchedDate = lastPlayedDate ?? DateTime.MinValue.AddDays(1);
|
||||
}
|
||||
|
||||
nextUpList.Add((lastWatchedDate, nextEpisode));
|
||||
@@ -152,11 +157,13 @@ namespace Emby.Server.Implementations.TV
|
||||
|
||||
if (nextPlayedEpisode is not null)
|
||||
{
|
||||
var (playedVersion, lastPlayedDate) = GetMostRecentlyPlayedVersion(result.LastWatchedForRewatching, user);
|
||||
nextPlayedEpisode = GetPreferredVersion(nextPlayedEpisode, result.LastWatchedForRewatching, playedVersion);
|
||||
|
||||
DateTime rewatchLastWatchedDate = DateTime.MinValue;
|
||||
if (result.LastWatchedForRewatching is not null)
|
||||
{
|
||||
var userData = _userDataManager.GetUserData(user, result.LastWatchedForRewatching);
|
||||
rewatchLastWatchedDate = userData?.LastPlayedDate ?? DateTime.MinValue.AddDays(1);
|
||||
rewatchLastWatchedDate = lastPlayedDate ?? DateTime.MinValue.AddDays(1);
|
||||
}
|
||||
|
||||
nextUpList.Add((rewatchLastWatchedDate, nextPlayedEpisode));
|
||||
@@ -219,10 +226,13 @@ namespace Emby.Server.Implementations.TV
|
||||
|
||||
if (nextEpisode is not null && !includeResumable)
|
||||
{
|
||||
var userData = _userDataManager.GetUserData(user, nextEpisode);
|
||||
if (userData?.PlaybackPositionTicks > 0)
|
||||
// The resume progress may live on an alternate version
|
||||
foreach (var version in nextEpisode.GetAllVersions())
|
||||
{
|
||||
return null;
|
||||
if (_userDataManager.GetUserData(user, version)?.PlaybackPositionTicks > 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,6 +247,74 @@ namespace Emby.Server.Implementations.TV
|
||||
return DetermineNextEpisode(result, user, includeSpecials, includeResumable: false, includePlayed: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the version of the last watched episode that was actually played, together with its last played date.
|
||||
/// The version that was played carries the most recent LastPlayedDate.
|
||||
/// dates.
|
||||
/// </summary>
|
||||
/// <param name="lastWatched">The last watched episode (any version).</param>
|
||||
/// <param name="user">The user.</param>
|
||||
/// <returns>The played version and its last played date.</returns>
|
||||
private (Video? PlayedVersion, DateTime? LastPlayedDate) GetMostRecentlyPlayedVersion(BaseItem? lastWatched, User user)
|
||||
{
|
||||
if (lastWatched is not Video lastWatchedVideo)
|
||||
{
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
var versions = lastWatchedVideo.GetAllVersions();
|
||||
var userDataByVersion = _userDataManager.GetUserDataBatch(versions, user);
|
||||
|
||||
var playedVersion = VersionPlaybackSelector.SelectMostRecentlyPlayed(
|
||||
versions,
|
||||
version => userDataByVersion.GetValueOrDefault(version.Id),
|
||||
data => data.LastPlayedDate.HasValue);
|
||||
|
||||
return (playedVersion, playedVersion is null ? null : userDataByVersion[playedVersion.Id].LastPlayedDate);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When the last watched episode was played as an alternate version, prefer the next episode's version with the matching name,
|
||||
/// so Next Up continues in the version the user has been watching instead of falling back to the primary.
|
||||
/// </summary>
|
||||
/// <param name="nextEpisode">The determined next episode (a primary).</param>
|
||||
/// <param name="lastWatched">The last watched episode.</param>
|
||||
/// <param name="playedVersion">The version of the last watched episode that was played.</param>
|
||||
/// <returns>The matching version of the next episode, or the episode itself.</returns>
|
||||
private Episode GetPreferredVersion(Episode nextEpisode, BaseItem? lastWatched, Video? playedVersion)
|
||||
{
|
||||
// No version preference, or the primary was played
|
||||
if (lastWatched is not Video lastWatchedVideo
|
||||
|| playedVersion is null
|
||||
|| !playedVersion.PrimaryVersionId.HasValue)
|
||||
{
|
||||
return nextEpisode;
|
||||
}
|
||||
|
||||
// Match by version name
|
||||
var playedVersionId = playedVersion.Id.ToString("N", CultureInfo.InvariantCulture);
|
||||
var playedVersionName = lastWatchedVideo.GetMediaSources(false)
|
||||
.FirstOrDefault(source => string.Equals(source.Id, playedVersionId, StringComparison.OrdinalIgnoreCase))?.Name;
|
||||
|
||||
if (string.IsNullOrEmpty(playedVersionName))
|
||||
{
|
||||
return nextEpisode;
|
||||
}
|
||||
|
||||
var matchingSource = nextEpisode.GetMediaSources(false)
|
||||
.FirstOrDefault(source => string.Equals(source.Name, playedVersionName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (matchingSource is not null
|
||||
&& Guid.TryParse(matchingSource.Id, out var matchingId)
|
||||
&& !matchingId.Equals(nextEpisode.Id)
|
||||
&& _libraryManager.GetItemById<Episode>(matchingId) is { } matchingVersion)
|
||||
{
|
||||
return matchingVersion;
|
||||
}
|
||||
|
||||
return nextEpisode;
|
||||
}
|
||||
|
||||
private static string GetUniqueSeriesKey(Series series)
|
||||
{
|
||||
return series.GetPresentationUniqueKey();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
@@ -32,6 +33,8 @@ namespace Emby.Server.Implementations.Updates
|
||||
/// </summary>
|
||||
public class InstallationManager : IInstallationManager
|
||||
{
|
||||
private static readonly SearchValues<char> InvalidPackageNameChars = SearchValues.Create([.. Path.GetInvalidFileNameChars(), '/', '\\']);
|
||||
|
||||
/// <summary>
|
||||
/// The logger.
|
||||
/// </summary>
|
||||
@@ -521,9 +524,27 @@ namespace Emby.Server.Implementations.Updates
|
||||
return;
|
||||
}
|
||||
|
||||
if (!IsValidPackageDirectoryName(package.Name))
|
||||
{
|
||||
_logger.LogError("Refusing to install package with invalid name {PackageName}.", package.Name);
|
||||
throw new InvalidDataException($"Plugin package name '{package.Name}' is not a valid directory name.");
|
||||
}
|
||||
|
||||
// Always override the passed-in target (which is a file) and figure it out again
|
||||
string targetDir = Path.Combine(_appPaths.PluginsPath, package.Name);
|
||||
|
||||
var pluginsRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(_appPaths.PluginsPath));
|
||||
var resolvedTarget = Path.GetFullPath(targetDir);
|
||||
if (!resolvedTarget.StartsWith(pluginsRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogError(
|
||||
"Refusing to install package {PackageName}: resolved target {Resolved} is outside plugins directory {Root}.",
|
||||
package.Name,
|
||||
resolvedTarget,
|
||||
pluginsRoot);
|
||||
throw new InvalidDataException($"Plugin package name '{package.Name}' resolves outside the plugins directory.");
|
||||
}
|
||||
|
||||
using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
|
||||
.GetAsync(new Uri(package.SourceUrl), cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
@@ -572,6 +593,26 @@ namespace Emby.Server.Implementations.Updates
|
||||
_pluginManager.ImportPluginFrom(targetDir);
|
||||
}
|
||||
|
||||
private static bool IsValidPackageDirectoryName(string? name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (name.Equals(".", StringComparison.Ordinal) || name.Equals("..", StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (name.IndexOfAny(InvalidPackageNameChars) >= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<bool> InstallPackageInternal(InstallationInfo package, CancellationToken cancellationToken)
|
||||
{
|
||||
LocalPlugin? plugin = _pluginManager.Plugins.FirstOrDefault(p => p.Id.Equals(package.Id) && p.Version.Equals(package.Version))
|
||||
|
||||
@@ -2049,7 +2049,7 @@ public class ImageController : BaseJellyfinApiController
|
||||
}
|
||||
|
||||
// Check If-Modified-Since header for time-based validation
|
||||
if (DateTime.TryParse(Request.Headers[HeaderNames.IfModifiedSince], out var ifModifiedSinceHeader))
|
||||
if (DateTime.TryParse(Request.Headers[HeaderNames.IfModifiedSince], CultureInfo.InvariantCulture, out var ifModifiedSinceHeader))
|
||||
{
|
||||
// Return 304 if the image has not been modified since the client's cached version
|
||||
if (dateImageModified <= ifModifiedSinceHeader)
|
||||
|
||||
@@ -279,6 +279,11 @@ public class ItemUpdateController : BaseJellyfinApiController
|
||||
item.DateCreated = NormalizeDateTime(request.DateCreated.Value);
|
||||
}
|
||||
|
||||
if (request.SeriesName is not null && item is IHasSeries hasSeries)
|
||||
{
|
||||
hasSeries.SeriesName = request.SeriesName;
|
||||
}
|
||||
|
||||
item.EndDate = request.EndDate.HasValue ? NormalizeDateTime(request.EndDate.Value) : null;
|
||||
item.PremiereDate = request.PremiereDate.HasValue ? NormalizeDateTime(request.PremiereDate.Value) : null;
|
||||
item.ProductionYear = request.ProductionYear;
|
||||
@@ -288,7 +293,7 @@ public class ItemUpdateController : BaseJellyfinApiController
|
||||
item.CustomRating = request.CustomRating;
|
||||
|
||||
var currentTags = item.Tags;
|
||||
var newTags = request.Tags.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
var newTags = request.Tags.Select(t => t.Trim()).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
var removedTags = currentTags.Except(newTags).ToList();
|
||||
var addedTags = newTags.Except(currentTags).ToList();
|
||||
item.Tags = newTags;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
@@ -42,6 +43,7 @@ public class ItemsController : BaseJellyfinApiController
|
||||
private readonly ILogger<ItemsController> _logger;
|
||||
private readonly ISessionManager _sessionManager;
|
||||
private readonly IUserDataManager _userDataRepository;
|
||||
private readonly ISearchManager _searchManager;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ItemsController"/> class.
|
||||
@@ -53,6 +55,7 @@ public class ItemsController : BaseJellyfinApiController
|
||||
/// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
|
||||
/// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param>
|
||||
/// <param name="userDataRepository">Instance of the <see cref="IUserDataManager"/> interface.</param>
|
||||
/// <param name="searchManager">Instance of the <see cref="ISearchManager"/> interface.</param>
|
||||
public ItemsController(
|
||||
IUserManager userManager,
|
||||
ILibraryManager libraryManager,
|
||||
@@ -60,7 +63,8 @@ public class ItemsController : BaseJellyfinApiController
|
||||
IDtoService dtoService,
|
||||
ILogger<ItemsController> logger,
|
||||
ISessionManager sessionManager,
|
||||
IUserDataManager userDataRepository)
|
||||
IUserDataManager userDataRepository,
|
||||
ISearchManager searchManager)
|
||||
{
|
||||
_userManager = userManager;
|
||||
_libraryManager = libraryManager;
|
||||
@@ -69,6 +73,7 @@ public class ItemsController : BaseJellyfinApiController
|
||||
_logger = logger;
|
||||
_sessionManager = sessionManager;
|
||||
_userDataRepository = userDataRepository;
|
||||
_searchManager = searchManager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -282,6 +287,8 @@ public class ItemsController : BaseJellyfinApiController
|
||||
QueryResult<BaseItem> result;
|
||||
|
||||
Guid[] linkedChildAncestorIds = [];
|
||||
|
||||
includeItemTypes ??= [];
|
||||
if (includeItemTypes.Length == 1
|
||||
&& (includeItemTypes[0] == BaseItemKind.BoxSet || includeItemTypes[0] == BaseItemKind.Playlist)
|
||||
&& item is not BoxSet
|
||||
@@ -309,27 +316,29 @@ public class ItemsController : BaseJellyfinApiController
|
||||
if (folder is IHasCollectionType hasCollectionType)
|
||||
{
|
||||
collectionType = hasCollectionType.CollectionType;
|
||||
includeItemTypes = [.. includeItemTypes.Union(DtoExtensions.GetBaseItemKindsForCollectionType(collectionType))];
|
||||
}
|
||||
|
||||
if (collectionType == CollectionType.playlists)
|
||||
{
|
||||
recursive = true;
|
||||
includeItemTypes = new[] { BaseItemKind.Playlist };
|
||||
includeItemTypes = [BaseItemKind.Playlist];
|
||||
}
|
||||
else if (folder is ICollectionFolder)
|
||||
else if (folder is ICollectionFolder && includeItemTypes.Length == 0)
|
||||
{
|
||||
// When the client doesn't specify recursive/includeItemTypes, force the query
|
||||
// through the database path where all filters (IsHD, genres, etc.) are applied.
|
||||
recursive ??= true;
|
||||
if (includeItemTypes.Length == 0)
|
||||
includeItemTypes = collectionType switch
|
||||
{
|
||||
includeItemTypes = collectionType switch
|
||||
{
|
||||
CollectionType.boxsets => [BaseItemKind.BoxSet],
|
||||
null => [BaseItemKind.Movie, BaseItemKind.Series],
|
||||
_ => []
|
||||
};
|
||||
}
|
||||
CollectionType.boxsets => [BaseItemKind.BoxSet],
|
||||
null => [BaseItemKind.Movie, BaseItemKind.Series],
|
||||
_ => []
|
||||
};
|
||||
}
|
||||
|
||||
// includeItemTypes on a library lists its contents recursively rather than just its
|
||||
// immediate children, so default to a recursive query when the client didn't choose.
|
||||
if (folder is ICollectionFolder && includeItemTypes.Length > 0)
|
||||
{
|
||||
recursive ??= true;
|
||||
}
|
||||
|
||||
if (item is not UserRootFolder
|
||||
@@ -342,218 +351,273 @@ public class ItemsController : BaseJellyfinApiController
|
||||
return Unauthorized($"{user.Username} is not permitted to access Library {item.Name}.");
|
||||
}
|
||||
|
||||
if ((recursive.HasValue && recursive.Value) || ids.Length != 0 || item is not UserRootFolder)
|
||||
// Build the query up front so the dispatch below can decide the path from it.
|
||||
// Use search providers when searchTerm is provided. Providers return only IDs and scores;
|
||||
// items are loaded server-side via folder.GetItems below, which applies user-access filtering.
|
||||
Dictionary<Guid, float>? searchResultScores = null;
|
||||
Guid[] itemIds = ids;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(searchTerm))
|
||||
{
|
||||
var query = new InternalItemsQuery(user)
|
||||
var searchProviderQuery = new SearchProviderQuery
|
||||
{
|
||||
IsPlayed = isPlayed,
|
||||
MediaTypes = mediaTypes,
|
||||
SearchTerm = searchTerm,
|
||||
UserId = userId,
|
||||
IncludeItemTypes = includeItemTypes,
|
||||
ExcludeItemTypes = excludeItemTypes,
|
||||
Recursive = recursive ?? false,
|
||||
OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder),
|
||||
IsFavorite = isFavorite,
|
||||
Limit = limit,
|
||||
StartIndex = startIndex,
|
||||
IsMissing = isMissing,
|
||||
IsUnaired = isUnaired,
|
||||
CollapseBoxSetItems = collapseBoxSetItems,
|
||||
NameLessThan = nameLessThan,
|
||||
NameStartsWith = nameStartsWith,
|
||||
NameStartsWithOrGreater = nameStartsWithOrGreater,
|
||||
HasImdbId = hasImdbId,
|
||||
IsPlaceHolder = isPlaceHolder,
|
||||
IsLocked = isLocked,
|
||||
MinWidth = minWidth,
|
||||
MinHeight = minHeight,
|
||||
MaxWidth = maxWidth,
|
||||
MaxHeight = maxHeight,
|
||||
Is3D = is3D,
|
||||
HasTvdbId = hasTvdbId,
|
||||
HasTmdbId = hasTmdbId,
|
||||
IsMovie = isMovie,
|
||||
IsSeries = isSeries,
|
||||
IsNews = isNews,
|
||||
IsKids = isKids,
|
||||
IsSports = isSports,
|
||||
HasOverview = hasOverview,
|
||||
HasOfficialRating = hasOfficialRating,
|
||||
HasParentalRating = hasParentalRating,
|
||||
HasSpecialFeature = hasSpecialFeature,
|
||||
HasSubtitles = hasSubtitles,
|
||||
HasThemeSong = hasThemeSong,
|
||||
HasThemeVideo = hasThemeVideo,
|
||||
HasTrailer = hasTrailer,
|
||||
IsHD = isHd,
|
||||
Is4K = is4K,
|
||||
Tags = tags,
|
||||
OfficialRatings = officialRatings,
|
||||
Genres = genres,
|
||||
ArtistIds = artistIds,
|
||||
AlbumArtistIds = albumArtistIds,
|
||||
ContributingArtistIds = contributingArtistIds,
|
||||
GenreIds = genreIds,
|
||||
StudioIds = studioIds,
|
||||
Person = person,
|
||||
PersonIds = personIds,
|
||||
PersonTypes = personTypes,
|
||||
Years = years,
|
||||
ImageTypes = imageTypes,
|
||||
VideoTypes = videoTypes,
|
||||
AdjacentTo = adjacentTo,
|
||||
ItemIds = ids,
|
||||
MinCommunityRating = minCommunityRating,
|
||||
MinCriticRating = minCriticRating,
|
||||
ParentId = parentId ?? Guid.Empty,
|
||||
IndexNumber = indexNumber,
|
||||
ParentIndexNumber = parentIndexNumber,
|
||||
EnableTotalRecordCount = enableTotalRecordCount,
|
||||
ExcludeItemIds = excludeItemIds,
|
||||
DtoOptions = dtoOptions,
|
||||
SearchTerm = searchTerm,
|
||||
MinDateLastSaved = minDateLastSaved?.ToUniversalTime(),
|
||||
MinDateLastSavedForUser = minDateLastSavedForUser?.ToUniversalTime(),
|
||||
MinPremiereDate = minPremiereDate?.ToUniversalTime(),
|
||||
MaxPremiereDate = maxPremiereDate?.ToUniversalTime(),
|
||||
AudioLanguages = audioLanguages,
|
||||
SubtitleLanguages = subtitleLanguages,
|
||||
LinkedChildAncestorIds = linkedChildAncestorIds,
|
||||
MediaTypes = mediaTypes,
|
||||
Limit = limit.HasValue ? limit.Value * 3 : null,
|
||||
ParentId = parentId
|
||||
};
|
||||
|
||||
if (ids.Length != 0 || !string.IsNullOrWhiteSpace(searchTerm))
|
||||
var searchResults = await _searchManager.GetSearchResultsAsync(searchProviderQuery, HttpContext.RequestAborted).ConfigureAwait(false);
|
||||
if (searchResults.Count > 0)
|
||||
{
|
||||
query.CollapseBoxSetItems = false;
|
||||
searchResultScores = searchResults.ToDictionary(r => r.ItemId, r => r.Score);
|
||||
itemIds = ids.Length > 0
|
||||
? ids.Concat(searchResultScores.Keys).Distinct().ToArray()
|
||||
: searchResultScores.Keys.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
if (query.SubtitleLanguages.Count > 0 && query.HasSubtitles.HasValue)
|
||||
var query = new InternalItemsQuery(user)
|
||||
{
|
||||
IsPlayed = isPlayed,
|
||||
MediaTypes = mediaTypes,
|
||||
IncludeItemTypes = includeItemTypes,
|
||||
ExcludeItemTypes = excludeItemTypes,
|
||||
Recursive = recursive ?? false,
|
||||
OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder),
|
||||
IsFavorite = isFavorite,
|
||||
Limit = searchResultScores is null ? limit : null,
|
||||
StartIndex = searchResultScores is null ? startIndex : null,
|
||||
IsMissing = isMissing,
|
||||
IsUnaired = isUnaired,
|
||||
CollapseBoxSetItems = collapseBoxSetItems,
|
||||
NameLessThan = nameLessThan,
|
||||
NameStartsWith = nameStartsWith,
|
||||
NameStartsWithOrGreater = nameStartsWithOrGreater,
|
||||
HasImdbId = hasImdbId,
|
||||
IsPlaceHolder = isPlaceHolder,
|
||||
IsLocked = isLocked,
|
||||
MinWidth = minWidth,
|
||||
MinHeight = minHeight,
|
||||
MaxWidth = maxWidth,
|
||||
MaxHeight = maxHeight,
|
||||
Is3D = is3D,
|
||||
HasTvdbId = hasTvdbId,
|
||||
HasTmdbId = hasTmdbId,
|
||||
IsMovie = isMovie,
|
||||
IsSeries = isSeries,
|
||||
IsNews = isNews,
|
||||
IsKids = isKids,
|
||||
IsSports = isSports,
|
||||
HasOverview = hasOverview,
|
||||
HasOfficialRating = hasOfficialRating,
|
||||
HasParentalRating = hasParentalRating,
|
||||
HasSpecialFeature = hasSpecialFeature,
|
||||
HasSubtitles = hasSubtitles,
|
||||
HasThemeSong = hasThemeSong,
|
||||
HasThemeVideo = hasThemeVideo,
|
||||
HasTrailer = hasTrailer,
|
||||
IsHD = isHd,
|
||||
Is4K = is4K,
|
||||
Tags = tags,
|
||||
OfficialRatings = officialRatings,
|
||||
Genres = genres,
|
||||
ArtistIds = artistIds,
|
||||
AlbumArtistIds = albumArtistIds,
|
||||
ContributingArtistIds = contributingArtistIds,
|
||||
GenreIds = genreIds,
|
||||
StudioIds = studioIds,
|
||||
Person = person,
|
||||
PersonIds = personIds,
|
||||
PersonTypes = personTypes,
|
||||
Years = years,
|
||||
ImageTypes = imageTypes,
|
||||
VideoTypes = videoTypes,
|
||||
AdjacentTo = adjacentTo,
|
||||
ItemIds = itemIds,
|
||||
MinCommunityRating = minCommunityRating,
|
||||
MinCriticRating = minCriticRating,
|
||||
ParentId = parentId ?? Guid.Empty,
|
||||
IndexNumber = indexNumber,
|
||||
ParentIndexNumber = parentIndexNumber,
|
||||
EnableTotalRecordCount = enableTotalRecordCount,
|
||||
ExcludeItemIds = excludeItemIds,
|
||||
DtoOptions = dtoOptions,
|
||||
SearchTerm = searchResultScores is null ? searchTerm : null,
|
||||
MinDateLastSaved = minDateLastSaved?.ToUniversalTime(),
|
||||
MinDateLastSavedForUser = minDateLastSavedForUser?.ToUniversalTime(),
|
||||
MinPremiereDate = minPremiereDate?.ToUniversalTime(),
|
||||
MaxPremiereDate = maxPremiereDate?.ToUniversalTime(),
|
||||
AudioLanguages = audioLanguages,
|
||||
SubtitleLanguages = subtitleLanguages,
|
||||
LinkedChildAncestorIds = linkedChildAncestorIds,
|
||||
};
|
||||
|
||||
if (ids.Length != 0 || !string.IsNullOrWhiteSpace(searchTerm))
|
||||
{
|
||||
query.CollapseBoxSetItems = false;
|
||||
}
|
||||
|
||||
if (query.SubtitleLanguages.Count > 0 && query.HasSubtitles.HasValue)
|
||||
{
|
||||
if (query.HasSubtitles.Value)
|
||||
{
|
||||
if (query.HasSubtitles.Value)
|
||||
// if we check for specific subtitles we don't need a separate check for subtitle existence
|
||||
query.HasSubtitles = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
// if we search for items without subtitles, we don't need to check for subtitles of a specific language
|
||||
query.SubtitleLanguages = [];
|
||||
}
|
||||
}
|
||||
|
||||
// for filter values that rely on media streams, we need to include alternative and linked versions
|
||||
if (query.HasSubtitles.HasValue
|
||||
|| query.SubtitleLanguages.Count > 0
|
||||
|| query.AudioLanguages.Count > 0
|
||||
|| query.Is3D.HasValue
|
||||
|| query.IsHD.HasValue
|
||||
|| query.Is4K.HasValue
|
||||
|| query.VideoTypes.Length > 0
|
||||
)
|
||||
{
|
||||
query.IncludeOwnedItems = true;
|
||||
}
|
||||
|
||||
query.ApplyFilters(filters);
|
||||
|
||||
// Filter by Series Status
|
||||
if (seriesStatus.Length != 0)
|
||||
{
|
||||
query.SeriesStatuses = seriesStatus;
|
||||
}
|
||||
|
||||
// Exclude Blocked Unrated Items
|
||||
var blockedUnratedItems = user?.GetPreferenceValues<UnratedItem>(PreferenceKind.BlockUnratedItems);
|
||||
if (blockedUnratedItems is not null)
|
||||
{
|
||||
query.BlockUnratedItems = blockedUnratedItems;
|
||||
}
|
||||
|
||||
// ExcludeLocationTypes
|
||||
if (excludeLocationTypes.Any(t => t == LocationType.Virtual))
|
||||
{
|
||||
query.IsVirtualItem = false;
|
||||
}
|
||||
|
||||
if (locationTypes.Length > 0 && locationTypes.Length < 4)
|
||||
{
|
||||
query.IsVirtualItem = locationTypes.Contains(LocationType.Virtual);
|
||||
}
|
||||
|
||||
// Min official rating
|
||||
if (!string.IsNullOrWhiteSpace(minOfficialRating))
|
||||
{
|
||||
query.MinParentalRating = _localization.GetRatingScore(minOfficialRating);
|
||||
}
|
||||
|
||||
// Max official rating
|
||||
if (!string.IsNullOrWhiteSpace(maxOfficialRating))
|
||||
{
|
||||
query.MaxParentalRating = _localization.GetRatingScore(maxOfficialRating);
|
||||
}
|
||||
|
||||
// Artists
|
||||
if (artists.Length != 0)
|
||||
{
|
||||
query.ArtistIds = artists.Select(i =>
|
||||
{
|
||||
try
|
||||
{
|
||||
// if we check for specific subtitles we don't need a separate check for subtitle existence
|
||||
query.HasSubtitles = null;
|
||||
return _libraryManager.GetArtist(i, new DtoOptions(false));
|
||||
}
|
||||
else
|
||||
catch
|
||||
{
|
||||
// if we search for items without subtitles, we don't need to check for subtitles of a specific language
|
||||
query.SubtitleLanguages = [];
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}).Where(i => i is not null).Select(i => i!.Id).ToArray();
|
||||
}
|
||||
|
||||
// for filter values that rely on media streams, we need to include alternative and linked versions
|
||||
if (query.HasSubtitles.HasValue
|
||||
|| query.SubtitleLanguages.Count > 0
|
||||
|| query.AudioLanguages.Count > 0
|
||||
|| query.Is3D.HasValue
|
||||
|| query.IsHD.HasValue
|
||||
|| query.Is4K.HasValue
|
||||
|| query.VideoTypes.Length > 0
|
||||
)
|
||||
// ExcludeArtistIds
|
||||
if (excludeArtistIds.Length != 0)
|
||||
{
|
||||
query.ExcludeArtistIds = excludeArtistIds;
|
||||
}
|
||||
|
||||
if (albumIds.Length != 0)
|
||||
{
|
||||
query.AlbumIds = albumIds;
|
||||
}
|
||||
|
||||
// Albums
|
||||
if (albums.Length != 0)
|
||||
{
|
||||
query.AlbumIds = albums.SelectMany(i =>
|
||||
{
|
||||
query.IncludeOwnedItems = true;
|
||||
}
|
||||
return _libraryManager.GetItemIds(new InternalItemsQuery { IncludeItemTypes = [BaseItemKind.MusicAlbum], Name = i, Limit = 1 });
|
||||
}).ToArray();
|
||||
}
|
||||
|
||||
query.ApplyFilters(filters);
|
||||
|
||||
// Filter by Series Status
|
||||
if (seriesStatus.Length != 0)
|
||||
// Studios
|
||||
if (studios.Length != 0)
|
||||
{
|
||||
query.StudioIds = studios.Select(i =>
|
||||
{
|
||||
query.SeriesStatuses = seriesStatus;
|
||||
}
|
||||
|
||||
// Exclude Blocked Unrated Items
|
||||
var blockedUnratedItems = user?.GetPreferenceValues<UnratedItem>(PreferenceKind.BlockUnratedItems);
|
||||
if (blockedUnratedItems is not null)
|
||||
{
|
||||
query.BlockUnratedItems = blockedUnratedItems;
|
||||
}
|
||||
|
||||
// ExcludeLocationTypes
|
||||
if (excludeLocationTypes.Any(t => t == LocationType.Virtual))
|
||||
{
|
||||
query.IsVirtualItem = false;
|
||||
}
|
||||
|
||||
if (locationTypes.Length > 0 && locationTypes.Length < 4)
|
||||
{
|
||||
query.IsVirtualItem = locationTypes.Contains(LocationType.Virtual);
|
||||
}
|
||||
|
||||
// Min official rating
|
||||
if (!string.IsNullOrWhiteSpace(minOfficialRating))
|
||||
{
|
||||
query.MinParentalRating = _localization.GetRatingScore(minOfficialRating);
|
||||
}
|
||||
|
||||
// Max official rating
|
||||
if (!string.IsNullOrWhiteSpace(maxOfficialRating))
|
||||
{
|
||||
query.MaxParentalRating = _localization.GetRatingScore(maxOfficialRating);
|
||||
}
|
||||
|
||||
// Artists
|
||||
if (artists.Length != 0)
|
||||
{
|
||||
query.ArtistIds = artists.Select(i =>
|
||||
try
|
||||
{
|
||||
try
|
||||
{
|
||||
return _libraryManager.GetArtist(i, new DtoOptions(false));
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}).Where(i => i is not null).Select(i => i!.Id).ToArray();
|
||||
}
|
||||
|
||||
// ExcludeArtistIds
|
||||
if (excludeArtistIds.Length != 0)
|
||||
{
|
||||
query.ExcludeArtistIds = excludeArtistIds;
|
||||
}
|
||||
|
||||
if (albumIds.Length != 0)
|
||||
{
|
||||
query.AlbumIds = albumIds;
|
||||
}
|
||||
|
||||
// Albums
|
||||
if (albums.Length != 0)
|
||||
{
|
||||
query.AlbumIds = albums.SelectMany(i =>
|
||||
{
|
||||
return _libraryManager.GetItemIds(new InternalItemsQuery { IncludeItemTypes = new[] { BaseItemKind.MusicAlbum }, Name = i, Limit = 1 });
|
||||
}).ToArray();
|
||||
}
|
||||
|
||||
// Studios
|
||||
if (studios.Length != 0)
|
||||
{
|
||||
query.StudioIds = studios.Select(i =>
|
||||
{
|
||||
try
|
||||
{
|
||||
return _libraryManager.GetStudio(i);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}).Where(i => i is not null).Select(i => i!.Id).ToArray();
|
||||
}
|
||||
|
||||
// Apply default sorting if none requested
|
||||
if (query.OrderBy.Count == 0)
|
||||
{
|
||||
// Albums by artist
|
||||
if (query.ArtistIds.Length > 0 && query.IncludeItemTypes.Length == 1 && query.IncludeItemTypes[0] == BaseItemKind.MusicAlbum)
|
||||
{
|
||||
query.OrderBy = new[] { (ItemSortBy.ProductionYear, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Ascending) };
|
||||
return _libraryManager.GetStudio(i);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}).Where(i => i is not null).Select(i => i!.Id).ToArray();
|
||||
}
|
||||
|
||||
query.Parent = null;
|
||||
// Apply default sorting if none requested
|
||||
if (query.OrderBy.Count == 0)
|
||||
{
|
||||
// Albums by artist
|
||||
if (query.ArtistIds.Length > 0 && query.IncludeItemTypes.Length == 1 && query.IncludeItemTypes[0] == BaseItemKind.MusicAlbum)
|
||||
{
|
||||
query.OrderBy = [(ItemSortBy.ProductionYear, SortOrder.Descending), (ItemSortBy.SortName, SortOrder.Ascending)];
|
||||
}
|
||||
}
|
||||
|
||||
query.Parent = null;
|
||||
|
||||
// At the user root an unfiltered, non-recursive request is a plain listing of the user's libraries
|
||||
if ((recursive.HasValue && recursive.Value) || ids.Length != 0 || item is not UserRootFolder || query.HasFilters)
|
||||
{
|
||||
// folder.GetItems applies user-access filtering via the InternalItemsQuery's User.
|
||||
result = folder.GetItems(query);
|
||||
if (searchResultScores is not null && searchResultScores.Count > 0)
|
||||
{
|
||||
var orderedItems = result.Items
|
||||
.OrderByDescending(item => searchResultScores.GetValueOrDefault(item.Id, 0f))
|
||||
.ThenBy(item => item.SortName)
|
||||
.ToArray();
|
||||
|
||||
var totalCount = orderedItems.Length;
|
||||
if (startIndex.HasValue && startIndex.Value > 0)
|
||||
{
|
||||
orderedItems = orderedItems.Skip(startIndex.Value).ToArray();
|
||||
}
|
||||
|
||||
if (limit.HasValue)
|
||||
{
|
||||
orderedItems = orderedItems.Take(limit.Value).ToArray();
|
||||
}
|
||||
|
||||
return new QueryResult<BaseItemDto>(
|
||||
startIndex,
|
||||
totalCount,
|
||||
_dtoService.GetBaseItemDtos(orderedItems, dtoOptions, user));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -901,15 +965,21 @@ public class ItemsController : BaseJellyfinApiController
|
||||
var excludeItemIds = Array.Empty<Guid>();
|
||||
if (excludeActiveSessions)
|
||||
{
|
||||
// NowPlayingItem.Id is the displayed/primary id, but resume queries surface the actually-played
|
||||
// alternate version's own id. Expand each active session to every version id so an in-progress
|
||||
// alternate is excluded too, instead of leaking back into the resume list.
|
||||
excludeItemIds = _sessionManager.Sessions
|
||||
.Where(s => s.UserId.Equals(requestUserId) && s.NowPlayingItem is not null)
|
||||
.Select(s => s.NowPlayingItem.Id)
|
||||
.SelectMany(s => _libraryManager.GetItemById(s.NowPlayingItem.Id) is Video video
|
||||
? video.GetAllVersions().Select(v => v.Id)
|
||||
: [s.NowPlayingItem.Id])
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
var itemsResult = _libraryManager.GetItemsResult(new InternalItemsQuery(user)
|
||||
{
|
||||
OrderBy = new[] { (ItemSortBy.DatePlayed, SortOrder.Descending) },
|
||||
OrderBy = [(ItemSortBy.DatePlayed, SortOrder.Descending)],
|
||||
IsResumable = true,
|
||||
StartIndex = startIndex,
|
||||
Limit = limit,
|
||||
@@ -919,6 +989,7 @@ public class ItemsController : BaseJellyfinApiController
|
||||
MediaTypes = mediaTypes,
|
||||
IsVirtualItem = false,
|
||||
CollapseBoxSetItems = false,
|
||||
IncludeOwnedItems = true,
|
||||
EnableTotalRecordCount = enableTotalRecordCount,
|
||||
AncestorIds = ancestorIds,
|
||||
IncludeItemTypes = includeItemTypes,
|
||||
|
||||
@@ -1002,9 +1002,7 @@ public class LiveTvController : BaseJellyfinApiController
|
||||
{
|
||||
if (!string.IsNullOrEmpty(pw))
|
||||
{
|
||||
// TODO: remove ToLower when Convert.ToHexString supports lowercase
|
||||
// Schedules Direct requires the hex to be lowercase
|
||||
listingsProviderInfo.Password = Convert.ToHexString(SHA1.HashData(Encoding.UTF8.GetBytes(pw))).ToLowerInvariant();
|
||||
listingsProviderInfo.Password = Convert.ToHexStringLower(SHA1.HashData(Encoding.UTF8.GetBytes(pw)));
|
||||
}
|
||||
|
||||
return await _listingsManager.SaveListingProvider(listingsProviderInfo, validateLogin, validateListings).ConfigureAwait(false);
|
||||
|
||||
@@ -213,7 +213,7 @@ public class MediaInfoController : BaseJellyfinApiController
|
||||
Request.HttpContext.GetNormalizedRemoteIP());
|
||||
}
|
||||
|
||||
_mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate);
|
||||
_mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate, item.Id);
|
||||
}
|
||||
|
||||
if (autoOpenLiveStream.Value)
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.ComponentModel;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Api.Helpers;
|
||||
using Jellyfin.Api.ModelBinders;
|
||||
using Jellyfin.Data.Enums;
|
||||
@@ -29,7 +30,7 @@ namespace Jellyfin.Api.Controllers;
|
||||
[Authorize]
|
||||
public class SearchController : BaseJellyfinApiController
|
||||
{
|
||||
private readonly ISearchEngine _searchEngine;
|
||||
private readonly ISearchManager _searchManager;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IDtoService _dtoService;
|
||||
private readonly IImageProcessor _imageProcessor;
|
||||
@@ -37,17 +38,17 @@ public class SearchController : BaseJellyfinApiController
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SearchController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="searchEngine">Instance of <see cref="ISearchEngine"/> interface.</param>
|
||||
/// <param name="searchManager">Instance of <see cref="ISearchManager"/> interface.</param>
|
||||
/// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param>
|
||||
/// <param name="dtoService">Instance of <see cref="IDtoService"/> interface.</param>
|
||||
/// <param name="imageProcessor">Instance of <see cref="IImageProcessor"/> interface.</param>
|
||||
public SearchController(
|
||||
ISearchEngine searchEngine,
|
||||
ISearchManager searchManager,
|
||||
ILibraryManager libraryManager,
|
||||
IDtoService dtoService,
|
||||
IImageProcessor imageProcessor)
|
||||
{
|
||||
_searchEngine = searchEngine;
|
||||
_searchManager = searchManager;
|
||||
_libraryManager = libraryManager;
|
||||
_dtoService = dtoService;
|
||||
_imageProcessor = imageProcessor;
|
||||
@@ -79,7 +80,7 @@ public class SearchController : BaseJellyfinApiController
|
||||
[HttpGet]
|
||||
[Description("Gets search hints based on a search term")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public ActionResult<SearchHintResult> GetSearchHints(
|
||||
public async Task<ActionResult<SearchHintResult>> GetSearchHints(
|
||||
[FromQuery] int? startIndex,
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery] Guid? userId,
|
||||
@@ -100,7 +101,7 @@ public class SearchController : BaseJellyfinApiController
|
||||
[FromQuery] bool includeArtists = true)
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var result = _searchEngine.GetSearchHints(new SearchQuery
|
||||
var result = await _searchManager.GetSearchHintsAsync(new SearchQuery
|
||||
{
|
||||
Limit = limit,
|
||||
SearchTerm = searchTerm,
|
||||
@@ -121,7 +122,7 @@ public class SearchController : BaseJellyfinApiController
|
||||
IsNews = isNews,
|
||||
IsSeries = isSeries,
|
||||
IsSports = isSports
|
||||
});
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
return new SearchHintResult(result.Items.Select(GetSearchHintResult).ToArray(), result.TotalRecordCount);
|
||||
}
|
||||
|
||||
@@ -122,6 +122,7 @@ public class TrailersController : BaseJellyfinApiController
|
||||
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the trailers.</returns>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[Obsolete("Use GetItems with includeItemTypes=Trailer instead.")]
|
||||
public async Task<ActionResult<QueryResult<BaseItemDto>>> GetTrailers(
|
||||
[FromQuery] Guid? userId,
|
||||
[FromQuery] string? maxOfficialRating,
|
||||
|
||||
@@ -232,7 +232,7 @@ public class TvShowsController : BaseJellyfinApiController
|
||||
|
||||
if (seasonId.HasValue) // Season id was supplied. Get episodes by season id.
|
||||
{
|
||||
var item = _libraryManager.GetItemById<BaseItem>(seasonId.Value);
|
||||
var item = _libraryManager.GetItemById<BaseItem>(seasonId.Value, user);
|
||||
if (item is not Season seasonItem)
|
||||
{
|
||||
return NotFound("No season exists with Id " + seasonId);
|
||||
@@ -242,7 +242,7 @@ public class TvShowsController : BaseJellyfinApiController
|
||||
}
|
||||
else if (season.HasValue) // Season number was supplied. Get episodes by season number
|
||||
{
|
||||
var series = _libraryManager.GetItemById<Series>(seriesId);
|
||||
var series = _libraryManager.GetItemById<Series>(seriesId, user);
|
||||
if (series is null)
|
||||
{
|
||||
return NotFound("Series not found");
|
||||
@@ -258,7 +258,7 @@ public class TvShowsController : BaseJellyfinApiController
|
||||
}
|
||||
else // No season number or season id was supplied. Returning all episodes.
|
||||
{
|
||||
if (_libraryManager.GetItemById<BaseItem>(seriesId) is not Series series)
|
||||
if (_libraryManager.GetItemById<BaseItem>(seriesId, user) is not Series series)
|
||||
{
|
||||
return NotFound("Series not found");
|
||||
}
|
||||
@@ -277,8 +277,15 @@ public class TvShowsController : BaseJellyfinApiController
|
||||
|
||||
if (startItemId.HasValue)
|
||||
{
|
||||
// The start item may be an alternate version, which is not part of the episode listing; start from its primary episode instead.
|
||||
var startId = startItemId.Value;
|
||||
if (_libraryManager.GetItemById<Video>(startId)?.PrimaryVersionId is { } primaryVersionId)
|
||||
{
|
||||
startId = primaryVersionId;
|
||||
}
|
||||
|
||||
episodes = episodes
|
||||
.SkipWhile(i => !startItemId.Value.Equals(i.Id))
|
||||
.SkipWhile(i => !startId.Equals(i.Id))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
|
||||
@@ -163,7 +163,7 @@ public class UniversalAudioController : BaseJellyfinApiController
|
||||
Request.HttpContext.GetNormalizedRemoteIP());
|
||||
}
|
||||
|
||||
_mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate);
|
||||
_mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate, item.Id);
|
||||
|
||||
foreach (var source in info.MediaSources)
|
||||
{
|
||||
|
||||
@@ -429,14 +429,8 @@ public class UserLibraryController : BaseJellyfinApiController
|
||||
}
|
||||
|
||||
var dtoOptions = new DtoOptions();
|
||||
if (item is IHasTrailers hasTrailers)
|
||||
{
|
||||
var trailers = hasTrailers.LocalTrailers;
|
||||
return Ok(_dtoService.GetBaseItemDtos(trailers, dtoOptions, user, item).AsEnumerable());
|
||||
}
|
||||
|
||||
return Ok(item.GetExtras()
|
||||
.Where(e => e.ExtraType == ExtraType.Trailer)
|
||||
return Ok(item.GetExtras([ExtraType.Trailer], user)
|
||||
.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item)));
|
||||
}
|
||||
|
||||
@@ -487,7 +481,7 @@ public class UserLibraryController : BaseJellyfinApiController
|
||||
var dtoOptions = new DtoOptions();
|
||||
|
||||
return Ok(item
|
||||
.GetExtras()
|
||||
.GetExtras(user)
|
||||
.Where(i => i.ExtraType.HasValue && BaseItem.DisplayExtraTypes.Contains(i.ExtraType.Value))
|
||||
.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item)));
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@ public class VideosController : BaseJellyfinApiController
|
||||
BaseItemDto[] items;
|
||||
if (item is Video video)
|
||||
{
|
||||
items = video.GetAdditionalParts()
|
||||
items = video.GetAdditionalParts(user)
|
||||
.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, video))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using Jellyfin.Data.Enums;
|
||||
using MediaBrowser.Controller.Dto;
|
||||
using MediaBrowser.Model.Entities;
|
||||
|
||||
@@ -9,6 +10,37 @@ namespace Jellyfin.Api.Extensions;
|
||||
/// </summary>
|
||||
public static class DtoExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the BaseItemKind values associated with the specified CollectionType.
|
||||
/// </summary>
|
||||
/// <param name="collectionType">The collection type to map to BaseItemKind values.</param>
|
||||
/// <returns>An array of BaseItemKind values that correspond to the collection type.</returns>
|
||||
public static BaseItemKind[] GetBaseItemKindsForCollectionType(CollectionType? collectionType)
|
||||
{
|
||||
switch (collectionType)
|
||||
{
|
||||
case CollectionType.movies:
|
||||
return [BaseItemKind.Movie];
|
||||
case CollectionType.tvshows:
|
||||
return [BaseItemKind.Series];
|
||||
case CollectionType.music:
|
||||
return [BaseItemKind.MusicAlbum, BaseItemKind.MusicArtist];
|
||||
case CollectionType.musicvideos:
|
||||
return [BaseItemKind.MusicVideo];
|
||||
case CollectionType.books:
|
||||
return [BaseItemKind.Book, BaseItemKind.AudioBook];
|
||||
case CollectionType.boxsets:
|
||||
return [BaseItemKind.BoxSet];
|
||||
case CollectionType.homevideos:
|
||||
case CollectionType.photos:
|
||||
return [BaseItemKind.Video, BaseItemKind.Photo];
|
||||
case CollectionType.folders:
|
||||
return [];
|
||||
default:
|
||||
return [BaseItemKind.Video, BaseItemKind.Audio, BaseItemKind.Photo, BaseItemKind.Movie, BaseItemKind.Series];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add additional DtoOptions.
|
||||
/// </summary>
|
||||
|
||||
@@ -351,11 +351,20 @@ public class MediaInfoHelper
|
||||
/// </summary>
|
||||
/// <param name="result">Playback info response.</param>
|
||||
/// <param name="maxBitrate">Max bitrate.</param>
|
||||
public void SortMediaSources(PlaybackInfoResponse result, long? maxBitrate)
|
||||
/// <param name="preferredItemId">The id of the queried item, whose own media source must stay the default.</param>
|
||||
public void SortMediaSources(PlaybackInfoResponse result, long? maxBitrate, Guid preferredItemId = default)
|
||||
{
|
||||
var originalList = result.MediaSources.ToList();
|
||||
|
||||
result.MediaSources = result.MediaSources.OrderBy(i =>
|
||||
// The queried item's source carries the user's resume state for that version, so it must stay the
|
||||
// default the client plays. An unfavorable bitrate means transcoding it, not switching to a sibling version.
|
||||
var preferredId = preferredItemId.IsEmpty()
|
||||
? null
|
||||
: preferredItemId.ToString("N", CultureInfo.InvariantCulture);
|
||||
|
||||
result.MediaSources = result.MediaSources
|
||||
.OrderByDescending(i => preferredId is not null && string.Equals(i.Id, preferredId, StringComparison.OrdinalIgnoreCase))
|
||||
.ThenBy(i =>
|
||||
{
|
||||
// Nothing beats direct playing a file
|
||||
if (i.SupportsDirectPlay && i.Protocol == MediaProtocol.File)
|
||||
|
||||
@@ -144,6 +144,15 @@ public static class StreamingHelpers
|
||||
mediaSource = liveStreamInfo.Item1;
|
||||
state.DirectStreamProvider = liveStreamInfo.Item2;
|
||||
|
||||
// The requested live stream is no longer open. This commonly happens when a client keeps
|
||||
// polling the HLS playlist (e.g. live.m3u8) after the stream was disposed because its
|
||||
// consumer count dropped to zero. GetLiveStreamWithDirectStreamProvider returns a null
|
||||
// MediaSource in that case, so return 404 instead of dereferencing it below.
|
||||
if (mediaSource is null)
|
||||
{
|
||||
throw new ResourceNotFoundException($"The live stream with id {streamingRequest.LiveStreamId} could not be found or is no longer available.");
|
||||
}
|
||||
|
||||
// Cap the max bitrate when it is too high. This is usually due to ffmpeg is unable to probe the source liveTV streams' bitrate.
|
||||
if (mediaSource.FallbackMaxStreamingBitrate is not null && streamingRequest.VideoBitRate is not null)
|
||||
{
|
||||
|
||||
@@ -56,11 +56,11 @@ public class ActivityManager : IActivityManager
|
||||
var dbContext = await _provider.CreateDbContextAsync().ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
// TODO switch to LeftJoin in .NET 10.
|
||||
var entries = from a in dbContext.ActivityLogs
|
||||
join u in dbContext.Users on a.UserId equals u.Id into ugj
|
||||
from u in ugj.DefaultIfEmpty()
|
||||
select new ExpandedActivityLog { ActivityLog = a, Username = u.Username };
|
||||
var entries = dbContext.ActivityLogs.LeftJoin(
|
||||
dbContext.Users,
|
||||
a => a.UserId,
|
||||
u => u.Id,
|
||||
(a, u) => new ExpandedActivityLog { ActivityLog = a, Username = u == null ? null : u.Username });
|
||||
|
||||
if (query.HasUserId is not null)
|
||||
{
|
||||
|
||||
@@ -213,8 +213,10 @@ namespace Jellyfin.Server.Implementations.Devices
|
||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
dbContext.Devices.Remove(device);
|
||||
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
||||
await dbContext.Devices
|
||||
.Where(d => d.Id == device.Id)
|
||||
.ExecuteDeleteAsync()
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Server.Implementations.StorageHelpers;
|
||||
using Jellyfin.Server.Implementations.SystemBackupService;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.SystemBackupService;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
@@ -33,6 +34,7 @@ public class BackupService : IBackupService
|
||||
private readonly IServerApplicationPaths _applicationPaths;
|
||||
private readonly IJellyfinDatabaseProvider _jellyfinDatabaseProvider;
|
||||
private readonly IHostApplicationLifetime _hostApplicationLifetime;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private static readonly JsonSerializerOptions _serializerSettings = new JsonSerializerOptions(JsonSerializerDefaults.General)
|
||||
{
|
||||
AllowTrailingCommas = true,
|
||||
@@ -50,13 +52,15 @@ public class BackupService : IBackupService
|
||||
/// <param name="applicationPaths">The application paths.</param>
|
||||
/// <param name="jellyfinDatabaseProvider">The Jellyfin database Provider in use.</param>
|
||||
/// <param name="applicationLifetime">The SystemManager.</param>
|
||||
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
|
||||
public BackupService(
|
||||
ILogger<BackupService> logger,
|
||||
IDbContextFactory<JellyfinDbContext> dbProvider,
|
||||
IServerApplicationHost applicationHost,
|
||||
IServerApplicationPaths applicationPaths,
|
||||
IJellyfinDatabaseProvider jellyfinDatabaseProvider,
|
||||
IHostApplicationLifetime applicationLifetime)
|
||||
IHostApplicationLifetime applicationLifetime,
|
||||
ILibraryManager libraryManager)
|
||||
{
|
||||
_logger = logger;
|
||||
_dbProvider = dbProvider;
|
||||
@@ -64,6 +68,7 @@ public class BackupService : IBackupService
|
||||
_applicationPaths = applicationPaths;
|
||||
_jellyfinDatabaseProvider = jellyfinDatabaseProvider;
|
||||
_hostApplicationLifetime = applicationLifetime;
|
||||
_libraryManager = libraryManager;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -263,6 +268,14 @@ public class BackupService : IBackupService
|
||||
/// <inheritdoc/>
|
||||
public async Task<BackupManifestDto> CreateBackupAsync(BackupOptionsDto backupOptions)
|
||||
{
|
||||
// Creating a backup runs a database optimization and reads the entire database under a transaction, both of
|
||||
// which heavily contend with an active library scan and could capture an inconsistent database state.
|
||||
if (_libraryManager.IsScanRunning)
|
||||
{
|
||||
_logger.LogWarning("Cannot create a backup while a library scan is running.");
|
||||
throw new InvalidOperationException("Cannot create a backup while a library scan is running. Please try again once the scan has finished.");
|
||||
}
|
||||
|
||||
var manifest = new BackupManifest()
|
||||
{
|
||||
DateCreated = DateTime.UtcNow,
|
||||
|
||||
@@ -62,18 +62,21 @@ public sealed partial class BaseItemRepository
|
||||
|
||||
private IQueryable<BaseItemEntity> ApplyGroupingFilter(JellyfinDbContext context, IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter)
|
||||
{
|
||||
// Collapse duplicates sharing a presentation key (e.g. alternate versions) by picking
|
||||
// the min Id per group. Keep the grouped ids as an IQueryable sub-select; materializing
|
||||
// Collapse duplicates sharing a presentation key (e.g. alternate versions), preferring the
|
||||
// primary version (PrimaryVersionId is null) so detail pages and actions target it instead
|
||||
// of an arbitrary alternate. Keep the grouped ids as an IQueryable sub-select; materializing
|
||||
// to a List would inline one bound parameter per id and hit SQLite's variable cap.
|
||||
var enableGroupByPresentationUniqueKey = EnableGroupByPresentationUniqueKey(filter);
|
||||
if (enableGroupByPresentationUniqueKey && filter.GroupBySeriesPresentationUniqueKey)
|
||||
{
|
||||
var groupedIds = dbQuery.GroupBy(e => new { e.PresentationUniqueKey, e.SeriesPresentationUniqueKey }).Select(e => e.Min(x => x.Id));
|
||||
var groupedIds = dbQuery.GroupBy(e => new { e.PresentationUniqueKey, e.SeriesPresentationUniqueKey })
|
||||
.Select(g => g.Where(e => e.PrimaryVersionId == null).Min(e => (Guid?)e.Id) ?? g.Min(e => (Guid?)e.Id));
|
||||
dbQuery = context.BaseItems.AsNoTracking().Where(e => groupedIds.Contains(e.Id));
|
||||
}
|
||||
else if (enableGroupByPresentationUniqueKey)
|
||||
{
|
||||
var groupedIds = dbQuery.GroupBy(e => e.PresentationUniqueKey).Select(e => e.Min(x => x.Id));
|
||||
var groupedIds = dbQuery.GroupBy(e => e.PresentationUniqueKey)
|
||||
.Select(g => g.Where(e => e.PrimaryVersionId == null).Min(e => (Guid?)e.Id) ?? g.Min(e => (Guid?)e.Id));
|
||||
dbQuery = context.BaseItems.AsNoTracking().Where(e => groupedIds.Contains(e.Id));
|
||||
}
|
||||
else if (filter.GroupBySeriesPresentationUniqueKey)
|
||||
|
||||
@@ -513,13 +513,17 @@ public sealed partial class BaseItemRepository
|
||||
if (filter.IsResumable.HasValue)
|
||||
{
|
||||
var hasSeries = filter.IncludeItemTypes.Contains(BaseItemKind.Series);
|
||||
var userId = filter.User!.Id;
|
||||
var isResumable = filter.IsResumable.Value;
|
||||
var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series];
|
||||
|
||||
// In-progress user data rows; alternate versions track their own progress.
|
||||
var inProgress = context.UserData
|
||||
.Where(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0);
|
||||
|
||||
IQueryable<Guid>? resumableSeriesIds = null;
|
||||
if (hasSeries)
|
||||
{
|
||||
var userId = filter.User!.Id;
|
||||
var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series];
|
||||
var isResumable = filter.IsResumable.Value;
|
||||
|
||||
// Aggregate per series in a single GROUP BY pass, instead of three full scans.
|
||||
var seriesEpisodeStats = context.BaseItems
|
||||
.AsNoTracking()
|
||||
@@ -535,26 +539,44 @@ public sealed partial class BaseItemRepository
|
||||
|
||||
// A series is resumable if it has an in-progress episode,
|
||||
// or if it has both played and unplayed episodes (partially watched).
|
||||
var resumableSeriesIds = seriesEpisodeStats
|
||||
resumableSeriesIds = seriesEpisodeStats
|
||||
.Where(s => s.HasInProgress || (s.HasPlayed && s.HasUnplayed))
|
||||
.Select(s => s.SeriesId);
|
||||
}
|
||||
|
||||
// Non-series items: resumable if PlaybackPositionTicks > 0
|
||||
var resumableItemIds = context.UserData
|
||||
.Where(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0)
|
||||
.Select(ud => ud.ItemId);
|
||||
if (isResumable)
|
||||
{
|
||||
// Resume queries surface the version that was actually played, which may be an alternate.
|
||||
// Match each version on its own progress rather than coalescing onto the primary.
|
||||
var inProgressIds = inProgress.Select(ud => ud.ItemId);
|
||||
|
||||
baseQuery = baseQuery.Where(e =>
|
||||
(e.Type == seriesTypeName && resumableSeriesIds.Contains(e.Id) == isResumable)
|
||||
|| (e.Type != seriesTypeName && resumableItemIds.Contains(e.Id) == isResumable));
|
||||
baseQuery = hasSeries
|
||||
? baseQuery.Where(e =>
|
||||
(e.Type == seriesTypeName && resumableSeriesIds!.Contains(e.Id))
|
||||
|| (e.Type != seriesTypeName && inProgressIds.Contains(e.Id)))
|
||||
: baseQuery.Where(e => inProgressIds.Contains(e.Id));
|
||||
|
||||
// When several versions of the same item are in progress, keep only the most recently played one, use id as tiebreaker.
|
||||
baseQuery = baseQuery.Where(e => e.Type == seriesTypeName || !context.BaseItems
|
||||
.Where(s => s.Id != e.Id && (s.PrimaryVersionId ?? s.Id) == (e.PrimaryVersionId ?? e.Id))
|
||||
.Any(s =>
|
||||
inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate)
|
||||
> inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate)
|
||||
|| (inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate)
|
||||
== inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate)
|
||||
&& s.Id.CompareTo(e.Id) < 0)));
|
||||
}
|
||||
else
|
||||
{
|
||||
var resumableItemIds = context.UserData
|
||||
.Where(ud => ud.UserId == filter.User!.Id && ud.PlaybackPositionTicks > 0)
|
||||
.Select(ud => ud.ItemId);
|
||||
var isResumable = filter.IsResumable.Value;
|
||||
baseQuery = baseQuery.Where(e => resumableItemIds.Contains(e.Id) == isResumable);
|
||||
// Not-resumable queries operate on primaries only.
|
||||
var resumableMovieIds = inProgress
|
||||
.Join(context.BaseItems, ud => ud.ItemId, bi => bi.Id, (ud, bi) => bi.PrimaryVersionId ?? bi.Id);
|
||||
|
||||
baseQuery = hasSeries
|
||||
? baseQuery.Where(e =>
|
||||
(e.Type == seriesTypeName && !resumableSeriesIds!.Contains(e.Id))
|
||||
|| (e.Type != seriesTypeName && !resumableMovieIds.Contains(e.Id)))
|
||||
: baseQuery.Where(e => !resumableMovieIds.Contains(e.Id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -586,8 +608,7 @@ public sealed partial class BaseItemRepository
|
||||
|
||||
if (filter.AlbumIds.Length > 0)
|
||||
{
|
||||
var subQuery = context.BaseItems.WhereOneOrMany(filter.AlbumIds, f => f.Id);
|
||||
baseQuery = baseQuery.Where(e => subQuery.Any(f => f.Name == e.Album));
|
||||
baseQuery = baseQuery.Where(e => e.ParentId.HasValue && filter.AlbumIds.Contains(e.ParentId.Value));
|
||||
}
|
||||
|
||||
if (filter.ExcludeArtistIds.Length > 0)
|
||||
@@ -742,10 +763,13 @@ public sealed partial class BaseItemRepository
|
||||
}
|
||||
else if (filter.OwnerIds.Length == 0 && filter.ExtraTypes.Length == 0 && !filter.IncludeOwnedItems)
|
||||
{
|
||||
// Exclude alternate versions and owned non-extra items from general queries.
|
||||
// Alternate versions have PrimaryVersionId set (pointing to their primary).
|
||||
// Exclude owned non-extra items from general queries.
|
||||
// Extras (trailers, etc.) have OwnerId set but also have ExtraType set - keep those.
|
||||
baseQuery = baseQuery.Where(e => e.PrimaryVersionId == null && (e.OwnerId == null || e.ExtraType != null));
|
||||
// Alternate versions (PrimaryVersionId set) are normally excluded too, but resume queries
|
||||
// keep them so the actually-played version can surface instead of collapsing onto the primary.
|
||||
baseQuery = filter.IsResumable == true
|
||||
? baseQuery.Where(e => e.OwnerId == null || e.ExtraType != null)
|
||||
: baseQuery.Where(e => e.PrimaryVersionId == null && (e.OwnerId == null || e.ExtraType != null));
|
||||
}
|
||||
|
||||
if (filter.OwnerIds.Length > 0)
|
||||
@@ -953,24 +977,17 @@ public sealed partial class BaseItemRepository
|
||||
|
||||
if (filter.ExcludeProviderIds is not null && filter.ExcludeProviderIds.Count > 0)
|
||||
{
|
||||
var exclude = filter.ExcludeProviderIds.Select(e => $"{e.Key}:{e.Value}").ToArray();
|
||||
baseQuery = baseQuery.Where(e => e.Provider!.Select(f => f.ProviderId + ":" + f.ProviderValue)!.All(f => !exclude.Contains(f)));
|
||||
baseQuery = baseQuery.WhereExcludeProviderIds(filter.ExcludeProviderIds);
|
||||
}
|
||||
|
||||
if (filter.HasAnyProviderId is not null && filter.HasAnyProviderId.Count > 0)
|
||||
{
|
||||
// Allow setting a null or empty value to get all items that have the specified provider set.
|
||||
var includeAny = filter.HasAnyProviderId.Where(e => string.IsNullOrEmpty(e.Value)).Select(e => e.Key).ToArray();
|
||||
if (includeAny.Length > 0)
|
||||
{
|
||||
baseQuery = baseQuery.Where(e => e.Provider!.Any(f => includeAny.Contains(f.ProviderId)));
|
||||
}
|
||||
baseQuery = baseQuery.WhereHasAnyProviderId(filter.HasAnyProviderId);
|
||||
}
|
||||
|
||||
var includeSelected = filter.HasAnyProviderId.Where(e => !string.IsNullOrEmpty(e.Value)).Select(e => $"{e.Key}:{e.Value}").ToArray();
|
||||
if (includeSelected.Length > 0)
|
||||
{
|
||||
baseQuery = baseQuery.Where(e => e.Provider!.Select(f => f.ProviderId + ":" + f.ProviderValue)!.Any(f => includeSelected.Contains(f)));
|
||||
}
|
||||
if (filter.HasAnyProviderIds is not null && filter.HasAnyProviderIds.Count > 0)
|
||||
{
|
||||
baseQuery = baseQuery.WhereHasAnyProviderIds(filter.HasAnyProviderIds);
|
||||
}
|
||||
|
||||
if (filter.HasAnyProviderIds is not null && filter.HasAnyProviderIds.Count > 0)
|
||||
|
||||
@@ -167,6 +167,14 @@ public sealed partial class BaseItemRepository
|
||||
return false;
|
||||
}
|
||||
|
||||
// Resume queries surface the actually-played version (which may be an alternate sharing the
|
||||
// primary's presentation key). The resumable filter already keeps one version per group, so
|
||||
// presentation-key grouping must not collapse the surfaced version back onto the primary.
|
||||
if (query.IsResumable == true)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (query.GroupBySeriesPresentationUniqueKey)
|
||||
{
|
||||
return false;
|
||||
|
||||
@@ -65,8 +65,13 @@ public class ItemPersistenceService : IItemPersistenceService
|
||||
descendantIds.Add(id);
|
||||
}
|
||||
|
||||
// Use WhereOneOrMany instead of a raw HashSet.Contains so large id sets are bound as a
|
||||
// single parameter (json_each) rather than one SQL variable per id, which would otherwise
|
||||
// overflow SQLite's variable limit when deleting many items at once (e.g. migrations).
|
||||
var ownerIds = descendantIds.ToArray();
|
||||
var extraIds = context.BaseItems
|
||||
.Where(e => e.OwnerId.HasValue && descendantIds.Contains(e.OwnerId.Value))
|
||||
.Where(e => e.OwnerId.HasValue)
|
||||
.WhereOneOrMany(ownerIds, e => e.OwnerId!.Value)
|
||||
.Select(e => e.Id)
|
||||
.ToArray();
|
||||
|
||||
@@ -557,9 +562,11 @@ public class ItemPersistenceService : IItemPersistenceService
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate; local (file-based) relationships take priority over linked (user-merged)
|
||||
// ones, matching the LinkedChildren migration.
|
||||
newLinkedChildren = newLinkedChildren
|
||||
.GroupBy(c => c.ChildId)
|
||||
.Select(g => g.Last())
|
||||
.Select(g => g.OrderBy(c => c.Type == LinkedChildType.LocalAlternateVersion ? 0 : 1).First())
|
||||
.ToList();
|
||||
|
||||
var childIdsToCheck = newLinkedChildren.Select(c => c.ChildId).ToList();
|
||||
|
||||
@@ -34,7 +34,14 @@ public static class OrderMapper
|
||||
(ItemSortBy.AirTime, _) => e => e.SortName,
|
||||
(ItemSortBy.Runtime, _) => e => e.RunTimeTicks,
|
||||
(ItemSortBy.Random, _) => e => EF.Functions.Random(),
|
||||
(ItemSortBy.DatePlayed, _) => e => e.UserData!.Where(f => f.UserId.Equals(query.User!.Id)).OrderBy(f => f.CustomDataKey).FirstOrDefault()!.LastPlayedDate,
|
||||
(ItemSortBy.DatePlayed, not null) => e =>
|
||||
jellyfinDbContext.UserData
|
||||
.Where(w => w.UserId == query.User.Id && (w.ItemId == e.Id || w.Item!.PrimaryVersionId == e.Id))
|
||||
.Max(f => f.LastPlayedDate),
|
||||
(ItemSortBy.DatePlayed, null) => e =>
|
||||
jellyfinDbContext.UserData
|
||||
.Where(w => w.ItemId == e.Id || w.Item!.PrimaryVersionId == e.Id)
|
||||
.Max(f => f.LastPlayedDate),
|
||||
(ItemSortBy.PlayCount, _) => e => e.UserData!.Where(f => f.UserId.Equals(query.User!.Id)).OrderBy(f => f.CustomDataKey).FirstOrDefault()!.PlayCount,
|
||||
(ItemSortBy.IsFavoriteOrLiked, _) => e => e.UserData!.Where(f => f.UserId.Equals(query.User!.Id)).OrderBy(f => f.CustomDataKey).Select(f => (bool?)f.IsFavorite).FirstOrDefault() ?? false,
|
||||
(ItemSortBy.IsFolder, _) => e => e.IsFolder,
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AsyncKeyedLock;
|
||||
@@ -28,7 +29,7 @@ namespace Jellyfin.Server.Implementations.Trickplay;
|
||||
/// <summary>
|
||||
/// ITrickplayManager implementation.
|
||||
/// </summary>
|
||||
public class TrickplayManager : ITrickplayManager
|
||||
public partial class TrickplayManager : ITrickplayManager
|
||||
{
|
||||
private readonly ILogger<TrickplayManager> _logger;
|
||||
private readonly IMediaEncoder _mediaEncoder;
|
||||
@@ -135,6 +136,147 @@ public class TrickplayManager : ITrickplayManager
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DiscoverExistingTrickplayAsync(Video video, bool saveWithMedia, CancellationToken cancellationToken)
|
||||
{
|
||||
var options = _config.Configuration.TrickplayOptions;
|
||||
var existing = await GetTrickplayResolutions(video.Id).ConfigureAwait(false);
|
||||
|
||||
// Remove DB rows whose on-disk folder no longer exists in either possible location.
|
||||
// Checking both locations avoids dropping rows mid-`SaveTrickplayWithMedia` migration.
|
||||
var orphanedWidths = new List<int>();
|
||||
foreach (var (width, info) in existing)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var localDir = GetTrickplayDirectory(video, info.TileWidth, info.TileHeight, info.Width, false);
|
||||
var mediaDir = GetTrickplayDirectory(video, info.TileWidth, info.TileHeight, info.Width, true);
|
||||
if (!HasTrickplayTiles(localDir) && !HasTrickplayTiles(mediaDir))
|
||||
{
|
||||
orphanedWidths.Add(width);
|
||||
}
|
||||
}
|
||||
|
||||
if (orphanedWidths.Count > 0)
|
||||
{
|
||||
var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
await dbContext.TrickplayInfos
|
||||
.Where(i => i.ItemId.Equals(video.Id) && orphanedWidths.Contains(i.Width))
|
||||
.ExecuteDeleteAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
foreach (var width in orphanedWidths)
|
||||
{
|
||||
_logger.LogInformation("Removed orphaned trickplay DB entry width={Width} for {Path}", width, video.Path);
|
||||
existing.Remove(width);
|
||||
}
|
||||
}
|
||||
|
||||
var trickplayDirectory = _pathManager.GetTrickplayDirectory(video, saveWithMedia);
|
||||
if (!Directory.Exists(trickplayDirectory))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var subdir in new DirectoryInfo(trickplayDirectory).EnumerateDirectories())
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var match = TrickplaySubdirRegex().Match(subdir.Name);
|
||||
if (!match.Success)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var width = int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture);
|
||||
var tileWidth = int.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture);
|
||||
var tileHeight = int.Parse(match.Groups[3].Value, CultureInfo.InvariantCulture);
|
||||
|
||||
if (existing.ContainsKey(width))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var tiles = subdir.GetFiles("*.jpg")
|
||||
.OrderBy(t => t.Name, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
if (tiles.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// The encoder pads the last tile to a full TileWidth*TileHeight grid, so the real
|
||||
// thumbnail count cannot be read from tile dimensions. Instead, bound the count from
|
||||
// the tile count and per-tile capacity, then pick an interval consistent with the
|
||||
// video runtime - snapping to the server's configured interval when it fits.
|
||||
var thumbsPerTile = tileWidth * tileHeight;
|
||||
var maxThumbs = tiles.Length * thumbsPerTile;
|
||||
var minThumbs = tiles.Length > 1 ? ((tiles.Length - 1) * thumbsPerTile) + 1 : 1;
|
||||
|
||||
int interval;
|
||||
int thumbnailCount;
|
||||
if (video.RunTimeTicks is long ticks)
|
||||
{
|
||||
var runtimeMs = ticks / TimeSpan.TicksPerMillisecond;
|
||||
var minInterval = Math.Max(1000L, (long)Math.Ceiling(runtimeMs / (double)maxThumbs));
|
||||
var maxInterval = Math.Max(minInterval, (long)Math.Floor(runtimeMs / (double)minThumbs));
|
||||
|
||||
if (options.Interval >= minInterval && options.Interval <= maxInterval)
|
||||
{
|
||||
interval = options.Interval;
|
||||
}
|
||||
else
|
||||
{
|
||||
var midpoint = (minInterval + maxInterval) / 2.0;
|
||||
var snapped = (long)Math.Round(midpoint / 1000d) * 1000L;
|
||||
interval = (int)Math.Clamp(snapped, minInterval, maxInterval);
|
||||
}
|
||||
|
||||
thumbnailCount = Math.Clamp(
|
||||
(int)Math.Round(runtimeMs / (double)interval),
|
||||
minThumbs,
|
||||
maxThumbs);
|
||||
}
|
||||
else
|
||||
{
|
||||
interval = Math.Max(1000, options.Interval);
|
||||
thumbnailCount = maxThumbs;
|
||||
}
|
||||
|
||||
var firstSize = _imageEncoder.GetImageSize(tiles[0].FullName);
|
||||
var thumbPxH = Math.Max(1, (int)Math.Ceiling((double)firstSize.Height / tileHeight));
|
||||
|
||||
var info = new TrickplayInfo
|
||||
{
|
||||
ItemId = video.Id,
|
||||
Width = width,
|
||||
Interval = interval,
|
||||
TileWidth = tileWidth,
|
||||
TileHeight = tileHeight,
|
||||
ThumbnailCount = thumbnailCount,
|
||||
Height = thumbPxH,
|
||||
Bandwidth = 0,
|
||||
};
|
||||
|
||||
foreach (var tile in tiles)
|
||||
{
|
||||
var bitrate = (int)Math.Ceiling((decimal)tile.Length * 8 / tileWidth / tileHeight / (interval / 1000m));
|
||||
info.Bandwidth = Math.Max(info.Bandwidth, bitrate);
|
||||
}
|
||||
|
||||
await SaveTrickplayInfo(info).ConfigureAwait(false);
|
||||
_logger.LogInformation(
|
||||
"Discovered existing trickplay {Width} - {TileWidth}x{TileHeight} ({ThumbnailCount} thumbnails, {Interval}ms interval) for {Path}",
|
||||
width,
|
||||
tileWidth,
|
||||
tileHeight,
|
||||
thumbnailCount,
|
||||
interval,
|
||||
video.Path);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RefreshTrickplayDataAsync(Video video, bool replace, LibraryOptions libraryOptions, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -144,11 +286,27 @@ public class TrickplayManager : ITrickplayManager
|
||||
return;
|
||||
}
|
||||
|
||||
var saveWithMedia = libraryOptions.SaveTrickplayWithMedia;
|
||||
|
||||
// Catalog any existing trickplay folders on disk before any prune/generate. This picks up
|
||||
// user-placed files even when their (width, tile dims) don't match the server's configured values.
|
||||
if (!replace)
|
||||
{
|
||||
await DiscoverExistingTrickplayAsync(video, saveWithMedia, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
var saveWithMedia = libraryOptions.SaveTrickplayWithMedia;
|
||||
var trickplayDirectory = _pathManager.GetTrickplayDirectory(video, saveWithMedia);
|
||||
|
||||
// When extraction is disabled and files live next to media, treat them as user-managed:
|
||||
// discovery above already catalogued whatever is on disk, leave it alone.
|
||||
if (!libraryOptions.EnableTrickplayImageExtraction && !replace && saveWithMedia)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!libraryOptions.EnableTrickplayImageExtraction || replace)
|
||||
{
|
||||
// Prune existing data
|
||||
@@ -688,6 +846,19 @@ public class TrickplayManager : ITrickplayManager
|
||||
return Path.Combine(path, subdirectory);
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"^(\d+) - (\d+)x(\d+)$")]
|
||||
private static partial Regex TrickplaySubdirRegex();
|
||||
|
||||
private static bool HasTrickplayTiles(string directory)
|
||||
{
|
||||
if (!Directory.Exists(directory))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return new DirectoryInfo(directory).EnumerateFiles("*.jpg").Any();
|
||||
}
|
||||
|
||||
private async Task<bool> HasTrickplayResolutionAsync(Guid itemId, int width)
|
||||
{
|
||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||
|
||||
@@ -51,7 +51,7 @@ namespace Jellyfin.Server.Implementations.Users
|
||||
private readonly DefaultPasswordResetProvider _defaultPasswordResetProvider;
|
||||
private readonly IServerConfigurationManager _serverConfigurationManager;
|
||||
|
||||
private readonly AsyncKeyedLocker<Guid> _userLock = new();
|
||||
private readonly LockHelper _userLock = new();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="UserManager"/> class.
|
||||
@@ -170,7 +170,7 @@ namespace Jellyfin.Server.Implementations.Users
|
||||
{
|
||||
ThrowIfInvalidUsername(newName);
|
||||
|
||||
if (oldName.Equals(newName, StringComparison.OrdinalIgnoreCase))
|
||||
if (oldName.Equals(newName, StringComparison.Ordinal))
|
||||
{
|
||||
throw new ArgumentException("The new and old names must be different.");
|
||||
}
|
||||
@@ -214,7 +214,58 @@ namespace Jellyfin.Server.Implementations.Users
|
||||
{
|
||||
using (await _userLock.LockAsync(user.Id).ConfigureAwait(false))
|
||||
{
|
||||
await UpdateUserInternalAsync(user).ConfigureAwait(false);
|
||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||
await using (dbContext.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -453,12 +504,14 @@ namespace Jellyfin.Server.Implementations.Users
|
||||
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 = GetUserById(user.Id) ?? user;
|
||||
user = await UserQuery(dbContext).FirstOrDefaultAsync(e => e.Id == user.Id).ConfigureAwait(false) ?? user;
|
||||
}
|
||||
|
||||
var authResult = await AuthenticateLocalUser(username, password, user)
|
||||
@@ -466,6 +519,13 @@ namespace Jellyfin.Server.Implementations.Users
|
||||
var authenticationProvider = authResult.AuthenticationProvider;
|
||||
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)
|
||||
{
|
||||
string updatedUsername = authResult.Username;
|
||||
@@ -479,11 +539,16 @@ namespace Jellyfin.Server.Implementations.Users
|
||||
|
||||
// Search the database for the user again
|
||||
// the authentication provider might have created it
|
||||
user = GetUserByName(username);
|
||||
#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)
|
||||
{
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -494,8 +559,10 @@ namespace Jellyfin.Server.Implementations.Users
|
||||
|
||||
if (providerId is not null && !string.Equals(providerId, user.AuthenticationProviderId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
user.AuthenticationProviderId = providerId;
|
||||
await UpdateUserInternalAsync(user).ConfigureAwait(false);
|
||||
await dbContext.Users
|
||||
.Where(e => e.Id == user.Id)
|
||||
.ExecuteUpdateAsync(e => e.SetProperty(f => f.AuthenticationProviderId, providerId))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -542,16 +609,43 @@ namespace Jellyfin.Server.Implementations.Users
|
||||
{
|
||||
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 UpdateUserInternalAsync(user).ConfigureAwait(false);
|
||||
await dbContext.Users
|
||||
.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);
|
||||
}
|
||||
else
|
||||
{
|
||||
await IncrementInvalidLoginAttemptCount(user).ConfigureAwait(false);
|
||||
user.InvalidLoginAttemptCount++;
|
||||
int? maxInvalidLogins = user.LoginAttemptsBeforeLockout;
|
||||
if (maxInvalidLogins.HasValue && user.InvalidLoginAttemptCount >= maxInvalidLogins)
|
||||
{
|
||||
user.SetPermission(PermissionKind.IsDisabled, true);
|
||||
dbContext.Update(user);
|
||||
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(
|
||||
"Authentication request for {UserName} has been denied (IP: {IP}).",
|
||||
user.Username,
|
||||
@@ -926,32 +1020,6 @@ 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 UpdateUserInternalAsync(user).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task UpdateUserInternalAsync(User user)
|
||||
{
|
||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
await UpdateUserInternalAsync(dbContext, user).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UpdateUserInternalAsync(JellyfinDbContext dbContext, User user)
|
||||
{
|
||||
dbContext.Users.Attach(user);
|
||||
@@ -977,5 +1045,70 @@ namespace Jellyfin.Server.Implementations.Users
|
||||
_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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,8 +215,11 @@ internal class JellyfinMigrationService
|
||||
logger.LogInformation("There are {Pending} migrations for stage {Stage}.", pendingCodeMigrations.Length, stage);
|
||||
migrations = pendingMigrations.OrderBy(e => e.Key).ToArray();
|
||||
|
||||
var migrationIndex = 0;
|
||||
foreach (var item in migrations)
|
||||
{
|
||||
// Surface generic "Running migration X of Y" progress in the always-visible startup UI header.
|
||||
SetupServer.ReportActivity(StartupActivity.Migration(++migrationIndex, migrations.Length));
|
||||
var migrationLogger = logger.With(_loggerFactory.CreateLogger(item.Migration.GetType().Name)).BeginGroup($"{item.Key}");
|
||||
try
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using Emby.Server.Implementations.Data;
|
||||
using Jellyfin.Database.Implementations;
|
||||
@@ -79,9 +80,9 @@ namespace Jellyfin.Server.Migrations.Routines
|
||||
foreach (var row in authenticatedDevices)
|
||||
{
|
||||
var dateCreatedStr = row.GetString(9);
|
||||
_ = DateTime.TryParse(dateCreatedStr, out var dateCreated);
|
||||
_ = DateTime.TryParse(dateCreatedStr, CultureInfo.InvariantCulture, out var dateCreated);
|
||||
var dateLastActivityStr = row.GetString(10);
|
||||
_ = DateTime.TryParse(dateLastActivityStr, out var dateLastActivity);
|
||||
_ = DateTime.TryParse(dateLastActivityStr, CultureInfo.InvariantCulture, out var dateLastActivity);
|
||||
|
||||
if (row.IsDBNull(6))
|
||||
{
|
||||
|
||||
@@ -223,6 +223,35 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
|
||||
|
||||
toInsert = toInsert.Where(lc => existingChildIds.Contains(lc.ChildId)).ToList();
|
||||
|
||||
// Drop linked (user-merged) entries that point at items the parent owns (local
|
||||
// file-based alternates or extras). These stem from legacy data that merged an
|
||||
// owned item onto its own primary and would wrongly mark server-merged groups
|
||||
// as user-merged (splittable).
|
||||
var linkedChildIds = toInsert
|
||||
.Where(lc => lc.ChildType == LinkedChildType.LinkedAlternateVersion)
|
||||
.Select(lc => lc.ChildId)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (linkedChildIds.Count > 0)
|
||||
{
|
||||
var ownerIdByChildId = context.BaseItems
|
||||
.WhereOneOrMany(linkedChildIds, b => b.Id)
|
||||
.Where(b => b.OwnerId.HasValue)
|
||||
.Select(b => new { b.Id, b.OwnerId })
|
||||
.ToDictionary(b => b.Id, b => b.OwnerId!.Value);
|
||||
|
||||
var removedCount = toInsert.RemoveAll(lc =>
|
||||
lc.ChildType == LinkedChildType.LinkedAlternateVersion
|
||||
&& ownerIdByChildId.TryGetValue(lc.ChildId, out var ownerId)
|
||||
&& ownerId.Equals(lc.ParentId));
|
||||
|
||||
if (removedCount > 0)
|
||||
{
|
||||
_logger.LogInformation("Skipped {Count} LinkedAlternateVersion records pointing at items owned by their parent.", removedCount);
|
||||
}
|
||||
}
|
||||
|
||||
context.LinkedChildren.AddRange(toInsert);
|
||||
context.SaveChanges();
|
||||
|
||||
|
||||
@@ -76,25 +76,36 @@ public class CleanupOrphanedExtras : IAsyncMigrationRoutine
|
||||
|
||||
_logger.LogInformation("Found {Count} orphaned extras to remove", orphanedItemIds.Count);
|
||||
|
||||
// Batch-resolve items for metadata path cleanup, then delete all at once
|
||||
var itemsToDelete = new List<BaseItem>();
|
||||
foreach (var itemId in orphanedItemIds)
|
||||
// Resolve items for metadata path cleanup, then delete in batches so we never issue one
|
||||
// massive delete transaction and progress stays visible on large libraries.
|
||||
_logger.LogInformation("Deleting {Count} orphaned extras...", orphanedItemIds.Count);
|
||||
const int deleteBatchSize = 500;
|
||||
var deletedSoFar = 0;
|
||||
for (var offset = 0; offset < orphanedItemIds.Count; offset += deleteBatchSize)
|
||||
{
|
||||
itemsToDelete.Add(BaseItemMapper.DeserializeBaseItem(
|
||||
new Database.Implementations.Entities.BaseItemEntity()
|
||||
{
|
||||
Id = itemId.Id,
|
||||
Path = itemId.Path,
|
||||
Type = itemId.Type
|
||||
},
|
||||
_logger,
|
||||
null,
|
||||
true)!);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var batch = orphanedItemIds.GetRange(offset, Math.Min(deleteBatchSize, orphanedItemIds.Count - offset));
|
||||
var itemsToDelete = batch
|
||||
.Select(itemId => BaseItemMapper.DeserializeBaseItem(
|
||||
new Database.Implementations.Entities.BaseItemEntity()
|
||||
{
|
||||
Id = itemId.Id,
|
||||
Path = itemId.Path,
|
||||
Type = itemId.Type
|
||||
},
|
||||
_logger,
|
||||
null,
|
||||
true)!)
|
||||
.ToList();
|
||||
|
||||
_libraryManager.DeleteItemsUnsafeFast(itemsToDelete);
|
||||
|
||||
deletedSoFar += batch.Count;
|
||||
_logger.LogInformation("Deleting orphaned extras: {Deleted}/{Total}", deletedSoFar, orphanedItemIds.Count);
|
||||
}
|
||||
|
||||
_libraryManager.DeleteItemsUnsafeFast(itemsToDelete);
|
||||
|
||||
_logger.LogInformation("Successfully removed {Count} orphaned extras", itemsToDelete.Count);
|
||||
_logger.LogInformation("Successfully removed {Count} orphaned extras", orphanedItemIds.Count);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,19 +136,38 @@ public class FixIncorrectOwnerIdRelationships : IAsyncMigrationRoutine
|
||||
|
||||
if (allIdsToDelete.Count > 0)
|
||||
{
|
||||
// Batch-resolve items for metadata path cleanup, then delete all at once
|
||||
var itemsToDelete = allIdsToDelete
|
||||
.Select(id => _libraryManager.GetItemById(id))
|
||||
.Where(item => item is not null)
|
||||
.ToList();
|
||||
_libraryManager.DeleteItemsUnsafeFast(itemsToDelete!);
|
||||
_logger.LogInformation("Deleting {Count} duplicate database entries...", allIdsToDelete.Count);
|
||||
|
||||
// Fall back to direct DB deletion for any items that couldn't be resolved via LibraryManager
|
||||
var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet();
|
||||
var unresolvedIds = allIdsToDelete.Where(id => !deletedIds.Contains(id)).ToList();
|
||||
if (unresolvedIds.Count > 0)
|
||||
// Delete in batches so progress is visible (item resolution and deletion can take a
|
||||
// long time on large libraries) and so we never issue one massive delete transaction.
|
||||
const int deleteBatchSize = 500;
|
||||
var deletedSoFar = 0;
|
||||
for (var offset = 0; offset < allIdsToDelete.Count; offset += deleteBatchSize)
|
||||
{
|
||||
_persistenceService.DeleteItem(unresolvedIds);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var batchIds = allIdsToDelete.GetRange(offset, Math.Min(deleteBatchSize, allIdsToDelete.Count - offset));
|
||||
|
||||
// Resolve items for metadata path cleanup, then delete this batch
|
||||
var itemsToDelete = batchIds
|
||||
.Select(id => _libraryManager.GetItemById(id))
|
||||
.Where(item => item is not null)
|
||||
.ToList();
|
||||
if (itemsToDelete.Count > 0)
|
||||
{
|
||||
_libraryManager.DeleteItemsUnsafeFast(itemsToDelete!);
|
||||
}
|
||||
|
||||
// Fall back to direct DB deletion for any items that couldn't be resolved via LibraryManager
|
||||
var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet();
|
||||
var unresolvedIds = batchIds.Where(id => !deletedIds.Contains(id)).ToList();
|
||||
if (unresolvedIds.Count > 0)
|
||||
{
|
||||
_persistenceService.DeleteItem(unresolvedIds);
|
||||
}
|
||||
|
||||
deletedSoFar += batchIds.Count;
|
||||
_logger.LogInformation("Deleting duplicates: {Deleted}/{Total} items", deletedSoFar, allIdsToDelete.Count);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -182,23 +182,35 @@ public class MergeDuplicateMusicArtists : IAsyncMigrationRoutine
|
||||
// Resolve via LibraryManager so DeleteItemsUnsafeFast can also remove the
|
||||
// %MetadataPath%/artists/<Name> directories that the duplicate stubs left behind.
|
||||
// Fall back to the persistence service for any items the LibraryManager can't resolve.
|
||||
var itemsToDelete = idsToDelete
|
||||
.Select(id => _libraryManager.GetItemById(id))
|
||||
.Where(item => item is not null)
|
||||
.ToList();
|
||||
if (itemsToDelete.Count > 0)
|
||||
// Delete in batches so we never issue one massive delete transaction and progress stays visible.
|
||||
_logger.LogInformation("Deleting {Count} duplicate MusicArtist records...", idsToDelete.Count);
|
||||
const int deleteBatchSize = 500;
|
||||
var deletedSoFar = 0;
|
||||
for (var offset = 0; offset < idsToDelete.Count; offset += deleteBatchSize)
|
||||
{
|
||||
_libraryManager.DeleteItemsUnsafeFast(itemsToDelete!);
|
||||
}
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet();
|
||||
var unresolvedIds = idsToDelete.Where(id => !deletedIds.Contains(id)).ToList();
|
||||
if (unresolvedIds.Count > 0)
|
||||
{
|
||||
_persistenceService.DeleteItem(unresolvedIds);
|
||||
}
|
||||
var batchIds = idsToDelete.GetRange(offset, Math.Min(deleteBatchSize, idsToDelete.Count - offset));
|
||||
|
||||
_logger.LogInformation("Removed {Count} duplicate MusicArtist records.", idsToDelete.Count);
|
||||
var itemsToDelete = batchIds
|
||||
.Select(id => _libraryManager.GetItemById(id))
|
||||
.Where(item => item is not null)
|
||||
.ToList();
|
||||
if (itemsToDelete.Count > 0)
|
||||
{
|
||||
_libraryManager.DeleteItemsUnsafeFast(itemsToDelete!);
|
||||
}
|
||||
|
||||
var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet();
|
||||
var unresolvedIds = batchIds.Where(id => !deletedIds.Contains(id)).ToList();
|
||||
if (unresolvedIds.Count > 0)
|
||||
{
|
||||
_persistenceService.DeleteItem(unresolvedIds);
|
||||
}
|
||||
|
||||
deletedSoFar += batchIds.Count;
|
||||
_logger.LogInformation("Deleting duplicate MusicArtist records: {Deleted}/{Total}", deletedSoFar, idsToDelete.Count);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user