mirror of
https://github.com/jellyfin/jellyfin.git
synced 2026-06-29 19:11:00 +01:00
Compare commits
208 Commits
standards-
...
v12.0-rc2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
feef2403c4 | ||
|
|
1b6342e217 | ||
|
|
62e6cf0196 | ||
|
|
8c6ee890cb | ||
|
|
eee26e6fee | ||
|
|
fb07067f8f | ||
|
|
a83920c5a7 | ||
|
|
75d71cb73c | ||
|
|
c158418e0b | ||
|
|
cbef19c313 | ||
|
|
fc13a7ca7d | ||
|
|
ff36b1b417 | ||
|
|
9ec19b8244 | ||
|
|
ed5e868a6b | ||
|
|
58de9b7a99 | ||
|
|
aa037c748a | ||
|
|
1efdad3443 | ||
|
|
f2ed842b4b | ||
|
|
c2cb18a9d1 | ||
|
|
f398b6d08b | ||
|
|
fa07a3abe8 | ||
|
|
b9db4566a7 | ||
|
|
d71b17fcc7 | ||
|
|
dff84c8490 | ||
|
|
1947296edd | ||
|
|
31070e8208 | ||
|
|
2c98ad99db | ||
|
|
e26f4a1005 | ||
|
|
e41f415594 | ||
|
|
da515e94b1 | ||
|
|
987744529a | ||
|
|
917244ab1d | ||
|
|
af82aceadb | ||
|
|
7f2cd5cf57 | ||
|
|
2feb588db3 | ||
|
|
58e9e3423a | ||
|
|
da2b994fff | ||
|
|
7f7e4dfa40 | ||
|
|
c257fd5004 | ||
|
|
0046adda29 | ||
|
|
b60c535c84 | ||
|
|
069eb40ebf | ||
|
|
4e80648fd3 | ||
|
|
f08a3f9fd9 | ||
|
|
083f9d291a | ||
|
|
e4383493a9 | ||
|
|
ce58e4400e | ||
|
|
3741d71965 | ||
|
|
11f642594d | ||
|
|
8d15529df7 | ||
|
|
310a47c1d4 | ||
|
|
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 | ||
|
|
d0a8445f76 | ||
|
|
7e3f758bee | ||
|
|
8e2c5607ef | ||
|
|
58508e60a4 | ||
|
|
2ede3c1342 | ||
|
|
dd42a121c4 | ||
|
|
5ac14f0688 | ||
|
|
d8acea21fb | ||
|
|
0ef94347c0 | ||
|
|
12f718e7bb | ||
|
|
5920d8dc3c | ||
|
|
25016babc1 | ||
|
|
eb38f462ad | ||
|
|
c693047467 | ||
|
|
894ee38f68 | ||
|
|
97fd210cd3 | ||
|
|
1a786f26c1 | ||
|
|
007515eb73 | ||
|
|
0a0060c9ca | ||
|
|
8b002e3fca | ||
|
|
f5cf68e979 | ||
|
|
f6ce2cedd9 | ||
|
|
e71914e993 | ||
|
|
fbe522592a | ||
|
|
59974490cc | ||
|
|
dee63ef3f1 | ||
|
|
2392e32779 | ||
|
|
6c931dcdda | ||
|
|
f584759638 | ||
|
|
fe1accc0e7 | ||
|
|
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,
|
"isRoot": true,
|
||||||
"tools": {
|
"tools": {
|
||||||
"dotnet-ef": {
|
"dotnet-ef": {
|
||||||
"version": "10.0.8",
|
"version": "10.0.9",
|
||||||
"commands": [
|
"commands": [
|
||||||
"dotnet-ef"
|
"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
|
label: Jellyfin Server version
|
||||||
description: What version of Jellyfin are you using?
|
description: What version of Jellyfin are you using?
|
||||||
options:
|
options:
|
||||||
|
- 10.11.11
|
||||||
- 10.11.10
|
- 10.11.10
|
||||||
- 10.11.9
|
- 10.11.9
|
||||||
- 10.11.8
|
- 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).
|
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**
|
**Changes**
|
||||||
<!-- Describe your changes here in 1-5 sentences. -->
|
<!-- 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**
|
**Issues**
|
||||||
<!-- Tag any issues that this PR solves here.
|
<!-- Tag any issues that this PR solves here.
|
||||||
ex. Fixes # -->
|
ex. Fixes # -->
|
||||||
|
|||||||
10
.github/workflows/ci-codeql-analysis.yml
vendored
10
.github/workflows/ci-codeql-analysis.yml
vendored
@@ -24,21 +24,21 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
|
||||||
- name: Setup .NET
|
- name: Setup .NET
|
||||||
uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
|
uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
|
||||||
with:
|
with:
|
||||||
dotnet-version: '10.0.x'
|
dotnet-version: '10.0.x'
|
||||||
|
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
queries: +security-extended
|
queries: +security-extended
|
||||||
|
|
||||||
- name: Autobuild
|
- name: Autobuild
|
||||||
uses: github/codeql-action/autobuild@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||||
|
|||||||
8
.github/workflows/ci-compat.yml
vendored
8
.github/workflows/ci-compat.yml
vendored
@@ -11,13 +11,13 @@ jobs:
|
|||||||
permissions: read-all
|
permissions: read-all
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha }}
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||||
|
|
||||||
- name: Setup .NET
|
- name: Setup .NET
|
||||||
uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
|
uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
|
||||||
with:
|
with:
|
||||||
dotnet-version: '10.0.x'
|
dotnet-version: '10.0.x'
|
||||||
|
|
||||||
@@ -40,14 +40,14 @@ jobs:
|
|||||||
permissions: read-all
|
permissions: read-all
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha }}
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Setup .NET
|
- name: Setup .NET
|
||||||
uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
|
uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
|
||||||
with:
|
with:
|
||||||
dotnet-version: '10.0.x'
|
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:
|
format-check:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
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:
|
with:
|
||||||
dotnet-version: ${{ env.SDK_VERSION }}
|
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 }}"
|
runs-on: "${{ matrix.os }}"
|
||||||
steps:
|
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:
|
with:
|
||||||
dotnet-version: ${{ env.SDK_VERSION }}
|
dotnet-version: ${{ env.SDK_VERSION }}
|
||||||
|
|
||||||
|
|||||||
6
.github/workflows/commands.yml
vendored
6
.github/workflows/commands.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
|||||||
reactions: '+1'
|
reactions: '+1'
|
||||||
|
|
||||||
- name: Checkout the latest code
|
- name: Checkout the latest code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.JF_BOT_TOKEN }}
|
token: ${{ secrets.JF_BOT_TOKEN }}
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -40,12 +40,12 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: pull in script
|
- name: pull in script
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
repository: jellyfin/jellyfin-triage-script
|
repository: jellyfin/jellyfin-triage-script
|
||||||
|
|
||||||
- name: install python
|
- name: install python
|
||||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||||
with:
|
with:
|
||||||
python-version: '3.14'
|
python-version: '3.14'
|
||||||
cache: 'pip'
|
cache: 'pip'
|
||||||
|
|||||||
4
.github/workflows/issue-template-check.yml
vendored
4
.github/workflows/issue-template-check.yml
vendored
@@ -10,12 +10,12 @@ jobs:
|
|||||||
issues: write
|
issues: write
|
||||||
steps:
|
steps:
|
||||||
- name: pull in script
|
- name: pull in script
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
repository: jellyfin/jellyfin-triage-script
|
repository: jellyfin/jellyfin-triage-script
|
||||||
|
|
||||||
- name: install python
|
- name: install python
|
||||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||||
with:
|
with:
|
||||||
python-version: '3.14'
|
python-version: '3.14'
|
||||||
cache: 'pip'
|
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
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
ref: ${{ inputs.ref }}
|
ref: ${{ inputs.ref }}
|
||||||
repository: ${{ inputs.repository }}
|
repository: ${{ inputs.repository }}
|
||||||
|
|
||||||
- name: Configure .NET
|
- name: Configure .NET
|
||||||
uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
|
uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
|
||||||
with:
|
with:
|
||||||
dotnet-version: '10.0.x'
|
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 }}
|
base_ref: ${{ steps.ancestor.outputs.base_ref }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha }}
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||||
|
|||||||
11
.github/workflows/pull-request-conflict.yml
vendored
11
.github/workflows/pull-request-conflict.yml
vendored
@@ -5,18 +5,19 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
pull_request_target:
|
pull_request_target:
|
||||||
issue_comment:
|
types: [synchronize]
|
||||||
|
|
||||||
permissions: {}
|
permissions: {}
|
||||||
jobs:
|
jobs:
|
||||||
label:
|
main:
|
||||||
name: Labeling
|
|
||||||
runs-on: ubuntu-latest
|
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:
|
steps:
|
||||||
- name: Apply label
|
- name: Apply label
|
||||||
uses: eps1lon/actions-label-merge-conflict@0273be72a0bbd58fcd71d0d6c02c209b50d1e5e1 # v3.1.0
|
uses: eps1lon/actions-label-merge-conflict@0273be72a0bbd58fcd71d0d6c02c209b50d1e5e1 # v3.1.0
|
||||||
if: ${{ github.event_name == 'push' || github.event_name == 'pull_request_target'}}
|
|
||||||
with:
|
with:
|
||||||
dirtyLabel: 'merge conflict'
|
dirtyLabel: 'merge conflict'
|
||||||
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'
|
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'
|
||||||
|
|||||||
4
.github/workflows/release-bump-version.yaml
vendored
4
.github/workflows/release-bump-version.yaml
vendored
@@ -33,7 +33,7 @@ jobs:
|
|||||||
yq-version: v4.9.8
|
yq-version: v4.9.8
|
||||||
|
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
ref: ${{ env.TAG_BRANCH }}
|
ref: ${{ env.TAG_BRANCH }}
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ jobs:
|
|||||||
NEXT_VERSION: ${{ github.event.inputs.NEXT_VERSION }}
|
NEXT_VERSION: ${{ github.event.inputs.NEXT_VERSION }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
ref: ${{ env.TAG_BRANCH }}
|
ref: ${{ env.TAG_BRANCH }}
|
||||||
|
|
||||||
|
|||||||
@@ -90,6 +90,7 @@
|
|||||||
- [mark-monteiro](https://github.com/mark-monteiro)
|
- [mark-monteiro](https://github.com/mark-monteiro)
|
||||||
- [MarkCiliaVincenti](https://github.com/MarkCiliaVincenti)
|
- [MarkCiliaVincenti](https://github.com/MarkCiliaVincenti)
|
||||||
- [Martin Reuter](https://github.com/reuterma24)
|
- [Martin Reuter](https://github.com/reuterma24)
|
||||||
|
- [Matt Teahan](https://github.com/matt-teahan)
|
||||||
- [Matt07211](https://github.com/Matt07211)
|
- [Matt07211](https://github.com/Matt07211)
|
||||||
- [Matthew Jones](https://github.com/matthew-jones-uk)
|
- [Matthew Jones](https://github.com/matthew-jones-uk)
|
||||||
- [Maxr1998](https://github.com/Maxr1998)
|
- [Maxr1998](https://github.com/Maxr1998)
|
||||||
|
|||||||
@@ -26,28 +26,28 @@
|
|||||||
<PackageVersion Include="libse" Version="4.0.12" />
|
<PackageVersion Include="libse" Version="4.0.12" />
|
||||||
<PackageVersion Include="LrcParser" Version="2025.623.0" />
|
<PackageVersion Include="LrcParser" Version="2025.623.0" />
|
||||||
<PackageVersion Include="MetaBrainz.MusicBrainz" Version="8.0.1" />
|
<PackageVersion Include="MetaBrainz.MusicBrainz" Version="8.0.1" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.8" />
|
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.9" />
|
||||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.8" />
|
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.9" />
|
||||||
<PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="4.14.0" />
|
<PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="4.14.0" />
|
||||||
<PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="5.3.0" />
|
<PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="5.3.0" />
|
||||||
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" 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.CodeAnalysis.Analyzers" Version="5.3.0" />
|
||||||
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.8" />
|
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.9" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.8" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.9" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.8" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.9" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.8" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.9" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.8" />
|
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.9" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.8" />
|
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.9" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.8" />
|
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.9" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.8" />
|
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.9" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.8" />
|
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.8" />
|
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.9" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.8" />
|
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.9" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.8" />
|
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.9" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.8" />
|
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.9" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.8" />
|
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.9" />
|
||||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.6.0" />
|
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
|
||||||
<PackageVersion Include="MimeTypes" Version="2.5.2" />
|
<PackageVersion Include="MimeTypes" Version="2.5.2" />
|
||||||
<PackageVersion Include="Morestachio" Version="5.0.1.670" />
|
<PackageVersion Include="Morestachio" Version="5.0.1.670" />
|
||||||
<PackageVersion Include="Moq" Version="4.18.4" />
|
<PackageVersion Include="Moq" Version="4.18.4" />
|
||||||
@@ -57,26 +57,27 @@
|
|||||||
<PackageVersion Include="prometheus-net.AspNetCore" Version="8.2.1" />
|
<PackageVersion Include="prometheus-net.AspNetCore" Version="8.2.1" />
|
||||||
<PackageVersion Include="prometheus-net.DotNetRuntime" Version="4.4.1" />
|
<PackageVersion Include="prometheus-net.DotNetRuntime" Version="4.4.1" />
|
||||||
<PackageVersion Include="prometheus-net" Version="8.2.1" />
|
<PackageVersion Include="prometheus-net" Version="8.2.1" />
|
||||||
<PackageVersion Include="Polly" Version="8.6.6" />
|
<PackageVersion Include="Polly" Version="8.7.0" />
|
||||||
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
|
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||||
<PackageVersion Include="Serilog.Enrichers.Thread" Version="4.0.0" />
|
<PackageVersion Include="Serilog.Enrichers.Thread" Version="4.0.0" />
|
||||||
<PackageVersion Include="Serilog.Expressions" Version="5.0.0" />
|
<PackageVersion Include="Serilog.Expressions" Version="5.0.0" />
|
||||||
<PackageVersion Include="Serilog.Settings.Configuration" Version="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.Async" Version="2.1.0" />
|
||||||
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
|
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||||
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
|
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||||
<PackageVersion Include="Serilog.Sinks.Graylog" Version="3.1.1" />
|
<PackageVersion Include="Serilog.Sinks.Graylog" Version="3.1.1" />
|
||||||
<PackageVersion Include="SerilogAnalyzer" Version="0.15.0" />
|
<PackageVersion Include="SerilogAnalyzer" Version="0.15.0" />
|
||||||
<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" Version="3.119.4" />
|
||||||
<PackageVersion Include="SkiaSharp.HarfBuzz" Version="3.119.4" />
|
<PackageVersion Include="SkiaSharp.HarfBuzz" Version="3.119.4" />
|
||||||
<PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="3.119.4" />
|
<PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="3.119.4" />
|
||||||
<PackageVersion Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" />
|
<PackageVersion Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" />
|
||||||
<PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" />
|
<PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" />
|
||||||
<PackageVersion Include="Svg.Skia" Version="3.7.0" />
|
<PackageVersion Include="Svg.Skia" Version="3.7.0" />
|
||||||
<PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="10.2.0" />
|
<PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="10.2.3" />
|
||||||
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.0" />
|
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.3" />
|
||||||
<PackageVersion Include="System.Text.Json" Version="10.0.8" />
|
<PackageVersion Include="System.Text.Json" Version="10.0.9" />
|
||||||
<PackageVersion Include="TagLibSharp" Version="2.3.0" />
|
<PackageVersion Include="TagLibSharp" Version="2.3.0" />
|
||||||
<PackageVersion Include="z440.atl.core" Version="7.15.3" />
|
<PackageVersion Include="z440.atl.core" Version="7.15.3" />
|
||||||
<PackageVersion Include="TMDbLib" Version="3.0.0" />
|
<PackageVersion Include="TMDbLib" Version="3.0.0" />
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
namespace Emby.Naming.Book
|
namespace Emby.Naming.Book
|
||||||
@@ -5,7 +6,7 @@ namespace Emby.Naming.Book
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Helper class to retrieve basic metadata from a book filename.
|
/// Helper class to retrieve basic metadata from a book filename.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class BookFileNameParser
|
public static partial class BookFileNameParser
|
||||||
{
|
{
|
||||||
private const string NameMatchGroup = "name";
|
private const string NameMatchGroup = "name";
|
||||||
private const string IndexMatchGroup = "index";
|
private const string IndexMatchGroup = "index";
|
||||||
@@ -15,14 +16,17 @@ namespace Emby.Naming.Book
|
|||||||
private static readonly Regex[] _nameMatches =
|
private static readonly Regex[] _nameMatches =
|
||||||
[
|
[
|
||||||
// seriesName (seriesYear) #index (of count) (year) where only seriesName and index are required
|
// 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(@"^(?<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]+)\)((\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]+)\s\-\s(?<name>.+?)((\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})\)"),
|
new Regex(@"(?<name>.*)\((?<year>[0-9]{4})\)"),
|
||||||
// last resort matches the whole string as the name
|
// last resort matches the whole string as the name
|
||||||
new Regex(@"(?<name>.*)")
|
new Regex(@"(?<name>.*)")
|
||||||
];
|
];
|
||||||
|
|
||||||
|
[GeneratedRegex(@"^(?<name>.+?)(\sv(?<volume>[0-9]+))?(\sc(?<chapter>[0-9]+))?$")]
|
||||||
|
private static partial Regex ComicRegex();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Parse a filename name to retrieve the book name, series name, index, and year.
|
/// Parse a filename name to retrieve the book name, series name, index, and year.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -48,7 +52,22 @@ namespace Emby.Naming.Book
|
|||||||
|
|
||||||
if (match.Groups.TryGetValue(NameMatchGroup, out Group? nameGroup) && nameGroup.Success)
|
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))
|
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
|
namespace Emby.Naming.Book
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -14,6 +12,7 @@ namespace Emby.Naming.Book
|
|||||||
{
|
{
|
||||||
Name = null;
|
Name = null;
|
||||||
Index = null;
|
Index = null;
|
||||||
|
ParentIndex = null;
|
||||||
Year = null;
|
Year = null;
|
||||||
SeriesName = null;
|
SeriesName = null;
|
||||||
}
|
}
|
||||||
@@ -28,6 +27,11 @@ namespace Emby.Naming.Book
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public int? Index { get; set; }
|
public int? Index { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the parent index number.
|
||||||
|
/// </summary>
|
||||||
|
public int? ParentIndex { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the publication year.
|
/// Gets or sets the publication year.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -25,5 +25,11 @@ namespace Emby.Naming.TV
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <value>The name of the series.</value>
|
/// <value>The name of the series.</value>
|
||||||
public string? Name { get; set; }
|
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
|
/// 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".
|
/// numeric) before the year, i.e. turns "1923 (2022)" into "1923".
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[GeneratedRegex(@"(?<title>.+?)\s*\(\d{4}\)")]
|
[GeneratedRegex(@"(?<title>.+?)\s*\((?<year>[0-9]{4})\)")]
|
||||||
private static partial Regex TitleWithYearRegex();
|
private static partial Regex TitleWithYearRegex();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -43,7 +43,8 @@ namespace Emby.Naming.TV
|
|||||||
seriesName = titleWithYearMatch.Groups["title"].Value.Trim();
|
seriesName = titleWithYearMatch.Groups["title"].Value.Trim();
|
||||||
return new SeriesInfo(path)
|
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.HttpServer.Security;
|
||||||
using Emby.Server.Implementations.IO;
|
using Emby.Server.Implementations.IO;
|
||||||
using Emby.Server.Implementations.Library;
|
using Emby.Server.Implementations.Library;
|
||||||
|
using Emby.Server.Implementations.Library.Search;
|
||||||
using Emby.Server.Implementations.Library.SimilarItems;
|
using Emby.Server.Implementations.Library.SimilarItems;
|
||||||
using Emby.Server.Implementations.Localization;
|
using Emby.Server.Implementations.Localization;
|
||||||
using Emby.Server.Implementations.Playlists;
|
using Emby.Server.Implementations.Playlists;
|
||||||
@@ -92,6 +93,9 @@ using MediaBrowser.Model.Net;
|
|||||||
using MediaBrowser.Model.Serialization;
|
using MediaBrowser.Model.Serialization;
|
||||||
using MediaBrowser.Model.System;
|
using MediaBrowser.Model.System;
|
||||||
using MediaBrowser.Model.Tasks;
|
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.Lyric;
|
||||||
using MediaBrowser.Providers.Manager;
|
using MediaBrowser.Providers.Manager;
|
||||||
using MediaBrowser.Providers.Plugins.ListenBrainz;
|
using MediaBrowser.Providers.Plugins.ListenBrainz;
|
||||||
@@ -495,6 +499,14 @@ namespace Emby.Server.Implementations
|
|||||||
serviceCollection.AddSingleton<ListenBrainzLabsClient>();
|
serviceCollection.AddSingleton<ListenBrainzLabsClient>();
|
||||||
serviceCollection.AddSingleton<ListenBrainzSimilarArtistProvider>();
|
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(NetManager);
|
||||||
|
|
||||||
serviceCollection.AddSingleton<ITaskManager, TaskManager>();
|
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<ILibraryMonitor>(provider.GetRequiredService<ILibraryMonitor>));
|
||||||
serviceCollection.AddTransient(provider => new Lazy<IProviderManager>(provider.GetRequiredService<IProviderManager>));
|
serviceCollection.AddTransient(provider => new Lazy<IProviderManager>(provider.GetRequiredService<IProviderManager>));
|
||||||
serviceCollection.AddTransient(provider => new Lazy<IUserViewManager>(provider.GetRequiredService<IUserViewManager>));
|
serviceCollection.AddTransient(provider => new Lazy<IUserViewManager>(provider.GetRequiredService<IUserViewManager>));
|
||||||
|
serviceCollection.AddTransient(provider => new Lazy<IExternalDataManager>(provider.GetRequiredService<IExternalDataManager>));
|
||||||
serviceCollection.AddSingleton<ILibraryManager, LibraryManager>();
|
serviceCollection.AddSingleton<ILibraryManager, LibraryManager>();
|
||||||
serviceCollection.AddSingleton<NamingOptions>();
|
serviceCollection.AddSingleton<NamingOptions>();
|
||||||
serviceCollection.AddSingleton<VideoListResolver>();
|
serviceCollection.AddSingleton<VideoListResolver>();
|
||||||
@@ -550,7 +563,8 @@ namespace Emby.Server.Implementations
|
|||||||
|
|
||||||
serviceCollection.AddSingleton<ISimilarItemsManager, SimilarItemsManager>();
|
serviceCollection.AddSingleton<ISimilarItemsManager, SimilarItemsManager>();
|
||||||
|
|
||||||
serviceCollection.AddSingleton<ISearchEngine, SearchEngine>();
|
serviceCollection.AddSingleton<ISearchManager, SearchManager>();
|
||||||
|
serviceCollection.AddSingleton<ISearchProvider, SqlSearchProvider>();
|
||||||
|
|
||||||
serviceCollection.AddSingleton<IWebSocketManager, WebSocketManager>();
|
serviceCollection.AddSingleton<IWebSocketManager, WebSocketManager>();
|
||||||
|
|
||||||
@@ -709,6 +723,7 @@ namespace Emby.Server.Implementations
|
|||||||
Resolve<IMediaSourceManager>().AddParts(GetExports<IMediaSourceProvider>());
|
Resolve<IMediaSourceManager>().AddParts(GetExports<IMediaSourceProvider>());
|
||||||
|
|
||||||
Resolve<ISimilarItemsManager>().AddParts(GetExports<ISimilarItemsProvider>());
|
Resolve<ISimilarItemsManager>().AddParts(GetExports<ISimilarItemsProvider>());
|
||||||
|
Resolve<ISearchManager>().AddParts(GetExports<ISearchProvider>());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -1539,6 +1539,21 @@ namespace Emby.Server.Implementations.Dto
|
|||||||
|
|
||||||
private void AddInheritedImages(BaseItemDto dto, BaseItem item, DtoOptions options, BaseItem? owner)
|
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)
|
if (!item.SupportsInheritedParentImages)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using MediaBrowser.Controller.Chapters;
|
using MediaBrowser.Controller.Chapters;
|
||||||
@@ -52,26 +51,33 @@ public class ExternalDataManager : IExternalDataManager
|
|||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task DeleteExternalItemDataAsync(BaseItem item, CancellationToken cancellationToken)
|
public async Task DeleteExternalItemDataAsync(BaseItem item, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var validPaths = _pathManager.GetExtractedDataPaths(item).Where(Directory.Exists).ToList();
|
DeleteExternalItemFiles(item);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
var itemId = item.Id;
|
||||||
await _keyframeManager.DeleteKeyframeDataAsync(itemId, cancellationToken).ConfigureAwait(false);
|
await _keyframeManager.DeleteKeyframeDataAsync(itemId, cancellationToken).ConfigureAwait(false);
|
||||||
await _mediaSegmentManager.DeleteSegmentsAsync(itemId, cancellationToken).ConfigureAwait(false);
|
await _mediaSegmentManager.DeleteSegmentsAsync(itemId, cancellationToken).ConfigureAwait(false);
|
||||||
await _trickplayManager.DeleteTrickplayDataAsync(itemId, cancellationToken).ConfigureAwait(false);
|
await _trickplayManager.DeleteTrickplayDataAsync(itemId, cancellationToken).ConfigureAwait(false);
|
||||||
await _chapterManager.DeleteChapterDataAsync(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 FastConcurrentLru<Guid, BaseItem> _cache;
|
||||||
private readonly DotIgnoreIgnoreRule _dotIgnoreIgnoreRule;
|
private readonly DotIgnoreIgnoreRule _dotIgnoreIgnoreRule;
|
||||||
private readonly IMediaStreamRepository _mediaStreamRepository;
|
private readonly IMediaStreamRepository _mediaStreamRepository;
|
||||||
|
private readonly Lazy<IExternalDataManager> _externalDataManagerFactory;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The _root folder sync lock.
|
/// The _root folder sync lock.
|
||||||
@@ -132,6 +133,7 @@ namespace Emby.Server.Implementations.Library
|
|||||||
/// <param name="pathManager">The path manager.</param>
|
/// <param name="pathManager">The path manager.</param>
|
||||||
/// <param name="dotIgnoreIgnoreRule">The .ignore rule handler.</param>
|
/// <param name="dotIgnoreIgnoreRule">The .ignore rule handler.</param>
|
||||||
/// <param name="mediaStreamRepository">The media stream repository.</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(
|
public LibraryManager(
|
||||||
IServerApplicationHost appHost,
|
IServerApplicationHost appHost,
|
||||||
ILoggerFactory loggerFactory,
|
ILoggerFactory loggerFactory,
|
||||||
@@ -155,7 +157,8 @@ namespace Emby.Server.Implementations.Library
|
|||||||
IPeopleRepository peopleRepository,
|
IPeopleRepository peopleRepository,
|
||||||
IPathManager pathManager,
|
IPathManager pathManager,
|
||||||
DotIgnoreIgnoreRule dotIgnoreIgnoreRule,
|
DotIgnoreIgnoreRule dotIgnoreIgnoreRule,
|
||||||
IMediaStreamRepository mediaStreamRepository)
|
IMediaStreamRepository mediaStreamRepository,
|
||||||
|
Lazy<IExternalDataManager> externalDataManagerFactory)
|
||||||
{
|
{
|
||||||
_appHost = appHost;
|
_appHost = appHost;
|
||||||
_logger = loggerFactory.CreateLogger<LibraryManager>();
|
_logger = loggerFactory.CreateLogger<LibraryManager>();
|
||||||
@@ -186,6 +189,7 @@ namespace Emby.Server.Implementations.Library
|
|||||||
_configurationManager.ConfigurationUpdated += ConfigurationUpdated;
|
_configurationManager.ConfigurationUpdated += ConfigurationUpdated;
|
||||||
|
|
||||||
_mediaStreamRepository = mediaStreamRepository;
|
_mediaStreamRepository = mediaStreamRepository;
|
||||||
|
_externalDataManagerFactory = externalDataManagerFactory;
|
||||||
|
|
||||||
RecordConfigurationValues(_configurationManager.Configuration);
|
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)]);
|
_persistenceService.DeleteItem([.. pathMaps.Select(f => f.Item.Id)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -576,6 +586,13 @@ namespace Emby.Server.Implementations.Library
|
|||||||
|
|
||||||
item.SetParent(null);
|
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)]);
|
_persistenceService.DeleteItem([item.Id, .. children.Select(f => f.Id)]);
|
||||||
_cache.TryRemove(item.Id, out _);
|
_cache.TryRemove(item.Id, out _);
|
||||||
foreach (var child in children)
|
foreach (var child in children)
|
||||||
@@ -1987,7 +2004,8 @@ namespace Emby.Server.Implementations.Library
|
|||||||
query.TopParentIds.Length == 0 &&
|
query.TopParentIds.Length == 0 &&
|
||||||
string.IsNullOrEmpty(query.AncestorWithPresentationUniqueKey) &&
|
string.IsNullOrEmpty(query.AncestorWithPresentationUniqueKey) &&
|
||||||
string.IsNullOrEmpty(query.SeriesPresentationUniqueKey) &&
|
string.IsNullOrEmpty(query.SeriesPresentationUniqueKey) &&
|
||||||
query.ItemIds.Length == 0)
|
query.ItemIds.Length == 0 &&
|
||||||
|
query.OwnerIds.Length == 0)
|
||||||
{
|
{
|
||||||
var userViews = UserViewManager.GetUserViews(new UserViewQuery
|
var userViews = UserViewManager.GetUserViews(new UserViewQuery
|
||||||
{
|
{
|
||||||
@@ -2432,8 +2450,14 @@ namespace Emby.Server.Implementations.Library
|
|||||||
var outdated = forceUpdate
|
var outdated = forceUpdate
|
||||||
? item.ImageInfos.Where(i => i.Path is not null).ToArray()
|
? item.ImageInfos.Where(i => i.Path is not null).ToArray()
|
||||||
: item.ImageInfos.Where(ImageNeedsRefresh).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);
|
RegisterItem(item);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ namespace Emby.Server.Implementations.Library
|
|||||||
list.Add(source);
|
list.Add(source);
|
||||||
}
|
}
|
||||||
|
|
||||||
return SortMediaSources(list).ToArray();
|
return SortMediaSources(list, item.Id).ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />>
|
/// <inheritdoc />>
|
||||||
@@ -386,6 +386,12 @@ namespace Emby.Server.Implementations.Library
|
|||||||
|
|
||||||
if (user is not null)
|
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)
|
foreach (var source in sources)
|
||||||
{
|
{
|
||||||
SetDefaultAudioAndSubtitleStreamIndices(item, source, user);
|
SetDefaultAudioAndSubtitleStreamIndices(item, source, user);
|
||||||
@@ -540,24 +546,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 =>
|
// The source belonging to the queried item sorts first so it stays the default that gets played.
|
||||||
{
|
var preferredId = preferredItemId.IsEmpty()
|
||||||
if (i.VideoType.HasValue && i.VideoType.Value == VideoType.VideoFile)
|
? 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;
|
return 1;
|
||||||
}).ThenBy(i => i.Video3DFormat.HasValue ? 1 : 0)
|
})
|
||||||
.ThenByDescending(i =>
|
.ThenBy(i => i.Video3DFormat.HasValue ? 1 : 0)
|
||||||
{
|
.ThenByDescending(i =>
|
||||||
var stream = i.VideoStream;
|
{
|
||||||
|
var stream = i.VideoStream;
|
||||||
|
|
||||||
return stream?.Width ?? 0;
|
return stream?.Width ?? 0;
|
||||||
})
|
})
|
||||||
.Where(i => i.Type != MediaSourceType.Placeholder);
|
.Where(i => i.Type != MediaSourceType.Placeholder);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Tuple<LiveStreamResponse, IDirectStreamProvider>> OpenLiveStreamInternal(LiveStreamRequest request, CancellationToken cancellationToken)
|
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, false));
|
||||||
paths.Add(GetTrickplayDirectory(item, true));
|
if (!string.IsNullOrEmpty(item.Path))
|
||||||
|
{
|
||||||
|
paths.Add(GetTrickplayDirectory(item, true));
|
||||||
|
}
|
||||||
|
|
||||||
paths.Add(GetChapterImageFolderPath(item));
|
paths.Add(GetChapterImageFolderPath(item));
|
||||||
|
|
||||||
return paths;
|
return paths;
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
#nullable disable
|
|
||||||
|
|
||||||
#pragma warning disable CS1591
|
#pragma warning disable CS1591
|
||||||
|
|
||||||
using System;
|
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" };
|
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();
|
var collectionType = args.GetCollectionType();
|
||||||
|
|
||||||
@@ -47,13 +45,14 @@ namespace Emby.Server.Implementations.Library.Resolvers.Books
|
|||||||
Path = args.Path,
|
Path = args.Path,
|
||||||
Name = result.Name ?? string.Empty,
|
Name = result.Name ?? string.Empty,
|
||||||
IndexNumber = result.Index,
|
IndexNumber = result.Index,
|
||||||
|
ParentIndexNumber = result.ParentIndex,
|
||||||
ProductionYear = result.Year,
|
ProductionYear = result.Year,
|
||||||
SeriesName = result.SeriesName ?? Path.GetFileName(Path.GetDirectoryName(args.Path)),
|
SeriesName = result.SeriesName ?? Path.GetFileName(Path.GetDirectoryName(args.Path)),
|
||||||
IsInMixedFolder = true,
|
IsInMixedFolder = true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private Book GetBook(ItemResolveArgs args)
|
private Book? GetBook(ItemResolveArgs args)
|
||||||
{
|
{
|
||||||
var bookFiles = args.FileSystemChildren.Where(f =>
|
var bookFiles = args.FileSystemChildren.Where(f =>
|
||||||
{
|
{
|
||||||
@@ -78,6 +77,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Books
|
|||||||
Path = bookFiles[0].FullName,
|
Path = bookFiles[0].FullName,
|
||||||
Name = result.Name ?? string.Empty,
|
Name = result.Name ?? string.Empty,
|
||||||
IndexNumber = result.Index,
|
IndexNumber = result.Index,
|
||||||
|
ParentIndexNumber = result.ParentIndex,
|
||||||
ProductionYear = result.Year,
|
ProductionYear = result.Year,
|
||||||
SeriesName = result.SeriesName ?? string.Empty,
|
SeriesName = result.SeriesName ?? string.Empty,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -57,6 +57,11 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (args.Parent is not null && args.Parent.IsRoot)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
var seriesInfo = Naming.TV.SeriesResolver.Resolve(_namingOptions, args.Path);
|
var seriesInfo = Naming.TV.SeriesResolver.Resolve(_namingOptions, args.Path);
|
||||||
|
|
||||||
var collectionType = args.GetCollectionType();
|
var collectionType = args.GetCollectionType();
|
||||||
@@ -69,7 +74,8 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
|
|||||||
return new Series
|
return new Series
|
||||||
{
|
{
|
||||||
Path = args.Path,
|
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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"
|
||||||
|
}
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
"ScheduledTaskFailedWithName": "{0} αποτυχία",
|
"ScheduledTaskFailedWithName": "{0} αποτυχία",
|
||||||
"Shows": "Σειρές",
|
"Shows": "Σειρές",
|
||||||
"StartupEmbyServerIsLoading": "Ο διακομιστής Jellyfin φορτώνει. Περιμένετε λίγο και δοκιμάστε ξανά.",
|
"StartupEmbyServerIsLoading": "Ο διακομιστής Jellyfin φορτώνει. Περιμένετε λίγο και δοκιμάστε ξανά.",
|
||||||
"SubtitleDownloadFailureFromForItem": "Αποτυχίες μεταφόρτωσης υποτίτλων από {0} για {1}",
|
"SubtitleDownloadFailureFromForItem": "Αποτυχία λήψης υποτίτλων από {0} για {1}",
|
||||||
"TvShows": "Τηλεοπτικές Σειρές",
|
"TvShows": "Τηλεοπτικές Σειρές",
|
||||||
"UserCreatedWithName": "Ο χρήστης {0} δημιουργήθηκε",
|
"UserCreatedWithName": "Ο χρήστης {0} δημιουργήθηκε",
|
||||||
"UserDeletedWithName": "Ο χρήστης {0} έχει διαγραφεί",
|
"UserDeletedWithName": "Ο χρήστης {0} έχει διαγραφεί",
|
||||||
@@ -106,5 +106,7 @@
|
|||||||
"TaskExtractMediaSegments": "Σάρωση τμημάτων πολυμέσων",
|
"TaskExtractMediaSegments": "Σάρωση τμημάτων πολυμέσων",
|
||||||
"TaskExtractMediaSegmentsDescription": "Εξάγει ή βρίσκει τμήματα πολυμέσων από επεκτάσεις που χρησιμοποιούν το MediaSegment.",
|
"TaskExtractMediaSegmentsDescription": "Εξάγει ή βρίσκει τμήματα πολυμέσων από επεκτάσεις που χρησιμοποιούν το MediaSegment.",
|
||||||
"CleanupUserDataTaskDescription": "Καθαρίζει όλα τα δεδομένα χρήστη (κατάσταση παρακολούθησης, κατάσταση αγαπημένων κ.λπ.) από πολυμέσα που δεν υπάρχουν πλέον για τουλάχιστον 90 ημέρες.",
|
"CleanupUserDataTaskDescription": "Καθαρίζει όλα τα δεδομένα χρήστη (κατάσταση παρακολούθησης, κατάσταση αγαπημένων κ.λπ.) από πολυμέσα που δεν υπάρχουν πλέον για τουλάχιστον 90 ημέρες.",
|
||||||
"CleanupUserDataTask": "Εργασία εκκαθάρισης δεδομένων χρήστη"
|
"CleanupUserDataTask": "Εργασία εκκαθάρισης δεδομένων χρήστη",
|
||||||
|
"LyricDownloadFailureFromForItem": "Αποτυχία λήψης στίχων από {0} για {1}",
|
||||||
|
"Original": "Πρωτότυπο"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,5 +106,7 @@
|
|||||||
"TaskMoveTrickplayImages": "Migrate Trickplay Image Location",
|
"TaskMoveTrickplayImages": "Migrate Trickplay Image Location",
|
||||||
"TaskMoveTrickplayImagesDescription": "Moves existing trickplay files according to the library settings.",
|
"TaskMoveTrickplayImagesDescription": "Moves existing trickplay files according to the library settings.",
|
||||||
"CleanupUserDataTask": "User data cleanup task",
|
"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.",
|
"TaskMoveTrickplayImagesDescription": "Mueve archivos existentes de trickplay de acuerdo a la configuración de la biblioteca.",
|
||||||
"TaskMoveTrickplayImages": "Migrar Ubicación de Imagen de Trickplay",
|
"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.",
|
"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",
|
"TaskRefreshTrickplayImages": "Xerar miniaturas de previsualización",
|
||||||
"TaskAudioNormalizationDescription": "Escanea ficheiros á procura de datos de normalización de volume.",
|
"TaskAudioNormalizationDescription": "Escanea ficheiros á procura de datos de normalización de volume.",
|
||||||
"CleanupUserDataTask": "Tarefa de limpeza de datos dos usuarios",
|
"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 @@
|
|||||||
"TaskExtractMediaSegments": "Scan Segmen media",
|
"TaskExtractMediaSegments": "Scan Segmen media",
|
||||||
"TaskMoveTrickplayImages": "Migrasikan Lokasi Gambar Trickplay",
|
"TaskMoveTrickplayImages": "Migrasikan Lokasi Gambar Trickplay",
|
||||||
"TaskDownloadMissingLyrics": "Unduh Lirik yang Hilang",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,7 +80,7 @@
|
|||||||
"NotificationOptionInstallationFailed": "ಸ್ಥಾಪನ ವೈಫಲ್ಯ",
|
"NotificationOptionInstallationFailed": "ಸ್ಥಾಪನ ವೈಫಲ್ಯ",
|
||||||
"NotificationOptionNewLibraryContent": "ಹೊಸ ವಿಷಯವನ್ನು ಒಳಗೊಂಡಿದೆ",
|
"NotificationOptionNewLibraryContent": "ಹೊಸ ವಿಷಯವನ್ನು ಒಳಗೊಂಡಿದೆ",
|
||||||
"NotificationOptionPluginError": "ಪ್ಲಗಿನ್ ವೈಫಲ್ಯ",
|
"NotificationOptionPluginError": "ಪ್ಲಗಿನ್ ವೈಫಲ್ಯ",
|
||||||
"NotificationOptionPluginInstalled": "ಪ್ಲಗಿನ್ ವೈಫಲ್ಯ",
|
"NotificationOptionPluginInstalled": "ಪ್ಲಗಿನ್ ಸ್ಥಾಪಿಸಲಾಗಿದೆ",
|
||||||
"NotificationOptionPluginUpdateInstalled": "ಪ್ಲಗಿನ್ ನವೀಕರಣವನ್ನು ಸ್ಥಾಪಿಸಲಾಗಿದೆ",
|
"NotificationOptionPluginUpdateInstalled": "ಪ್ಲಗಿನ್ ನವೀಕರಣವನ್ನು ಸ್ಥಾಪಿಸಲಾಗಿದೆ",
|
||||||
"NotificationOptionServerRestartRequired": "ಸರ್ವರ್ ಮರುಪ್ರಾರಂಭದ ಅಗತ್ಯವಿದೆ",
|
"NotificationOptionServerRestartRequired": "ಸರ್ವರ್ ಮರುಪ್ರಾರಂಭದ ಅಗತ್ಯವಿದೆ",
|
||||||
"NotificationOptionTaskFailed": "ನಿಗದಿತ ಕಾರ್ಯ ವೈಫಲ್ಯ",
|
"NotificationOptionTaskFailed": "ನಿಗದಿತ ಕಾರ್ಯ ವೈಫಲ್ಯ",
|
||||||
|
|||||||
@@ -106,5 +106,7 @@
|
|||||||
"TaskDownloadMissingLyrics": "누락된 가사 다운로드",
|
"TaskDownloadMissingLyrics": "누락된 가사 다운로드",
|
||||||
"TaskDownloadMissingLyricsDescription": "가사 다운로드",
|
"TaskDownloadMissingLyricsDescription": "가사 다운로드",
|
||||||
"CleanupUserDataTask": "사용자 데이터 정리 작업",
|
"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.",
|
"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.",
|
"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",
|
"CleanupUserDataTask": "Limpeza de dados de utilizador",
|
||||||
"Original": "Original"
|
"Original": "Original",
|
||||||
|
"LyricDownloadFailureFromForItem": "Erro ao descarregar letras de {0} para {1}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,5 +106,7 @@
|
|||||||
"TaskDownloadMissingLyrics": "Stiahnuť chýbajúce texty piesní",
|
"TaskDownloadMissingLyrics": "Stiahnuť chýbajúce texty piesní",
|
||||||
"TaskDownloadMissingLyricsDescription": "Stiahne texty pre piesne",
|
"TaskDownloadMissingLyricsDescription": "Stiahne texty pre piesne",
|
||||||
"CleanupUserDataTask": "Prečistiť používateľské dáta",
|
"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",
|
"TaskAudioNormalization": "Normalizacija zvoka",
|
||||||
"TaskAudioNormalizationDescription": "Pregled datotek za podatke o normalizaciji zvoka.",
|
"TaskAudioNormalizationDescription": "Pregled datotek za podatke o normalizaciji zvoka.",
|
||||||
"CleanupUserDataTask": "Čiščenje uporabniških podatkov",
|
"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 @@
|
|||||||
"CleanupUserDataTask": "Задатак чишћења корисничких података",
|
"CleanupUserDataTask": "Задатак чишћења корисничких података",
|
||||||
"CleanupUserDataTaskDescription": "Чисти све корисничке податке (напредак гледања, ознаке за омиљено...) медија који нису доступни 90 дана или дуже.",
|
"CleanupUserDataTaskDescription": "Чисти све корисничке податке (напредак гледања, ознаке за омиљено...) медија који нису доступни 90 дана или дуже.",
|
||||||
"TaskMoveTrickplayImages": "Промени локацију сличица за визуелно премотавање",
|
"TaskMoveTrickplayImages": "Промени локацију сличица за визуелно премотавање",
|
||||||
"TaskDownloadMissingLyricsDescription": "Преузми стихове песама"
|
"TaskDownloadMissingLyricsDescription": "Преузми стихове песама",
|
||||||
|
"LyricDownloadFailureFromForItem": "Није успело преузимање стихова са {0} за {1}",
|
||||||
|
"Original": "Изворно"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1,5 @@
|
|||||||
{}
|
{
|
||||||
|
"Artists": "Wasanii",
|
||||||
|
"Books": "Vitabu",
|
||||||
|
"Collections": "Mikusanyiko"
|
||||||
|
}
|
||||||
|
|||||||
@@ -106,5 +106,7 @@
|
|||||||
"TaskMoveTrickplayImagesDescription": "根據媒體櫃設定,將現有嘅 Trickplay(快轉預覽)檔案搬去對應位置。",
|
"TaskMoveTrickplayImagesDescription": "根據媒體櫃設定,將現有嘅 Trickplay(快轉預覽)檔案搬去對應位置。",
|
||||||
"TaskMoveTrickplayImages": "搬移快轉預覽圖嘅位置",
|
"TaskMoveTrickplayImages": "搬移快轉預覽圖嘅位置",
|
||||||
"CleanupUserDataTask": "清理使用者資料嘅任務",
|
"CleanupUserDataTask": "清理使用者資料嘅任務",
|
||||||
"CleanupUserDataTaskDescription": "清理已消失至少 90 日嘅媒體用家數據(包括觀看狀態、心水狀態等)。"
|
"CleanupUserDataTaskDescription": "清理已消失至少 90 日嘅媒體用家數據(包括觀看狀態、心水狀態等)。",
|
||||||
|
"LyricDownloadFailureFromForItem": "冇辦法從 {0} 下載 {1} 嘅歌詞",
|
||||||
|
"Original": "原始"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -566,11 +566,15 @@ namespace Emby.Server.Implementations.Localization
|
|||||||
|
|
||||||
private static string GetResourceFilename(string culture)
|
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
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -92,7 +92,8 @@ public class ChapterImagesTask : IScheduledTask
|
|||||||
EnableImages = false
|
EnableImages = false
|
||||||
},
|
},
|
||||||
SourceTypes = [SourceType.Library],
|
SourceTypes = [SourceType.Library],
|
||||||
IsVirtualItem = false
|
IsVirtualItem = false,
|
||||||
|
IncludeOwnedItems = true
|
||||||
})
|
})
|
||||||
.OfType<Video>()
|
.OfType<Video>()
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ public class MediaSegmentExtractionTask : IScheduledTask
|
|||||||
DtoOptions = new DtoOptions(true),
|
DtoOptions = new DtoOptions(true),
|
||||||
SourceTypes = [SourceType.Library],
|
SourceTypes = [SourceType.Library],
|
||||||
Recursive = true,
|
Recursive = true,
|
||||||
|
IncludeOwnedItems = true,
|
||||||
Limit = pagesize
|
Limit = pagesize
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
|||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Jellyfin.Database.Implementations;
|
using Jellyfin.Database.Implementations;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
using MediaBrowser.Model.Globalization;
|
using MediaBrowser.Model.Globalization;
|
||||||
using MediaBrowser.Model.Tasks;
|
using MediaBrowser.Model.Tasks;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
@@ -17,6 +18,7 @@ public class OptimizeDatabaseTask : IScheduledTask, IConfigurableScheduledTask
|
|||||||
private readonly ILogger<OptimizeDatabaseTask> _logger;
|
private readonly ILogger<OptimizeDatabaseTask> _logger;
|
||||||
private readonly ILocalizationManager _localization;
|
private readonly ILocalizationManager _localization;
|
||||||
private readonly IJellyfinDatabaseProvider _jellyfinDatabaseProvider;
|
private readonly IJellyfinDatabaseProvider _jellyfinDatabaseProvider;
|
||||||
|
private readonly ILibraryManager _libraryManager;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="OptimizeDatabaseTask" /> class.
|
/// 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="logger">Instance of the <see cref="ILogger"/> interface.</param>
|
||||||
/// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
|
/// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
|
||||||
/// <param name="jellyfinDatabaseProvider">Instance of the JellyfinDatabaseProvider that can be used for provider specific operations.</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(
|
public OptimizeDatabaseTask(
|
||||||
ILogger<OptimizeDatabaseTask> logger,
|
ILogger<OptimizeDatabaseTask> logger,
|
||||||
ILocalizationManager localization,
|
ILocalizationManager localization,
|
||||||
IJellyfinDatabaseProvider jellyfinDatabaseProvider)
|
IJellyfinDatabaseProvider jellyfinDatabaseProvider,
|
||||||
|
ILibraryManager libraryManager)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_localization = localization;
|
_localization = localization;
|
||||||
_jellyfinDatabaseProvider = jellyfinDatabaseProvider;
|
_jellyfinDatabaseProvider = jellyfinDatabaseProvider;
|
||||||
|
_libraryManager = libraryManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -68,6 +73,15 @@ public class OptimizeDatabaseTask : IScheduledTask, IConfigurableScheduledTask
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
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...");
|
_logger.LogInformation("Optimizing and vacuuming jellyfin.db...");
|
||||||
|
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ using MediaBrowser.Controller.Library;
|
|||||||
using MediaBrowser.Model.Globalization;
|
using MediaBrowser.Model.Globalization;
|
||||||
using MediaBrowser.Model.Tasks;
|
using MediaBrowser.Model.Tasks;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace Emby.Server.Implementations.ScheduledTasks.Tasks;
|
namespace Emby.Server.Implementations.ScheduledTasks.Tasks;
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
|
|||||||
private readonly ILibraryManager _libraryManager;
|
private readonly ILibraryManager _libraryManager;
|
||||||
private readonly ILocalizationManager _localization;
|
private readonly ILocalizationManager _localization;
|
||||||
private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory;
|
private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory;
|
||||||
|
private readonly ILogger<PeopleValidationTask> _logger;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="PeopleValidationTask" /> class.
|
/// Initializes a new instance of the <see cref="PeopleValidationTask" /> class.
|
||||||
@@ -27,11 +29,13 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
|
|||||||
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
|
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
|
||||||
/// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
|
/// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
|
||||||
/// <param name="dbContextFactory">Instance of the <see cref="IDbContextFactory{TContext}"/> 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="logger">Instance of the <see cref="ILogger{PeopleValidationTask}"/> interface.</param>
|
||||||
|
public PeopleValidationTask(ILibraryManager libraryManager, ILocalizationManager localization, IDbContextFactory<JellyfinDbContext> dbContextFactory, ILogger<PeopleValidationTask> logger)
|
||||||
{
|
{
|
||||||
_libraryManager = libraryManager;
|
_libraryManager = libraryManager;
|
||||||
_localization = localization;
|
_localization = localization;
|
||||||
_dbContextFactory = dbContextFactory;
|
_dbContextFactory = dbContextFactory;
|
||||||
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -71,13 +75,18 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
IProgress<double> subProgress = new Progress<double>((val) => progress.Report(val / 2));
|
// People validation performs heavy database writes that contend with an active library scan.
|
||||||
await _libraryManager.ValidatePeopleAsync(subProgress, cancellationToken).ConfigureAwait(false);
|
// 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));
|
|
||||||
var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||||
await using (context.ConfigureAwait(false))
|
await using (context.ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
|
IProgress<double> subProgress = new Progress<double>((val) => progress.Report(val / 2));
|
||||||
var dupQuery = context.Peoples
|
var dupQuery = context.Peoples
|
||||||
.GroupBy(e => new { e.Name, e.PersonType })
|
.GroupBy(e => new { e.Name, e.PersonType })
|
||||||
.Where(e => e.Count() > 1)
|
.Where(e => e.Count() > 1)
|
||||||
@@ -123,7 +132,18 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
|
|||||||
ArrayPool<Guid[]>.Shared.Return(buffer);
|
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);
|
subProgress.Report(100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
IProgress<double> validateProgress = new Progress<double>((val) => progress.Report((val / 2) + 50));
|
||||||
|
await _libraryManager.ValidatePeopleAsync(validateProgress, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
progress.Report(100);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -343,6 +343,10 @@ namespace Emby.Server.Implementations.Session
|
|||||||
_activeLiveStreamSessions.TryRemove(liveStreamId, out _);
|
_activeLiveStreamSessions.TryRemove(liveStreamId, out _);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
liveStreamNeedsToBeClosed = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (liveStreamNeedsToBeClosed)
|
if (liveStreamNeedsToBeClosed)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -206,7 +206,8 @@ namespace Emby.Server.Implementations.SyncPlay
|
|||||||
foreach (var itemId in queue)
|
foreach (var itemId in queue)
|
||||||
{
|
{
|
||||||
var item = _libraryManager.GetItemById(itemId);
|
var item = _libraryManager.GetItemById(itemId);
|
||||||
if (!item.IsVisibleStandalone(user))
|
|
||||||
|
if (item is null || !item.IsVisibleStandalone(user))
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Buffers;
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
@@ -32,6 +33,8 @@ namespace Emby.Server.Implementations.Updates
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class InstallationManager : IInstallationManager
|
public class InstallationManager : IInstallationManager
|
||||||
{
|
{
|
||||||
|
private static readonly SearchValues<char> InvalidPackageNameChars = SearchValues.Create([.. Path.GetInvalidFileNameChars(), '/', '\\']);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The logger.
|
/// The logger.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -521,9 +524,27 @@ namespace Emby.Server.Implementations.Updates
|
|||||||
return;
|
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
|
// Always override the passed-in target (which is a file) and figure it out again
|
||||||
string targetDir = Path.Combine(_appPaths.PluginsPath, package.Name);
|
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)
|
using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
|
||||||
.GetAsync(new Uri(package.SourceUrl), cancellationToken).ConfigureAwait(false);
|
.GetAsync(new Uri(package.SourceUrl), cancellationToken).ConfigureAwait(false);
|
||||||
response.EnsureSuccessStatusCode();
|
response.EnsureSuccessStatusCode();
|
||||||
@@ -572,6 +593,26 @@ namespace Emby.Server.Implementations.Updates
|
|||||||
_pluginManager.ImportPluginFrom(targetDir);
|
_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)
|
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))
|
LocalPlugin? plugin = _pluginManager.Plugins.FirstOrDefault(p => p.Id.Equals(package.Id) && p.Version.Equals(package.Version))
|
||||||
|
|||||||
@@ -288,7 +288,7 @@ public class ItemUpdateController : BaseJellyfinApiController
|
|||||||
item.CustomRating = request.CustomRating;
|
item.CustomRating = request.CustomRating;
|
||||||
|
|
||||||
var currentTags = item.Tags;
|
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 removedTags = currentTags.Except(newTags).ToList();
|
||||||
var addedTags = newTags.Except(currentTags).ToList();
|
var addedTags = newTags.Except(currentTags).ToList();
|
||||||
item.Tags = newTags;
|
item.Tags = newTags;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
@@ -42,6 +43,7 @@ public class ItemsController : BaseJellyfinApiController
|
|||||||
private readonly ILogger<ItemsController> _logger;
|
private readonly ILogger<ItemsController> _logger;
|
||||||
private readonly ISessionManager _sessionManager;
|
private readonly ISessionManager _sessionManager;
|
||||||
private readonly IUserDataManager _userDataRepository;
|
private readonly IUserDataManager _userDataRepository;
|
||||||
|
private readonly ISearchManager _searchManager;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="ItemsController"/> class.
|
/// 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="logger">Instance of the <see cref="ILogger"/> interface.</param>
|
||||||
/// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> 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="userDataRepository">Instance of the <see cref="IUserDataManager"/> interface.</param>
|
||||||
|
/// <param name="searchManager">Instance of the <see cref="ISearchManager"/> interface.</param>
|
||||||
public ItemsController(
|
public ItemsController(
|
||||||
IUserManager userManager,
|
IUserManager userManager,
|
||||||
ILibraryManager libraryManager,
|
ILibraryManager libraryManager,
|
||||||
@@ -60,7 +63,8 @@ public class ItemsController : BaseJellyfinApiController
|
|||||||
IDtoService dtoService,
|
IDtoService dtoService,
|
||||||
ILogger<ItemsController> logger,
|
ILogger<ItemsController> logger,
|
||||||
ISessionManager sessionManager,
|
ISessionManager sessionManager,
|
||||||
IUserDataManager userDataRepository)
|
IUserDataManager userDataRepository,
|
||||||
|
ISearchManager searchManager)
|
||||||
{
|
{
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
_libraryManager = libraryManager;
|
_libraryManager = libraryManager;
|
||||||
@@ -69,6 +73,7 @@ public class ItemsController : BaseJellyfinApiController
|
|||||||
_logger = logger;
|
_logger = logger;
|
||||||
_sessionManager = sessionManager;
|
_sessionManager = sessionManager;
|
||||||
_userDataRepository = userDataRepository;
|
_userDataRepository = userDataRepository;
|
||||||
|
_searchManager = searchManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -314,22 +319,23 @@ public class ItemsController : BaseJellyfinApiController
|
|||||||
if (collectionType == CollectionType.playlists)
|
if (collectionType == CollectionType.playlists)
|
||||||
{
|
{
|
||||||
recursive = true;
|
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
|
includeItemTypes = collectionType switch
|
||||||
// through the database path where all filters (IsHD, genres, etc.) are applied.
|
|
||||||
recursive ??= true;
|
|
||||||
if (includeItemTypes.Length == 0)
|
|
||||||
{
|
{
|
||||||
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
|
if (item is not UserRootFolder
|
||||||
@@ -342,218 +348,273 @@ public class ItemsController : BaseJellyfinApiController
|
|||||||
return Unauthorized($"{user.Username} is not permitted to access Library {item.Name}.");
|
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,
|
SearchTerm = searchTerm,
|
||||||
MediaTypes = mediaTypes,
|
UserId = userId,
|
||||||
IncludeItemTypes = includeItemTypes,
|
IncludeItemTypes = includeItemTypes,
|
||||||
ExcludeItemTypes = excludeItemTypes,
|
ExcludeItemTypes = excludeItemTypes,
|
||||||
Recursive = recursive ?? false,
|
MediaTypes = mediaTypes,
|
||||||
OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder),
|
Limit = limit.HasValue ? limit.Value * 3 : null,
|
||||||
IsFavorite = isFavorite,
|
ParentId = parentId
|
||||||
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,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
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
|
return _libraryManager.GetArtist(i, new DtoOptions(false));
|
||||||
query.HasSubtitles = null;
|
|
||||||
}
|
}
|
||||||
else
|
catch
|
||||||
{
|
{
|
||||||
// if we search for items without subtitles, we don't need to check for subtitles of a specific language
|
return null;
|
||||||
query.SubtitleLanguages = [];
|
|
||||||
}
|
}
|
||||||
}
|
}).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
|
// ExcludeArtistIds
|
||||||
if (query.HasSubtitles.HasValue
|
if (excludeArtistIds.Length != 0)
|
||||||
|| query.SubtitleLanguages.Count > 0
|
{
|
||||||
|| query.AudioLanguages.Count > 0
|
query.ExcludeArtistIds = excludeArtistIds;
|
||||||
|| query.Is3D.HasValue
|
}
|
||||||
|| query.IsHD.HasValue
|
|
||||||
|| query.Is4K.HasValue
|
if (albumIds.Length != 0)
|
||||||
|| query.VideoTypes.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);
|
// Studios
|
||||||
|
if (studios.Length != 0)
|
||||||
// Filter by Series Status
|
{
|
||||||
if (seriesStatus.Length != 0)
|
query.StudioIds = studios.Select(i =>
|
||||||
{
|
{
|
||||||
query.SeriesStatuses = seriesStatus;
|
try
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
return _libraryManager.GetStudio(i);
|
||||||
{
|
|
||||||
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) };
|
|
||||||
}
|
}
|
||||||
}
|
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);
|
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
|
else
|
||||||
{
|
{
|
||||||
@@ -909,7 +970,7 @@ public class ItemsController : BaseJellyfinApiController
|
|||||||
|
|
||||||
var itemsResult = _libraryManager.GetItemsResult(new InternalItemsQuery(user)
|
var itemsResult = _libraryManager.GetItemsResult(new InternalItemsQuery(user)
|
||||||
{
|
{
|
||||||
OrderBy = new[] { (ItemSortBy.DatePlayed, SortOrder.Descending) },
|
OrderBy = [(ItemSortBy.DatePlayed, SortOrder.Descending)],
|
||||||
IsResumable = true,
|
IsResumable = true,
|
||||||
StartIndex = startIndex,
|
StartIndex = startIndex,
|
||||||
Limit = limit,
|
Limit = limit,
|
||||||
@@ -919,6 +980,7 @@ public class ItemsController : BaseJellyfinApiController
|
|||||||
MediaTypes = mediaTypes,
|
MediaTypes = mediaTypes,
|
||||||
IsVirtualItem = false,
|
IsVirtualItem = false,
|
||||||
CollapseBoxSetItems = false,
|
CollapseBoxSetItems = false,
|
||||||
|
IncludeOwnedItems = true,
|
||||||
EnableTotalRecordCount = enableTotalRecordCount,
|
EnableTotalRecordCount = enableTotalRecordCount,
|
||||||
AncestorIds = ancestorIds,
|
AncestorIds = ancestorIds,
|
||||||
IncludeItemTypes = includeItemTypes,
|
IncludeItemTypes = includeItemTypes,
|
||||||
|
|||||||
@@ -1002,9 +1002,7 @@ public class LiveTvController : BaseJellyfinApiController
|
|||||||
{
|
{
|
||||||
if (!string.IsNullOrEmpty(pw))
|
if (!string.IsNullOrEmpty(pw))
|
||||||
{
|
{
|
||||||
// TODO: remove ToLower when Convert.ToHexString supports lowercase
|
listingsProviderInfo.Password = Convert.ToHexStringLower(SHA1.HashData(Encoding.UTF8.GetBytes(pw)));
|
||||||
// Schedules Direct requires the hex to be lowercase
|
|
||||||
listingsProviderInfo.Password = Convert.ToHexString(SHA1.HashData(Encoding.UTF8.GetBytes(pw))).ToLowerInvariant();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return await _listingsManager.SaveListingProvider(listingsProviderInfo, validateLogin, validateListings).ConfigureAwait(false);
|
return await _listingsManager.SaveListingProvider(listingsProviderInfo, validateLogin, validateListings).ConfigureAwait(false);
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ public class MediaInfoController : BaseJellyfinApiController
|
|||||||
Request.HttpContext.GetNormalizedRemoteIP());
|
Request.HttpContext.GetNormalizedRemoteIP());
|
||||||
}
|
}
|
||||||
|
|
||||||
_mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate);
|
_mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate, item.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (autoOpenLiveStream.Value)
|
if (autoOpenLiveStream.Value)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.ComponentModel;
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
using Jellyfin.Api.Helpers;
|
using Jellyfin.Api.Helpers;
|
||||||
using Jellyfin.Api.ModelBinders;
|
using Jellyfin.Api.ModelBinders;
|
||||||
using Jellyfin.Data.Enums;
|
using Jellyfin.Data.Enums;
|
||||||
@@ -29,7 +30,7 @@ namespace Jellyfin.Api.Controllers;
|
|||||||
[Authorize]
|
[Authorize]
|
||||||
public class SearchController : BaseJellyfinApiController
|
public class SearchController : BaseJellyfinApiController
|
||||||
{
|
{
|
||||||
private readonly ISearchEngine _searchEngine;
|
private readonly ISearchManager _searchManager;
|
||||||
private readonly ILibraryManager _libraryManager;
|
private readonly ILibraryManager _libraryManager;
|
||||||
private readonly IDtoService _dtoService;
|
private readonly IDtoService _dtoService;
|
||||||
private readonly IImageProcessor _imageProcessor;
|
private readonly IImageProcessor _imageProcessor;
|
||||||
@@ -37,17 +38,17 @@ public class SearchController : BaseJellyfinApiController
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="SearchController"/> class.
|
/// Initializes a new instance of the <see cref="SearchController"/> class.
|
||||||
/// </summary>
|
/// </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="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param>
|
||||||
/// <param name="dtoService">Instance of <see cref="IDtoService"/> interface.</param>
|
/// <param name="dtoService">Instance of <see cref="IDtoService"/> interface.</param>
|
||||||
/// <param name="imageProcessor">Instance of <see cref="IImageProcessor"/> interface.</param>
|
/// <param name="imageProcessor">Instance of <see cref="IImageProcessor"/> interface.</param>
|
||||||
public SearchController(
|
public SearchController(
|
||||||
ISearchEngine searchEngine,
|
ISearchManager searchManager,
|
||||||
ILibraryManager libraryManager,
|
ILibraryManager libraryManager,
|
||||||
IDtoService dtoService,
|
IDtoService dtoService,
|
||||||
IImageProcessor imageProcessor)
|
IImageProcessor imageProcessor)
|
||||||
{
|
{
|
||||||
_searchEngine = searchEngine;
|
_searchManager = searchManager;
|
||||||
_libraryManager = libraryManager;
|
_libraryManager = libraryManager;
|
||||||
_dtoService = dtoService;
|
_dtoService = dtoService;
|
||||||
_imageProcessor = imageProcessor;
|
_imageProcessor = imageProcessor;
|
||||||
@@ -79,7 +80,7 @@ public class SearchController : BaseJellyfinApiController
|
|||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Description("Gets search hints based on a search term")]
|
[Description("Gets search hints based on a search term")]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
public ActionResult<SearchHintResult> GetSearchHints(
|
public async Task<ActionResult<SearchHintResult>> GetSearchHints(
|
||||||
[FromQuery] int? startIndex,
|
[FromQuery] int? startIndex,
|
||||||
[FromQuery] int? limit,
|
[FromQuery] int? limit,
|
||||||
[FromQuery] Guid? userId,
|
[FromQuery] Guid? userId,
|
||||||
@@ -100,7 +101,7 @@ public class SearchController : BaseJellyfinApiController
|
|||||||
[FromQuery] bool includeArtists = true)
|
[FromQuery] bool includeArtists = true)
|
||||||
{
|
{
|
||||||
userId = RequestHelpers.GetUserId(User, userId);
|
userId = RequestHelpers.GetUserId(User, userId);
|
||||||
var result = _searchEngine.GetSearchHints(new SearchQuery
|
var result = await _searchManager.GetSearchHintsAsync(new SearchQuery
|
||||||
{
|
{
|
||||||
Limit = limit,
|
Limit = limit,
|
||||||
SearchTerm = searchTerm,
|
SearchTerm = searchTerm,
|
||||||
@@ -121,7 +122,7 @@ public class SearchController : BaseJellyfinApiController
|
|||||||
IsNews = isNews,
|
IsNews = isNews,
|
||||||
IsSeries = isSeries,
|
IsSeries = isSeries,
|
||||||
IsSports = isSports
|
IsSports = isSports
|
||||||
});
|
}).ConfigureAwait(false);
|
||||||
|
|
||||||
return new SearchHintResult(result.Items.Select(GetSearchHintResult).ToArray(), result.TotalRecordCount);
|
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>
|
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the trailers.</returns>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[Obsolete("Use GetItems with includeItemTypes=Trailer instead.")]
|
||||||
public async Task<ActionResult<QueryResult<BaseItemDto>>> GetTrailers(
|
public async Task<ActionResult<QueryResult<BaseItemDto>>> GetTrailers(
|
||||||
[FromQuery] Guid? userId,
|
[FromQuery] Guid? userId,
|
||||||
[FromQuery] string? maxOfficialRating,
|
[FromQuery] string? maxOfficialRating,
|
||||||
|
|||||||
@@ -232,7 +232,7 @@ public class TvShowsController : BaseJellyfinApiController
|
|||||||
|
|
||||||
if (seasonId.HasValue) // Season id was supplied. Get episodes by season id.
|
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)
|
if (item is not Season seasonItem)
|
||||||
{
|
{
|
||||||
return NotFound("No season exists with Id " + seasonId);
|
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
|
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)
|
if (series is null)
|
||||||
{
|
{
|
||||||
return NotFound("Series not found");
|
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.
|
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");
|
return NotFound("Series not found");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ public class UniversalAudioController : BaseJellyfinApiController
|
|||||||
Request.HttpContext.GetNormalizedRemoteIP());
|
Request.HttpContext.GetNormalizedRemoteIP());
|
||||||
}
|
}
|
||||||
|
|
||||||
_mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate);
|
_mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate, item.Id);
|
||||||
|
|
||||||
foreach (var source in info.MediaSources)
|
foreach (var source in info.MediaSources)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -429,14 +429,8 @@ public class UserLibraryController : BaseJellyfinApiController
|
|||||||
}
|
}
|
||||||
|
|
||||||
var dtoOptions = new DtoOptions();
|
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()
|
return Ok(item.GetExtras([ExtraType.Trailer], user)
|
||||||
.Where(e => e.ExtraType == ExtraType.Trailer)
|
|
||||||
.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item)));
|
.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -487,7 +481,7 @@ public class UserLibraryController : BaseJellyfinApiController
|
|||||||
var dtoOptions = new DtoOptions();
|
var dtoOptions = new DtoOptions();
|
||||||
|
|
||||||
return Ok(item
|
return Ok(item
|
||||||
.GetExtras()
|
.GetExtras(user)
|
||||||
.Where(i => i.ExtraType.HasValue && BaseItem.DisplayExtraTypes.Contains(i.ExtraType.Value))
|
.Where(i => i.ExtraType.HasValue && BaseItem.DisplayExtraTypes.Contains(i.ExtraType.Value))
|
||||||
.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item)));
|
.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item)));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ public class VideosController : BaseJellyfinApiController
|
|||||||
BaseItemDto[] items;
|
BaseItemDto[] items;
|
||||||
if (item is Video video)
|
if (item is Video video)
|
||||||
{
|
{
|
||||||
items = video.GetAdditionalParts()
|
items = video.GetAdditionalParts(user)
|
||||||
.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, video))
|
.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, video))
|
||||||
.ToArray();
|
.ToArray();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -351,11 +351,20 @@ public class MediaInfoHelper
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="result">Playback info response.</param>
|
/// <param name="result">Playback info response.</param>
|
||||||
/// <param name="maxBitrate">Max bitrate.</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();
|
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
|
// Nothing beats direct playing a file
|
||||||
if (i.SupportsDirectPlay && i.Protocol == MediaProtocol.File)
|
if (i.SupportsDirectPlay && i.Protocol == MediaProtocol.File)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ using Jellyfin.Database.Implementations;
|
|||||||
using Jellyfin.Server.Implementations.StorageHelpers;
|
using Jellyfin.Server.Implementations.StorageHelpers;
|
||||||
using Jellyfin.Server.Implementations.SystemBackupService;
|
using Jellyfin.Server.Implementations.SystemBackupService;
|
||||||
using MediaBrowser.Controller;
|
using MediaBrowser.Controller;
|
||||||
|
using MediaBrowser.Controller.Library;
|
||||||
using MediaBrowser.Controller.SystemBackupService;
|
using MediaBrowser.Controller.SystemBackupService;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
@@ -33,6 +34,7 @@ public class BackupService : IBackupService
|
|||||||
private readonly IServerApplicationPaths _applicationPaths;
|
private readonly IServerApplicationPaths _applicationPaths;
|
||||||
private readonly IJellyfinDatabaseProvider _jellyfinDatabaseProvider;
|
private readonly IJellyfinDatabaseProvider _jellyfinDatabaseProvider;
|
||||||
private readonly IHostApplicationLifetime _hostApplicationLifetime;
|
private readonly IHostApplicationLifetime _hostApplicationLifetime;
|
||||||
|
private readonly ILibraryManager _libraryManager;
|
||||||
private static readonly JsonSerializerOptions _serializerSettings = new JsonSerializerOptions(JsonSerializerDefaults.General)
|
private static readonly JsonSerializerOptions _serializerSettings = new JsonSerializerOptions(JsonSerializerDefaults.General)
|
||||||
{
|
{
|
||||||
AllowTrailingCommas = true,
|
AllowTrailingCommas = true,
|
||||||
@@ -50,13 +52,15 @@ public class BackupService : IBackupService
|
|||||||
/// <param name="applicationPaths">The application paths.</param>
|
/// <param name="applicationPaths">The application paths.</param>
|
||||||
/// <param name="jellyfinDatabaseProvider">The Jellyfin database Provider in use.</param>
|
/// <param name="jellyfinDatabaseProvider">The Jellyfin database Provider in use.</param>
|
||||||
/// <param name="applicationLifetime">The SystemManager.</param>
|
/// <param name="applicationLifetime">The SystemManager.</param>
|
||||||
|
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
|
||||||
public BackupService(
|
public BackupService(
|
||||||
ILogger<BackupService> logger,
|
ILogger<BackupService> logger,
|
||||||
IDbContextFactory<JellyfinDbContext> dbProvider,
|
IDbContextFactory<JellyfinDbContext> dbProvider,
|
||||||
IServerApplicationHost applicationHost,
|
IServerApplicationHost applicationHost,
|
||||||
IServerApplicationPaths applicationPaths,
|
IServerApplicationPaths applicationPaths,
|
||||||
IJellyfinDatabaseProvider jellyfinDatabaseProvider,
|
IJellyfinDatabaseProvider jellyfinDatabaseProvider,
|
||||||
IHostApplicationLifetime applicationLifetime)
|
IHostApplicationLifetime applicationLifetime,
|
||||||
|
ILibraryManager libraryManager)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_dbProvider = dbProvider;
|
_dbProvider = dbProvider;
|
||||||
@@ -64,6 +68,7 @@ public class BackupService : IBackupService
|
|||||||
_applicationPaths = applicationPaths;
|
_applicationPaths = applicationPaths;
|
||||||
_jellyfinDatabaseProvider = jellyfinDatabaseProvider;
|
_jellyfinDatabaseProvider = jellyfinDatabaseProvider;
|
||||||
_hostApplicationLifetime = applicationLifetime;
|
_hostApplicationLifetime = applicationLifetime;
|
||||||
|
_libraryManager = libraryManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
@@ -263,6 +268,14 @@ public class BackupService : IBackupService
|
|||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<BackupManifestDto> CreateBackupAsync(BackupOptionsDto backupOptions)
|
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()
|
var manifest = new BackupManifest()
|
||||||
{
|
{
|
||||||
DateCreated = DateTime.UtcNow,
|
DateCreated = DateTime.UtcNow,
|
||||||
|
|||||||
@@ -586,8 +586,7 @@ public sealed partial class BaseItemRepository
|
|||||||
|
|
||||||
if (filter.AlbumIds.Length > 0)
|
if (filter.AlbumIds.Length > 0)
|
||||||
{
|
{
|
||||||
var subQuery = context.BaseItems.WhereOneOrMany(filter.AlbumIds, f => f.Id);
|
baseQuery = baseQuery.Where(e => e.ParentId.HasValue && filter.AlbumIds.Contains(e.ParentId.Value));
|
||||||
baseQuery = baseQuery.Where(e => subQuery.Any(f => f.Name == e.Album));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter.ExcludeArtistIds.Length > 0)
|
if (filter.ExcludeArtistIds.Length > 0)
|
||||||
@@ -953,24 +952,17 @@ public sealed partial class BaseItemRepository
|
|||||||
|
|
||||||
if (filter.ExcludeProviderIds is not null && filter.ExcludeProviderIds.Count > 0)
|
if (filter.ExcludeProviderIds is not null && filter.ExcludeProviderIds.Count > 0)
|
||||||
{
|
{
|
||||||
var exclude = filter.ExcludeProviderIds.Select(e => $"{e.Key}:{e.Value}").ToArray();
|
baseQuery = baseQuery.WhereExcludeProviderIds(filter.ExcludeProviderIds);
|
||||||
baseQuery = baseQuery.Where(e => e.Provider!.Select(f => f.ProviderId + ":" + f.ProviderValue)!.All(f => !exclude.Contains(f)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter.HasAnyProviderId is not null && filter.HasAnyProviderId.Count > 0)
|
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.
|
baseQuery = baseQuery.WhereHasAnyProviderId(filter.HasAnyProviderId);
|
||||||
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)));
|
|
||||||
}
|
|
||||||
|
|
||||||
var includeSelected = filter.HasAnyProviderId.Where(e => !string.IsNullOrEmpty(e.Value)).Select(e => $"{e.Key}:{e.Value}").ToArray();
|
if (filter.HasAnyProviderIds is not null && filter.HasAnyProviderIds.Count > 0)
|
||||||
if (includeSelected.Length > 0)
|
{
|
||||||
{
|
baseQuery = baseQuery.WhereHasAnyProviderIds(filter.HasAnyProviderIds);
|
||||||
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)
|
if (filter.HasAnyProviderIds is not null && filter.HasAnyProviderIds.Count > 0)
|
||||||
|
|||||||
@@ -65,8 +65,13 @@ public class ItemPersistenceService : IItemPersistenceService
|
|||||||
descendantIds.Add(id);
|
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
|
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)
|
.Select(e => e.Id)
|
||||||
.ToArray();
|
.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
|
newLinkedChildren = newLinkedChildren
|
||||||
.GroupBy(c => c.ChildId)
|
.GroupBy(c => c.ChildId)
|
||||||
.Select(g => g.Last())
|
.Select(g => g.OrderBy(c => c.Type == LinkedChildType.LocalAlternateVersion ? 0 : 1).First())
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var childIdsToCheck = newLinkedChildren.Select(c => c.ChildId).ToList();
|
var childIdsToCheck = newLinkedChildren.Select(c => c.ChildId).ToList();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using System.Globalization;
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using AsyncKeyedLock;
|
using AsyncKeyedLock;
|
||||||
@@ -28,7 +29,7 @@ namespace Jellyfin.Server.Implementations.Trickplay;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// ITrickplayManager implementation.
|
/// ITrickplayManager implementation.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class TrickplayManager : ITrickplayManager
|
public partial class TrickplayManager : ITrickplayManager
|
||||||
{
|
{
|
||||||
private readonly ILogger<TrickplayManager> _logger;
|
private readonly ILogger<TrickplayManager> _logger;
|
||||||
private readonly IMediaEncoder _mediaEncoder;
|
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 />
|
/// <inheritdoc />
|
||||||
public async Task RefreshTrickplayDataAsync(Video video, bool replace, LibraryOptions libraryOptions, CancellationToken cancellationToken)
|
public async Task RefreshTrickplayDataAsync(Video video, bool replace, LibraryOptions libraryOptions, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -144,11 +286,27 @@ public class TrickplayManager : ITrickplayManager
|
|||||||
return;
|
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);
|
var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||||
await using (dbContext.ConfigureAwait(false))
|
await using (dbContext.ConfigureAwait(false))
|
||||||
{
|
{
|
||||||
var saveWithMedia = libraryOptions.SaveTrickplayWithMedia;
|
|
||||||
var trickplayDirectory = _pathManager.GetTrickplayDirectory(video, saveWithMedia);
|
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)
|
if (!libraryOptions.EnableTrickplayImageExtraction || replace)
|
||||||
{
|
{
|
||||||
// Prune existing data
|
// Prune existing data
|
||||||
@@ -688,6 +846,19 @@ public class TrickplayManager : ITrickplayManager
|
|||||||
return Path.Combine(path, subdirectory);
|
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)
|
private async Task<bool> HasTrickplayResolutionAsync(Guid itemId, int width)
|
||||||
{
|
{
|
||||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
private readonly DefaultPasswordResetProvider _defaultPasswordResetProvider;
|
private readonly DefaultPasswordResetProvider _defaultPasswordResetProvider;
|
||||||
private readonly IServerConfigurationManager _serverConfigurationManager;
|
private readonly IServerConfigurationManager _serverConfigurationManager;
|
||||||
|
|
||||||
private readonly AsyncKeyedLocker<Guid> _userLock = new();
|
private readonly LockHelper _userLock = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="UserManager"/> class.
|
/// Initializes a new instance of the <see cref="UserManager"/> class.
|
||||||
@@ -214,7 +214,58 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
{
|
{
|
||||||
using (await _userLock.LockAsync(user.Id).ConfigureAwait(false))
|
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);
|
var user = GetUserByName(username);
|
||||||
using (await _userLock.LockAsync(user?.Id ?? Guid.Empty).ConfigureAwait(false))
|
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.
|
// 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
|
// GetUserByName uses AsNoTracking and the snapshot may be stale if another
|
||||||
// write (e.g. a concurrent login) incremented RowVersion after our initial load.
|
// write (e.g. a concurrent login) incremented RowVersion after our initial load.
|
||||||
if (user is not null)
|
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)
|
var authResult = await AuthenticateLocalUser(username, password, user)
|
||||||
@@ -466,6 +519,13 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
var authenticationProvider = authResult.AuthenticationProvider;
|
var authenticationProvider = authResult.AuthenticationProvider;
|
||||||
success = authResult.Success;
|
success = authResult.Success;
|
||||||
|
|
||||||
|
if (success && user is not null)
|
||||||
|
{
|
||||||
|
// refresh the user if the auth provider might have updated it in the auth method.
|
||||||
|
// this is a hack, this needs removal once the LDAP plugin uses the correct interface to get the user we hand in here and update that one instead.
|
||||||
|
user = await UserQuery(dbContext).FirstOrDefaultAsync(e => e.Id == user.Id).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
if (user is null)
|
if (user is null)
|
||||||
{
|
{
|
||||||
string updatedUsername = authResult.Username;
|
string updatedUsername = authResult.Username;
|
||||||
@@ -479,11 +539,16 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
|
|
||||||
// Search the database for the user again
|
// Search the database for the user again
|
||||||
// the authentication provider might have created it
|
// the authentication provider might have created it
|
||||||
user = 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)
|
if (authenticationProvider is IHasNewUserPolicy hasNewUserPolicy && user is not null)
|
||||||
{
|
{
|
||||||
await UpdatePolicyAsync(user.Id, hasNewUserPolicy.GetNewUserPolicy()).ConfigureAwait(false);
|
await UpdatePolicyAsync(user.Id, hasNewUserPolicy.GetNewUserPolicy()).ConfigureAwait(false);
|
||||||
|
user = await UserQuery(dbContext)
|
||||||
|
.FirstOrDefaultAsync(e => e.NormalizedUsername == username.ToUpperInvariant()).ConfigureAwait(false);
|
||||||
|
#pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -494,8 +559,10 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
|
|
||||||
if (providerId is not null && !string.Equals(providerId, user.AuthenticationProviderId, StringComparison.OrdinalIgnoreCase))
|
if (providerId is not null && !string.Equals(providerId, user.AuthenticationProviderId, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
user.AuthenticationProviderId = providerId;
|
await dbContext.Users
|
||||||
await UpdateUserInternalAsync(user).ConfigureAwait(false);
|
.Where(e => e.Id == user.Id)
|
||||||
|
.ExecuteUpdateAsync(e => e.SetProperty(f => f.AuthenticationProviderId, providerId))
|
||||||
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -542,16 +609,42 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
{
|
{
|
||||||
if (isUserSession)
|
if (isUserSession)
|
||||||
{
|
{
|
||||||
user.LastActivityDate = user.LastLoginDate = DateTime.UtcNow;
|
var date = DateTime.UtcNow;
|
||||||
|
await dbContext.Users
|
||||||
|
.Where(e => e.Id == user.Id)
|
||||||
|
.ExecuteUpdateAsync(e => e
|
||||||
|
.SetProperty(f => f.LastActivityDate, date)
|
||||||
|
.SetProperty(f => f.LastLoginDate, date))
|
||||||
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
user.InvalidLoginAttemptCount = 0;
|
await dbContext.Users
|
||||||
await UpdateUserInternalAsync(user).ConfigureAwait(false);
|
.Where(e => e.Id == user.Id)
|
||||||
|
.ExecuteUpdateAsync(e => e.SetProperty(f => f.InvalidLoginAttemptCount, 0))
|
||||||
|
.ConfigureAwait(false);
|
||||||
_logger.LogInformation("Authentication request for {UserName} has succeeded.", user.Username);
|
_logger.LogInformation("Authentication request for {UserName} has succeeded.", user.Username);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
await IncrementInvalidLoginAttemptCount(user).ConfigureAwait(false);
|
user.InvalidLoginAttemptCount++;
|
||||||
|
int? maxInvalidLogins = user.LoginAttemptsBeforeLockout;
|
||||||
|
if (maxInvalidLogins.HasValue && user.InvalidLoginAttemptCount >= maxInvalidLogins)
|
||||||
|
{
|
||||||
|
user.SetPermission(PermissionKind.IsDisabled, true);
|
||||||
|
await dbContext.SaveChangesAsync()
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
await _eventManager.PublishAsync(new UserLockedOutEventArgs(user)).ConfigureAwait(false);
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Disabling user {Username} due to {Attempts} unsuccessful login attempts.",
|
||||||
|
user.Username,
|
||||||
|
user.InvalidLoginAttemptCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
await dbContext.Users
|
||||||
|
.Where(e => e.Id == user.Id)
|
||||||
|
.ExecuteUpdateAsync(e => e.SetProperty(f => f.InvalidLoginAttemptCount, f => f.InvalidLoginAttemptCount + 1))
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"Authentication request for {UserName} has been denied (IP: {IP}).",
|
"Authentication request for {UserName} has been denied (IP: {IP}).",
|
||||||
user.Username,
|
user.Username,
|
||||||
@@ -926,32 +1019,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)
|
private async Task UpdateUserInternalAsync(JellyfinDbContext dbContext, User user)
|
||||||
{
|
{
|
||||||
dbContext.Users.Attach(user);
|
dbContext.Users.Attach(user);
|
||||||
@@ -977,5 +1044,70 @@ namespace Jellyfin.Server.Implementations.Users
|
|||||||
_userLock.Dispose();
|
_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);
|
logger.LogInformation("There are {Pending} migrations for stage {Stage}.", pendingCodeMigrations.Length, stage);
|
||||||
migrations = pendingMigrations.OrderBy(e => e.Key).ToArray();
|
migrations = pendingMigrations.OrderBy(e => e.Key).ToArray();
|
||||||
|
|
||||||
|
var migrationIndex = 0;
|
||||||
foreach (var item in migrations)
|
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}");
|
var migrationLogger = logger.With(_loggerFactory.CreateLogger(item.Migration.GetType().Name)).BeginGroup($"{item.Key}");
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -223,6 +223,35 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
|
|||||||
|
|
||||||
toInsert = toInsert.Where(lc => existingChildIds.Contains(lc.ChildId)).ToList();
|
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.LinkedChildren.AddRange(toInsert);
|
||||||
context.SaveChanges();
|
context.SaveChanges();
|
||||||
|
|
||||||
|
|||||||
@@ -76,25 +76,36 @@ public class CleanupOrphanedExtras : IAsyncMigrationRoutine
|
|||||||
|
|
||||||
_logger.LogInformation("Found {Count} orphaned extras to remove", orphanedItemIds.Count);
|
_logger.LogInformation("Found {Count} orphaned extras to remove", orphanedItemIds.Count);
|
||||||
|
|
||||||
// Batch-resolve items for metadata path cleanup, then delete all at once
|
// Resolve items for metadata path cleanup, then delete in batches so we never issue one
|
||||||
var itemsToDelete = new List<BaseItem>();
|
// massive delete transaction and progress stays visible on large libraries.
|
||||||
foreach (var itemId in orphanedItemIds)
|
_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(
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
new Database.Implementations.Entities.BaseItemEntity()
|
|
||||||
{
|
var batch = orphanedItemIds.GetRange(offset, Math.Min(deleteBatchSize, orphanedItemIds.Count - offset));
|
||||||
Id = itemId.Id,
|
var itemsToDelete = batch
|
||||||
Path = itemId.Path,
|
.Select(itemId => BaseItemMapper.DeserializeBaseItem(
|
||||||
Type = itemId.Type
|
new Database.Implementations.Entities.BaseItemEntity()
|
||||||
},
|
{
|
||||||
_logger,
|
Id = itemId.Id,
|
||||||
null,
|
Path = itemId.Path,
|
||||||
true)!);
|
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", orphanedItemIds.Count);
|
||||||
|
|
||||||
_logger.LogInformation("Successfully removed {Count} orphaned extras", itemsToDelete.Count);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,19 +136,38 @@ public class FixIncorrectOwnerIdRelationships : IAsyncMigrationRoutine
|
|||||||
|
|
||||||
if (allIdsToDelete.Count > 0)
|
if (allIdsToDelete.Count > 0)
|
||||||
{
|
{
|
||||||
// Batch-resolve items for metadata path cleanup, then delete all at once
|
_logger.LogInformation("Deleting {Count} duplicate database entries...", allIdsToDelete.Count);
|
||||||
var itemsToDelete = allIdsToDelete
|
|
||||||
.Select(id => _libraryManager.GetItemById(id))
|
|
||||||
.Where(item => item is not null)
|
|
||||||
.ToList();
|
|
||||||
_libraryManager.DeleteItemsUnsafeFast(itemsToDelete!);
|
|
||||||
|
|
||||||
// Fall back to direct DB deletion for any items that couldn't be resolved via LibraryManager
|
// Delete in batches so progress is visible (item resolution and deletion can take a
|
||||||
var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet();
|
// long time on large libraries) and so we never issue one massive delete transaction.
|
||||||
var unresolvedIds = allIdsToDelete.Where(id => !deletedIds.Contains(id)).ToList();
|
const int deleteBatchSize = 500;
|
||||||
if (unresolvedIds.Count > 0)
|
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
|
// Resolve via LibraryManager so DeleteItemsUnsafeFast can also remove the
|
||||||
// %MetadataPath%/artists/<Name> directories that the duplicate stubs left behind.
|
// %MetadataPath%/artists/<Name> directories that the duplicate stubs left behind.
|
||||||
// Fall back to the persistence service for any items the LibraryManager can't resolve.
|
// Fall back to the persistence service for any items the LibraryManager can't resolve.
|
||||||
var itemsToDelete = idsToDelete
|
// Delete in batches so we never issue one massive delete transaction and progress stays visible.
|
||||||
.Select(id => _libraryManager.GetItemById(id))
|
_logger.LogInformation("Deleting {Count} duplicate MusicArtist records...", idsToDelete.Count);
|
||||||
.Where(item => item is not null)
|
const int deleteBatchSize = 500;
|
||||||
.ToList();
|
var deletedSoFar = 0;
|
||||||
if (itemsToDelete.Count > 0)
|
for (var offset = 0; offset < idsToDelete.Count; offset += deleteBatchSize)
|
||||||
{
|
{
|
||||||
_libraryManager.DeleteItemsUnsafeFast(itemsToDelete!);
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
}
|
|
||||||
|
|
||||||
var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet();
|
var batchIds = idsToDelete.GetRange(offset, Math.Min(deleteBatchSize, idsToDelete.Count - offset));
|
||||||
var unresolvedIds = idsToDelete.Where(id => !deletedIds.Contains(id)).ToList();
|
|
||||||
if (unresolvedIds.Count > 0)
|
|
||||||
{
|
|
||||||
_persistenceService.DeleteItem(unresolvedIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
_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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -184,23 +184,35 @@ public class MergeDuplicatePeople : IAsyncMigrationRoutine
|
|||||||
|
|
||||||
// Resolve via LibraryManager so DeleteItemsUnsafeFast can also remove the
|
// Resolve via LibraryManager so DeleteItemsUnsafeFast can also remove the
|
||||||
// %MetadataPath%/People/<Letter>/<Name> directories the duplicate stubs left behind.
|
// %MetadataPath%/People/<Letter>/<Name> directories the duplicate stubs left behind.
|
||||||
var itemsToDelete = idsToDelete
|
// Delete in batches so we never issue one massive delete transaction and progress stays visible.
|
||||||
.Select(id => _libraryManager.GetItemById(id))
|
_logger.LogInformation("Deleting {Count} duplicate Person BaseItems...", idsToDelete.Count);
|
||||||
.Where(item => item is not null)
|
const int deleteBatchSize = 500;
|
||||||
.ToList();
|
var deletedSoFar = 0;
|
||||||
if (itemsToDelete.Count > 0)
|
for (var offset = 0; offset < idsToDelete.Count; offset += deleteBatchSize)
|
||||||
{
|
{
|
||||||
_libraryManager.DeleteItemsUnsafeFast(itemsToDelete!);
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
}
|
|
||||||
|
|
||||||
var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet();
|
var batchIds = idsToDelete.GetRange(offset, Math.Min(deleteBatchSize, idsToDelete.Count - offset));
|
||||||
var unresolvedIds = idsToDelete.Where(id => !deletedIds.Contains(id)).ToList();
|
|
||||||
if (unresolvedIds.Count > 0)
|
|
||||||
{
|
|
||||||
_persistenceService.DeleteItem(unresolvedIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation("Removed {Count} duplicate Person BaseItems.", 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 Person BaseItems: {Deleted}/{Total}", deletedSoFar, idsToDelete.Count);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task MergePeoplesRowsAsync(JellyfinDbContext context, CancellationToken cancellationToken)
|
private async Task MergePeoplesRowsAsync(JellyfinDbContext context, CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Jellyfin.Database.Implementations;
|
||||||
|
using Jellyfin.Server.ServerSetupApp;
|
||||||
|
using MediaBrowser.Common.Configuration;
|
||||||
|
using MediaBrowser.Controller;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace Jellyfin.Server.Migrations.Routines;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes on-disk external item data (attachments, subtitles, trickplay tiles, chapter images) for items that
|
||||||
|
/// no longer exist in the <c>BaseItems</c> table. The database side is cleaned up synchronously by
|
||||||
|
/// <c>IItemPersistenceService.DeleteItem</c>, so the leftover orphans live on the filesystem.
|
||||||
|
/// </summary>
|
||||||
|
[JellyfinMigration("2026-05-25T01:00:00", nameof(CleanupOrphanedExternalData))]
|
||||||
|
[JellyfinMigrationBackup(JellyfinDb = true)]
|
||||||
|
public class CleanupOrphanedExternalData : IAsyncMigrationRoutine
|
||||||
|
{
|
||||||
|
private const int ProgressLogStep = 500;
|
||||||
|
|
||||||
|
private readonly IStartupLogger<CleanupOrphanedExternalData> _logger;
|
||||||
|
private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory;
|
||||||
|
private readonly IApplicationPaths _appPaths;
|
||||||
|
private readonly IServerApplicationPaths _serverPaths;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="CleanupOrphanedExternalData"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="logger">The startup logger.</param>
|
||||||
|
/// <param name="dbContextFactory">The database context factory.</param>
|
||||||
|
/// <param name="appPaths">The application paths.</param>
|
||||||
|
/// <param name="serverPaths">The server application paths.</param>
|
||||||
|
public CleanupOrphanedExternalData(
|
||||||
|
IStartupLogger<CleanupOrphanedExternalData> logger,
|
||||||
|
IDbContextFactory<JellyfinDbContext> dbContextFactory,
|
||||||
|
IApplicationPaths appPaths,
|
||||||
|
IServerApplicationPaths serverPaths)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_dbContextFactory = dbContextFactory;
|
||||||
|
_appPaths = appPaths;
|
||||||
|
_serverPaths = serverPaths;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task PerformAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var knownIds = await LoadKnownItemIdsAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
CleanupGuidIndexedRoot(
|
||||||
|
"attachment",
|
||||||
|
Path.Combine(_appPaths.DataPath, "attachments"),
|
||||||
|
knownIds,
|
||||||
|
deleteSubPath: null,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
CleanupGuidIndexedRoot(
|
||||||
|
"subtitle",
|
||||||
|
Path.Combine(_appPaths.DataPath, "subtitles"),
|
||||||
|
knownIds,
|
||||||
|
deleteSubPath: null,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
CleanupGuidIndexedRoot(
|
||||||
|
"trickplay",
|
||||||
|
_appPaths.TrickplayPath,
|
||||||
|
knownIds,
|
||||||
|
deleteSubPath: null,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
CleanupGuidIndexedRoot(
|
||||||
|
"chapter image",
|
||||||
|
Path.Combine(_serverPaths.InternalMetadataPath, "library"),
|
||||||
|
knownIds,
|
||||||
|
deleteSubPath: "chapters",
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<HashSet<Guid>> LoadKnownItemIdsAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
await using (context.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
var ids = await context.BaseItems
|
||||||
|
.AsNoTracking()
|
||||||
|
.Select(b => b.Id)
|
||||||
|
.ToListAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
return [.. ids];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CleanupGuidIndexedRoot(
|
||||||
|
string label,
|
||||||
|
string root,
|
||||||
|
HashSet<Guid> knownIds,
|
||||||
|
string? deleteSubPath,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(root) || !Directory.Exists(root))
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Skipping {Label} cleanup; root {Root} does not exist", label, root);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Scanning for orphaned {Label} data under {Root}", label, root);
|
||||||
|
|
||||||
|
var scanned = 0;
|
||||||
|
var removed = 0;
|
||||||
|
foreach (var prefixDir in Directory.EnumerateDirectories(root))
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var prefixName = Path.GetFileName(prefixDir);
|
||||||
|
if (prefixName.Length != 2)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var guidDir in Directory.EnumerateDirectories(prefixDir))
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
scanned++;
|
||||||
|
if (scanned % ProgressLogStep == 0)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Scanning {Label}: {Scanned} directories examined, {Removed} orphans removed so far", label, scanned, removed);
|
||||||
|
}
|
||||||
|
|
||||||
|
var leafName = Path.GetFileName(guidDir);
|
||||||
|
if (!Guid.TryParse(leafName, CultureInfo.InvariantCulture, out var id))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (knownIds.Contains(id))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var target = deleteSubPath is null ? guidDir : Path.Combine(guidDir, deleteSubPath);
|
||||||
|
if (deleteSubPath is not null && !Directory.Exists(target))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TryDelete(target))
|
||||||
|
{
|
||||||
|
removed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Finished {Label} cleanup: scanned {Scanned} directories, removed {Removed} orphans", label, scanned, removed);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool TryDelete(string dir)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.Delete(dir, recursive: true);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (IOException ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to delete orphaned directory {Dir}", dir);
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Permission denied deleting orphaned directory {Dir}", dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,22 +12,22 @@ using Microsoft.Extensions.Logging;
|
|||||||
namespace Jellyfin.Server.Migrations.Routines;
|
namespace Jellyfin.Server.Migrations.Routines;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Migration to refresh CleanName values for all library items.
|
/// Migration to refresh CleanName values for all library items and CleanValue values for all item values.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[JellyfinMigration("2025-10-08T12:00:00", nameof(RefreshCleanNames))]
|
[JellyfinMigration("2026-06-10T12:00:00", nameof(RefreshCleanNamesAndValues))]
|
||||||
[JellyfinMigrationBackup(JellyfinDb = true)]
|
[JellyfinMigrationBackup(JellyfinDb = true)]
|
||||||
public class RefreshCleanNames : IAsyncMigrationRoutine
|
public class RefreshCleanNamesAndValues : IAsyncMigrationRoutine
|
||||||
{
|
{
|
||||||
private readonly IStartupLogger<RefreshCleanNames> _logger;
|
private readonly IStartupLogger<RefreshCleanNamesAndValues> _logger;
|
||||||
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
|
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="RefreshCleanNames"/> class.
|
/// Initializes a new instance of the <see cref="RefreshCleanNamesAndValues"/> class.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="logger">The logger.</param>
|
/// <param name="logger">The logger.</param>
|
||||||
/// <param name="dbProvider">Instance of the <see cref="IDbContextFactory{JellyfinDbContext}"/> interface.</param>
|
/// <param name="dbProvider">Instance of the <see cref="IDbContextFactory{JellyfinDbContext}"/> interface.</param>
|
||||||
public RefreshCleanNames(
|
public RefreshCleanNamesAndValues(
|
||||||
IStartupLogger<RefreshCleanNames> logger,
|
IStartupLogger<RefreshCleanNamesAndValues> logger,
|
||||||
IDbContextFactory<JellyfinDbContext> dbProvider)
|
IDbContextFactory<JellyfinDbContext> dbProvider)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
@@ -36,6 +36,12 @@ public class RefreshCleanNames : IAsyncMigrationRoutine
|
|||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task PerformAsync(CancellationToken cancellationToken)
|
public async Task PerformAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await RefreshCleanNamesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
await RefreshCleanValuesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RefreshCleanNamesAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
const int Limit = 10000;
|
const int Limit = 10000;
|
||||||
int itemCount = 0;
|
int itemCount = 0;
|
||||||
@@ -99,4 +105,69 @@ public class RefreshCleanNames : IAsyncMigrationRoutine
|
|||||||
records,
|
records,
|
||||||
sw.Elapsed);
|
sw.Elapsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task RefreshCleanValuesAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
const int Limit = 10000;
|
||||||
|
int itemCount = 0;
|
||||||
|
|
||||||
|
var sw = Stopwatch.StartNew();
|
||||||
|
|
||||||
|
using var context = _dbProvider.CreateDbContext();
|
||||||
|
var records = context.ItemValues.Count(b => !string.IsNullOrEmpty(b.Value));
|
||||||
|
_logger.LogInformation("Refreshing CleanValue for {Count} item values", records);
|
||||||
|
|
||||||
|
var processedInPartition = 0;
|
||||||
|
|
||||||
|
await foreach (var item in context.ItemValues
|
||||||
|
.Where(b => !string.IsNullOrEmpty(b.Value))
|
||||||
|
.OrderBy(e => e.ItemValueId)
|
||||||
|
.WithPartitionProgress((partition) => _logger.LogInformation("Processed: {Offset}/{Total} - Updated: {UpdatedCount} - Time: {Elapsed}", partition * Limit, records, itemCount, sw.Elapsed))
|
||||||
|
.PartitionEagerAsync(Limit, cancellationToken)
|
||||||
|
.WithCancellation(cancellationToken)
|
||||||
|
.ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var newCleanValue = string.IsNullOrWhiteSpace(item.Value) ? string.Empty : item.Value.GetCleanValue();
|
||||||
|
if (!string.Equals(newCleanValue, item.CleanValue, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
_logger.LogDebug(
|
||||||
|
"Updating CleanValue for item value {Id}: '{OldValue}' -> '{NewValue}'",
|
||||||
|
item.ItemValueId,
|
||||||
|
item.CleanValue,
|
||||||
|
newCleanValue);
|
||||||
|
item.CleanValue = newCleanValue;
|
||||||
|
itemCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to update CleanValue for item value {Id} ({Value})", item.ItemValueId, item.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
processedInPartition++;
|
||||||
|
|
||||||
|
if (processedInPartition >= Limit)
|
||||||
|
{
|
||||||
|
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
// Clear tracked entities to avoid memory growth across partitions
|
||||||
|
context.ChangeTracker.Clear();
|
||||||
|
processedInPartition = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save any remaining changes after the loop
|
||||||
|
if (processedInPartition > 0)
|
||||||
|
{
|
||||||
|
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
context.ChangeTracker.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Refreshed CleanValue for {UpdatedCount} out of {TotalCount} item values in {Time}",
|
||||||
|
itemCount,
|
||||||
|
records,
|
||||||
|
sw.Elapsed);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -133,10 +133,12 @@ namespace Jellyfin.Server
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SetupServer.ReportActivity(StartupActivity.CheckingStorage);
|
||||||
StorageHelper.TestCommonPathsForStorageCapacity(appPaths, StartupLogger.Logger.With(_loggerFactory.CreateLogger<Startup>()).BeginGroup($"Storage Check"));
|
StorageHelper.TestCommonPathsForStorageCapacity(appPaths, StartupLogger.Logger.With(_loggerFactory.CreateLogger<Startup>()).BeginGroup($"Storage Check"));
|
||||||
|
|
||||||
StartupHelpers.PerformStaticInitialization();
|
StartupHelpers.PerformStaticInitialization();
|
||||||
|
|
||||||
|
SetupServer.ReportActivity(StartupActivity.Initializing);
|
||||||
await ApplyStartupMigrationAsync(appPaths, startupConfig, options).ConfigureAwait(false);
|
await ApplyStartupMigrationAsync(appPaths, startupConfig, options).ConfigureAwait(false);
|
||||||
|
|
||||||
do
|
do
|
||||||
@@ -195,6 +197,7 @@ namespace Jellyfin.Server
|
|||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(_restoreFromBackup))
|
if (!string.IsNullOrWhiteSpace(_restoreFromBackup))
|
||||||
{
|
{
|
||||||
|
SetupServer.ReportActivity(StartupActivity.RestoringBackup);
|
||||||
await appHost.ServiceProvider.GetService<IBackupService>()!.RestoreBackupAsync(_restoreFromBackup).ConfigureAwait(false);
|
await appHost.ServiceProvider.GetService<IBackupService>()!.RestoreBackupAsync(_restoreFromBackup).ConfigureAwait(false);
|
||||||
_restoreFromBackup = null;
|
_restoreFromBackup = null;
|
||||||
_restartOnShutdown = true;
|
_restartOnShutdown = true;
|
||||||
@@ -202,9 +205,13 @@ namespace Jellyfin.Server
|
|||||||
}
|
}
|
||||||
|
|
||||||
var jellyfinMigrationService = ActivatorUtilities.CreateInstance<JellyfinMigrationService>(appHost.ServiceProvider);
|
var jellyfinMigrationService = ActivatorUtilities.CreateInstance<JellyfinMigrationService>(appHost.ServiceProvider);
|
||||||
|
SetupServer.ReportActivity(StartupActivity.PreparingMigrations);
|
||||||
await jellyfinMigrationService.PrepareSystemForMigration(_logger).ConfigureAwait(false);
|
await jellyfinMigrationService.PrepareSystemForMigration(_logger).ConfigureAwait(false);
|
||||||
|
// "Preparing migrations" carries through the DB read; per-migration progress is reported
|
||||||
|
// as "Running migration X of Y" from inside the step once the pending set is known.
|
||||||
await jellyfinMigrationService.MigrateStepAsync(JellyfinMigrationStageTypes.CoreInitialisation, appHost.ServiceProvider).ConfigureAwait(false);
|
await jellyfinMigrationService.MigrateStepAsync(JellyfinMigrationStageTypes.CoreInitialisation, appHost.ServiceProvider).ConfigureAwait(false);
|
||||||
|
|
||||||
|
SetupServer.ReportActivity(StartupActivity.InitializingServices);
|
||||||
await appHost.InitializeServices(startupConfig).ConfigureAwait(false);
|
await appHost.InitializeServices(startupConfig).ConfigureAwait(false);
|
||||||
_appHost = appHost;
|
_appHost = appHost;
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ using Jellyfin.Server.Extensions;
|
|||||||
using MediaBrowser.Common.Configuration;
|
using MediaBrowser.Common.Configuration;
|
||||||
using MediaBrowser.Common.Net;
|
using MediaBrowser.Common.Net;
|
||||||
using MediaBrowser.Controller;
|
using MediaBrowser.Controller;
|
||||||
using MediaBrowser.Model.IO;
|
|
||||||
using MediaBrowser.Model.System;
|
using MediaBrowser.Model.System;
|
||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
using Microsoft.AspNetCore.Hosting;
|
using Microsoft.AspNetCore.Hosting;
|
||||||
@@ -25,9 +24,6 @@ using Microsoft.Extensions.Diagnostics.HealthChecks;
|
|||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Primitives;
|
using Microsoft.Extensions.Primitives;
|
||||||
using Morestachio;
|
|
||||||
using Morestachio.Framework.IO.SingleStream;
|
|
||||||
using Morestachio.Rendering;
|
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using ILogger = Microsoft.Extensions.Logging.ILogger;
|
using ILogger = Microsoft.Extensions.Logging.ILogger;
|
||||||
|
|
||||||
@@ -44,7 +40,8 @@ public sealed class SetupServer : IDisposable
|
|||||||
private readonly ILoggerFactory _loggerFactory;
|
private readonly ILoggerFactory _loggerFactory;
|
||||||
private readonly IConfiguration _startupConfiguration;
|
private readonly IConfiguration _startupConfiguration;
|
||||||
private readonly ServerConfigurationManager _configurationManager;
|
private readonly ServerConfigurationManager _configurationManager;
|
||||||
private IRenderer? _startupUiRenderer;
|
private static volatile string _currentActivity = StartupActivity.Starting;
|
||||||
|
private StartupUiRenderer? _startupUiRenderer;
|
||||||
private IHost? _startupServer;
|
private IHost? _startupServer;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
private bool _isUnhealthy;
|
private bool _isUnhealthy;
|
||||||
@@ -76,6 +73,12 @@ public sealed class SetupServer : IDisposable
|
|||||||
|
|
||||||
internal static ConcurrentQueue<StartupLogTopic>? LogQueue { get; set; } = new();
|
internal static ConcurrentQueue<StartupLogTopic>? LogQueue { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a generic, non-identifying summary of what startup is currently doing. This is shown in the
|
||||||
|
/// always-visible header of the startup UI to unauthenticated clients, so it never contains server specific details.
|
||||||
|
/// </summary>
|
||||||
|
internal static string CurrentActivity => _currentActivity;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets a value indicating whether Startup server is currently running.
|
/// Gets a value indicating whether Startup server is currently running.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -87,64 +90,9 @@ public sealed class SetupServer : IDisposable
|
|||||||
/// <returns>A Task.</returns>
|
/// <returns>A Task.</returns>
|
||||||
public async Task RunAsync()
|
public async Task RunAsync()
|
||||||
{
|
{
|
||||||
var fileTemplate = await File.ReadAllTextAsync(Path.Combine(AppContext.BaseDirectory, "ServerSetupApp", "index.mstemplate.html")).ConfigureAwait(false);
|
ReportActivity(StartupActivity.Starting);
|
||||||
_startupUiRenderer = (await ParserOptionsBuilder.New()
|
_startupUiRenderer = await StartupUiRenderer.CreateAsync(
|
||||||
.WithTemplate(fileTemplate)
|
Path.Combine(AppContext.BaseDirectory, "ServerSetupApp", "index.mstemplate.html")).ConfigureAwait(false);
|
||||||
.WithFormatter(
|
|
||||||
(Version version, int arg) =>
|
|
||||||
{
|
|
||||||
// version type does not for some stupid reason implement IFormattable which morestachio relies on for ToString support therefor we need to do it manually.
|
|
||||||
return version.ToString(arg);
|
|
||||||
},
|
|
||||||
"ToString")
|
|
||||||
.WithFormatter(
|
|
||||||
(StartupLogTopic logEntry, IEnumerable<StartupLogTopic> children) =>
|
|
||||||
{
|
|
||||||
if (children.Any())
|
|
||||||
{
|
|
||||||
var maxLevel = logEntry.LogLevel;
|
|
||||||
var stack = new Stack<StartupLogTopic>(children);
|
|
||||||
|
|
||||||
while (maxLevel != LogLevel.Error && stack.Count > 0 && (logEntry = stack.Pop()) is not null) // error is the highest inherted error level.
|
|
||||||
{
|
|
||||||
maxLevel = maxLevel < logEntry.LogLevel ? logEntry.LogLevel : maxLevel;
|
|
||||||
foreach (var child in logEntry.Children)
|
|
||||||
{
|
|
||||||
stack.Push(child);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return maxLevel;
|
|
||||||
}
|
|
||||||
|
|
||||||
return logEntry.LogLevel;
|
|
||||||
},
|
|
||||||
"FormatLogLevel")
|
|
||||||
.WithFormatter(
|
|
||||||
(LogLevel logLevel) =>
|
|
||||||
{
|
|
||||||
switch (logLevel)
|
|
||||||
{
|
|
||||||
case LogLevel.Trace:
|
|
||||||
case LogLevel.Debug:
|
|
||||||
case LogLevel.None:
|
|
||||||
return "success";
|
|
||||||
case LogLevel.Information:
|
|
||||||
return "info";
|
|
||||||
case LogLevel.Warning:
|
|
||||||
return "warn";
|
|
||||||
case LogLevel.Error:
|
|
||||||
return "danger";
|
|
||||||
case LogLevel.Critical:
|
|
||||||
return "danger-strong";
|
|
||||||
}
|
|
||||||
|
|
||||||
return string.Empty;
|
|
||||||
},
|
|
||||||
"ToString")
|
|
||||||
.BuildAndParseAsync()
|
|
||||||
.ConfigureAwait(false))
|
|
||||||
.CreateCompiledRenderer();
|
|
||||||
|
|
||||||
ThrowIfDisposed();
|
ThrowIfDisposed();
|
||||||
var retryAfterValue = TimeSpan.FromSeconds(5);
|
var retryAfterValue = TimeSpan.FromSeconds(5);
|
||||||
@@ -257,13 +205,14 @@ public sealed class SetupServer : IDisposable
|
|||||||
new Dictionary<string, object>()
|
new Dictionary<string, object>()
|
||||||
{
|
{
|
||||||
{ "isInReportingMode", _isUnhealthy },
|
{ "isInReportingMode", _isUnhealthy },
|
||||||
|
{ "currentActivity", CurrentActivity },
|
||||||
{ "retryValue", retryAfterValue },
|
{ "retryValue", retryAfterValue },
|
||||||
{ "version", version },
|
{ "version", version },
|
||||||
{ "logs", startupLogEntries },
|
{ "logs", startupLogEntries },
|
||||||
{ "networkManagerReady", networkManager is not null },
|
{ "networkManagerReady", networkManager is not null },
|
||||||
{ "localNetworkRequest", networkManager is not null && context.Connection.RemoteIpAddress is not null && networkManager.IsInLocalNetwork(context.Connection.RemoteIpAddress) }
|
{ "localNetworkRequest", networkManager is not null && context.Connection.RemoteIpAddress is not null && networkManager.IsInLocalNetwork(context.Connection.RemoteIpAddress) }
|
||||||
},
|
},
|
||||||
new ByteCounterStream(context.Response.BodyWriter.AsStream(), IODefaults.FileStreamBufferSize, true, _startupUiRenderer.ParserOptions))
|
context.Response.BodyWriter.AsStream())
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -309,6 +258,16 @@ public sealed class SetupServer : IDisposable
|
|||||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reports the current startup activity shown to all clients in the startup UI header.
|
||||||
|
/// Only pass generic, non-identifying text from <see cref="StartupActivity"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="activity">A generic description such as <see cref="StartupActivity.PreparingMigrations"/>.</param>
|
||||||
|
internal static void ReportActivity(string activity)
|
||||||
|
{
|
||||||
|
_currentActivity = activity;
|
||||||
|
}
|
||||||
|
|
||||||
internal void SoftStop()
|
internal void SoftStop()
|
||||||
{
|
{
|
||||||
_isUnhealthy = true;
|
_isUnhealthy = true;
|
||||||
|
|||||||
41
Jellyfin.Server/ServerSetupApp/StartupActivity.cs
Normal file
41
Jellyfin.Server/ServerSetupApp/StartupActivity.cs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace Jellyfin.Server.ServerSetupApp;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A curated vocabulary of generic, non-identifying descriptions of what the server is doing during startup.
|
||||||
|
/// These are shown in the always-visible header of the startup UI to <b>unauthenticated</b> clients, so every
|
||||||
|
/// value must stay generic and must never contain server specific details (paths, names, plugin or migration ids, counts of items, etc.).
|
||||||
|
/// </summary>
|
||||||
|
public static class StartupActivity
|
||||||
|
{
|
||||||
|
/// <summary>The default state before any work has been reported.</summary>
|
||||||
|
public const string Starting = "Starting up";
|
||||||
|
|
||||||
|
/// <summary>Validating that the configured storage locations are usable.</summary>
|
||||||
|
public const string CheckingStorage = "Checking storage";
|
||||||
|
|
||||||
|
/// <summary>Bringing up the migration subsystem and running early startup checks.</summary>
|
||||||
|
public const string Initializing = "Initializing server";
|
||||||
|
|
||||||
|
/// <summary>Preparing the system for migrations (e.g. taking safety backups).</summary>
|
||||||
|
public const string PreparingMigrations = "Preparing migrations";
|
||||||
|
|
||||||
|
/// <summary>Restoring from a backup.</summary>
|
||||||
|
public const string RestoringBackup = "Restoring backup";
|
||||||
|
|
||||||
|
/// <summary>Bringing up core services and plugins.</summary>
|
||||||
|
public const string InitializingServices = "Initializing services";
|
||||||
|
|
||||||
|
/// <summary>Running the final startup tasks.</summary>
|
||||||
|
public const string FinishingStartup = "Finishing startup";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a generic "Running migration X of Y" description. Only the numeric position and total are exposed.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="current">The 1-based index of the migration currently running.</param>
|
||||||
|
/// <param name="total">The total number of migrations in this batch.</param>
|
||||||
|
/// <returns>A generic progress description.</returns>
|
||||||
|
public static string Migration(int current, int total)
|
||||||
|
=> string.Format(CultureInfo.InvariantCulture, "Running migration {0} of {1}", current, total);
|
||||||
|
}
|
||||||
109
Jellyfin.Server/ServerSetupApp/StartupUiRenderer.cs
Normal file
109
Jellyfin.Server/ServerSetupApp/StartupUiRenderer.cs
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using MediaBrowser.Model.IO;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Morestachio;
|
||||||
|
using Morestachio.Framework.IO.SingleStream;
|
||||||
|
using Morestachio.Rendering;
|
||||||
|
|
||||||
|
namespace Jellyfin.Server.ServerSetupApp;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Compiles and renders the startup UI Morestachio template.
|
||||||
|
/// Shared by the live <see cref="SetupServer"/> and the standalone startup UI preview tool so both
|
||||||
|
/// exercise the exact same template and formatters.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class StartupUiRenderer
|
||||||
|
{
|
||||||
|
private readonly IRenderer _renderer;
|
||||||
|
|
||||||
|
private StartupUiRenderer(IRenderer renderer)
|
||||||
|
{
|
||||||
|
_renderer = renderer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Compiles the startup UI template located at <paramref name="templatePath"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="templatePath">The full path to the <c>index.mstemplate.html</c> template.</param>
|
||||||
|
/// <returns>A ready to use <see cref="StartupUiRenderer"/>.</returns>
|
||||||
|
public static async Task<StartupUiRenderer> CreateAsync(string templatePath)
|
||||||
|
{
|
||||||
|
var fileTemplate = await File.ReadAllTextAsync(templatePath).ConfigureAwait(false);
|
||||||
|
var renderer = (await ParserOptionsBuilder.New()
|
||||||
|
.WithTemplate(fileTemplate)
|
||||||
|
.WithFormatter(
|
||||||
|
(Version version, int arg) =>
|
||||||
|
{
|
||||||
|
// version type does not for some stupid reason implement IFormattable which morestachio relies on for ToString support therefor we need to do it manually.
|
||||||
|
return version.ToString(arg);
|
||||||
|
},
|
||||||
|
"ToString")
|
||||||
|
.WithFormatter(
|
||||||
|
(StartupLogTopic logEntry, IEnumerable<StartupLogTopic> children) =>
|
||||||
|
{
|
||||||
|
if (children.Any())
|
||||||
|
{
|
||||||
|
var maxLevel = logEntry.LogLevel;
|
||||||
|
var stack = new Stack<StartupLogTopic>(children);
|
||||||
|
|
||||||
|
while (maxLevel != LogLevel.Error && stack.Count > 0 && (logEntry = stack.Pop()) is not null) // error is the highest inherted error level.
|
||||||
|
{
|
||||||
|
maxLevel = maxLevel < logEntry.LogLevel ? logEntry.LogLevel : maxLevel;
|
||||||
|
foreach (var child in logEntry.Children)
|
||||||
|
{
|
||||||
|
stack.Push(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return maxLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
return logEntry.LogLevel;
|
||||||
|
},
|
||||||
|
"FormatLogLevel")
|
||||||
|
.WithFormatter(
|
||||||
|
(LogLevel logLevel) =>
|
||||||
|
{
|
||||||
|
switch (logLevel)
|
||||||
|
{
|
||||||
|
case LogLevel.Trace:
|
||||||
|
case LogLevel.Debug:
|
||||||
|
case LogLevel.None:
|
||||||
|
return "success";
|
||||||
|
case LogLevel.Information:
|
||||||
|
return "info";
|
||||||
|
case LogLevel.Warning:
|
||||||
|
return "warn";
|
||||||
|
case LogLevel.Error:
|
||||||
|
return "danger";
|
||||||
|
case LogLevel.Critical:
|
||||||
|
return "danger-strong";
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Empty;
|
||||||
|
},
|
||||||
|
"ToString")
|
||||||
|
.BuildAndParseAsync()
|
||||||
|
.ConfigureAwait(false))
|
||||||
|
.CreateCompiledRenderer();
|
||||||
|
|
||||||
|
return new StartupUiRenderer(renderer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Renders the template with the provided model into the target stream.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="model">The values made available to the template.</param>
|
||||||
|
/// <param name="output">The stream the rendered HTML is written to.</param>
|
||||||
|
/// <returns>A Task.</returns>
|
||||||
|
public Task RenderAsync(IDictionary<string, object> model, Stream output)
|
||||||
|
{
|
||||||
|
return _renderer.RenderAsync(
|
||||||
|
model,
|
||||||
|
new ByteCounterStream(output, IODefaults.FileStreamBufferSize, true, _renderer.ParserOptions));
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -2718,7 +2718,7 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
|
|
||||||
public IReadOnlyList<BaseItem> GetThemeSongs(User user, IEnumerable<(ItemSortBy SortBy, SortOrder SortOrder)> orderBy)
|
public IReadOnlyList<BaseItem> GetThemeSongs(User user, IEnumerable<(ItemSortBy SortBy, SortOrder SortOrder)> orderBy)
|
||||||
{
|
{
|
||||||
return LibraryManager.Sort(GetExtras().Where(e => e.ExtraType == Model.Entities.ExtraType.ThemeSong), user, orderBy).ToArray();
|
return LibraryManager.Sort(GetExtras(user).Where(e => e.ExtraType == Model.Entities.ExtraType.ThemeSong), user, orderBy).ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
public IReadOnlyList<BaseItem> GetThemeVideos(User user = null)
|
public IReadOnlyList<BaseItem> GetThemeVideos(User user = null)
|
||||||
@@ -2728,16 +2728,17 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
|
|
||||||
public IReadOnlyList<BaseItem> GetThemeVideos(User user, IEnumerable<(ItemSortBy SortBy, SortOrder SortOrder)> orderBy)
|
public IReadOnlyList<BaseItem> GetThemeVideos(User user, IEnumerable<(ItemSortBy SortBy, SortOrder SortOrder)> orderBy)
|
||||||
{
|
{
|
||||||
return LibraryManager.Sort(GetExtras().Where(e => e.ExtraType == Model.Entities.ExtraType.ThemeVideo), user, orderBy).ToArray();
|
return LibraryManager.Sort(GetExtras(user).Where(e => e.ExtraType == Model.Entities.ExtraType.ThemeVideo), user, orderBy).ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Get all extras associated with this item, sorted by <see cref="SortName"/>.
|
/// Get all extras associated with this item, sorted by <see cref="SortName"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="user">The user to apply parental restrictions for, or <c>null</c> to skip restriction checks.</param>
|
||||||
/// <returns>An enumerable containing the items.</returns>
|
/// <returns>An enumerable containing the items.</returns>
|
||||||
public IEnumerable<BaseItem> GetExtras()
|
public IEnumerable<BaseItem> GetExtras(User user = null)
|
||||||
{
|
{
|
||||||
return LibraryManager.GetItemList(new InternalItemsQuery()
|
return LibraryManager.GetItemList(new InternalItemsQuery(user)
|
||||||
{
|
{
|
||||||
OwnerIds = [Id],
|
OwnerIds = [Id],
|
||||||
OrderBy = [(ItemSortBy.SortName, SortOrder.Ascending)]
|
OrderBy = [(ItemSortBy.SortName, SortOrder.Ascending)]
|
||||||
@@ -2748,10 +2749,11 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
/// Get all extras with specific types that are associated with this item.
|
/// Get all extras with specific types that are associated with this item.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="extraTypes">The types of extras to retrieve.</param>
|
/// <param name="extraTypes">The types of extras to retrieve.</param>
|
||||||
|
/// <param name="user">The user to apply parental restrictions for, or <c>null</c> to skip restriction checks.</param>
|
||||||
/// <returns>An enumerable containing the extras.</returns>
|
/// <returns>An enumerable containing the extras.</returns>
|
||||||
public IEnumerable<BaseItem> GetExtras(IReadOnlyCollection<ExtraType> extraTypes)
|
public IEnumerable<BaseItem> GetExtras(IReadOnlyCollection<ExtraType> extraTypes, User user = null)
|
||||||
{
|
{
|
||||||
return LibraryManager.GetItemList(new InternalItemsQuery()
|
return LibraryManager.GetItemList(new InternalItemsQuery(user)
|
||||||
{
|
{
|
||||||
OwnerIds = [Id],
|
OwnerIds = [Id],
|
||||||
ExtraTypes = extraTypes.ToArray(),
|
ExtraTypes = extraTypes.ToArray(),
|
||||||
|
|||||||
@@ -906,7 +906,10 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
query.Parent = this;
|
query.Parent = this;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (query.IncludeItemTypes.Length == 1 && query.IncludeItemTypes[0] == BaseItemKind.BoxSet)
|
// BoxSets and Playlists can have per-user visibility (shares/open access) that is stored in the
|
||||||
|
// serialized item data and cannot be evaluated by the database query, so filter them in memory.
|
||||||
|
if (query.IncludeItemTypes.Length > 0
|
||||||
|
&& query.IncludeItemTypes.All(t => t == BaseItemKind.BoxSet || t == BaseItemKind.Playlist))
|
||||||
{
|
{
|
||||||
return QueryWithPostFiltering(query);
|
return QueryWithPostFiltering(query);
|
||||||
}
|
}
|
||||||
@@ -927,7 +930,7 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
|
|
||||||
if (user is not null)
|
if (user is not null)
|
||||||
{
|
{
|
||||||
// needed for boxsets
|
// needed for boxsets and playlists
|
||||||
itemsList = itemsList.Where(i => i.IsVisibleStandalone(query.User));
|
itemsList = itemsList.Where(i => i.IsVisibleStandalone(query.User));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,102 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a value indicating whether the query carries any criteria that narrows the
|
||||||
|
/// result set, as opposed to user context, pagination, sorting or DTO options.
|
||||||
|
/// </summary>
|
||||||
|
public bool HasFilters =>
|
||||||
|
IncludeItemTypes.Length > 0
|
||||||
|
|| ExcludeItemTypes.Length > 0
|
||||||
|
|| Genres.Count > 0
|
||||||
|
|| GenreIds.Count > 0
|
||||||
|
|| Years.Length > 0
|
||||||
|
|| Tags.Length > 0
|
||||||
|
|| ExcludeTags.Length > 0
|
||||||
|
|| OfficialRatings.Length > 0
|
||||||
|
|| StudioIds.Length > 0
|
||||||
|
|| ArtistIds.Length > 0
|
||||||
|
|| AlbumArtistIds.Length > 0
|
||||||
|
|| ContributingArtistIds.Length > 0
|
||||||
|
|| ExcludeArtistIds.Length > 0
|
||||||
|
|| AlbumIds.Length > 0
|
||||||
|
|| PersonIds.Length > 0
|
||||||
|
|| PersonTypes.Length > 0
|
||||||
|
|| MediaTypes.Length > 0
|
||||||
|
|| VideoTypes.Length > 0
|
||||||
|
|| ImageTypes.Length > 0
|
||||||
|
|| SeriesStatuses.Length > 0
|
||||||
|
|| ItemIds.Length > 0
|
||||||
|
|| ExcludeItemIds.Length > 0
|
||||||
|
|| AudioLanguages.Count > 0
|
||||||
|
|| SubtitleLanguages.Count > 0
|
||||||
|
|| LinkedChildAncestorIds.Length > 0
|
||||||
|
|| AncestorIds.Length > 0
|
||||||
|
|| IsFavorite.HasValue
|
||||||
|
|| IsFavoriteOrLiked.HasValue
|
||||||
|
|| IsLiked.HasValue
|
||||||
|
|| IsPlayed.HasValue
|
||||||
|
|| IsResumable.HasValue
|
||||||
|
|| IsFolder.HasValue
|
||||||
|
|| IsMissing.HasValue
|
||||||
|
|| IsUnaired.HasValue
|
||||||
|
|| IsSpecialSeason.HasValue
|
||||||
|
|| Is3D.HasValue
|
||||||
|
|| IsHD.HasValue
|
||||||
|
|| Is4K.HasValue
|
||||||
|
|| IsLocked.HasValue
|
||||||
|
|| IsPlaceHolder.HasValue
|
||||||
|
|| IsMovie.HasValue
|
||||||
|
|| IsSports.HasValue
|
||||||
|
|| IsKids.HasValue
|
||||||
|
|| IsNews.HasValue
|
||||||
|
|| IsSeries.HasValue
|
||||||
|
|| IsAiring.HasValue
|
||||||
|
|| IsVirtualItem.HasValue
|
||||||
|
|| HasImdbId.HasValue
|
||||||
|
|| HasTmdbId.HasValue
|
||||||
|
|| HasTvdbId.HasValue
|
||||||
|
|| HasOverview.HasValue
|
||||||
|
|| HasOfficialRating.HasValue
|
||||||
|
|| HasParentalRating.HasValue
|
||||||
|
|| HasThemeSong.HasValue
|
||||||
|
|| HasThemeVideo.HasValue
|
||||||
|
|| HasSubtitles.HasValue
|
||||||
|
|| HasSpecialFeature.HasValue
|
||||||
|
|| HasTrailer.HasValue
|
||||||
|
|| HasChapterImages.HasValue
|
||||||
|
|| MinCriticRating.HasValue
|
||||||
|
|| MinCommunityRating.HasValue
|
||||||
|
|| MinParentalRating is not null
|
||||||
|
|| MinIndexNumber.HasValue
|
||||||
|
|| MinParentAndIndexNumber.HasValue
|
||||||
|
|| IndexNumber.HasValue
|
||||||
|
|| ParentIndexNumber.HasValue
|
||||||
|
|| AiredDuringSeason.HasValue
|
||||||
|
|| MinWidth.HasValue
|
||||||
|
|| MinHeight.HasValue
|
||||||
|
|| MaxWidth.HasValue
|
||||||
|
|| MaxHeight.HasValue
|
||||||
|
|| MinPremiereDate.HasValue
|
||||||
|
|| MaxPremiereDate.HasValue
|
||||||
|
|| MinStartDate.HasValue
|
||||||
|
|| MaxStartDate.HasValue
|
||||||
|
|| MinEndDate.HasValue
|
||||||
|
|| MaxEndDate.HasValue
|
||||||
|
|| MinDateCreated.HasValue
|
||||||
|
|| MinDateLastSaved.HasValue
|
||||||
|
|| MinDateLastSavedForUser.HasValue
|
||||||
|
|| AdjacentTo.HasValue
|
||||||
|
|| !string.IsNullOrEmpty(NameStartsWith)
|
||||||
|
|| !string.IsNullOrEmpty(NameStartsWithOrGreater)
|
||||||
|
|| !string.IsNullOrEmpty(NameLessThan)
|
||||||
|
|| !string.IsNullOrEmpty(NameContains)
|
||||||
|
|| !string.IsNullOrEmpty(MinSortName)
|
||||||
|
|| !string.IsNullOrEmpty(Name)
|
||||||
|
|| !string.IsNullOrEmpty(Person)
|
||||||
|
|| !string.IsNullOrEmpty(SearchTerm)
|
||||||
|
|| !string.IsNullOrEmpty(Path);
|
||||||
|
|
||||||
public bool Recursive { get; set; }
|
public bool Recursive { get; set; }
|
||||||
|
|
||||||
public int? StartIndex { get; set; }
|
public int? StartIndex { get; set; }
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
throw new ArgumentNullException(nameof(name));
|
throw new ArgumentNullException(nameof(name));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
name = name.Trim();
|
||||||
var current = item.Tags;
|
var current = item.Tags;
|
||||||
|
|
||||||
if (!current.Contains(name, StringComparison.OrdinalIgnoreCase))
|
if (!current.Contains(name, StringComparison.OrdinalIgnoreCase))
|
||||||
|
|||||||
@@ -69,8 +69,14 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
|
|
||||||
protected override QueryResult<BaseItem> GetItemsInternal(InternalItemsQuery query)
|
protected override QueryResult<BaseItem> GetItemsInternal(InternalItemsQuery query)
|
||||||
{
|
{
|
||||||
if (query.Recursive)
|
// The user root holds no items of its own - a plain listing returns the user's
|
||||||
|
// views. But a request carrying any filter is a search across the libraries, so
|
||||||
|
// resolve it through the recursive query path even when Recursive wasn't set;
|
||||||
|
// otherwise the filters would be silently dropped. Recursive is set so the
|
||||||
|
// downstream query (ancestor/top-parent scoping) treats it as a recursive search.
|
||||||
|
if (query.Recursive || query.HasFilters)
|
||||||
{
|
{
|
||||||
|
query.Recursive = true;
|
||||||
return QueryRecursive(query);
|
return QueryRecursive(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ using System.Text.Json.Serialization;
|
|||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Jellyfin.Data.Enums;
|
using Jellyfin.Data.Enums;
|
||||||
|
using Jellyfin.Database.Implementations.Entities;
|
||||||
using Jellyfin.Extensions;
|
using Jellyfin.Extensions;
|
||||||
using MediaBrowser.Controller.Library;
|
using MediaBrowser.Controller.Library;
|
||||||
using MediaBrowser.Controller.LiveTv;
|
using MediaBrowser.Controller.LiveTv;
|
||||||
@@ -390,13 +391,13 @@ namespace MediaBrowser.Controller.Entities
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the additional parts.
|
/// Gets the additional parts.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="user">The user to apply parental restrictions for, or <c>null</c> to skip restriction checks.</param>
|
||||||
/// <returns>IEnumerable{Video}.</returns>
|
/// <returns>IEnumerable{Video}.</returns>
|
||||||
public IOrderedEnumerable<Video> GetAdditionalParts()
|
public IOrderedEnumerable<Video> GetAdditionalParts(User user = null)
|
||||||
{
|
{
|
||||||
return GetAdditionalPartIds()
|
return GetAdditionalPartIds()
|
||||||
.Select(i => LibraryManager.GetItemById(i))
|
.Select(i => LibraryManager.GetItemById<Video>(i, user))
|
||||||
.Where(i => i is not null)
|
.Where(i => i is not null)
|
||||||
.OfType<Video>()
|
|
||||||
.OrderBy(i => i.SortName);
|
.OrderBy(i => i.SortName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,4 +16,11 @@ public interface IExternalDataManager
|
|||||||
/// <param name="cancellationToken">The cancellation token.</param>
|
/// <param name="cancellationToken">The cancellation token.</param>
|
||||||
/// <returns>Task.</returns>
|
/// <returns>Task.</returns>
|
||||||
Task DeleteExternalItemDataAsync(BaseItem item, CancellationToken cancellationToken);
|
Task DeleteExternalItemDataAsync(BaseItem item, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes only the filesystem-side external item data (attachments, subtitles, trickplay, chapter images).
|
||||||
|
/// Use this when DB-side cleanup is already handled by another code path (e.g. <c>IItemPersistenceService.DeleteItem</c>).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="item">The item.</param>
|
||||||
|
void DeleteExternalItemFiles(BaseItem item);
|
||||||
}
|
}
|
||||||
|
|||||||
20
MediaBrowser.Controller/Library/IExternalSearchProvider.cs
Normal file
20
MediaBrowser.Controller/Library/IExternalSearchProvider.cs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
|
||||||
|
namespace MediaBrowser.Controller.Library;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Interface for external search providers that offer enhanced search capabilities.
|
||||||
|
/// </summary>
|
||||||
|
public interface IExternalSearchProvider : ISearchProvider
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Searches for items matching the query.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="query">The search query.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>Async enumerable of search results with relevance scores.</returns>
|
||||||
|
new IAsyncEnumerable<SearchResult> SearchAsync(
|
||||||
|
SearchProviderQuery query,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace MediaBrowser.Controller.Library;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Marker interface for internal search providers that typically query the local database directly.
|
||||||
|
/// </summary>
|
||||||
|
public interface IInternalSearchProvider : ISearchProvider
|
||||||
|
{
|
||||||
|
}
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
using MediaBrowser.Model.Querying;
|
|
||||||
using MediaBrowser.Model.Search;
|
|
||||||
|
|
||||||
namespace MediaBrowser.Controller.Library
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Interface ILibrarySearchEngine.
|
|
||||||
/// </summary>
|
|
||||||
public interface ISearchEngine
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the search hints.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="query">The query.</param>
|
|
||||||
/// <returns>Task{IEnumerable{SearchHintInfo}}.</returns>
|
|
||||||
QueryResult<SearchHintInfo> GetSearchHints(SearchQuery query);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
48
MediaBrowser.Controller/Library/ISearchManager.cs
Normal file
48
MediaBrowser.Controller/Library/ISearchManager.cs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using MediaBrowser.Model.Querying;
|
||||||
|
using MediaBrowser.Model.Search;
|
||||||
|
|
||||||
|
namespace MediaBrowser.Controller.Library;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Orchestrates search operations across registered search providers.
|
||||||
|
/// </summary>
|
||||||
|
public interface ISearchManager
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Searches for items and returns hints suitable for autocomplete/typeahead UI.
|
||||||
|
/// Results are ordered by relevance score from search providers.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="query">The search query including filters and pagination.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>Paginated search hints with item metadata for display.</returns>
|
||||||
|
Task<QueryResult<SearchHintInfo>> GetSearchHintsAsync(
|
||||||
|
SearchQuery query,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets ranked search results from registered providers. Returns only item IDs and
|
||||||
|
/// relevance scores; callers are responsible for loading items and applying user-access filtering.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="query">The search provider query with type/media filters.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>Search results containing item IDs and relevance scores.</returns>
|
||||||
|
Task<IReadOnlyList<SearchResult>> GetSearchResultsAsync(
|
||||||
|
SearchProviderQuery query,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers search providers discovered through dependency injection.
|
||||||
|
/// Called during application startup.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="providers">The search providers to register.</param>
|
||||||
|
void AddParts(IEnumerable<ISearchProvider> providers);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all registered search providers ordered by priority.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>The list of search providers including the SQL fallback provider.</returns>
|
||||||
|
IReadOnlyList<ISearchProvider> GetProviders();
|
||||||
|
}
|
||||||
44
MediaBrowser.Controller/Library/ISearchProvider.cs
Normal file
44
MediaBrowser.Controller/Library/ISearchProvider.cs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using MediaBrowser.Model.Configuration;
|
||||||
|
|
||||||
|
namespace MediaBrowser.Controller.Library;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Interface for search providers.
|
||||||
|
/// </summary>
|
||||||
|
public interface ISearchProvider
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the name of the provider.
|
||||||
|
/// </summary>
|
||||||
|
string Name { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the type of the provider.
|
||||||
|
/// </summary>
|
||||||
|
MetadataPluginType Type { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the priority of the provider. Lower values execute first.
|
||||||
|
/// </summary>
|
||||||
|
int Priority { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Searches for items matching the query.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="query">The search query.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>Ranked list of candidate item IDs with scores.</returns>
|
||||||
|
Task<IReadOnlyList<SearchResult>> SearchAsync(
|
||||||
|
SearchProviderQuery query,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines whether this provider can handle the given query.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="query">The search query to evaluate.</param>
|
||||||
|
/// <returns>True if this provider can search for the query; otherwise, false.</returns>
|
||||||
|
bool CanSearch(SearchProviderQuery query);
|
||||||
|
}
|
||||||
45
MediaBrowser.Controller/Library/SearchProviderQuery.cs
Normal file
45
MediaBrowser.Controller/Library/SearchProviderQuery.cs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
using System;
|
||||||
|
using Jellyfin.Data.Enums;
|
||||||
|
|
||||||
|
namespace MediaBrowser.Controller.Library;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Query object for search providers.
|
||||||
|
/// </summary>
|
||||||
|
public class SearchProviderQuery
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the search term.
|
||||||
|
/// </summary>
|
||||||
|
public required string SearchTerm { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the user ID for user-specific searches.
|
||||||
|
/// </summary>
|
||||||
|
public Guid? UserId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the item types to include in the search.
|
||||||
|
/// </summary>
|
||||||
|
public BaseItemKind[] IncludeItemTypes { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the item types to exclude from the search.
|
||||||
|
/// </summary>
|
||||||
|
public BaseItemKind[] ExcludeItemTypes { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the media types to include in the search.
|
||||||
|
/// </summary>
|
||||||
|
public MediaType[] MediaTypes { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the maximum number of results to return.
|
||||||
|
/// </summary>
|
||||||
|
public int? Limit { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the parent ID to scope the search.
|
||||||
|
/// </summary>
|
||||||
|
public Guid? ParentId { get; init; }
|
||||||
|
}
|
||||||
60
MediaBrowser.Controller/Library/SearchResult.cs
Normal file
60
MediaBrowser.Controller/Library/SearchResult.cs
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace MediaBrowser.Controller.Library;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents an item matched by a search query with its relevance score.
|
||||||
|
/// </summary>
|
||||||
|
public readonly struct SearchResult : IEquatable<SearchResult>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="SearchResult"/> struct.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="itemId">The item ID.</param>
|
||||||
|
/// <param name="score">The relevance score.</param>
|
||||||
|
public SearchResult(Guid itemId, float score)
|
||||||
|
{
|
||||||
|
ItemId = itemId;
|
||||||
|
Score = score;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the ID of the matching item.
|
||||||
|
/// </summary>
|
||||||
|
public Guid ItemId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the relevance score. Higher values indicate more relevant results.
|
||||||
|
/// </summary>
|
||||||
|
public float Score { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Compares two <see cref="SearchResult"/> instances for equality.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="left">The left operand.</param>
|
||||||
|
/// <param name="right">The right operand.</param>
|
||||||
|
/// <returns>True if the instances are equal; otherwise, false.</returns>
|
||||||
|
public static bool operator ==(SearchResult left, SearchResult right)
|
||||||
|
=> left.Equals(right);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Compares two <see cref="SearchResult"/> instances for inequality.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="left">The left operand.</param>
|
||||||
|
/// <param name="right">The right operand.</param>
|
||||||
|
/// <returns>True if the instances are not equal; otherwise, false.</returns>
|
||||||
|
public static bool operator !=(SearchResult left, SearchResult right)
|
||||||
|
=> !left.Equals(right);
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public override bool Equals(object? obj)
|
||||||
|
=> obj is SearchResult other && Equals(other);
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public bool Equals(SearchResult other)
|
||||||
|
=> ItemId.Equals(other.ItemId) && Score.Equals(other.Score);
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public override int GetHashCode()
|
||||||
|
=> HashCode.Combine(ItemId, Score);
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ using MediaBrowser.Model.Dlna;
|
|||||||
using MediaBrowser.Model.Dto;
|
using MediaBrowser.Model.Dto;
|
||||||
using MediaBrowser.Model.Entities;
|
using MediaBrowser.Model.Entities;
|
||||||
using MediaBrowser.Model.MediaInfo;
|
using MediaBrowser.Model.MediaInfo;
|
||||||
|
using MediaBrowser.Model.Session;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using IConfigurationManager = MediaBrowser.Common.Configuration.IConfigurationManager;
|
using IConfigurationManager = MediaBrowser.Common.Configuration.IConfigurationManager;
|
||||||
|
|
||||||
@@ -444,6 +445,13 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
|| state.VideoStream.VideoRangeType == VideoRangeType.HLG);
|
|| state.VideoStream.VideoRangeType == VideoRangeType.HLG);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool IsDeinterlaceAvailable(EncodingJobInfo state)
|
||||||
|
{
|
||||||
|
var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
|
||||||
|
var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
|
||||||
|
return doDeintH264 || doDeintHevc;
|
||||||
|
}
|
||||||
|
|
||||||
private bool IsVideoStreamHevcRext(EncodingJobInfo state)
|
private bool IsVideoStreamHevcRext(EncodingJobInfo state)
|
||||||
{
|
{
|
||||||
var videoStream = state.VideoStream;
|
var videoStream = state.VideoStream;
|
||||||
@@ -2604,56 +2612,66 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
}
|
}
|
||||||
|
|
||||||
public bool CanStreamCopyAudio(EncodingJobInfo state, MediaStream audioStream, IEnumerable<string> supportedAudioCodecs)
|
public bool CanStreamCopyAudio(EncodingJobInfo state, MediaStream audioStream, IEnumerable<string> supportedAudioCodecs)
|
||||||
|
=> CanStreamCopyAudio(state, audioStream, supportedAudioCodecs, out _);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines whether the given audio stream can be stream-copied and, regardless of the outcome,
|
||||||
|
/// reports the codec/parameter incompatibilities that would force a re-encode via <paramref name="failureReasons"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="state">The encoding job state.</param>
|
||||||
|
/// <param name="audioStream">The source audio stream.</param>
|
||||||
|
/// <param name="supportedAudioCodecs">The audio codecs the target supports.</param>
|
||||||
|
/// <param name="failureReasons">The codec/parameter incompatibilities preventing a copy, or <c>0</c> if the stream is copy-compatible.</param>
|
||||||
|
/// <returns><c>true</c> if the audio stream can be stream-copied; otherwise, <c>false</c>.</returns>
|
||||||
|
public bool CanStreamCopyAudio(EncodingJobInfo state, MediaStream audioStream, IEnumerable<string> supportedAudioCodecs, out TranscodeReason failureReasons)
|
||||||
{
|
{
|
||||||
var request = state.BaseRequest;
|
var request = state.BaseRequest;
|
||||||
|
|
||||||
if (!request.AllowAudioStreamCopy)
|
// Policy-independent compatibility check, so the reasons are reported even when a policy gate is what ultimately prevents the copy.
|
||||||
{
|
failureReasons = GetAudioStreamCopyFailureReasons(state, audioStream, supportedAudioCodecs);
|
||||||
return false;
|
|
||||||
}
|
return request.AllowAudioStreamCopy
|
||||||
|
&& request.EnableAutoStreamCopy
|
||||||
|
&& failureReasons == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TranscodeReason GetAudioStreamCopyFailureReasons(EncodingJobInfo state, MediaStream audioStream, IEnumerable<string> supportedAudioCodecs)
|
||||||
|
{
|
||||||
|
var request = state.BaseRequest;
|
||||||
|
TranscodeReason reasons = 0;
|
||||||
|
|
||||||
var maxBitDepth = state.GetRequestedAudioBitDepth(audioStream.Codec);
|
var maxBitDepth = state.GetRequestedAudioBitDepth(audioStream.Codec);
|
||||||
if (maxBitDepth.HasValue
|
if (maxBitDepth.HasValue
|
||||||
&& audioStream.BitDepth.HasValue
|
&& audioStream.BitDepth.HasValue
|
||||||
&& audioStream.BitDepth.Value > maxBitDepth.Value)
|
&& audioStream.BitDepth.Value > maxBitDepth.Value)
|
||||||
{
|
{
|
||||||
return false;
|
reasons |= TranscodeReason.AudioBitDepthNotSupported;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Source and target codecs must match
|
// Source and target codecs must match
|
||||||
if (string.IsNullOrEmpty(audioStream.Codec)
|
if (string.IsNullOrEmpty(audioStream.Codec)
|
||||||
|| !supportedAudioCodecs.Contains(audioStream.Codec, StringComparison.OrdinalIgnoreCase))
|
|| !supportedAudioCodecs.Contains(audioStream.Codec, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
return false;
|
reasons |= TranscodeReason.AudioCodecNotSupported;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Channels must fall within requested value
|
// Channels must fall within requested value
|
||||||
var channels = state.GetRequestedAudioChannels(audioStream.Codec);
|
var channels = state.GetRequestedAudioChannels(audioStream.Codec);
|
||||||
if (channels.HasValue)
|
if (channels.HasValue
|
||||||
|
&& (!audioStream.Channels.HasValue
|
||||||
|
|| audioStream.Channels.Value <= 0
|
||||||
|
|| audioStream.Channels.Value > channels.Value))
|
||||||
{
|
{
|
||||||
if (!audioStream.Channels.HasValue || audioStream.Channels.Value <= 0)
|
reasons |= TranscodeReason.AudioChannelsNotSupported;
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (audioStream.Channels.Value > channels.Value)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sample rate must fall within requested value
|
// Sample rate must fall within requested value
|
||||||
if (request.AudioSampleRate.HasValue)
|
if (request.AudioSampleRate.HasValue
|
||||||
|
&& (!audioStream.SampleRate.HasValue
|
||||||
|
|| audioStream.SampleRate.Value <= 0
|
||||||
|
|| audioStream.SampleRate.Value > request.AudioSampleRate.Value))
|
||||||
{
|
{
|
||||||
if (!audioStream.SampleRate.HasValue || audioStream.SampleRate.Value <= 0)
|
reasons |= TranscodeReason.AudioSampleRateNotSupported;
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (audioStream.SampleRate.Value > request.AudioSampleRate.Value)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Audio bitrate must fall within requested value
|
// Audio bitrate must fall within requested value
|
||||||
@@ -2661,10 +2679,10 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
&& audioStream.BitRate.HasValue
|
&& audioStream.BitRate.HasValue
|
||||||
&& audioStream.BitRate.Value > request.AudioBitRate.Value)
|
&& audioStream.BitRate.Value > request.AudioBitRate.Value)
|
||||||
{
|
{
|
||||||
return false;
|
reasons |= TranscodeReason.AudioBitrateNotSupported;
|
||||||
}
|
}
|
||||||
|
|
||||||
return request.EnableAutoStreamCopy;
|
return reasons;
|
||||||
}
|
}
|
||||||
|
|
||||||
public int GetVideoBitrateParamValue(BaseEncodingJobOptions request, MediaStream videoStream, string outputVideoCodec)
|
public int GetVideoBitrateParamValue(BaseEncodingJobOptions request, MediaStream videoStream, string outputVideoCodec)
|
||||||
@@ -3850,9 +3868,7 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
var isVaapiEncoder = vidEncoder.Contains("vaapi", StringComparison.OrdinalIgnoreCase);
|
var isVaapiEncoder = vidEncoder.Contains("vaapi", StringComparison.OrdinalIgnoreCase);
|
||||||
var isV4l2Encoder = vidEncoder.Contains("h264_v4l2m2m", StringComparison.OrdinalIgnoreCase);
|
var isV4l2Encoder = vidEncoder.Contains("h264_v4l2m2m", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
|
var doDeintH2645 = IsDeinterlaceAvailable(state);
|
||||||
var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
|
|
||||||
var doDeintH2645 = doDeintH264 || doDeintHevc;
|
|
||||||
var doToneMap = IsSwTonemapAvailable(state, options);
|
var doToneMap = IsSwTonemapAvailable(state, options);
|
||||||
var requireDoviReshaping = doToneMap && state.VideoStream.VideoRangeType == VideoRangeType.DOVI;
|
var requireDoviReshaping = doToneMap && state.VideoStream.VideoRangeType == VideoRangeType.DOVI;
|
||||||
|
|
||||||
@@ -4004,9 +4020,7 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
var isCuInCuOut = isNvDecoder && isNvencEncoder;
|
var isCuInCuOut = isNvDecoder && isNvencEncoder;
|
||||||
|
|
||||||
var doubleRateDeint = options.DeinterlaceDoubleRate && (state.VideoStream?.ReferenceFrameRate ?? 60) <= 30;
|
var doubleRateDeint = options.DeinterlaceDoubleRate && (state.VideoStream?.ReferenceFrameRate ?? 60) <= 30;
|
||||||
var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
|
var doDeintH2645 = IsDeinterlaceAvailable(state);
|
||||||
var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
|
|
||||||
var doDeintH2645 = doDeintH264 || doDeintHevc;
|
|
||||||
var doCuTonemap = IsHwTonemapAvailable(state, options);
|
var doCuTonemap = IsHwTonemapAvailable(state, options);
|
||||||
|
|
||||||
var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state);
|
var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state);
|
||||||
@@ -4215,9 +4229,7 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase);
|
var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase);
|
||||||
var isDxInDxOut = isD3d11vaDecoder && isAmfEncoder;
|
var isDxInDxOut = isD3d11vaDecoder && isAmfEncoder;
|
||||||
|
|
||||||
var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
|
var doDeintH2645 = IsDeinterlaceAvailable(state);
|
||||||
var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
|
|
||||||
var doDeintH2645 = doDeintH264 || doDeintHevc;
|
|
||||||
var doOclTonemap = IsHwTonemapAvailable(state, options);
|
var doOclTonemap = IsHwTonemapAvailable(state, options);
|
||||||
|
|
||||||
var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state);
|
var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state);
|
||||||
@@ -4463,9 +4475,7 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase);
|
var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase);
|
||||||
var isQsvInQsvOut = isHwDecoder && isQsvEncoder;
|
var isQsvInQsvOut = isHwDecoder && isQsvEncoder;
|
||||||
|
|
||||||
var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
|
var doDeintH2645 = IsDeinterlaceAvailable(state);
|
||||||
var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
|
|
||||||
var doDeintH2645 = doDeintH264 || doDeintHevc;
|
|
||||||
var doVppTonemap = IsIntelVppTonemapAvailable(state, options);
|
var doVppTonemap = IsIntelVppTonemapAvailable(state, options);
|
||||||
var doOclTonemap = !doVppTonemap && IsHwTonemapAvailable(state, options);
|
var doOclTonemap = !doVppTonemap && IsHwTonemapAvailable(state, options);
|
||||||
var doTonemap = doVppTonemap || doOclTonemap;
|
var doTonemap = doVppTonemap || doOclTonemap;
|
||||||
@@ -4757,12 +4767,10 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase);
|
var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase);
|
||||||
var isQsvInQsvOut = isHwDecoder && isQsvEncoder;
|
var isQsvInQsvOut = isHwDecoder && isQsvEncoder;
|
||||||
|
|
||||||
var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
|
|
||||||
var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
|
|
||||||
var doVaVppTonemap = IsIntelVppTonemapAvailable(state, options);
|
var doVaVppTonemap = IsIntelVppTonemapAvailable(state, options);
|
||||||
var doOclTonemap = !doVaVppTonemap && IsHwTonemapAvailable(state, options);
|
var doOclTonemap = !doVaVppTonemap && IsHwTonemapAvailable(state, options);
|
||||||
var doTonemap = doVaVppTonemap || doOclTonemap;
|
var doTonemap = doVaVppTonemap || doOclTonemap;
|
||||||
var doDeintH2645 = doDeintH264 || doDeintHevc;
|
var doDeintH2645 = IsDeinterlaceAvailable(state);
|
||||||
|
|
||||||
var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state);
|
var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state);
|
||||||
var hasTextSubs = hasSubs && state.SubtitleStream.IsTextSubtitleStream;
|
var hasTextSubs = hasSubs && state.SubtitleStream.IsTextSubtitleStream;
|
||||||
@@ -5088,12 +5096,10 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase);
|
var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase);
|
||||||
var isVaInVaOut = isVaapiDecoder && isVaapiEncoder;
|
var isVaInVaOut = isVaapiDecoder && isVaapiEncoder;
|
||||||
|
|
||||||
var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
|
|
||||||
var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
|
|
||||||
var doVaVppTonemap = isVaapiDecoder && IsIntelVppTonemapAvailable(state, options);
|
var doVaVppTonemap = isVaapiDecoder && IsIntelVppTonemapAvailable(state, options);
|
||||||
var doOclTonemap = !doVaVppTonemap && IsHwTonemapAvailable(state, options);
|
var doOclTonemap = !doVaVppTonemap && IsHwTonemapAvailable(state, options);
|
||||||
var doTonemap = doVaVppTonemap || doOclTonemap;
|
var doTonemap = doVaVppTonemap || doOclTonemap;
|
||||||
var doDeintH2645 = doDeintH264 || doDeintHevc;
|
var doDeintH2645 = IsDeinterlaceAvailable(state);
|
||||||
|
|
||||||
var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state);
|
var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state);
|
||||||
var hasTextSubs = hasSubs && state.SubtitleStream.IsTextSubtitleStream;
|
var hasTextSubs = hasSubs && state.SubtitleStream.IsTextSubtitleStream;
|
||||||
@@ -5325,10 +5331,8 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
var isSwEncoder = !isVaapiEncoder;
|
var isSwEncoder = !isVaapiEncoder;
|
||||||
var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase);
|
var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
|
|
||||||
var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
|
|
||||||
var doVkTonemap = IsVulkanHwTonemapAvailable(state, options);
|
var doVkTonemap = IsVulkanHwTonemapAvailable(state, options);
|
||||||
var doDeintH2645 = doDeintH264 || doDeintHevc;
|
var doDeintH2645 = IsDeinterlaceAvailable(state);
|
||||||
|
|
||||||
var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state);
|
var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state);
|
||||||
var hasTextSubs = hasSubs && state.SubtitleStream.IsTextSubtitleStream;
|
var hasTextSubs = hasSubs && state.SubtitleStream.IsTextSubtitleStream;
|
||||||
@@ -5565,9 +5569,7 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
var isi965Driver = _mediaEncoder.IsVaapiDeviceInteli965;
|
var isi965Driver = _mediaEncoder.IsVaapiDeviceInteli965;
|
||||||
var isAmdDriver = _mediaEncoder.IsVaapiDeviceAmd;
|
var isAmdDriver = _mediaEncoder.IsVaapiDeviceAmd;
|
||||||
|
|
||||||
var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
|
var doDeintH2645 = IsDeinterlaceAvailable(state);
|
||||||
var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
|
|
||||||
var doDeintH2645 = doDeintH264 || doDeintHevc;
|
|
||||||
var doOclTonemap = IsHwTonemapAvailable(state, options);
|
var doOclTonemap = IsHwTonemapAvailable(state, options);
|
||||||
|
|
||||||
var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state);
|
var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state);
|
||||||
@@ -5798,9 +5800,7 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
var reqMaxH = state.BaseRequest.MaxHeight;
|
var reqMaxH = state.BaseRequest.MaxHeight;
|
||||||
var threeDFormat = state.MediaSource.Video3DFormat;
|
var threeDFormat = state.MediaSource.Video3DFormat;
|
||||||
|
|
||||||
var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
|
var doDeintH2645 = IsDeinterlaceAvailable(state);
|
||||||
var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
|
|
||||||
var doDeintH2645 = doDeintH264 || doDeintHevc;
|
|
||||||
var doVtTonemap = IsVideoToolboxTonemapAvailable(state, options);
|
var doVtTonemap = IsVideoToolboxTonemapAvailable(state, options);
|
||||||
var doMetalTonemap = !doVtTonemap && IsHwTonemapAvailable(state, options);
|
var doMetalTonemap = !doVtTonemap && IsHwTonemapAvailable(state, options);
|
||||||
var usingHwSurface = isVtDecoder && (_mediaEncoder.EncoderVersion >= _minFFmpegWorkingVtHwSurface);
|
var usingHwSurface = isVtDecoder && (_mediaEncoder.EncoderVersion >= _minFFmpegWorkingVtHwSurface);
|
||||||
@@ -5999,9 +5999,7 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
&& (vidEncoder.Contains("h264", StringComparison.OrdinalIgnoreCase)
|
&& (vidEncoder.Contains("h264", StringComparison.OrdinalIgnoreCase)
|
||||||
|| vidEncoder.Contains("hevc", StringComparison.OrdinalIgnoreCase));
|
|| vidEncoder.Contains("hevc", StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
|
var doDeintH2645 = IsDeinterlaceAvailable(state);
|
||||||
var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
|
|
||||||
var doDeintH2645 = doDeintH264 || doDeintHevc;
|
|
||||||
var doOclTonemap = IsHwTonemapAvailable(state, options);
|
var doOclTonemap = IsHwTonemapAvailable(state, options);
|
||||||
|
|
||||||
var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state);
|
var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state);
|
||||||
@@ -6265,12 +6263,21 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
overlayFilters?.RemoveAll(string.IsNullOrEmpty);
|
overlayFilters?.RemoveAll(string.IsNullOrEmpty);
|
||||||
|
|
||||||
var framerate = GetFramerateParam(state);
|
var framerate = GetFramerateParam(state);
|
||||||
if (framerate.HasValue)
|
if (mainFilters is not null && framerate.HasValue)
|
||||||
{
|
{
|
||||||
mainFilters.Insert(0, string.Format(
|
var doDeintH2645 = IsDeinterlaceAvailable(state);
|
||||||
CultureInfo.InvariantCulture,
|
var fpsFilter = string.Format(CultureInfo.InvariantCulture, "fps={0}", framerate.Value);
|
||||||
"fps={0}",
|
|
||||||
framerate.Value));
|
// For filter chain containing the deinterlace filter,
|
||||||
|
// place the fps filter at the end to preserve temporal info.
|
||||||
|
if (doDeintH2645)
|
||||||
|
{
|
||||||
|
mainFilters.Add(fpsFilter);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
mainFilters.Insert(0, fpsFilter);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var mainStr = string.Empty;
|
var mainStr = string.Empty;
|
||||||
@@ -7221,8 +7228,9 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
&& !IsCopyCodec(state.OutputVideoCodec)
|
&& !IsCopyCodec(state.OutputVideoCodec)
|
||||||
&& options.HlsAudioSeekStrategy is HlsAudioSeekStrategy.TranscodeAudio;
|
&& options.HlsAudioSeekStrategy is HlsAudioSeekStrategy.TranscodeAudio;
|
||||||
|
|
||||||
|
TranscodeReason audioCopyFailureReasons = 0;
|
||||||
if (state.AudioStream is not null
|
if (state.AudioStream is not null
|
||||||
&& CanStreamCopyAudio(state, state.AudioStream, state.SupportedAudioCodecs)
|
&& CanStreamCopyAudio(state, state.AudioStream, state.SupportedAudioCodecs, out audioCopyFailureReasons)
|
||||||
&& !preventHlsAudioCopy)
|
&& !preventHlsAudioCopy)
|
||||||
{
|
{
|
||||||
state.OutputAudioCodec = "copy";
|
state.OutputAudioCodec = "copy";
|
||||||
@@ -7236,6 +7244,13 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
{
|
{
|
||||||
state.OutputAudioCodec = "copy";
|
state.OutputAudioCodec = "copy";
|
||||||
}
|
}
|
||||||
|
else if (state.AudioStream is not null && !IsCopyCodec(state.OutputAudioCodec))
|
||||||
|
{
|
||||||
|
// Audio is actually being re-encoded although the playback determination may have considered the source copyable.
|
||||||
|
// Only carry the primary "cannot be passed through" cause - the codec mismatch.
|
||||||
|
// Bitrate/channels/sample-rate/bit-depth copy refusals are consequences of the chosen transcode target.
|
||||||
|
state.AddTranscodeReason(audioCopyFailureReasons & TranscodeReason.AudioCodecNotSupported);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7855,13 +7870,14 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
audioTranscodeParams.Add("-ar " + state.BaseRequest.AudioBitRate);
|
audioTranscodeParams.Add("-ar " + state.BaseRequest.AudioBitRate);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.Equals(outputCodec, "opus", StringComparison.OrdinalIgnoreCase))
|
var sampleRate = state.OutputAudioSampleRate;
|
||||||
|
if (sampleRate.HasValue)
|
||||||
{
|
{
|
||||||
// opus only supports specific sampling rates
|
var sampleRateValue = sampleRate.Value;
|
||||||
var sampleRate = state.OutputAudioSampleRate;
|
if (string.Equals(outputCodec, "opus", StringComparison.OrdinalIgnoreCase))
|
||||||
if (sampleRate.HasValue)
|
|
||||||
{
|
{
|
||||||
var sampleRateValue = sampleRate.Value switch
|
// opus only supports specific sampling rates
|
||||||
|
sampleRateValue = sampleRate.Value switch
|
||||||
{
|
{
|
||||||
<= 8000 => 8000,
|
<= 8000 => 8000,
|
||||||
<= 12000 => 12000,
|
<= 12000 => 12000,
|
||||||
@@ -7869,9 +7885,9 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
<= 24000 => 24000,
|
<= 24000 => 24000,
|
||||||
_ => 48000
|
_ => 48000
|
||||||
};
|
};
|
||||||
|
|
||||||
audioTranscodeParams.Add("-ar " + sampleRateValue.ToString(CultureInfo.InvariantCulture));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
audioTranscodeParams.Add("-ar " + sampleRateValue.ToString(CultureInfo.InvariantCulture));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy the movflags from GetProgressiveVideoFullCommandLine
|
// Copy the movflags from GetProgressiveVideoFullCommandLine
|
||||||
|
|||||||
@@ -515,6 +515,15 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||||||
|
|
||||||
public int HlsListSize => 0;
|
public int HlsListSize => 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds the specified reason(s) to <see cref="TranscodeReasons"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="reason">The transcode reason(s) to add.</param>
|
||||||
|
public void AddTranscodeReason(TranscodeReason reason)
|
||||||
|
{
|
||||||
|
_transcodeReasons = TranscodeReasons | reason;
|
||||||
|
}
|
||||||
|
|
||||||
private int? GetMediaStreamCount(MediaStreamType type, int limit)
|
private int? GetMediaStreamCount(MediaStreamType type, int limit)
|
||||||
{
|
{
|
||||||
var count = MediaSource.GetStreamCount(type);
|
var count = MediaSource.GetStreamCount(type);
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using AsyncKeyedLock;
|
using AsyncKeyedLock;
|
||||||
@@ -102,13 +104,10 @@ namespace MediaBrowser.MediaEncoding.Attachments
|
|||||||
&& (a.FileName.Contains('/', StringComparison.OrdinalIgnoreCase) || a.FileName.Contains('\\', StringComparison.OrdinalIgnoreCase)));
|
&& (a.FileName.Contains('/', StringComparison.OrdinalIgnoreCase) || a.FileName.Contains('\\', StringComparison.OrdinalIgnoreCase)));
|
||||||
if (shouldExtractOneByOne && !inputFile.EndsWith(".mks", StringComparison.OrdinalIgnoreCase))
|
if (shouldExtractOneByOne && !inputFile.EndsWith(".mks", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
foreach (var attachment in mediaSource.MediaAttachments)
|
await ExtractAllAttachmentsIndividuallyInternal(
|
||||||
{
|
inputFile,
|
||||||
if (!string.Equals(attachment.Codec, "mjpeg", StringComparison.OrdinalIgnoreCase))
|
mediaSource,
|
||||||
{
|
cancellationToken).ConfigureAwait(false);
|
||||||
await ExtractAttachment(inputFile, mediaSource, attachment, cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -119,6 +118,140 @@ namespace MediaBrowser.MediaEncoding.Attachments
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task ExtractAllAttachmentsIndividuallyInternal(
|
||||||
|
string inputFile,
|
||||||
|
MediaSourceInfo mediaSource,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var inputPath = _mediaEncoder.GetInputArgument(inputFile, mediaSource);
|
||||||
|
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(inputPath);
|
||||||
|
|
||||||
|
var outputFolder = _pathManager.GetAttachmentFolderPath(mediaSource.Id);
|
||||||
|
if (outputFolder is null)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Skipping attachment extraction for input {InputFile}: MediaSource Id is not a GUID.", inputFile);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using (await _semaphoreLocks.LockAsync(outputFolder, cancellationToken).ConfigureAwait(false))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(outputFolder);
|
||||||
|
|
||||||
|
var dumpArgs = new StringBuilder();
|
||||||
|
var missingPaths = new List<string>();
|
||||||
|
foreach (var attachment in mediaSource.MediaAttachments)
|
||||||
|
{
|
||||||
|
if (string.Equals(attachment.Codec, "mjpeg", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var indexName = attachment.Index.ToString(CultureInfo.InvariantCulture);
|
||||||
|
var attachmentPath = _pathManager.GetAttachmentPath(mediaSource.Id, attachment.FileName ?? indexName)
|
||||||
|
?? _pathManager.GetAttachmentPath(mediaSource.Id, indexName)!;
|
||||||
|
if (File.Exists(attachmentPath))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
dumpArgs.AppendFormat(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
"-dump_attachment:{0} \"{1}\" ",
|
||||||
|
attachment.Index,
|
||||||
|
EncodingUtils.NormalizePath(attachmentPath));
|
||||||
|
missingPaths.Add(attachmentPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missingPaths.Count == 0)
|
||||||
|
{
|
||||||
|
// Skip extraction if all files already exist
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasVideoOrAudioStream = mediaSource.MediaStreams
|
||||||
|
.Any(s => s.Type == MediaStreamType.Video || s.Type == MediaStreamType.Audio);
|
||||||
|
var processArgs = string.Format(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
"{0}{1} -i {2} {3}",
|
||||||
|
dumpArgs,
|
||||||
|
inputPath.EndsWith(".concat\"", StringComparison.OrdinalIgnoreCase) ? "-f concat -safe 0" : string.Empty,
|
||||||
|
inputPath,
|
||||||
|
hasVideoOrAudioStream ? "-t 0 -f null null" : string.Empty);
|
||||||
|
|
||||||
|
int exitCode;
|
||||||
|
|
||||||
|
using (var process = new Process
|
||||||
|
{
|
||||||
|
StartInfo = new ProcessStartInfo
|
||||||
|
{
|
||||||
|
Arguments = processArgs,
|
||||||
|
FileName = _mediaEncoder.EncoderPath,
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = true,
|
||||||
|
WindowStyle = ProcessWindowStyle.Hidden,
|
||||||
|
ErrorDialog = false
|
||||||
|
},
|
||||||
|
EnableRaisingEvents = true
|
||||||
|
})
|
||||||
|
{
|
||||||
|
_logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments);
|
||||||
|
|
||||||
|
process.Start();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
exitCode = process.ExitCode;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
process.Kill(true);
|
||||||
|
exitCode = -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var failed = false;
|
||||||
|
|
||||||
|
if (exitCode != 0 && (hasVideoOrAudioStream || exitCode != 1))
|
||||||
|
{
|
||||||
|
failed = true;
|
||||||
|
|
||||||
|
foreach (var path in missingPaths)
|
||||||
|
{
|
||||||
|
if (!File.Exists(path))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_fileSystem.DeleteFile(path);
|
||||||
|
}
|
||||||
|
catch (IOException ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error deleting extracted attachment {Path}", path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!failed && missingPaths.Exists(p => !File.Exists(p)))
|
||||||
|
{
|
||||||
|
failed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failed)
|
||||||
|
{
|
||||||
|
_logger.LogError("ffmpeg attachment extraction failed for {InputPath} to {OutputPath}", inputPath, outputFolder);
|
||||||
|
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
string.Format(CultureInfo.InvariantCulture, "ffmpeg attachment extraction failed for {0} to {1}", inputPath, outputFolder));
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("ffmpeg attachment extraction completed for {InputPath} to {OutputPath}", inputPath, outputFolder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task ExtractAllAttachmentsInternal(
|
private async Task ExtractAllAttachmentsInternal(
|
||||||
string inputFile,
|
string inputFile,
|
||||||
MediaSourceInfo mediaSource,
|
MediaSourceInfo mediaSource,
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
#pragma warning disable CA1031
|
#pragma warning disable CA1031
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
|
using System.Buffers;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
using System.Runtime.Versioning;
|
using System.Runtime.Versioning;
|
||||||
|
using System.Text;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace MediaBrowser.MediaEncoding.Encoder;
|
namespace MediaBrowser.MediaEncoding.Encoder;
|
||||||
@@ -12,43 +14,43 @@ namespace MediaBrowser.MediaEncoding.Encoder;
|
|||||||
/// Helper class for Apple platform specific operations.
|
/// Helper class for Apple platform specific operations.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[SupportedOSPlatform("macos")]
|
[SupportedOSPlatform("macos")]
|
||||||
public static class ApplePlatformHelper
|
public static partial class ApplePlatformHelper
|
||||||
{
|
{
|
||||||
private static readonly string[] _av1DecodeBlacklistedCpuClass = ["M1", "M2"];
|
private static readonly string[] _av1DecodeBlacklistedCpuClass = ["M1", "M2"];
|
||||||
|
|
||||||
private static string GetSysctlValue(ReadOnlySpan<byte> name)
|
internal static string GetSysctlValue(string name)
|
||||||
{
|
{
|
||||||
IntPtr length = IntPtr.Zero;
|
nuint length = 0;
|
||||||
// Get length of the value
|
// Get length of the value
|
||||||
int osStatus = SysctlByName(name, IntPtr.Zero, ref length, IntPtr.Zero, 0);
|
int osStatus = sysctlbyname(name, Span<byte>.Empty, ref length, IntPtr.Zero, 0);
|
||||||
|
if (osStatus != 0 || length == 0)
|
||||||
if (osStatus != 0)
|
|
||||||
{
|
{
|
||||||
throw new NotSupportedException($"Failed to get sysctl value for {System.Text.Encoding.UTF8.GetString(name)} with error {osStatus}");
|
throw new NotSupportedException($"Failed to get sysctl value for {name} with error {osStatus}");
|
||||||
}
|
}
|
||||||
|
|
||||||
IntPtr buffer = Marshal.AllocHGlobal(length.ToInt32());
|
byte[] buffer = ArrayPool<byte>.Shared.Rent((int)length);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
osStatus = SysctlByName(name, buffer, ref length, IntPtr.Zero, 0);
|
osStatus = sysctlbyname(name, buffer.AsSpan()[..(int)length], ref length, IntPtr.Zero, 0);
|
||||||
if (osStatus != 0)
|
if (osStatus != 0)
|
||||||
{
|
{
|
||||||
throw new NotSupportedException($"Failed to get sysctl value for {System.Text.Encoding.UTF8.GetString(name)} with error {osStatus}");
|
throw new NotSupportedException($"Failed to get sysctl value for {name} with error {osStatus}");
|
||||||
}
|
}
|
||||||
|
|
||||||
return Marshal.PtrToStringAnsi(buffer) ?? string.Empty;
|
if (length < 1)
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
ReadOnlySpan<byte> data = buffer.AsSpan()[..(int)(length - 1)];
|
||||||
|
return Encoding.UTF8.GetString(data);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
Marshal.FreeHGlobal(buffer);
|
ArrayPool<byte>.Shared.Return(buffer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int SysctlByName(ReadOnlySpan<byte> name, IntPtr oldp, ref IntPtr oldlenp, IntPtr newp, uint newlen)
|
|
||||||
{
|
|
||||||
return NativeMethods.SysctlByName(name.ToArray(), oldp, ref oldlenp, newp, newlen);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Check if the current system has hardware acceleration for AV1 decoding.
|
/// Check if the current system has hardware acceleration for AV1 decoding.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -63,7 +65,7 @@ public static class ApplePlatformHelper
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
string cpuBrandString = GetSysctlValue("machdep.cpu.brand_string"u8);
|
string cpuBrandString = GetSysctlValue("machdep.cpu.brand_string");
|
||||||
return !_av1DecodeBlacklistedCpuClass.Any(blacklistedCpuClass => cpuBrandString.Contains(blacklistedCpuClass, StringComparison.OrdinalIgnoreCase));
|
return !_av1DecodeBlacklistedCpuClass.Any(blacklistedCpuClass => cpuBrandString.Contains(blacklistedCpuClass, StringComparison.OrdinalIgnoreCase));
|
||||||
}
|
}
|
||||||
catch (NotSupportedException e)
|
catch (NotSupportedException e)
|
||||||
@@ -78,10 +80,7 @@ public static class ApplePlatformHelper
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static class NativeMethods
|
[LibraryImport("libc", EntryPoint = "sysctlbyname", SetLastError = true)]
|
||||||
{
|
[DefaultDllImportSearchPaths(DllImportSearchPath.SafeDirectories)]
|
||||||
[DllImport("libc", EntryPoint = "sysctlbyname", SetLastError = true)]
|
internal static partial int sysctlbyname([MarshalAs(UnmanagedType.LPStr)] string name, Span<byte> oldp, ref nuint oldlenp, IntPtr newp, nuint newlen);
|
||||||
[DefaultDllImportSearchPaths(DllImportSearchPath.SafeDirectories)]
|
|
||||||
internal static extern int SysctlByName(byte[] name, IntPtr oldp, ref IntPtr oldlenp, IntPtr newp, uint newlen);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
|
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
|
||||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
|
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user