Compare commits
299 Commits
master
...
feature/cu
Author | SHA1 | Date | |
---|---|---|---|
a936f76a12 | |||
203af39e26 | |||
96b133b7b4 | |||
34c63a4c15 | |||
4bb0259097 | |||
9fba024add | |||
|
7bbdfc0d49 | ||
|
3c3ebe8344 | ||
|
8c4d23435e | ||
|
9e4befa52e | ||
|
b3efae71c0 | ||
|
70f4f2e8c2 | ||
|
cd2f2ca178 | ||
|
8095d39954 | ||
|
e1ac30ba17 | ||
|
3b94cfa837 | ||
|
2fe13f54ea | ||
|
eb6a6d319b | ||
|
b74c9cae1b | ||
|
24d482b36b | ||
|
fff4477a93 | ||
|
9810d22d96 | ||
|
5027e3cd53 | ||
|
9645955629 | ||
|
078ee1f2de | ||
|
236c7649dd | ||
|
c1e52df0b7 | ||
|
be949af59e | ||
|
122da8f447 | ||
|
486c7fa51e | ||
|
6709c80f0a | ||
|
3f3145600c | ||
|
bc613b8344 | ||
|
0eb5897100 | ||
|
ee0094d889 | ||
|
7051a18be0 | ||
|
fce3a5d241 | ||
|
900acc03aa | ||
|
a475a7d50a | ||
|
b7bc0e1c96 | ||
|
3c79d7a3f3 | ||
|
1739962f52 | ||
|
7f0f93eb4a | ||
|
71c13057f4 | ||
|
7f12677dc3 | ||
|
4f2b1736ab | ||
|
c05049e54e | ||
|
dd5f6406a2 | ||
|
79c0a7d7f0 | ||
|
3c6485f0a1 | ||
|
8a8b2c4380 | ||
|
7403428864 | ||
|
235da65a75 | ||
|
26eab7aa2e | ||
|
d235378133 | ||
|
5a62c7a146 | ||
|
4afa6db108 | ||
|
f7a90b6383 | ||
|
b8e2d8e11a | ||
|
c1f7ccbca4 | ||
|
5bab02fa54 | ||
|
f2fa0b9025 | ||
|
9c0edd2905 | ||
|
62deebc04c | ||
|
478d8b07bf | ||
|
c9b6ebd94f | ||
|
30fc089dd5 | ||
|
25f02658f0 | ||
|
2266a00337 | ||
|
afeff31dca | ||
|
476dc01f4d | ||
|
b81b674ae1 | ||
|
15eb7a25b9 | ||
|
aadd57bc48 | ||
|
cbbe5db813 | ||
|
4601097d3e | ||
|
10cd9a7f79 | ||
|
6cf98d4930 | ||
|
34a65980e3 | ||
|
6010bc01c3 | ||
|
a00f9e1a10 | ||
|
85078d8f10 | ||
|
1606b6c0f6 | ||
|
f097aad01e | ||
|
bf53f1ae38 | ||
|
31237f778a | ||
|
55d245a77b | ||
|
9f35f56eaf | ||
|
f2a5ccf102 | ||
|
2b78980747 | ||
|
a89678074e | ||
|
d813f83b4a | ||
|
37b7e953f7 | ||
|
08b64c5502 | ||
|
23a660e917 | ||
|
78eb9b2f78 | ||
|
d90f504ca7 | ||
|
56104d3042 | ||
|
fcec1fcc4d | ||
|
1a14902da8 | ||
|
34bdf8bf78 | ||
|
7ff3f6af6c | ||
|
bd8b0c4c03 | ||
|
0c560a313a | ||
|
8882bb495c | ||
|
e4078f984a | ||
|
8e5a2f565c | ||
|
8b442a7749 | ||
|
b63f7a2bc0 | ||
|
f9e7d5229e | ||
|
b24d05bff7 | ||
|
fd009fc71b | ||
|
302eea1cb7 | ||
|
b116a2742e | ||
|
99a04e23d9 | ||
|
19a89d5a60 | ||
|
feb20c131a | ||
|
ec82023265 | ||
|
e4f3f0b3b6 | ||
|
28274d4c75 | ||
|
b6595e4efc | ||
|
4046ef1c13 | ||
|
b25d6d1e48 | ||
|
cf59140276 | ||
|
cc4563a477 | ||
|
0d984b5162 | ||
|
279cba008b | ||
|
2b5d458456 | ||
|
f41efb3b2c | ||
|
0155293c64 | ||
|
b78efd6b1e | ||
|
bfcc09db8a | ||
|
a46c17e19f | ||
|
b0bb22b650 | ||
|
0c039145e5 | ||
|
2a3c904a9f | ||
|
7cbdb6708b | ||
|
7058db2b04 | ||
|
8f7df590cd | ||
|
0f67a5ba2f | ||
|
19fb00b5b7 | ||
|
8683253c6d | ||
|
918a36d564 | ||
|
57ae0b5796 | ||
|
262f7dd98f | ||
|
4714b3af67 | ||
|
0359035000 | ||
|
b14edb8876 | ||
|
47c5e0c2c7 | ||
|
8709d94783 | ||
|
23b1251393 | ||
|
484aea1cdb | ||
|
d1c00ba4ed | ||
|
b1a5fe2f55 | ||
|
d7ff6d023c | ||
|
253e95dcba | ||
|
c7ce1aa4c7 | ||
|
3d87885577 | ||
|
a7e2271845 | ||
|
2a02abee46 | ||
|
ab4315742f | ||
|
2ddb15c784 | ||
|
95c7d997c1 | ||
|
e2c909f50f | ||
|
a53ea029fa | ||
|
869dab2ba2 | ||
|
d2be2ee480 | ||
|
bc8ef94f0d | ||
|
ed1b880359 | ||
|
7221e7ca68 | ||
|
0392daa103 | ||
|
d602b6dbc5 | ||
|
b8a0cf6a9e | ||
|
26419c64f5 | ||
|
a71e2d9f0a | ||
|
cfe67ff17d | ||
|
78e3ee15f9 | ||
|
2cb74e3dd0 | ||
|
8e979bdb4b | ||
|
e099fd6141 | ||
|
35962bcc42 | ||
|
ae584beaac | ||
|
563033786f | ||
|
f8c7f36a34 | ||
|
4746c88633 | ||
|
b7d6bedbbb | ||
|
8db79c05dd | ||
|
8fa7ff647a | ||
|
d0336cd67e | ||
|
cfab4eb2fc | ||
|
5f1c5009d3 | ||
|
97d7151289 | ||
|
475fa36ea3 | ||
|
e8d1ee0934 | ||
|
d07ec4ad0f | ||
|
684dfedbcc | ||
|
730b01fb14 | ||
|
45e8872cc0 | ||
|
cc2c00d764 | ||
|
402a5e2c9f | ||
|
bcf884ccfa | ||
|
2eece01acc | ||
|
ef985896e2 | ||
|
5e7514243c | ||
|
b9c0fc69e8 | ||
|
4a344bebc0 | ||
|
b2d54b82fa | ||
|
e7b1162cb3 | ||
|
d89e5a0074 | ||
|
4a54e5ddeb | ||
|
d9232e05f1 | ||
|
52be8be28f | ||
|
ab6c2424db | ||
|
eb437e7163 | ||
|
2ddf2a7866 | ||
|
60232ce9be | ||
|
952995f796 | ||
|
933a285bf5 | ||
|
f8da69f8e5 | ||
|
d5e29bfce3 | ||
|
ab36c4c011 | ||
|
c6e29647fc | ||
|
95a6291c34 | ||
|
fa2bff30f6 | ||
|
58041e1f9d | ||
|
447f73caf4 | ||
|
9145be6bfc | ||
|
86129589ef | ||
|
f5a8fca22f | ||
|
2a612611b8 | ||
|
0b64426cf2 | ||
|
f3bf9bcdc8 | ||
|
06a5ddda5e | ||
|
8a5a93ee80 | ||
|
6777f47e0e | ||
|
cf04e1d8e5 | ||
|
d608f1e3cc | ||
|
86f5c93434 | ||
|
4fcbeef5e6 | ||
|
7d983ae0dd | ||
|
a2ab34ef4c | ||
|
77abafca8e | ||
|
e67eb48540 | ||
|
37d7e8f5bf | ||
|
e6eef8bece | ||
|
52cfd9f261 | ||
|
9a9e8e2648 | ||
|
2cebd5e05f | ||
|
7fa72260ca | ||
|
b19b346670 | ||
|
99de0ca45f | ||
|
c106b399d7 | ||
|
c274062e87 | ||
|
53de8c0805 | ||
|
76854b5eff | ||
|
5200633574 | ||
|
832e27a8fb | ||
|
2da06bc0b1 | ||
|
46c748d888 | ||
|
430d450828 | ||
|
02937873b1 | ||
|
d303ca56e3 | ||
|
c647143e53 | ||
|
dd0ab8ed56 | ||
|
18e6c1ef7d | ||
|
dec2032e13 | ||
|
287e06d6dc | ||
|
dc93cc13b5 | ||
|
26714e2c62 | ||
|
c6c48a2b47 | ||
|
f8b67ec44c | ||
|
ddd5c302b4 | ||
|
9b98638b2b | ||
|
2ca8ce6f60 | ||
|
56a158e5c9 | ||
|
9b65d243a8 | ||
|
c45dd5d6fb | ||
|
af4b732080 | ||
|
1cdf0f5cc4 | ||
|
20a1da1855 | ||
|
8aee50020b | ||
|
c1615419b9 | ||
|
15489eeae3 | ||
|
80c9589885 | ||
|
a5d60c4521 | ||
|
2cb052a119 | ||
|
0756174b13 | ||
|
3f760e6685 | ||
|
d5dc4435d9 | ||
|
48228430c0 | ||
|
f396a95f05 | ||
|
717afcdc82 | ||
|
25c50bcc5d | ||
|
f77a5d0c5c | ||
|
6689d837d6 | ||
|
c1907354e8 | ||
|
efba619acb | ||
|
7d271547c6 | ||
|
327f92bb2e |
|
@ -3,7 +3,7 @@
|
|||
"isRoot": true,
|
||||
"tools": {
|
||||
"dotnet-ef": {
|
||||
"version": "8.0.4",
|
||||
"version": "8.0.7",
|
||||
"commands": [
|
||||
"dotnet-ef"
|
||||
]
|
||||
|
|
8
.github/workflows/ci-codeql-analysis.yml
vendored
8
.github/workflows/ci-codeql-analysis.yml
vendored
|
@ -20,18 +20,18 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4
|
||||
uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@4d6c8fcf3c8f7a60068d26b594648e99df24cee3 # v4.0.0
|
||||
with:
|
||||
dotnet-version: '8.0.x'
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@d39d31e687223d841ef683f52467bd88e9b21c14 # v3.25.3
|
||||
uses: github/codeql-action/init@9fdb3e49720b44c48891d036bb502feb25684276 # v3.25.6
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: +security-extended
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@d39d31e687223d841ef683f52467bd88e9b21c14 # v3.25.3
|
||||
uses: github/codeql-action/autobuild@9fdb3e49720b44c48891d036bb502feb25684276 # v3.25.6
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@d39d31e687223d841ef683f52467bd88e9b21c14 # v3.25.3
|
||||
uses: github/codeql-action/analyze@9fdb3e49720b44c48891d036bb502feb25684276 # v3.25.6
|
||||
|
|
108
.github/workflows/ci-openapi.yml
vendored
108
.github/workflows/ci-openapi.yml
vendored
|
@ -3,6 +3,8 @@ on:
|
|||
push:
|
||||
branches:
|
||||
- master
|
||||
tags:
|
||||
- 'v*'
|
||||
pull_request_target:
|
||||
|
||||
permissions: {}
|
||||
|
@ -14,7 +16,7 @@ jobs:
|
|||
permissions: read-all
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4
|
||||
uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
|
@ -39,7 +41,7 @@ jobs:
|
|||
permissions: read-all
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4
|
||||
uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
|
@ -99,11 +101,24 @@ jobs:
|
|||
- id: read-diff
|
||||
name: Read openapi-diff output
|
||||
run: |
|
||||
# Read and fix markdown
|
||||
body=$(cat openapi-changes.md)
|
||||
body="${body//'%'/'%25'}"
|
||||
body="${body//$'\n'/'%0A'}"
|
||||
body="${body//$'\r'/'%0D'}"
|
||||
echo ::set-output name=body::$body
|
||||
# Write to workflow summary
|
||||
echo "$body" >> $GITHUB_STEP_SUMMARY
|
||||
# Set ApiChanged var
|
||||
if [ "$body" != '' ]; then
|
||||
echo "ApiChanged=1" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "ApiChanged=0" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
# Add header/footer for diff comment
|
||||
echo '<!--openapi-diff-workflow-comment-->' > openapi-changes-reply.md
|
||||
echo "<details>" >> openapi-changes-reply.md
|
||||
echo "<summary>Changes in OpenAPI specification found. Expand to see details.</summary>" >> openapi-changes-reply.md
|
||||
echo "" >> openapi-changes-reply.md
|
||||
echo "$body" >> openapi-changes-reply.md
|
||||
echo "" >> openapi-changes-reply.md
|
||||
echo "</details>" >> openapi-changes-reply.md
|
||||
- name: Find difference comment
|
||||
uses: peter-evans/find-comment@3eae4d37986fb5a8592848f6a574fdf654e61f9e # v3.1.0
|
||||
id: find-comment
|
||||
|
@ -113,22 +128,15 @@ jobs:
|
|||
body-includes: openapi-diff-workflow-comment
|
||||
- name: Reply or edit difference comment (changed)
|
||||
uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0
|
||||
if: ${{ steps.read-diff.outputs.body != '' }}
|
||||
if: ${{ steps.read-diff.outputs.ApiChanged == '1' }}
|
||||
with:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
comment-id: ${{ steps.find-comment.outputs.comment-id }}
|
||||
edit-mode: replace
|
||||
body: |
|
||||
<!--openapi-diff-workflow-comment-->
|
||||
<details>
|
||||
<summary>Changes in OpenAPI specification found. Expand to see details.</summary>
|
||||
|
||||
${{ steps.read-diff.outputs.body }}
|
||||
|
||||
</details>
|
||||
body-path: openapi-changes-reply.md
|
||||
- name: Edit difference comment (unchanged)
|
||||
uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0
|
||||
if: ${{ steps.read-diff.outputs.body == '' && steps.find-comment.outputs.comment-id != '' }}
|
||||
if: ${{ steps.read-diff.outputs.ApiChanged == '0' && steps.find-comment.outputs.comment-id != '' }}
|
||||
with:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
comment-id: ${{ steps.find-comment.outputs.comment-id }}
|
||||
|
@ -138,11 +146,9 @@ jobs:
|
|||
|
||||
No changes to OpenAPI specification found. See history of this comment for previous changes.
|
||||
|
||||
publish:
|
||||
publish-unstable:
|
||||
name: OpenAPI - Publish Unstable Spec
|
||||
if: |
|
||||
github.event_name != 'pull_request_target' &&
|
||||
contains(github.repository_owner, 'jellyfin')
|
||||
if: ${{ github.event_name != 'pull_request_target' && !startsWith(github.ref, 'refs/tags/v') && contains(github.repository_owner, 'jellyfin') }}
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- openapi-head
|
||||
|
@ -201,3 +207,65 @@ jobs:
|
|||
sudo ln -s unstable/${LAST_SPEC} ${TGT_DIR}/jellyfin-openapi-unstable_previous.json
|
||||
fi
|
||||
) 200>/run/workflows/openapi-unstable.lock
|
||||
|
||||
publish-stable:
|
||||
name: OpenAPI - Publish Stable Spec
|
||||
if: ${{ startsWith(github.ref, 'refs/tags/v') && contains(github.repository_owner, 'jellyfin') }}
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- openapi-head
|
||||
steps:
|
||||
- name: Set version number
|
||||
id: version
|
||||
run: |-
|
||||
echo "JELLYFIN_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
|
||||
- name: Download openapi-head
|
||||
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
|
||||
with:
|
||||
name: openapi-head
|
||||
path: openapi-head
|
||||
- name: Upload openapi.json (stable) to repository server
|
||||
uses: appleboy/scp-action@917f8b81dfc1ccd331fef9e2d61bdc6c8be94634 # v0.1.7
|
||||
with:
|
||||
host: "${{ secrets.REPO_HOST }}"
|
||||
username: "${{ secrets.REPO_USER }}"
|
||||
key: "${{ secrets.REPO_KEY }}"
|
||||
source: openapi-head/openapi.json
|
||||
strip_components: 1
|
||||
target: "/srv/incoming/openapi/stable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}"
|
||||
- name: Move openapi.json (stable) into place
|
||||
uses: appleboy/ssh-action@029f5b4aeeeb58fdfe1410a5d17f967dacf36262 # v1.0.3
|
||||
with:
|
||||
host: "${{ secrets.REPO_HOST }}"
|
||||
username: "${{ secrets.REPO_USER }}"
|
||||
key: "${{ secrets.REPO_KEY }}"
|
||||
debug: false
|
||||
script_stop: false
|
||||
script: |
|
||||
if ! test -d /run/workflows; then
|
||||
sudo mkdir -p /run/workflows
|
||||
sudo chown ${{ secrets.REPO_USER }} /run/workflows
|
||||
fi
|
||||
(
|
||||
flock -x -w 300 200 || exit 1
|
||||
TGT_DIR="/srv/repository/main/openapi"
|
||||
LAST_SPEC="$( ls -lt ${TGT_DIR}/stable/ | grep 'jellyfin-openapi' | head -1 | awk '{ print $NF }' )"
|
||||
# If new and previous spec don't differ (diff retcode 0), remove incoming and finish
|
||||
if diff /srv/incoming/openapi/stable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}/openapi.json ${TGT_DIR}/stable/${LAST_SPEC} &>/dev/null; then
|
||||
rm -r /srv/incoming/openapi/stable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}
|
||||
exit 0
|
||||
fi
|
||||
# Move new spec into place
|
||||
sudo mv /srv/incoming/openapi/stable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}/openapi.json ${TGT_DIR}/stable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}.json
|
||||
# Delete previous jellyfin-openapi-stable_previous.json
|
||||
sudo rm ${TGT_DIR}/jellyfin-openapi-stable_previous.json
|
||||
# Move current jellyfin-openapi-stable.json symlink to jellyfin-openapi-stable_previous.json
|
||||
sudo mv ${TGT_DIR}/jellyfin-openapi-stable.json ${TGT_DIR}/jellyfin-openapi-stable_previous.json
|
||||
# Create new jellyfin-openapi-stable.json symlink
|
||||
sudo ln -s stable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}.json ${TGT_DIR}/jellyfin-openapi-stable.json
|
||||
# Check that the previous openapi stable spec link is correct
|
||||
if [[ "$( readlink ${TGT_DIR}/jellyfin-openapi-stable_previous.json )" != "stable/${LAST_SPEC}" ]]; then
|
||||
sudo rm ${TGT_DIR}/jellyfin-openapi-stable_previous.json
|
||||
sudo ln -s stable/${LAST_SPEC} ${TGT_DIR}/jellyfin-openapi-stable_previous.json
|
||||
fi
|
||||
) 200>/run/workflows/openapi-stable.lock
|
||||
|
|
4
.github/workflows/ci-tests.yml
vendored
4
.github/workflows/ci-tests.yml
vendored
|
@ -19,7 +19,7 @@ jobs:
|
|||
|
||||
runs-on: "${{ matrix.os }}"
|
||||
steps:
|
||||
- uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4
|
||||
- uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
|
||||
|
||||
- uses: actions/setup-dotnet@4d6c8fcf3c8f7a60068d26b594648e99df24cee3 # v4.0.0
|
||||
with:
|
||||
|
@ -34,7 +34,7 @@ jobs:
|
|||
--verbosity minimal
|
||||
|
||||
- name: Merge code coverage results
|
||||
uses: danielpalme/ReportGenerator-GitHub-Action@2a2d60ea1c7e811f54684179af6ac1ae8c1ce69a # 5.2.5
|
||||
uses: danielpalme/ReportGenerator-GitHub-Action@6b06171d1a131e7fd85121120a1c00c1ed03e033 # 5.3.0
|
||||
with:
|
||||
reports: "**/coverage.cobertura.xml"
|
||||
targetdir: "merged/"
|
||||
|
|
6
.github/workflows/commands.yml
vendored
6
.github/workflows/commands.yml
vendored
|
@ -24,7 +24,7 @@ jobs:
|
|||
reactions: '+1'
|
||||
|
||||
- name: Checkout the latest code
|
||||
uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4
|
||||
uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
|
||||
with:
|
||||
token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
fetch-depth: 0
|
||||
|
@ -51,7 +51,7 @@ jobs:
|
|||
reactions: eyes
|
||||
|
||||
- name: Checkout the latest code
|
||||
uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4
|
||||
uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
|
||||
with:
|
||||
token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
fetch-depth: 0
|
||||
|
@ -128,7 +128,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: pull in script
|
||||
uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4
|
||||
uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
|
||||
with:
|
||||
repository: jellyfin/jellyfin-triage-script
|
||||
- name: install python
|
||||
|
|
2
.github/workflows/issue-template-check.yml
vendored
2
.github/workflows/issue-template-check.yml
vendored
|
@ -10,7 +10,7 @@ jobs:
|
|||
issues: write
|
||||
steps:
|
||||
- name: pull in script
|
||||
uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4
|
||||
uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
|
||||
with:
|
||||
repository: jellyfin/jellyfin-triage-script
|
||||
- name: install python
|
||||
|
|
2
.github/workflows/pull-request-conflict.yml
vendored
2
.github/workflows/pull-request-conflict.yml
vendored
|
@ -15,7 +15,7 @@ jobs:
|
|||
if: ${{ github.repository == 'jellyfin/jellyfin' }}
|
||||
steps:
|
||||
- name: Apply label
|
||||
uses: eps1lon/actions-label-merge-conflict@e62d7a53ff8be8b97684bffb6cfbbf3fc1115e2e # v3.0.0
|
||||
uses: eps1lon/actions-label-merge-conflict@6d74047dcef155976a15e4a124dde2c7fe0c5522 # v3.0.1
|
||||
if: ${{ github.event_name == 'push' || github.event_name == 'pull_request_target'}}
|
||||
with:
|
||||
dirtyLabel: 'merge conflict'
|
||||
|
|
4
.github/workflows/release-bump-version.yaml
vendored
4
.github/workflows/release-bump-version.yaml
vendored
|
@ -33,7 +33,7 @@ jobs:
|
|||
yq-version: v4.9.8
|
||||
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4
|
||||
uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
|
||||
with:
|
||||
ref: ${{ env.TAG_BRANCH }}
|
||||
|
||||
|
@ -66,7 +66,7 @@ jobs:
|
|||
NEXT_VERSION: ${{ github.event.inputs.NEXT_VERSION }}
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4
|
||||
uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
|
||||
with:
|
||||
ref: ${{ env.TAG_BRANCH }}
|
||||
|
||||
|
|
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -277,3 +277,5 @@ apiclient/generated
|
|||
|
||||
# Omnisharp crash logs
|
||||
mono_crash.*.json
|
||||
|
||||
out
|
||||
|
|
|
@ -183,6 +183,7 @@
|
|||
- [btopherjohnson](https://github.com/btopherjohnson)
|
||||
- [GeorgeH005](https://github.com/GeorgeH005)
|
||||
- [Vedant](https://github.com/viktory36/)
|
||||
- [NotSaifA](https://github.com/NotSaifA)
|
||||
|
||||
# Emby Contributors
|
||||
|
||||
|
@ -255,3 +256,4 @@
|
|||
- [JPUC1143](https://github.com/Jpuc1143/)
|
||||
- [0x25CBFC4F](https://github.com/0x25CBFC4F)
|
||||
- [Robert Lützner](https://github.com/rluetzner)
|
||||
- [Nathan McCrina](https://github.com/nfmccrina)
|
||||
|
|
|
@ -16,40 +16,39 @@
|
|||
<PackageVersion Include="Diacritics" Version="3.3.29" />
|
||||
<PackageVersion Include="DiscUtils.Udf" Version="0.16.13" />
|
||||
<PackageVersion Include="DotNet.Glob" Version="3.1.3" />
|
||||
<PackageVersion Include="EFCoreSecondLevelCacheInterceptor" Version="4.4.3" />
|
||||
<PackageVersion Include="FsCheck.Xunit" Version="2.16.6" />
|
||||
<PackageVersion Include="HarfBuzzSharp.NativeAssets.Linux" Version="7.3.0.2" />
|
||||
<PackageVersion Include="ICU4N.Transliterator" Version="60.1.0-alpha.356" />
|
||||
<PackageVersion Include="IDisposableAnalyzers" Version="4.0.7" />
|
||||
<PackageVersion Include="Jellyfin.XmlTv" Version="10.8.0" />
|
||||
<PackageVersion Include="libse" Version="4.0.5" />
|
||||
<PackageVersion Include="libse" Version="4.0.7" />
|
||||
<PackageVersion Include="LrcParser" Version="2023.524.0" />
|
||||
<PackageVersion Include="MetaBrainz.MusicBrainz" Version="6.1.0" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="8.0.4" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="8.0.7" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.HttpOverrides" Version="2.2.0" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.4" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.7" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4" />
|
||||
<PackageVersion Include="Microsoft.Data.Sqlite" Version="8.0.4" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.4" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.4" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.4" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.4" />
|
||||
<PackageVersion Include="Microsoft.Data.Sqlite" Version="8.0.7" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.7" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.7" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.7" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="8.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="8.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.2" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="8.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="8.0.4" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="8.0.4" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="8.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="8.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="8.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="8.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="8.0.2" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
|
||||
<PackageVersion Include="MimeTypes" Version="2.4.0" />
|
||||
<PackageVersion Include="Mono.Nat" Version="3.0.4" />
|
||||
<PackageVersion Include="Moq" Version="4.18.4" />
|
||||
|
@ -59,12 +58,12 @@
|
|||
<PackageVersion Include="prometheus-net.AspNetCore" Version="8.2.1" />
|
||||
<PackageVersion Include="prometheus-net.DotNetRuntime" Version="4.4.0" />
|
||||
<PackageVersion Include="prometheus-net" Version="8.2.1" />
|
||||
<PackageVersion Include="Serilog.AspNetCore" Version="8.0.1" />
|
||||
<PackageVersion Include="Serilog.AspNetCore" Version="8.0.2" />
|
||||
<PackageVersion Include="Serilog.Enrichers.Thread" Version="3.1.0" />
|
||||
<PackageVersion Include="Serilog.Settings.Configuration" Version="8.0.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.Async" Version="1.5.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.Console" Version="5.0.1" />
|
||||
<PackageVersion Include="Serilog.Sinks.File" Version="5.0.0" />
|
||||
<PackageVersion Include="Serilog.Settings.Configuration" Version="8.0.2" />
|
||||
<PackageVersion Include="Serilog.Sinks.Async" Version="2.0.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.File" Version="6.0.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.Graylog" Version="3.1.1" />
|
||||
<PackageVersion Include="SerilogAnalyzer" Version="0.15.0" />
|
||||
<PackageVersion Include="SharpFuzz" Version="2.1.1" />
|
||||
|
@ -74,19 +73,19 @@
|
|||
<PackageVersion Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" />
|
||||
<PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" />
|
||||
<PackageVersion Include="Svg.Skia" Version="1.0.0.18" />
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="6.5.0" />
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="6.6.2" />
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore" Version="6.2.3" />
|
||||
<PackageVersion Include="System.Globalization" Version="4.3.0" />
|
||||
<PackageVersion Include="System.Linq.Async" Version="6.0.1" />
|
||||
<PackageVersion Include="System.Text.Encoding.CodePages" Version="8.0.0" />
|
||||
<PackageVersion Include="System.Text.Json" Version="8.0.3" />
|
||||
<PackageVersion Include="System.Threading.Tasks.Dataflow" Version="8.0.0" />
|
||||
<PackageVersion Include="System.Text.Json" Version="8.0.4" />
|
||||
<PackageVersion Include="System.Threading.Tasks.Dataflow" Version="8.0.1" />
|
||||
<PackageVersion Include="TagLibSharp" Version="2.3.0" />
|
||||
<PackageVersion Include="TMDbLib" Version="2.2.0" />
|
||||
<PackageVersion Include="UTF.Unknown" Version="2.5.1" />
|
||||
<PackageVersion Include="Xunit.Priority" Version="1.1.6" />
|
||||
<PackageVersion Include="xunit.runner.visualstudio" Version="2.5.8" />
|
||||
<PackageVersion Include="xunit.runner.visualstudio" Version="2.8.1" />
|
||||
<PackageVersion Include="Xunit.SkippableFact" Version="1.4.13" />
|
||||
<PackageVersion Include="xunit" Version="2.7.1" />
|
||||
<PackageVersion Include="xunit" Version="2.8.1" />
|
||||
</ItemGroup>
|
||||
</Project>
|
|
@ -36,7 +36,7 @@
|
|||
<PropertyGroup>
|
||||
<Authors>Jellyfin Contributors</Authors>
|
||||
<PackageId>Jellyfin.Naming</PackageId>
|
||||
<VersionPrefix>10.9.0</VersionPrefix>
|
||||
<VersionPrefix>10.9.11</VersionPrefix>
|
||||
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
||||
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
||||
</PropertyGroup>
|
||||
|
|
|
@ -107,7 +107,7 @@ namespace Emby.Naming.ExternalFiles
|
|||
pathInfo.Language = culture.ThreeLetterISOLanguageName;
|
||||
extraString = extraString.Replace(currentSlice, string.Empty, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
else if (_namingOptions.MediaHearingImpairedFlags.Any(s => currentSliceWithoutSeparator.Contains(s, StringComparison.OrdinalIgnoreCase)))
|
||||
else if (_namingOptions.MediaHearingImpairedFlags.Any(s => currentSliceWithoutSeparator.Equals(s, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
pathInfo.IsHearingImpaired = true;
|
||||
extraString = extraString.Replace(currentSlice, string.Empty, StringComparison.OrdinalIgnoreCase);
|
||||
|
|
44
Emby.Naming/TV/TvParserHelpers.cs
Normal file
44
Emby.Naming/TV/TvParserHelpers.cs
Normal file
|
@ -0,0 +1,44 @@
|
|||
using System;
|
||||
using System.Linq;
|
||||
using MediaBrowser.Model.Entities;
|
||||
|
||||
namespace Emby.Naming.TV;
|
||||
|
||||
/// <summary>
|
||||
/// Helper class for TV metadata parsing.
|
||||
/// </summary>
|
||||
public static class TvParserHelpers
|
||||
{
|
||||
private static readonly string[] _continuingState = ["Pilot", "Returning Series", "Returning"];
|
||||
private static readonly string[] _endedState = ["Cancelled", "Canceled"];
|
||||
|
||||
/// <summary>
|
||||
/// Tries to parse a string into <see cref="SeriesStatus"/>.
|
||||
/// </summary>
|
||||
/// <param name="status">The status string.</param>
|
||||
/// <param name="enumValue">The <see cref="SeriesStatus"/>.</param>
|
||||
/// <returns>Returns true if parsing was successful.</returns>
|
||||
public static bool TryParseSeriesStatus(string status, out SeriesStatus? enumValue)
|
||||
{
|
||||
if (Enum.TryParse(status, true, out SeriesStatus seriesStatus))
|
||||
{
|
||||
enumValue = seriesStatus;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (_continuingState.Contains(status, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
enumValue = SeriesStatus.Continuing;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (_endedState.Contains(status, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
enumValue = SeriesStatus.Ended;
|
||||
return true;
|
||||
}
|
||||
|
||||
enumValue = null;
|
||||
return false;
|
||||
}
|
||||
}
|
|
@ -422,7 +422,7 @@ namespace Emby.Server.Implementations
|
|||
// Initialize runtime stat collection
|
||||
if (ConfigurationManager.Configuration.EnableMetrics)
|
||||
{
|
||||
DotNetRuntimeStatsBuilder.Default().StartCollecting();
|
||||
_disposableParts.Add(DotNetRuntimeStatsBuilder.Default().StartCollecting());
|
||||
}
|
||||
|
||||
var networkConfiguration = ConfigurationManager.GetNetworkConfiguration();
|
||||
|
|
|
@ -119,5 +119,56 @@ namespace Emby.Server.Implementations.Configuration
|
|||
EnsureWriteAccess(newPath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logs a message.
|
||||
/// </summary>
|
||||
/// <param name="message">The message to log.</param>
|
||||
public void LogMessage(string message)
|
||||
{
|
||||
Logger.LogInformation("(MJH) {Message}", message);
|
||||
}
|
||||
|
||||
private bool IsSubPath(string basePath, string testPath)
|
||||
{
|
||||
string normalizedBasePath = Path.GetFullPath(basePath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
string normalizedTestPath = Path.GetFullPath(testPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
|
||||
var isSubPath = normalizedTestPath.StartsWith(normalizedBasePath, StringComparison.OrdinalIgnoreCase)
|
||||
&& normalizedTestPath.Length > normalizedBasePath.Length
|
||||
&& (normalizedTestPath[normalizedBasePath.Length] == Path.DirectorySeparatorChar
|
||||
|| normalizedTestPath[normalizedBasePath.Length] == Path.AltDirectorySeparatorChar);
|
||||
return isSubPath;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the folder priority.
|
||||
/// </summary>
|
||||
/// <param name="path">The path to test.</param>
|
||||
/// <returns>The priority int value (higher comes first).</returns>
|
||||
public int FetchFolderPriority(string path)
|
||||
{
|
||||
if (this.IsSubPath("/mnt/media-pool", path))
|
||||
{
|
||||
return 4;
|
||||
}
|
||||
|
||||
if (this.IsSubPath("/mnt/lee/laptop-new", path))
|
||||
{
|
||||
return 3;
|
||||
}
|
||||
|
||||
if (this.IsSubPath("/mnt/lee/laptop-seagate", path))
|
||||
{
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (this.IsSubPath("/mnt/lee/pi-elements", path))
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using Jellyfin.Extensions;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
@ -13,6 +14,8 @@ namespace Emby.Server.Implementations.Data
|
|||
public abstract class BaseSqliteRepository : IDisposable
|
||||
{
|
||||
private bool _disposed = false;
|
||||
private SemaphoreSlim _writeLock = new SemaphoreSlim(1, 1);
|
||||
private SqliteConnection _writeConnection;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="BaseSqliteRepository"/> class.
|
||||
|
@ -98,9 +101,55 @@ namespace Emby.Server.Implementations.Data
|
|||
}
|
||||
}
|
||||
|
||||
protected SqliteConnection GetConnection()
|
||||
protected ManagedConnection GetConnection(bool readOnly = false)
|
||||
{
|
||||
var connection = new SqliteConnection($"Filename={DbFilePath}");
|
||||
if (!readOnly)
|
||||
{
|
||||
_writeLock.Wait();
|
||||
if (_writeConnection is not null)
|
||||
{
|
||||
return new ManagedConnection(_writeConnection, _writeLock);
|
||||
}
|
||||
|
||||
var writeConnection = new SqliteConnection($"Filename={DbFilePath};Pooling=False");
|
||||
writeConnection.Open();
|
||||
|
||||
if (CacheSize.HasValue)
|
||||
{
|
||||
writeConnection.Execute("PRAGMA cache_size=" + CacheSize.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(LockingMode))
|
||||
{
|
||||
writeConnection.Execute("PRAGMA locking_mode=" + LockingMode);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(JournalMode))
|
||||
{
|
||||
writeConnection.Execute("PRAGMA journal_mode=" + JournalMode);
|
||||
}
|
||||
|
||||
if (JournalSizeLimit.HasValue)
|
||||
{
|
||||
writeConnection.Execute("PRAGMA journal_size_limit=" + JournalSizeLimit.Value);
|
||||
}
|
||||
|
||||
if (Synchronous.HasValue)
|
||||
{
|
||||
writeConnection.Execute("PRAGMA synchronous=" + (int)Synchronous.Value);
|
||||
}
|
||||
|
||||
if (PageSize.HasValue)
|
||||
{
|
||||
writeConnection.Execute("PRAGMA page_size=" + PageSize.Value);
|
||||
}
|
||||
|
||||
writeConnection.Execute("PRAGMA temp_store=" + (int)TempStore);
|
||||
|
||||
return new ManagedConnection(_writeConnection = writeConnection, _writeLock);
|
||||
}
|
||||
|
||||
var connection = new SqliteConnection($"Filename={DbFilePath};Mode=ReadOnly");
|
||||
connection.Open();
|
||||
|
||||
if (CacheSize.HasValue)
|
||||
|
@ -135,17 +184,17 @@ namespace Emby.Server.Implementations.Data
|
|||
|
||||
connection.Execute("PRAGMA temp_store=" + (int)TempStore);
|
||||
|
||||
return connection;
|
||||
return new ManagedConnection(connection, null);
|
||||
}
|
||||
|
||||
public SqliteCommand PrepareStatement(SqliteConnection connection, string sql)
|
||||
public SqliteCommand PrepareStatement(ManagedConnection connection, string sql)
|
||||
{
|
||||
var command = connection.CreateCommand();
|
||||
command.CommandText = sql;
|
||||
return command;
|
||||
}
|
||||
|
||||
protected bool TableExists(SqliteConnection connection, string name)
|
||||
protected bool TableExists(ManagedConnection connection, string name)
|
||||
{
|
||||
using var statement = PrepareStatement(connection, "select DISTINCT tbl_name from sqlite_master");
|
||||
foreach (var row in statement.ExecuteQuery())
|
||||
|
@ -159,7 +208,7 @@ namespace Emby.Server.Implementations.Data
|
|||
return false;
|
||||
}
|
||||
|
||||
protected List<string> GetColumnNames(SqliteConnection connection, string table)
|
||||
protected List<string> GetColumnNames(ManagedConnection connection, string table)
|
||||
{
|
||||
var columnNames = new List<string>();
|
||||
|
||||
|
@ -174,7 +223,7 @@ namespace Emby.Server.Implementations.Data
|
|||
return columnNames;
|
||||
}
|
||||
|
||||
protected void AddColumn(SqliteConnection connection, string table, string columnName, string type, List<string> existingColumnNames)
|
||||
protected void AddColumn(ManagedConnection connection, string table, string columnName, string type, List<string> existingColumnNames)
|
||||
{
|
||||
if (existingColumnNames.Contains(columnName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
|
@ -207,6 +256,24 @@ namespace Emby.Server.Implementations.Data
|
|||
return;
|
||||
}
|
||||
|
||||
if (dispose)
|
||||
{
|
||||
_writeLock.Wait();
|
||||
try
|
||||
{
|
||||
_writeConnection.Dispose();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_writeLock.Release();
|
||||
}
|
||||
|
||||
_writeLock.Dispose();
|
||||
}
|
||||
|
||||
_writeConnection = null;
|
||||
_writeLock = null;
|
||||
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
|
|
62
Emby.Server.Implementations/Data/ManagedConnection.cs
Normal file
62
Emby.Server.Implementations/Data/ManagedConnection.cs
Normal file
|
@ -0,0 +1,62 @@
|
|||
#pragma warning disable CS1591
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace Emby.Server.Implementations.Data;
|
||||
|
||||
public sealed class ManagedConnection : IDisposable
|
||||
{
|
||||
private readonly SemaphoreSlim? _writeLock;
|
||||
|
||||
private SqliteConnection _db;
|
||||
|
||||
private bool _disposed = false;
|
||||
|
||||
public ManagedConnection(SqliteConnection db, SemaphoreSlim? writeLock)
|
||||
{
|
||||
_db = db;
|
||||
_writeLock = writeLock;
|
||||
}
|
||||
|
||||
public SqliteTransaction BeginTransaction()
|
||||
=> _db.BeginTransaction();
|
||||
|
||||
public SqliteCommand CreateCommand()
|
||||
=> _db.CreateCommand();
|
||||
|
||||
public void Execute(string commandText)
|
||||
=> _db.Execute(commandText);
|
||||
|
||||
public SqliteCommand PrepareStatement(string sql)
|
||||
=> _db.PrepareStatement(sql);
|
||||
|
||||
public IEnumerable<SqliteDataReader> Query(string commandText)
|
||||
=> _db.Query(commandText);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_writeLock is null)
|
||||
{
|
||||
// Read connections are managed with an internal pool
|
||||
_db.Dispose();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Write lock is managed by BaseSqliteRepository
|
||||
// Don't dispose here
|
||||
_writeLock.Release();
|
||||
}
|
||||
|
||||
_db = null!;
|
||||
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
|
@ -601,7 +601,7 @@ namespace Emby.Server.Implementations.Data
|
|||
transaction.Commit();
|
||||
}
|
||||
|
||||
private void SaveItemsInTransaction(SqliteConnection db, IEnumerable<(BaseItem Item, List<Guid> AncestorIds, BaseItem TopParent, string UserDataKey, List<string> InheritedTags)> tuples)
|
||||
private void SaveItemsInTransaction(ManagedConnection db, IEnumerable<(BaseItem Item, List<Guid> AncestorIds, BaseItem TopParent, string UserDataKey, List<string> InheritedTags)> tuples)
|
||||
{
|
||||
using (var saveItemStatement = PrepareStatement(db, SaveItemCommandText))
|
||||
using (var deleteAncestorsStatement = PrepareStatement(db, "delete from AncestorIds where ItemId=@ItemId"))
|
||||
|
@ -1261,7 +1261,7 @@ namespace Emby.Server.Implementations.Data
|
|||
|
||||
CheckDisposed();
|
||||
|
||||
using (var connection = GetConnection())
|
||||
using (var connection = GetConnection(true))
|
||||
using (var statement = PrepareStatement(connection, _retrieveItemColumnsSelectQuery))
|
||||
{
|
||||
statement.TryBind("@guid", id);
|
||||
|
@ -1298,16 +1298,15 @@ namespace Emby.Server.Implementations.Data
|
|||
&& type != typeof(Book)
|
||||
&& type != typeof(LiveTvProgram)
|
||||
&& type != typeof(AudioBook)
|
||||
&& type != typeof(Audio)
|
||||
&& type != typeof(MusicAlbum);
|
||||
}
|
||||
|
||||
private BaseItem GetItem(SqliteDataReader reader, InternalItemsQuery query)
|
||||
{
|
||||
return GetItem(reader, query, HasProgramAttributes(query), HasEpisodeAttributes(query), HasServiceName(query), HasStartDate(query), HasTrailerTypes(query), HasArtistFields(query), HasSeriesFields(query));
|
||||
return GetItem(reader, query, HasProgramAttributes(query), HasEpisodeAttributes(query), HasServiceName(query), HasStartDate(query), HasTrailerTypes(query), HasArtistFields(query), HasSeriesFields(query), false);
|
||||
}
|
||||
|
||||
private BaseItem GetItem(SqliteDataReader reader, InternalItemsQuery query, bool enableProgramAttributes, bool hasEpisodeAttributes, bool hasServiceName, bool queryHasStartDate, bool hasTrailerTypes, bool hasArtistFields, bool hasSeriesFields)
|
||||
private BaseItem GetItem(SqliteDataReader reader, InternalItemsQuery query, bool enableProgramAttributes, bool hasEpisodeAttributes, bool hasServiceName, bool queryHasStartDate, bool hasTrailerTypes, bool hasArtistFields, bool hasSeriesFields, bool skipDeserialization)
|
||||
{
|
||||
var typeString = reader.GetString(0);
|
||||
|
||||
|
@ -1320,7 +1319,7 @@ namespace Emby.Server.Implementations.Data
|
|||
|
||||
BaseItem item = null;
|
||||
|
||||
if (TypeRequiresDeserialization(type))
|
||||
if (TypeRequiresDeserialization(type) && !skipDeserialization)
|
||||
{
|
||||
try
|
||||
{
|
||||
|
@ -1888,7 +1887,7 @@ namespace Emby.Server.Implementations.Data
|
|||
CheckDisposed();
|
||||
|
||||
var chapters = new List<ChapterInfo>();
|
||||
using (var connection = GetConnection())
|
||||
using (var connection = GetConnection(true))
|
||||
using (var statement = PrepareStatement(connection, "select StartPositionTicks,Name,ImagePath,ImageDateModified from " + ChaptersTableName + " where ItemId = @ItemId order by ChapterIndex asc"))
|
||||
{
|
||||
statement.TryBind("@ItemId", item.Id);
|
||||
|
@ -1907,7 +1906,7 @@ namespace Emby.Server.Implementations.Data
|
|||
{
|
||||
CheckDisposed();
|
||||
|
||||
using (var connection = GetConnection())
|
||||
using (var connection = GetConnection(true))
|
||||
using (var statement = PrepareStatement(connection, "select StartPositionTicks,Name,ImagePath,ImageDateModified from " + ChaptersTableName + " where ItemId = @ItemId and ChapterIndex=@ChapterIndex"))
|
||||
{
|
||||
statement.TryBind("@ItemId", item.Id);
|
||||
|
@ -1981,7 +1980,7 @@ namespace Emby.Server.Implementations.Data
|
|||
transaction.Commit();
|
||||
}
|
||||
|
||||
private void InsertChapters(Guid idBlob, IReadOnlyList<ChapterInfo> chapters, SqliteConnection db)
|
||||
private void InsertChapters(Guid idBlob, IReadOnlyList<ChapterInfo> chapters, ManagedConnection db)
|
||||
{
|
||||
var startIndex = 0;
|
||||
var limit = 100;
|
||||
|
@ -2323,7 +2322,7 @@ namespace Emby.Server.Implementations.Data
|
|||
|
||||
columns.Add(builder.ToString());
|
||||
|
||||
query.ExcludeItemIds = [..query.ExcludeItemIds, item.Id, ..item.ExtraIds];
|
||||
query.ExcludeItemIds = [.. query.ExcludeItemIds, item.Id, .. item.ExtraIds];
|
||||
query.ExcludeProviderIds = item.ProviderIds;
|
||||
}
|
||||
|
||||
|
@ -2470,7 +2469,7 @@ namespace Emby.Server.Implementations.Data
|
|||
var commandText = commandTextBuilder.ToString();
|
||||
|
||||
using (new QueryTimeLogger(Logger, commandText))
|
||||
using (var connection = GetConnection())
|
||||
using (var connection = GetConnection(true))
|
||||
using (var statement = PrepareStatement(connection, commandText))
|
||||
{
|
||||
if (EnableJoinUserData(query))
|
||||
|
@ -2538,7 +2537,7 @@ namespace Emby.Server.Implementations.Data
|
|||
var commandText = commandTextBuilder.ToString();
|
||||
var items = new List<BaseItem>();
|
||||
using (new QueryTimeLogger(Logger, commandText))
|
||||
using (var connection = GetConnection())
|
||||
using (var connection = GetConnection(true))
|
||||
using (var statement = PrepareStatement(connection, commandText))
|
||||
{
|
||||
if (EnableJoinUserData(query))
|
||||
|
@ -2562,7 +2561,7 @@ namespace Emby.Server.Implementations.Data
|
|||
|
||||
foreach (var row in statement.ExecuteQuery())
|
||||
{
|
||||
var item = GetItem(row, query, hasProgramAttributes, hasEpisodeAttributes, hasServiceName, hasStartDate, hasTrailerTypes, hasArtistFields, hasSeriesFields);
|
||||
var item = GetItem(row, query, hasProgramAttributes, hasEpisodeAttributes, hasServiceName, hasStartDate, hasTrailerTypes, hasArtistFields, hasSeriesFields, query.SkipDeserialization);
|
||||
if (item is not null)
|
||||
{
|
||||
items.Add(item);
|
||||
|
@ -2746,7 +2745,7 @@ namespace Emby.Server.Implementations.Data
|
|||
|
||||
var list = new List<BaseItem>();
|
||||
var result = new QueryResult<BaseItem>();
|
||||
using var connection = GetConnection();
|
||||
using var connection = GetConnection(true);
|
||||
using var transaction = connection.BeginTransaction();
|
||||
if (!isReturningZeroItems)
|
||||
{
|
||||
|
@ -2774,7 +2773,7 @@ namespace Emby.Server.Implementations.Data
|
|||
|
||||
foreach (var row in statement.ExecuteQuery())
|
||||
{
|
||||
var item = GetItem(row, query, hasProgramAttributes, hasEpisodeAttributes, hasServiceName, hasStartDate, hasTrailerTypes, hasArtistFields, hasSeriesFields);
|
||||
var item = GetItem(row, query, hasProgramAttributes, hasEpisodeAttributes, hasServiceName, hasStartDate, hasTrailerTypes, hasArtistFields, hasSeriesFields, false);
|
||||
if (item is not null)
|
||||
{
|
||||
list.Add(item);
|
||||
|
@ -2831,7 +2830,7 @@ namespace Emby.Server.Implementations.Data
|
|||
prepend.Add((ItemSortBy.Random, SortOrder.Ascending));
|
||||
}
|
||||
|
||||
orderBy = query.OrderBy = [..prepend, ..orderBy];
|
||||
orderBy = query.OrderBy = [.. prepend, .. orderBy];
|
||||
}
|
||||
else if (orderBy.Count == 0)
|
||||
{
|
||||
|
@ -2928,7 +2927,7 @@ namespace Emby.Server.Implementations.Data
|
|||
var commandText = commandTextBuilder.ToString();
|
||||
var list = new List<Guid>();
|
||||
using (new QueryTimeLogger(Logger, commandText))
|
||||
using (var connection = GetConnection())
|
||||
using (var connection = GetConnection(true))
|
||||
using (var statement = PrepareStatement(connection, commandText))
|
||||
{
|
||||
if (EnableJoinUserData(query))
|
||||
|
@ -4477,7 +4476,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type
|
|||
transaction.Commit();
|
||||
}
|
||||
|
||||
private void ExecuteWithSingleParam(SqliteConnection db, string query, Guid value)
|
||||
private void ExecuteWithSingleParam(ManagedConnection db, string query, Guid value)
|
||||
{
|
||||
using (var statement = PrepareStatement(db, query))
|
||||
{
|
||||
|
@ -4510,7 +4509,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type
|
|||
}
|
||||
|
||||
var list = new List<string>();
|
||||
using (var connection = GetConnection())
|
||||
using (var connection = GetConnection(true))
|
||||
using (var statement = PrepareStatement(connection, commandText.ToString()))
|
||||
{
|
||||
// Run this again to bind the params
|
||||
|
@ -4548,7 +4547,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type
|
|||
}
|
||||
|
||||
var list = new List<PersonInfo>();
|
||||
using (var connection = GetConnection())
|
||||
using (var connection = GetConnection(true))
|
||||
using (var statement = PrepareStatement(connection, commandText.ToString()))
|
||||
{
|
||||
// Run this again to bind the params
|
||||
|
@ -4633,7 +4632,7 @@ AND Type = @InternalPersonType)");
|
|||
return whereClauses;
|
||||
}
|
||||
|
||||
private void UpdateAncestors(Guid itemId, List<Guid> ancestorIds, SqliteConnection db, SqliteCommand deleteAncestorsStatement)
|
||||
private void UpdateAncestors(Guid itemId, List<Guid> ancestorIds, ManagedConnection db, SqliteCommand deleteAncestorsStatement)
|
||||
{
|
||||
if (itemId.IsEmpty())
|
||||
{
|
||||
|
@ -4788,7 +4787,7 @@ AND Type = @InternalPersonType)");
|
|||
|
||||
var list = new List<string>();
|
||||
using (new QueryTimeLogger(Logger, commandText))
|
||||
using (var connection = GetConnection())
|
||||
using (var connection = GetConnection(true))
|
||||
using (var statement = PrepareStatement(connection, commandText))
|
||||
{
|
||||
foreach (var row in statement.ExecuteQuery())
|
||||
|
@ -4988,8 +4987,8 @@ AND Type = @InternalPersonType)");
|
|||
var list = new List<(BaseItem, ItemCounts)>();
|
||||
var result = new QueryResult<(BaseItem, ItemCounts)>();
|
||||
using (new QueryTimeLogger(Logger, commandText))
|
||||
using (var connection = GetConnection())
|
||||
using (var transaction = connection.BeginTransaction(deferred: true))
|
||||
using (var connection = GetConnection(true))
|
||||
using (var transaction = connection.BeginTransaction())
|
||||
{
|
||||
if (!isReturningZeroItems)
|
||||
{
|
||||
|
@ -5021,7 +5020,7 @@ AND Type = @InternalPersonType)");
|
|||
|
||||
foreach (var row in statement.ExecuteQuery())
|
||||
{
|
||||
var item = GetItem(row, query, hasProgramAttributes, hasEpisodeAttributes, hasServiceName, hasStartDate, hasTrailerTypes, hasArtistFields, hasSeriesFields);
|
||||
var item = GetItem(row, query, hasProgramAttributes, hasEpisodeAttributes, hasServiceName, hasStartDate, hasTrailerTypes, hasArtistFields, hasSeriesFields, false);
|
||||
if (item is not null)
|
||||
{
|
||||
var countStartColumn = columns.Count - 1;
|
||||
|
@ -5144,12 +5143,12 @@ AND Type = @InternalPersonType)");
|
|||
list.AddRange(inheritedTags.Select(i => (6, i)));
|
||||
|
||||
// Remove all invalid values.
|
||||
list.RemoveAll(i => string.IsNullOrEmpty(i.Item2));
|
||||
list.RemoveAll(i => string.IsNullOrWhiteSpace(i.Item2));
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
private void UpdateItemValues(Guid itemId, List<(int MagicNumber, string Value)> values, SqliteConnection db)
|
||||
private void UpdateItemValues(Guid itemId, List<(int MagicNumber, string Value)> values, ManagedConnection db)
|
||||
{
|
||||
if (itemId.IsEmpty())
|
||||
{
|
||||
|
@ -5168,7 +5167,7 @@ AND Type = @InternalPersonType)");
|
|||
InsertItemValues(itemId, values, db);
|
||||
}
|
||||
|
||||
private void InsertItemValues(Guid id, List<(int MagicNumber, string Value)> values, SqliteConnection db)
|
||||
private void InsertItemValues(Guid id, List<(int MagicNumber, string Value)> values, ManagedConnection db)
|
||||
{
|
||||
const int Limit = 100;
|
||||
var startIndex = 0;
|
||||
|
@ -5202,12 +5201,6 @@ AND Type = @InternalPersonType)");
|
|||
|
||||
var itemValue = currentValueInfo.Value;
|
||||
|
||||
// Don't save if invalid
|
||||
if (string.IsNullOrWhiteSpace(itemValue))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
statement.TryBind("@Type" + index, currentValueInfo.MagicNumber);
|
||||
statement.TryBind("@Value" + index, itemValue);
|
||||
statement.TryBind("@CleanValue" + index, GetCleanValue(itemValue));
|
||||
|
@ -5228,24 +5221,25 @@ AND Type = @InternalPersonType)");
|
|||
throw new ArgumentNullException(nameof(itemId));
|
||||
}
|
||||
|
||||
ArgumentNullException.ThrowIfNull(people);
|
||||
|
||||
CheckDisposed();
|
||||
|
||||
using var connection = GetConnection();
|
||||
using var transaction = connection.BeginTransaction();
|
||||
// First delete chapters
|
||||
// Delete all existing people first
|
||||
using var command = connection.CreateCommand();
|
||||
command.CommandText = "delete from People where ItemId=@ItemId";
|
||||
command.TryBind("@ItemId", itemId);
|
||||
command.ExecuteNonQuery();
|
||||
|
||||
InsertPeople(itemId, people, connection);
|
||||
if (people is not null)
|
||||
{
|
||||
InsertPeople(itemId, people, connection);
|
||||
}
|
||||
|
||||
transaction.Commit();
|
||||
}
|
||||
|
||||
private void InsertPeople(Guid id, List<PersonInfo> people, SqliteConnection db)
|
||||
private void InsertPeople(Guid id, List<PersonInfo> people, ManagedConnection db)
|
||||
{
|
||||
const int Limit = 100;
|
||||
var startIndex = 0;
|
||||
|
@ -5341,7 +5335,7 @@ AND Type = @InternalPersonType)");
|
|||
|
||||
cmdText += " order by StreamIndex ASC";
|
||||
|
||||
using (var connection = GetConnection())
|
||||
using (var connection = GetConnection(true))
|
||||
{
|
||||
var list = new List<MediaStream>();
|
||||
|
||||
|
@ -5394,7 +5388,7 @@ AND Type = @InternalPersonType)");
|
|||
transaction.Commit();
|
||||
}
|
||||
|
||||
private void InsertMediaStreams(Guid id, IReadOnlyList<MediaStream> streams, SqliteConnection db)
|
||||
private void InsertMediaStreams(Guid id, IReadOnlyList<MediaStream> streams, ManagedConnection db)
|
||||
{
|
||||
const int Limit = 10;
|
||||
var startIndex = 0;
|
||||
|
@ -5700,13 +5694,17 @@ AND Type = @InternalPersonType)");
|
|||
|
||||
item.IsHearingImpaired = reader.TryGetBoolean(43, out var result) && result;
|
||||
|
||||
if (item.Type == MediaStreamType.Subtitle)
|
||||
if (item.Type is MediaStreamType.Audio or MediaStreamType.Subtitle)
|
||||
{
|
||||
item.LocalizedUndefined = _localization.GetLocalizedString("Undefined");
|
||||
item.LocalizedDefault = _localization.GetLocalizedString("Default");
|
||||
item.LocalizedForced = _localization.GetLocalizedString("Forced");
|
||||
item.LocalizedExternal = _localization.GetLocalizedString("External");
|
||||
item.LocalizedHearingImpaired = _localization.GetLocalizedString("HearingImpaired");
|
||||
|
||||
if (item.Type is MediaStreamType.Subtitle)
|
||||
{
|
||||
item.LocalizedUndefined = _localization.GetLocalizedString("Undefined");
|
||||
item.LocalizedForced = _localization.GetLocalizedString("Forced");
|
||||
item.LocalizedHearingImpaired = _localization.GetLocalizedString("HearingImpaired");
|
||||
}
|
||||
}
|
||||
|
||||
return item;
|
||||
|
@ -5728,7 +5726,7 @@ AND Type = @InternalPersonType)");
|
|||
cmdText += " order by AttachmentIndex ASC";
|
||||
|
||||
var list = new List<MediaAttachment>();
|
||||
using (var connection = GetConnection())
|
||||
using (var connection = GetConnection(true))
|
||||
using (var statement = PrepareStatement(connection, cmdText))
|
||||
{
|
||||
statement.TryBind("@ItemId", query.ItemId);
|
||||
|
@ -5778,7 +5776,7 @@ AND Type = @InternalPersonType)");
|
|||
private void InsertMediaAttachments(
|
||||
Guid id,
|
||||
IReadOnlyList<MediaAttachment> attachments,
|
||||
SqliteConnection db,
|
||||
ManagedConnection db,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
const int InsertAtOnce = 10;
|
||||
|
|
|
@ -86,7 +86,7 @@ namespace Emby.Server.Implementations.Data
|
|||
}
|
||||
}
|
||||
|
||||
private void ImportUserIds(SqliteConnection db, IEnumerable<User> users)
|
||||
private void ImportUserIds(ManagedConnection db, IEnumerable<User> users)
|
||||
{
|
||||
var userIdsWithUserData = GetAllUserIdsWithUserData(db);
|
||||
|
||||
|
@ -107,7 +107,7 @@ namespace Emby.Server.Implementations.Data
|
|||
}
|
||||
}
|
||||
|
||||
private List<Guid> GetAllUserIdsWithUserData(SqliteConnection db)
|
||||
private List<Guid> GetAllUserIdsWithUserData(ManagedConnection db)
|
||||
{
|
||||
var list = new List<Guid>();
|
||||
|
||||
|
@ -176,7 +176,7 @@ namespace Emby.Server.Implementations.Data
|
|||
}
|
||||
}
|
||||
|
||||
private static void SaveUserData(SqliteConnection db, long internalUserId, string key, UserItemData userData)
|
||||
private static void SaveUserData(ManagedConnection db, long internalUserId, string key, UserItemData userData)
|
||||
{
|
||||
using (var statement = db.PrepareStatement("replace into UserDatas (key, userId, rating,played,playCount,isFavorite,playbackPositionTicks,lastPlayedDate,AudioStreamIndex,SubtitleStreamIndex) values (@key, @userId, @rating,@played,@playCount,@isFavorite,@playbackPositionTicks,@lastPlayedDate,@AudioStreamIndex,@SubtitleStreamIndex)"))
|
||||
{
|
||||
|
@ -267,7 +267,7 @@ namespace Emby.Server.Implementations.Data
|
|||
|
||||
ArgumentException.ThrowIfNullOrEmpty(key);
|
||||
|
||||
using (var connection = GetConnection())
|
||||
using (var connection = GetConnection(true))
|
||||
{
|
||||
using (var statement = connection.PrepareStatement("select key,userid,rating,played,playCount,isFavorite,playbackPositionTicks,lastPlayedDate,AudioStreamIndex,SubtitleStreamIndex from UserDatas where key =@Key and userId=@UserId"))
|
||||
{
|
||||
|
|
|
@ -80,12 +80,14 @@ namespace Emby.Server.Implementations.IO
|
|||
public virtual string MakeAbsolutePath(string folderPath, string filePath)
|
||||
{
|
||||
// path is actually a stream
|
||||
if (string.IsNullOrWhiteSpace(filePath) || filePath.Contains("://", StringComparison.Ordinal))
|
||||
if (string.IsNullOrWhiteSpace(filePath))
|
||||
{
|
||||
return filePath;
|
||||
}
|
||||
|
||||
if (filePath.Length > 3 && filePath[1] == ':' && filePath[2] == '/')
|
||||
var isAbsolutePath = Path.IsPathRooted(filePath) && (!OperatingSystem.IsWindows() || filePath[0] != '\\');
|
||||
|
||||
if (isAbsolutePath)
|
||||
{
|
||||
// absolute local path
|
||||
return filePath;
|
||||
|
@ -97,17 +99,10 @@ namespace Emby.Server.Implementations.IO
|
|||
return filePath;
|
||||
}
|
||||
|
||||
var firstChar = filePath[0];
|
||||
if (firstChar == '/')
|
||||
{
|
||||
// for this we don't really know
|
||||
return filePath;
|
||||
}
|
||||
|
||||
var filePathSpan = filePath.AsSpan();
|
||||
|
||||
// relative path
|
||||
if (firstChar == '\\')
|
||||
// relative path on windows
|
||||
if (filePath[0] == '\\')
|
||||
{
|
||||
filePathSpan = filePathSpan.Slice(1);
|
||||
}
|
||||
|
@ -394,7 +389,7 @@ namespace Emby.Server.Implementations.IO
|
|||
var info = new FileInfo(path);
|
||||
|
||||
if (info.Exists &&
|
||||
((info.Attributes & FileAttributes.Hidden) == FileAttributes.Hidden) != isHidden)
|
||||
(info.Attributes & FileAttributes.Hidden) == FileAttributes.Hidden != isHidden)
|
||||
{
|
||||
if (isHidden)
|
||||
{
|
||||
|
@ -422,8 +417,8 @@ namespace Emby.Server.Implementations.IO
|
|||
return;
|
||||
}
|
||||
|
||||
if (((info.Attributes & FileAttributes.ReadOnly) == FileAttributes.ReadOnly) == readOnly
|
||||
&& ((info.Attributes & FileAttributes.Hidden) == FileAttributes.Hidden) == isHidden)
|
||||
if ((info.Attributes & FileAttributes.ReadOnly) == FileAttributes.ReadOnly == readOnly
|
||||
&& (info.Attributes & FileAttributes.Hidden) == FileAttributes.Hidden == isHidden)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
|
|
@ -11,7 +11,6 @@ using MediaBrowser.Controller.Library;
|
|||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.IO;
|
||||
using MediaBrowser.Model.Querying;
|
||||
|
||||
namespace Emby.Server.Implementations.Images
|
||||
{
|
||||
|
@ -33,12 +32,12 @@ namespace Emby.Server.Implementations.Images
|
|||
Parent = item,
|
||||
Recursive = true,
|
||||
DtoOptions = new DtoOptions(true),
|
||||
ImageTypes = new ImageType[] { ImageType.Primary },
|
||||
OrderBy = new (ItemSortBy, SortOrder)[]
|
||||
{
|
||||
ImageTypes = [ImageType.Primary],
|
||||
OrderBy =
|
||||
[
|
||||
(ItemSortBy.IsFolder, SortOrder.Ascending),
|
||||
(ItemSortBy.SortName, SortOrder.Ascending)
|
||||
},
|
||||
],
|
||||
Limit = 1
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,7 +1,10 @@
|
|||
#pragma warning disable CS1591
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Controller.Drawing;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.Audio;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
|
@ -15,5 +18,13 @@ namespace Emby.Server.Implementations.Images
|
|||
: base(fileSystem, providerManager, applicationPaths, imageProcessor, libraryManager)
|
||||
{
|
||||
}
|
||||
|
||||
protected override IReadOnlyList<BaseItem> GetItemsWithImages(BaseItem item)
|
||||
{
|
||||
var items = base.GetItemsWithImages(item);
|
||||
|
||||
// Ignore any folders because they can have generated collages
|
||||
return items.Where(i => i is not Folder).ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1029,7 +1029,7 @@ namespace Emby.Server.Implementations.Library
|
|||
}
|
||||
}
|
||||
|
||||
private async Task ValidateTopLibraryFolders(CancellationToken cancellationToken, bool removeRoot = false)
|
||||
public async Task ValidateTopLibraryFolders(CancellationToken cancellationToken, bool removeRoot = false)
|
||||
{
|
||||
await RootFolder.RefreshMetadata(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
|
@ -1884,7 +1884,7 @@ namespace Emby.Server.Implementations.Library
|
|||
try
|
||||
{
|
||||
var index = item.GetImageIndex(img);
|
||||
image = await ConvertImageToLocal(item, img, index, removeOnFailure: true).ConfigureAwait(false);
|
||||
image = await ConvertImageToLocal(item, img, index, true).ConfigureAwait(false);
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
|
@ -2812,8 +2812,10 @@ namespace Emby.Server.Implementations.Library
|
|||
}
|
||||
|
||||
_itemRepository.UpdatePeople(item.Id, people);
|
||||
|
||||
await SavePeopleMetadataAsync(people, cancellationToken).ConfigureAwait(false);
|
||||
if (people is not null)
|
||||
{
|
||||
await SavePeopleMetadataAsync(people, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ItemImageInfo> ConvertImageToLocal(BaseItem item, ItemImageInfo image, int imageIndex, bool removeOnFailure)
|
||||
|
@ -3207,5 +3209,10 @@ namespace Emby.Server.Implementations.Library
|
|||
|
||||
return item is UserRootFolder || item.IsVisibleStandalone(user);
|
||||
}
|
||||
|
||||
public void LogMessage(string message)
|
||||
{
|
||||
_logger.LogInformation("(MJH) {Message}", message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Emby.Naming.Audio;
|
||||
using Emby.Naming.Common;
|
||||
using Jellyfin.Data.Enums;
|
||||
using MediaBrowser.Controller.Entities.Audio;
|
||||
|
@ -85,6 +86,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio
|
|||
}
|
||||
|
||||
var albumResolver = new MusicAlbumResolver(_logger, _namingOptions, _directoryService);
|
||||
var albumParser = new AlbumParser(_namingOptions);
|
||||
|
||||
var directories = args.FileSystemChildren.Where(i => i.IsDirectory);
|
||||
|
||||
|
@ -100,6 +102,12 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio
|
|||
}
|
||||
}
|
||||
|
||||
// If the folder is a multi-disc folder, then it is not an artist folder
|
||||
if (albumParser.IsMultiPart(fileSystemInfo.FullName))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// If we contain a music album assume we are an artist folder
|
||||
if (albumResolver.IsMusicAlbum(fileSystemInfo.FullName, _directoryService))
|
||||
{
|
||||
|
|
|
@ -54,7 +54,8 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
|
|||
{
|
||||
IndexNumber = seasonParserResult.SeasonNumber,
|
||||
SeriesId = series.Id,
|
||||
SeriesName = series.Name
|
||||
SeriesName = series.Name,
|
||||
Path = seasonParserResult.IsSeasonFolder ? path : null
|
||||
};
|
||||
|
||||
if (!season.IndexNumber.HasValue || !seasonParserResult.IsSeasonFolder)
|
||||
|
@ -78,27 +79,16 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
|
|||
}
|
||||
}
|
||||
|
||||
if (season.IndexNumber.HasValue)
|
||||
if (season.IndexNumber.HasValue && string.IsNullOrEmpty(season.Name))
|
||||
{
|
||||
var seasonNumber = season.IndexNumber.Value;
|
||||
if (string.IsNullOrEmpty(season.Name))
|
||||
{
|
||||
var seasonNames = series.SeasonNames;
|
||||
if (seasonNames.TryGetValue(seasonNumber, out var seasonName))
|
||||
{
|
||||
season.Name = seasonName;
|
||||
}
|
||||
else
|
||||
{
|
||||
season.Name = seasonNumber == 0 ?
|
||||
args.LibraryOptions.SeasonZeroDisplayName :
|
||||
string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
_localization.GetLocalizedString("NameSeasonNumber"),
|
||||
seasonNumber,
|
||||
args.LibraryOptions.PreferredMetadataLanguage);
|
||||
}
|
||||
}
|
||||
season.Name = seasonNumber == 0 ?
|
||||
args.LibraryOptions.SeasonZeroDisplayName :
|
||||
string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
_localization.GetLocalizedString("NameSeasonNumber"),
|
||||
seasonNumber,
|
||||
args.LibraryOptions.PreferredMetadataLanguage);
|
||||
}
|
||||
|
||||
return season;
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
"DeviceOfflineWithName": "{0} has disconnected",
|
||||
"DeviceOnlineWithName": "{0} is connected",
|
||||
"External": "External",
|
||||
"FailedLoginAttemptWithUserName": "Failed login try from {0}",
|
||||
"FailedLoginAttemptWithUserName": "Failed login attempt from {0}",
|
||||
"Favorites": "Favorites",
|
||||
"Folders": "Folders",
|
||||
"Forced": "Forced",
|
||||
|
|
|
@ -321,7 +321,11 @@ namespace Emby.Server.Implementations.Localization
|
|||
// Try splitting by : to handle "Germany: FSK-18"
|
||||
if (rating.Contains(':', StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return GetRatingLevel(rating.AsSpan().RightPart(':').ToString());
|
||||
var ratingLevelRightPart = rating.AsSpan().RightPart(':');
|
||||
if (ratingLevelRightPart.Length != 0)
|
||||
{
|
||||
return GetRatingLevel(ratingLevelRightPart.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
// Handle prefix country code to handle "DE-18"
|
||||
|
@ -332,8 +336,12 @@ namespace Emby.Server.Implementations.Localization
|
|||
// Extract culture from country prefix
|
||||
var culture = FindLanguageInfo(ratingSpan.LeftPart('-').ToString());
|
||||
|
||||
// Check rating system of culture
|
||||
return GetRatingLevel(ratingSpan.RightPart('-').ToString(), culture?.TwoLetterISOLanguageName);
|
||||
var ratingLevelRightPart = ratingSpan.RightPart('-');
|
||||
if (ratingLevelRightPart.Length != 0)
|
||||
{
|
||||
// Check rating system of culture
|
||||
return GetRatingLevel(ratingLevelRightPart.ToString(), culture?.TwoLetterISOLanguageName);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
Exempt,0
|
||||
G,0
|
||||
7+,7
|
||||
PG,15
|
||||
M,15
|
||||
MA,15
|
||||
MA15+,15
|
||||
MA 15+,15
|
||||
PG,16
|
||||
16+,16
|
||||
R,18
|
||||
R18+,18
|
||||
|
|
|
|
@ -170,8 +170,13 @@ namespace Emby.Server.Implementations.Playlists
|
|||
private List<Playlist> GetUserPlaylists(Guid userId)
|
||||
{
|
||||
var user = _userManager.GetUserById(userId);
|
||||
var playlistsFolder = GetPlaylistsFolder(userId);
|
||||
if (playlistsFolder is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return GetPlaylistsFolder(userId).GetChildren(user, true).OfType<Playlist>().ToList();
|
||||
return playlistsFolder.GetChildren(user, true).OfType<Playlist>().ToList();
|
||||
}
|
||||
|
||||
private static string GetTargetPath(string path)
|
||||
|
@ -184,11 +189,11 @@ namespace Emby.Server.Implementations.Playlists
|
|||
return path;
|
||||
}
|
||||
|
||||
private IReadOnlyList<BaseItem> GetPlaylistItems(IEnumerable<Guid> itemIds, MediaType playlistMediaType, User user, DtoOptions options)
|
||||
private IReadOnlyList<BaseItem> GetPlaylistItems(IEnumerable<Guid> itemIds, User user, DtoOptions options)
|
||||
{
|
||||
var items = itemIds.Select(_libraryManager.GetItemById).Where(i => i is not null);
|
||||
|
||||
return Playlist.GetPlaylistItems(playlistMediaType, items, user, options);
|
||||
return Playlist.GetPlaylistItems(items, user, options);
|
||||
}
|
||||
|
||||
public Task AddItemToPlaylistAsync(Guid playlistId, IReadOnlyCollection<Guid> itemIds, Guid userId)
|
||||
|
@ -208,7 +213,7 @@ namespace Emby.Server.Implementations.Playlists
|
|||
?? throw new ArgumentException("No Playlist exists with Id " + playlistId);
|
||||
|
||||
// Retrieve all the items to be added to the playlist
|
||||
var newItems = GetPlaylistItems(newItemIds, playlist.MediaType, user, options)
|
||||
var newItems = GetPlaylistItems(newItemIds, user, options)
|
||||
.Where(i => i.SupportsAddingToPlaylist);
|
||||
|
||||
// Filter out duplicate items, if necessary
|
||||
|
|
|
@ -8,6 +8,7 @@ using System.Text.RegularExpressions;
|
|||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.Audio;
|
||||
|
@ -69,7 +70,7 @@ public partial class AudioNormalizationTask : IScheduledTask
|
|||
/// <inheritdoc />
|
||||
public string Key => "AudioNormalization";
|
||||
|
||||
[GeneratedRegex(@"I:\s+(.*?)\s+LUFS")]
|
||||
[GeneratedRegex(@"^\s+I:\s+(.*?)\s+LUFS")]
|
||||
private static partial Regex LUFSRegex();
|
||||
|
||||
/// <inheritdoc />
|
||||
|
@ -105,13 +106,20 @@ public partial class AudioNormalizationTask : IScheduledTask
|
|||
continue;
|
||||
}
|
||||
|
||||
var tempFile = Path.Join(_configurationManager.GetTranscodePath(), Guid.NewGuid() + ".concat");
|
||||
_logger.LogInformation("Calculating LUFS for album: {Album} with id: {Id}", a.Name, a.Id);
|
||||
var tempFile = Path.Join(_configurationManager.GetTranscodePath(), a.Id + ".concat");
|
||||
var inputLines = albumTracks.Select(x => string.Format(CultureInfo.InvariantCulture, "file '{0}'", x.Path.Replace("'", @"'\''", StringComparison.Ordinal)));
|
||||
await File.WriteAllLinesAsync(tempFile, inputLines, cancellationToken).ConfigureAwait(false);
|
||||
a.LUFS = await CalculateLUFSAsync(
|
||||
string.Format(CultureInfo.InvariantCulture, "-f concat -safe 0 -i \"{0}\"", tempFile),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
File.Delete(tempFile);
|
||||
try
|
||||
{
|
||||
a.LUFS = await CalculateLUFSAsync(
|
||||
string.Format(CultureInfo.InvariantCulture, "-f concat -safe 0 -i \"{0}\"", tempFile),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(tempFile);
|
||||
}
|
||||
}
|
||||
|
||||
_itemRepository.SaveItems(albums, cancellationToken);
|
||||
|
@ -179,16 +187,17 @@ public partial class AudioNormalizationTask : IScheduledTask
|
|||
}
|
||||
|
||||
using var reader = process.StandardError;
|
||||
var output = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
MatchCollection split = LUFSRegex().Matches(output);
|
||||
|
||||
if (split.Count != 0)
|
||||
await foreach (var line in reader.ReadAllLinesAsync(cancellationToken))
|
||||
{
|
||||
return float.Parse(split[0].Groups[1].ValueSpan, CultureInfo.InvariantCulture.NumberFormat);
|
||||
Match match = LUFSRegex().Match(line);
|
||||
|
||||
if (match.Success)
|
||||
{
|
||||
return float.Parse(match.Groups[1].ValueSpan, CultureInfo.InvariantCulture.NumberFormat);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogError("Failed to find LUFS value in output:\n{Output}", output);
|
||||
_logger.LogError("Failed to find LUFS value in output");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -127,15 +127,8 @@ public class CleanupCollectionAndPlaylistPathsTask : IScheduledTask
|
|||
{
|
||||
_logger.LogDebug("Updating {FolderName}", folder.Name);
|
||||
folder.LinkedChildren = folder.LinkedChildren.Except(itemsToRemove).ToArray();
|
||||
_providerManager.SaveMetadataAsync(folder, ItemUpdateType.MetadataEdit);
|
||||
folder.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken);
|
||||
|
||||
_providerManager.QueueRefresh(
|
||||
folder.Id,
|
||||
new MetadataRefreshOptions(new DirectoryService(_fileSystem))
|
||||
{
|
||||
ForceSave = true
|
||||
},
|
||||
RefreshPriority.High);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -5,6 +5,7 @@ using System.Linq;
|
|||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Controller.IO;
|
||||
using MediaBrowser.Model.Globalization;
|
||||
using MediaBrowser.Model.IO;
|
||||
using MediaBrowser.Model.Tasks;
|
||||
|
@ -133,53 +134,14 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks
|
|||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
DeleteFile(file.FullName);
|
||||
FileSystemHelper.DeleteFile(_fileSystem, file.FullName, _logger);
|
||||
|
||||
index++;
|
||||
}
|
||||
|
||||
DeleteEmptyFolders(directory);
|
||||
FileSystemHelper.DeleteEmptyFolders(_fileSystem, directory, _logger);
|
||||
|
||||
progress.Report(100);
|
||||
}
|
||||
|
||||
private void DeleteEmptyFolders(string parent)
|
||||
{
|
||||
foreach (var directory in _fileSystem.GetDirectoryPaths(parent))
|
||||
{
|
||||
DeleteEmptyFolders(directory);
|
||||
if (!_fileSystem.GetFileSystemEntryPaths(directory).Any())
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(directory, false);
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error deleting directory {Path}", directory);
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error deleting directory {Path}", directory);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DeleteFile(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
_fileSystem.DeleteFile(path);
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error deleting file {Path}", path);
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error deleting file {Path}", path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Controller.IO;
|
||||
using MediaBrowser.Model.Globalization;
|
||||
using MediaBrowser.Model.IO;
|
||||
using MediaBrowser.Model.Tasks;
|
||||
|
@ -113,53 +113,14 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks
|
|||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
DeleteFile(file.FullName);
|
||||
FileSystemHelper.DeleteFile(_fileSystem, file.FullName, _logger);
|
||||
|
||||
index++;
|
||||
}
|
||||
|
||||
DeleteEmptyFolders(directory);
|
||||
FileSystemHelper.DeleteEmptyFolders(_fileSystem, directory, _logger);
|
||||
|
||||
progress.Report(100);
|
||||
}
|
||||
|
||||
private void DeleteEmptyFolders(string parent)
|
||||
{
|
||||
foreach (var directory in _fileSystem.GetDirectoryPaths(parent))
|
||||
{
|
||||
DeleteEmptyFolders(directory);
|
||||
if (!_fileSystem.GetFileSystemEntryPaths(directory).Any())
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(directory, false);
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error deleting directory {Path}", directory);
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error deleting directory {Path}", directory);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DeleteFile(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
_fileSystem.DeleteFile(path);
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error deleting file {Path}", path);
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error deleting file {Path}", path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -237,7 +237,7 @@ namespace Emby.Server.Implementations.Session
|
|||
ArgumentException.ThrowIfNullOrEmpty(deviceId);
|
||||
|
||||
var activityDate = DateTime.UtcNow;
|
||||
var session = await GetSessionInfo(appName, appVersion, deviceId, deviceName, remoteEndPoint, user).ConfigureAwait(false);
|
||||
var session = GetSessionInfo(appName, appVersion, deviceId, deviceName, remoteEndPoint, user);
|
||||
var lastActivityDate = session.LastActivityDate;
|
||||
session.LastActivityDate = activityDate;
|
||||
|
||||
|
@ -435,7 +435,7 @@ namespace Emby.Server.Implementations.Session
|
|||
/// <param name="remoteEndPoint">The remote end point.</param>
|
||||
/// <param name="user">The user.</param>
|
||||
/// <returns>SessionInfo.</returns>
|
||||
private async Task<SessionInfo> GetSessionInfo(
|
||||
private SessionInfo GetSessionInfo(
|
||||
string appName,
|
||||
string appVersion,
|
||||
string deviceId,
|
||||
|
@ -453,7 +453,7 @@ namespace Emby.Server.Implementations.Session
|
|||
|
||||
if (!_activeConnections.TryGetValue(key, out var sessionInfo))
|
||||
{
|
||||
sessionInfo = await CreateSession(key, appName, appVersion, deviceId, deviceName, remoteEndPoint, user).ConfigureAwait(false);
|
||||
sessionInfo = CreateSession(key, appName, appVersion, deviceId, deviceName, remoteEndPoint, user);
|
||||
_activeConnections[key] = sessionInfo;
|
||||
}
|
||||
|
||||
|
@ -478,7 +478,7 @@ namespace Emby.Server.Implementations.Session
|
|||
return sessionInfo;
|
||||
}
|
||||
|
||||
private async Task<SessionInfo> CreateSession(
|
||||
private SessionInfo CreateSession(
|
||||
string key,
|
||||
string appName,
|
||||
string appVersion,
|
||||
|
@ -508,7 +508,7 @@ namespace Emby.Server.Implementations.Session
|
|||
deviceName = "Network Device";
|
||||
}
|
||||
|
||||
var deviceOptions = await _deviceManager.GetDeviceOptions(deviceId).ConfigureAwait(false);
|
||||
var deviceOptions = _deviceManager.GetDeviceOptions(deviceId);
|
||||
if (string.IsNullOrEmpty(deviceOptions.CustomName))
|
||||
{
|
||||
sessionInfo.DeviceName = deviceName;
|
||||
|
@ -1202,7 +1202,8 @@ namespace Emby.Server.Implementations.Session
|
|||
new DtoOptions(false)
|
||||
{
|
||||
EnableImages = false
|
||||
})
|
||||
},
|
||||
user.DisplayMissingEpisodes)
|
||||
.Where(i => !i.IsVirtualItem)
|
||||
.SkipWhile(i => !i.Id.Equals(episode.Id))
|
||||
.ToList();
|
||||
|
@ -1296,7 +1297,7 @@ namespace Emby.Server.Implementations.Session
|
|||
return new[] { item };
|
||||
}
|
||||
|
||||
private IEnumerable<BaseItem> TranslateItemForInstantMix(Guid id, User user)
|
||||
private List<BaseItem> TranslateItemForInstantMix(Guid id, User user)
|
||||
{
|
||||
var item = _libraryManager.GetItemById(id);
|
||||
|
||||
|
@ -1306,7 +1307,7 @@ namespace Emby.Server.Implementations.Session
|
|||
return new List<BaseItem>();
|
||||
}
|
||||
|
||||
return _musicManager.GetInstantMixFromItem(item, user, new DtoOptions(false) { EnableImages = false });
|
||||
return _musicManager.GetInstantMixFromItem(item, user, new DtoOptions(false) { EnableImages = false }).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
@ -1519,12 +1520,12 @@ namespace Emby.Server.Implementations.Session
|
|||
// This should be validated above, but if it isn't don't delete all tokens.
|
||||
ArgumentException.ThrowIfNullOrEmpty(deviceId);
|
||||
|
||||
var existing = (await _deviceManager.GetDevices(
|
||||
var existing = _deviceManager.GetDevices(
|
||||
new DeviceQuery
|
||||
{
|
||||
DeviceId = deviceId,
|
||||
UserId = user.Id
|
||||
}).ConfigureAwait(false)).Items;
|
||||
}).Items;
|
||||
|
||||
foreach (var auth in existing)
|
||||
{
|
||||
|
@ -1552,12 +1553,12 @@ namespace Emby.Server.Implementations.Session
|
|||
|
||||
ArgumentException.ThrowIfNullOrEmpty(accessToken);
|
||||
|
||||
var existing = (await _deviceManager.GetDevices(
|
||||
var existing = _deviceManager.GetDevices(
|
||||
new DeviceQuery
|
||||
{
|
||||
Limit = 1,
|
||||
AccessToken = accessToken
|
||||
}).ConfigureAwait(false)).Items;
|
||||
}).Items;
|
||||
|
||||
if (existing.Count > 0)
|
||||
{
|
||||
|
@ -1596,10 +1597,10 @@ namespace Emby.Server.Implementations.Session
|
|||
{
|
||||
CheckDisposed();
|
||||
|
||||
var existing = await _deviceManager.GetDevices(new DeviceQuery
|
||||
var existing = _deviceManager.GetDevices(new DeviceQuery
|
||||
{
|
||||
UserId = userId
|
||||
}).ConfigureAwait(false);
|
||||
});
|
||||
|
||||
foreach (var info in existing.Items)
|
||||
{
|
||||
|
@ -1786,11 +1787,11 @@ namespace Emby.Server.Implementations.Session
|
|||
/// <inheritdoc />
|
||||
public async Task<SessionInfo> GetSessionByAuthenticationToken(string token, string deviceId, string remoteEndpoint)
|
||||
{
|
||||
var items = (await _deviceManager.GetDevices(new DeviceQuery
|
||||
var items = _deviceManager.GetDevices(new DeviceQuery
|
||||
{
|
||||
AccessToken = token,
|
||||
Limit = 1
|
||||
}).ConfigureAwait(false)).Items;
|
||||
}).Items;
|
||||
|
||||
if (items.Count == 0)
|
||||
{
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
using System.Threading.Tasks;
|
||||
using Jellyfin.Api.Constants;
|
||||
using Jellyfin.Api.Extensions;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
|
@ -24,20 +25,31 @@ namespace Jellyfin.Api.Auth.FirstTimeSetupPolicy
|
|||
/// <inheritdoc />
|
||||
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, FirstTimeSetupRequirement requirement)
|
||||
{
|
||||
// Succeed if the startup wizard / first time setup is not complete
|
||||
if (!_configurationManager.CommonConfiguration.IsStartupWizardCompleted)
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
}
|
||||
else if (requirement.RequireAdmin && !context.User.IsInRole(UserRoles.Administrator))
|
||||
|
||||
// Succeed if user is admin
|
||||
else if (context.User.IsInRole(UserRoles.Administrator))
|
||||
{
|
||||
context.Fail();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Any user-specific checks are handled in the DefaultAuthorizationHandler.
|
||||
context.Succeed(requirement);
|
||||
}
|
||||
|
||||
// Fail if admin is required and user is not admin
|
||||
else if (requirement.RequireAdmin)
|
||||
{
|
||||
context.Fail();
|
||||
}
|
||||
|
||||
// Succeed if admin is not required and user is not guest
|
||||
else if (context.User.IsInRole(UserRoles.User))
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
}
|
||||
|
||||
// Any user-specific checks are handled in the DefaultAuthorizationHandler.
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -47,10 +47,10 @@ public class DevicesController : BaseJellyfinApiController
|
|||
/// <returns>An <see cref="OkResult"/> containing the list of devices.</returns>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public async Task<ActionResult<QueryResult<DeviceInfo>>> GetDevices([FromQuery] Guid? userId)
|
||||
public ActionResult<QueryResult<DeviceInfo>> GetDevices([FromQuery] Guid? userId)
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
return await _deviceManager.GetDevicesForUser(userId).ConfigureAwait(false);
|
||||
return _deviceManager.GetDevicesForUser(userId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
@ -63,9 +63,9 @@ public class DevicesController : BaseJellyfinApiController
|
|||
[HttpGet("Info")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<DeviceInfo>> GetDeviceInfo([FromQuery, Required] string id)
|
||||
public ActionResult<DeviceInfo> GetDeviceInfo([FromQuery, Required] string id)
|
||||
{
|
||||
var deviceInfo = await _deviceManager.GetDevice(id).ConfigureAwait(false);
|
||||
var deviceInfo = _deviceManager.GetDevice(id);
|
||||
if (deviceInfo is null)
|
||||
{
|
||||
return NotFound();
|
||||
|
@ -84,9 +84,9 @@ public class DevicesController : BaseJellyfinApiController
|
|||
[HttpGet("Options")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<DeviceOptions>> GetDeviceOptions([FromQuery, Required] string id)
|
||||
public ActionResult<DeviceOptions> GetDeviceOptions([FromQuery, Required] string id)
|
||||
{
|
||||
var deviceInfo = await _deviceManager.GetDeviceOptions(id).ConfigureAwait(false);
|
||||
var deviceInfo = _deviceManager.GetDeviceOptions(id);
|
||||
if (deviceInfo is null)
|
||||
{
|
||||
return NotFound();
|
||||
|
@ -124,13 +124,13 @@ public class DevicesController : BaseJellyfinApiController
|
|||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult> DeleteDevice([FromQuery, Required] string id)
|
||||
{
|
||||
var existingDevice = await _deviceManager.GetDevice(id).ConfigureAwait(false);
|
||||
var existingDevice = _deviceManager.GetDevice(id);
|
||||
if (existingDevice is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var sessions = await _deviceManager.GetDevices(new DeviceQuery { DeviceId = id }).ConfigureAwait(false);
|
||||
var sessions = _deviceManager.GetDevices(new DeviceQuery { DeviceId = id });
|
||||
|
||||
foreach (var session in sessions.Items)
|
||||
{
|
||||
|
|
|
@ -2089,6 +2089,8 @@ public class ImageController : BaseJellyfinApiController
|
|||
Response.Headers.Append(HeaderNames.Age, Convert.ToInt64((DateTime.UtcNow - dateImageModified).TotalSeconds).ToString(CultureInfo.InvariantCulture));
|
||||
Response.Headers.Append(HeaderNames.Vary, HeaderNames.Accept);
|
||||
|
||||
Response.Headers.ContentDisposition = "attachment";
|
||||
|
||||
if (disableCaching)
|
||||
{
|
||||
Response.Headers.Append(HeaderNames.CacheControl, "no-cache, no-store, must-revalidate");
|
||||
|
|
|
@ -80,7 +80,8 @@ public class ItemRefreshController : BaseJellyfinApiController
|
|||
|| imageRefreshMode == MetadataRefreshMode.FullRefresh
|
||||
|| replaceAllImages
|
||||
|| replaceAllMetadata,
|
||||
IsAutomated = false
|
||||
IsAutomated = false,
|
||||
RemoveOldMetadata = replaceAllMetadata
|
||||
};
|
||||
|
||||
_providerManager.QueueRefresh(item.Id, refreshOptions, RefreshPriority.High);
|
||||
|
|
|
@ -290,17 +290,35 @@ public class ItemUpdateController : BaseJellyfinApiController
|
|||
{
|
||||
foreach (var season in rseries.Children.OfType<Season>())
|
||||
{
|
||||
season.OfficialRating = request.OfficialRating;
|
||||
if (!season.LockedFields.Contains(MetadataField.OfficialRating))
|
||||
{
|
||||
season.OfficialRating = request.OfficialRating;
|
||||
}
|
||||
|
||||
season.CustomRating = request.CustomRating;
|
||||
season.Tags = season.Tags.Concat(addedTags).Except(removedTags).Distinct().ToArray();
|
||||
|
||||
if (!season.LockedFields.Contains(MetadataField.Tags))
|
||||
{
|
||||
season.Tags = season.Tags.Concat(addedTags).Except(removedTags).Distinct().ToArray();
|
||||
}
|
||||
|
||||
season.OnMetadataChanged();
|
||||
await season.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
foreach (var ep in season.Children.OfType<Episode>())
|
||||
{
|
||||
ep.OfficialRating = request.OfficialRating;
|
||||
if (!ep.LockedFields.Contains(MetadataField.OfficialRating))
|
||||
{
|
||||
ep.OfficialRating = request.OfficialRating;
|
||||
}
|
||||
|
||||
ep.CustomRating = request.CustomRating;
|
||||
ep.Tags = ep.Tags.Concat(addedTags).Except(removedTags).Distinct().ToArray();
|
||||
|
||||
if (!ep.LockedFields.Contains(MetadataField.Tags))
|
||||
{
|
||||
ep.Tags = ep.Tags.Concat(addedTags).Except(removedTags).Distinct().ToArray();
|
||||
}
|
||||
|
||||
ep.OnMetadataChanged();
|
||||
await ep.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
@ -310,9 +328,18 @@ public class ItemUpdateController : BaseJellyfinApiController
|
|||
{
|
||||
foreach (var ep in season.Children.OfType<Episode>())
|
||||
{
|
||||
ep.OfficialRating = request.OfficialRating;
|
||||
if (!ep.LockedFields.Contains(MetadataField.OfficialRating))
|
||||
{
|
||||
ep.OfficialRating = request.OfficialRating;
|
||||
}
|
||||
|
||||
ep.CustomRating = request.CustomRating;
|
||||
ep.Tags = ep.Tags.Concat(addedTags).Except(removedTags).Distinct().ToArray();
|
||||
|
||||
if (!ep.LockedFields.Contains(MetadataField.Tags))
|
||||
{
|
||||
ep.Tags = ep.Tags.Concat(addedTags).Except(removedTags).Distinct().ToArray();
|
||||
}
|
||||
|
||||
ep.OnMetadataChanged();
|
||||
await ep.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
@ -321,9 +348,18 @@ public class ItemUpdateController : BaseJellyfinApiController
|
|||
{
|
||||
foreach (BaseItem track in album.Children)
|
||||
{
|
||||
track.OfficialRating = request.OfficialRating;
|
||||
if (!track.LockedFields.Contains(MetadataField.OfficialRating))
|
||||
{
|
||||
track.OfficialRating = request.OfficialRating;
|
||||
}
|
||||
|
||||
track.CustomRating = request.CustomRating;
|
||||
track.Tags = track.Tags.Concat(addedTags).Except(removedTags).Distinct().ToArray();
|
||||
|
||||
if (!track.LockedFields.Contains(MetadataField.Tags))
|
||||
{
|
||||
track.Tags = track.Tags.Concat(addedTags).Except(removedTags).Distinct().ToArray();
|
||||
}
|
||||
|
||||
track.OnMetadataChanged();
|
||||
await track.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
|
|
@ -180,7 +180,21 @@ public class LibraryStructureController : BaseJellyfinApiController
|
|||
// No need to start if scanning the library because it will handle it
|
||||
if (refreshLibrary)
|
||||
{
|
||||
await _libraryManager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None).ConfigureAwait(false);
|
||||
await _libraryManager.ValidateTopLibraryFolders(CancellationToken.None, true).ConfigureAwait(false);
|
||||
var newLib = _libraryManager.GetUserRootFolder().Children.FirstOrDefault(f => f.Path.Equals(newPath, StringComparison.OrdinalIgnoreCase));
|
||||
if (newLib is CollectionFolder folder)
|
||||
{
|
||||
foreach (var child in folder.GetPhysicalFolders())
|
||||
{
|
||||
await child.RefreshMetadata(CancellationToken.None).ConfigureAwait(false);
|
||||
await child.ValidateChildren(new Progress<double>(), CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// We don't know if this one can be validated individually, trigger a new validation
|
||||
await _libraryManager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
|
@ -319,7 +333,7 @@ public class LibraryStructureController : BaseJellyfinApiController
|
|||
public ActionResult UpdateLibraryOptions(
|
||||
[FromBody] UpdateLibraryOptionsDto request)
|
||||
{
|
||||
var item = _libraryManager.GetItemById<CollectionFolder>(request.Id, User.GetUserId());
|
||||
var item = _libraryManager.GetItemById<CollectionFolder>(request.Id);
|
||||
if (item is null)
|
||||
{
|
||||
return NotFound();
|
||||
|
|
|
@ -233,6 +233,8 @@ public class PluginsController : BaseJellyfinApiController
|
|||
return NotFound();
|
||||
}
|
||||
|
||||
Response.Headers.ContentDisposition = "attachment";
|
||||
|
||||
imagePath = Path.Combine(plugin.Path, plugin.Manifest.ImagePath);
|
||||
return PhysicalFile(imagePath, MimeTypes.GetMimeType(imagePath));
|
||||
}
|
||||
|
|
|
@ -95,6 +95,7 @@ public class TrickplayController : BaseJellyfinApiController
|
|||
var path = _trickplayManager.GetTrickplayTilePath(item, width, index);
|
||||
if (System.IO.File.Exists(path))
|
||||
{
|
||||
Response.Headers.ContentDisposition = "attachment";
|
||||
return PhysicalFile(path, MediaTypeNames.Image.Jpeg);
|
||||
}
|
||||
|
||||
|
|
|
@ -231,6 +231,7 @@ public class TvShowsController : BaseJellyfinApiController
|
|||
var dtoOptions = new DtoOptions { Fields = fields }
|
||||
.AddClientFields(User)
|
||||
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
|
||||
var shouldIncludeMissingEpisodes = (user is not null && user.DisplayMissingEpisodes) || User.GetIsApiKey();
|
||||
|
||||
if (seasonId.HasValue) // Season id was supplied. Get episodes by season id.
|
||||
{
|
||||
|
@ -240,7 +241,7 @@ public class TvShowsController : BaseJellyfinApiController
|
|||
return NotFound("No season exists with Id " + seasonId);
|
||||
}
|
||||
|
||||
episodes = seasonItem.GetEpisodes(user, dtoOptions);
|
||||
episodes = seasonItem.GetEpisodes(user, dtoOptions, shouldIncludeMissingEpisodes);
|
||||
}
|
||||
else if (season.HasValue) // Season number was supplied. Get episodes by season number
|
||||
{
|
||||
|
@ -256,7 +257,7 @@ public class TvShowsController : BaseJellyfinApiController
|
|||
|
||||
episodes = seasonItem is null ?
|
||||
new List<BaseItem>()
|
||||
: ((Season)seasonItem).GetEpisodes(user, dtoOptions);
|
||||
: ((Season)seasonItem).GetEpisodes(user, dtoOptions, shouldIncludeMissingEpisodes);
|
||||
}
|
||||
else // No season number or season id was supplied. Returning all episodes.
|
||||
{
|
||||
|
@ -265,7 +266,7 @@ public class TvShowsController : BaseJellyfinApiController
|
|||
return NotFound("Series not found");
|
||||
}
|
||||
|
||||
episodes = series.GetEpisodes(user, dtoOptions).ToList();
|
||||
episodes = series.GetEpisodes(user, dtoOptions, shouldIncludeMissingEpisodes).ToList();
|
||||
}
|
||||
|
||||
// Filter after the fact in case the ui doesn't want them
|
||||
|
|
|
@ -2,6 +2,7 @@ using System;
|
|||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
|
@ -173,6 +174,19 @@ public class VideosController : BaseJellyfinApiController
|
|||
return NoContent();
|
||||
}
|
||||
|
||||
private bool IsSubPath(string basePath, string testPath)
|
||||
{
|
||||
string normalizedBasePath = Path.GetFullPath(basePath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
string normalizedTestPath = Path.GetFullPath(testPath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
|
||||
var isSubPath = normalizedTestPath.StartsWith(normalizedBasePath, StringComparison.OrdinalIgnoreCase)
|
||||
&& normalizedTestPath.Length > normalizedBasePath.Length
|
||||
&& (normalizedTestPath[normalizedBasePath.Length] == Path.DirectorySeparatorChar
|
||||
|| normalizedTestPath[normalizedBasePath.Length] == Path.AltDirectorySeparatorChar);
|
||||
|
||||
return isSubPath;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges videos into a single record.
|
||||
/// </summary>
|
||||
|
@ -198,11 +212,21 @@ public class VideosController : BaseJellyfinApiController
|
|||
return BadRequest("Please supply at least two videos to merge.");
|
||||
}
|
||||
|
||||
var primaryVersion = items.FirstOrDefault(i => i.MediaSourceCount > 1 && string.IsNullOrEmpty(i.PrimaryVersionId));
|
||||
var primaryVersion = items.FirstOrDefault(i =>
|
||||
{
|
||||
if (i.GetMediaSourceCount((message) => _serverConfigurationManager.LogMessage(message)) > 1 && string.IsNullOrEmpty(i.PrimaryVersionId))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
if (primaryVersion is null)
|
||||
{
|
||||
primaryVersion = items
|
||||
.OrderBy(i =>
|
||||
.OrderBy(i => _serverConfigurationManager.FetchFolderPriority(i.GetContainingFolderPath()))
|
||||
.ThenBy(i =>
|
||||
{
|
||||
if (i.Video3DFormat.HasValue || i.VideoType != VideoType.VideoFile)
|
||||
{
|
||||
|
|
|
@ -120,7 +120,12 @@ public static class RequestHelpers
|
|||
internal static async Task<SessionInfo> GetSession(ISessionManager sessionManager, IUserManager userManager, HttpContext httpContext, Guid? userId = null)
|
||||
{
|
||||
userId ??= httpContext.User.GetUserId();
|
||||
var user = userManager.GetUserById(userId.Value);
|
||||
User? user = null;
|
||||
if (!userId.IsNullOrEmpty())
|
||||
{
|
||||
user = userManager.GetUserById(userId.Value);
|
||||
}
|
||||
|
||||
var session = await sessionManager.LogSessionActivity(
|
||||
httpContext.User.GetClient(),
|
||||
httpContext.User.GetVersion(),
|
||||
|
|
|
@ -142,6 +142,25 @@ public static class StreamingHelpers
|
|||
}
|
||||
else
|
||||
{
|
||||
// Enforce more restrictive transcoding profile for LiveTV due to compatability reasons
|
||||
// Cap the MaxStreamingBitrate to 30Mbps, because we are unable to reliably probe source bitrate,
|
||||
// which will cause the client to request extremely high bitrate that may fail the player/encoder
|
||||
streamingRequest.VideoBitRate = streamingRequest.VideoBitRate > 30000000 ? 30000000 : streamingRequest.VideoBitRate;
|
||||
|
||||
if (streamingRequest.SegmentContainer is not null)
|
||||
{
|
||||
// Remove all fmp4 transcoding profiles, because it causes playback error and/or A/V sync issues
|
||||
// Notably: Some channels won't play on FireFox and LG webOS
|
||||
// Some channels from HDHomerun will experience A/V sync issues
|
||||
streamingRequest.SegmentContainer = "ts";
|
||||
streamingRequest.VideoCodec = "h264";
|
||||
streamingRequest.AudioCodec = "aac";
|
||||
state.SupportedVideoCodecs = ["h264"];
|
||||
state.Request.VideoCodec = "h264";
|
||||
state.SupportedAudioCodecs = ["aac"];
|
||||
state.Request.AudioCodec = "aac";
|
||||
}
|
||||
|
||||
var liveStreamInfo = await mediaSourceManager.GetLiveStreamWithDirectStreamProvider(streamingRequest.LiveStreamId, cancellationToken).ConfigureAwait(false);
|
||||
mediaSource = liveStreamInfo.Item1;
|
||||
state.DirectStreamProvider = liveStreamInfo.Item2;
|
||||
|
|
|
@ -18,7 +18,7 @@
|
|||
<PropertyGroup>
|
||||
<Authors>Jellyfin Contributors</Authors>
|
||||
<PackageId>Jellyfin.Data</PackageId>
|
||||
<VersionPrefix>10.9.0</VersionPrefix>
|
||||
<VersionPrefix>10.9.11</VersionPrefix>
|
||||
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
||||
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
||||
</PropertyGroup>
|
||||
|
|
|
@ -27,6 +27,8 @@ namespace Jellyfin.Server.Implementations.Devices
|
|||
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly ConcurrentDictionary<string, ClientCapabilities> _capabilitiesMap = new();
|
||||
private readonly ConcurrentDictionary<int, Device> _devices;
|
||||
private readonly ConcurrentDictionary<string, DeviceOptions> _deviceOptions;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DeviceManager"/> class.
|
||||
|
@ -37,6 +39,23 @@ namespace Jellyfin.Server.Implementations.Devices
|
|||
{
|
||||
_dbProvider = dbProvider;
|
||||
_userManager = userManager;
|
||||
_devices = new ConcurrentDictionary<int, Device>();
|
||||
_deviceOptions = new ConcurrentDictionary<string, DeviceOptions>();
|
||||
|
||||
using var dbContext = _dbProvider.CreateDbContext();
|
||||
foreach (var device in dbContext.Devices
|
||||
.OrderBy(d => d.Id)
|
||||
.AsEnumerable())
|
||||
{
|
||||
_devices.TryAdd(device.Id, device);
|
||||
}
|
||||
|
||||
foreach (var deviceOption in dbContext.DeviceOptions
|
||||
.OrderBy(d => d.Id)
|
||||
.AsEnumerable())
|
||||
{
|
||||
_deviceOptions.TryAdd(deviceOption.DeviceId, deviceOption);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
@ -66,6 +85,8 @@ namespace Jellyfin.Server.Implementations.Devices
|
|||
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_deviceOptions[deviceId] = deviceOptions;
|
||||
|
||||
DeviceOptionsUpdated?.Invoke(this, new GenericEventArgs<Tuple<string, DeviceOptions>>(new Tuple<string, DeviceOptions>(deviceId, deviceOptions)));
|
||||
}
|
||||
|
||||
|
@ -76,25 +97,17 @@ namespace Jellyfin.Server.Implementations.Devices
|
|||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
dbContext.Devices.Add(device);
|
||||
|
||||
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
||||
_devices.TryAdd(device.Id, device);
|
||||
}
|
||||
|
||||
return device;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DeviceOptions> GetDeviceOptions(string deviceId)
|
||||
public DeviceOptions GetDeviceOptions(string deviceId)
|
||||
{
|
||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||
DeviceOptions? deviceOptions;
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
deviceOptions = await dbContext.DeviceOptions
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(d => d.DeviceId == deviceId)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
_deviceOptions.TryGetValue(deviceId, out var deviceOptions);
|
||||
|
||||
return deviceOptions ?? new DeviceOptions(deviceId);
|
||||
}
|
||||
|
@ -108,57 +121,43 @@ namespace Jellyfin.Server.Implementations.Devices
|
|||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DeviceInfo?> GetDevice(string id)
|
||||
public DeviceInfo? GetDevice(string id)
|
||||
{
|
||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
var device = await dbContext.Devices
|
||||
.Where(d => d.DeviceId == id)
|
||||
.OrderByDescending(d => d.DateLastActivity)
|
||||
.Include(d => d.User)
|
||||
.SelectMany(d => dbContext.DeviceOptions.Where(o => o.DeviceId == d.DeviceId).DefaultIfEmpty(), (d, o) => new { Device = d, Options = o })
|
||||
.FirstOrDefaultAsync()
|
||||
.ConfigureAwait(false);
|
||||
var device = _devices.Values.Where(d => d.DeviceId == id).OrderByDescending(d => d.DateLastActivity).FirstOrDefault();
|
||||
_deviceOptions.TryGetValue(id, out var deviceOption);
|
||||
|
||||
var deviceInfo = device is null ? null : ToDeviceInfo(device.Device, device.Options);
|
||||
|
||||
return deviceInfo;
|
||||
}
|
||||
var deviceInfo = device is null ? null : ToDeviceInfo(device, deviceOption);
|
||||
return deviceInfo;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<QueryResult<Device>> GetDevices(DeviceQuery query)
|
||||
public QueryResult<Device> GetDevices(DeviceQuery query)
|
||||
{
|
||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
IEnumerable<Device> devices = _devices.Values
|
||||
.Where(device => !query.UserId.HasValue || device.UserId.Equals(query.UserId.Value))
|
||||
.Where(device => query.DeviceId == null || device.DeviceId == query.DeviceId)
|
||||
.Where(device => query.AccessToken == null || device.AccessToken == query.AccessToken)
|
||||
.OrderBy(d => d.Id)
|
||||
.ToList();
|
||||
var count = devices.Count();
|
||||
|
||||
if (query.Skip.HasValue)
|
||||
{
|
||||
var devices = dbContext.Devices
|
||||
.OrderBy(d => d.Id)
|
||||
.Where(device => !query.UserId.HasValue || device.UserId.Equals(query.UserId.Value))
|
||||
.Where(device => query.DeviceId == null || device.DeviceId == query.DeviceId)
|
||||
.Where(device => query.AccessToken == null || device.AccessToken == query.AccessToken);
|
||||
|
||||
var count = await devices.CountAsync().ConfigureAwait(false);
|
||||
|
||||
if (query.Skip.HasValue)
|
||||
{
|
||||
devices = devices.Skip(query.Skip.Value);
|
||||
}
|
||||
|
||||
if (query.Limit.HasValue)
|
||||
{
|
||||
devices = devices.Take(query.Limit.Value);
|
||||
}
|
||||
|
||||
return new QueryResult<Device>(query.Skip, count, await devices.ToListAsync().ConfigureAwait(false));
|
||||
devices = devices.Skip(query.Skip.Value);
|
||||
}
|
||||
|
||||
if (query.Limit.HasValue)
|
||||
{
|
||||
devices = devices.Take(query.Limit.Value);
|
||||
}
|
||||
|
||||
return new QueryResult<Device>(query.Skip, count, devices.ToList());
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<QueryResult<DeviceInfo>> GetDeviceInfos(DeviceQuery query)
|
||||
public QueryResult<DeviceInfo> GetDeviceInfos(DeviceQuery query)
|
||||
{
|
||||
var devices = await GetDevices(query).ConfigureAwait(false);
|
||||
var devices = GetDevices(query);
|
||||
|
||||
return new QueryResult<DeviceInfo>(
|
||||
devices.StartIndex,
|
||||
|
@ -167,38 +166,36 @@ namespace Jellyfin.Server.Implementations.Devices
|
|||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<QueryResult<DeviceInfo>> GetDevicesForUser(Guid? userId)
|
||||
public QueryResult<DeviceInfo> GetDevicesForUser(Guid? userId)
|
||||
{
|
||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
IEnumerable<Device> devices = _devices.Values
|
||||
.OrderByDescending(d => d.DateLastActivity)
|
||||
.ThenBy(d => d.DeviceId);
|
||||
|
||||
if (!userId.IsNullOrEmpty())
|
||||
{
|
||||
var sessions = dbContext.Devices
|
||||
.Include(d => d.User)
|
||||
.OrderByDescending(d => d.DateLastActivity)
|
||||
.ThenBy(d => d.DeviceId)
|
||||
.SelectMany(d => dbContext.DeviceOptions.Where(o => o.DeviceId == d.DeviceId).DefaultIfEmpty(), (d, o) => new { Device = d, Options = o })
|
||||
.AsAsyncEnumerable();
|
||||
|
||||
if (userId.HasValue)
|
||||
var user = _userManager.GetUserById(userId.Value);
|
||||
if (user is null)
|
||||
{
|
||||
var user = _userManager.GetUserById(userId.Value);
|
||||
if (user is null)
|
||||
{
|
||||
throw new ResourceNotFoundException();
|
||||
}
|
||||
|
||||
sessions = sessions.Where(i => CanAccessDevice(user, i.Device.DeviceId));
|
||||
throw new ResourceNotFoundException();
|
||||
}
|
||||
|
||||
var array = await sessions.Select(device => ToDeviceInfo(device.Device, device.Options)).ToArrayAsync().ConfigureAwait(false);
|
||||
|
||||
return new QueryResult<DeviceInfo>(array);
|
||||
devices = devices.Where(i => CanAccessDevice(user, i.DeviceId));
|
||||
}
|
||||
|
||||
var array = devices.Select(device =>
|
||||
{
|
||||
_deviceOptions.TryGetValue(device.DeviceId, out var option);
|
||||
return ToDeviceInfo(device, option);
|
||||
}).ToArray();
|
||||
|
||||
return new QueryResult<DeviceInfo>(array);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteDevice(Device device)
|
||||
{
|
||||
_devices.TryRemove(device.Id, out _);
|
||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
|
@ -207,6 +204,19 @@ namespace Jellyfin.Server.Implementations.Devices
|
|||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task UpdateDevice(Device device)
|
||||
{
|
||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
dbContext.Devices.Update(device);
|
||||
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_devices[device.Id] = device;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanAccessDevice(User user, string deviceId)
|
||||
{
|
||||
|
@ -225,6 +235,11 @@ namespace Jellyfin.Server.Implementations.Devices
|
|||
private DeviceInfo ToDeviceInfo(Device authInfo, DeviceOptions? options = null)
|
||||
{
|
||||
var caps = GetCapabilities(authInfo.DeviceId);
|
||||
var user = _userManager.GetUserById(authInfo.UserId);
|
||||
if (user is null)
|
||||
{
|
||||
throw new ResourceNotFoundException("User with UserId " + authInfo.UserId + " not found");
|
||||
}
|
||||
|
||||
return new DeviceInfo
|
||||
{
|
||||
|
@ -232,7 +247,7 @@ namespace Jellyfin.Server.Implementations.Devices
|
|||
AppVersion = authInfo.AppVersion,
|
||||
Id = authInfo.DeviceId,
|
||||
LastUserId = authInfo.UserId,
|
||||
LastUserName = authInfo.User.Username,
|
||||
LastUserName = user.Username,
|
||||
Name = authInfo.DeviceName,
|
||||
DateLastActivity = authInfo.DateLastActivity,
|
||||
IconUrl = caps.IconUrl,
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using EFCoreSecondLevelCacheInterceptor;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
@ -19,18 +18,10 @@ public static class ServiceCollectionExtensions
|
|||
/// <returns>The updated service collection.</returns>
|
||||
public static IServiceCollection AddJellyfinDbContext(this IServiceCollection serviceCollection)
|
||||
{
|
||||
serviceCollection.AddEFSecondLevelCache(options =>
|
||||
options.UseMemoryCacheProvider()
|
||||
.CacheAllQueries(CacheExpirationMode.Sliding, TimeSpan.FromMinutes(10))
|
||||
.UseCacheKeyPrefix("EF_")
|
||||
// Don't cache null values. Remove this optional setting if it's not necessary.
|
||||
.SkipCachingResults(result => result.Value is null or EFTableRows { RowsCount: 0 }));
|
||||
|
||||
serviceCollection.AddPooledDbContextFactory<JellyfinDbContext>((serviceProvider, opt) =>
|
||||
{
|
||||
var applicationPaths = serviceProvider.GetRequiredService<IApplicationPaths>();
|
||||
opt.UseSqlite($"Filename={Path.Combine(applicationPaths.DataPath, "jellyfin.db")}")
|
||||
.AddInterceptors(serviceProvider.GetRequiredService<SecondLevelCacheInterceptor>());
|
||||
opt.UseSqlite($"Filename={Path.Combine(applicationPaths.DataPath, "jellyfin.db")}");
|
||||
});
|
||||
|
||||
return serviceCollection;
|
||||
|
|
|
@ -27,7 +27,6 @@
|
|||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AsyncKeyedLock" />
|
||||
<PackageReference Include="EFCoreSecondLevelCacheInterceptor" />
|
||||
<PackageReference Include="System.Linq.Async" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
|
||||
|
|
|
@ -4,7 +4,10 @@ using System;
|
|||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data.Queries;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Controller.Devices;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Net;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
@ -17,15 +20,18 @@ namespace Jellyfin.Server.Implementations.Security
|
|||
{
|
||||
private readonly IDbContextFactory<JellyfinDbContext> _jellyfinDbProvider;
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly IDeviceManager _deviceManager;
|
||||
private readonly IServerApplicationHost _serverApplicationHost;
|
||||
|
||||
public AuthorizationContext(
|
||||
IDbContextFactory<JellyfinDbContext> jellyfinDb,
|
||||
IUserManager userManager,
|
||||
IDeviceManager deviceManager,
|
||||
IServerApplicationHost serverApplicationHost)
|
||||
{
|
||||
_jellyfinDbProvider = jellyfinDb;
|
||||
_userManager = userManager;
|
||||
_deviceManager = deviceManager;
|
||||
_serverApplicationHost = serverApplicationHost;
|
||||
}
|
||||
|
||||
|
@ -121,7 +127,11 @@ namespace Jellyfin.Server.Implementations.Security
|
|||
var dbContext = await _jellyfinDbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
var device = await dbContext.Devices.FirstOrDefaultAsync(d => d.AccessToken == token).ConfigureAwait(false);
|
||||
var device = _deviceManager.GetDevices(
|
||||
new DeviceQuery
|
||||
{
|
||||
AccessToken = token
|
||||
}).Items.FirstOrDefault();
|
||||
|
||||
if (device is not null)
|
||||
{
|
||||
|
@ -178,8 +188,7 @@ namespace Jellyfin.Server.Implementations.Security
|
|||
|
||||
if (updateToken)
|
||||
{
|
||||
dbContext.Devices.Update(device);
|
||||
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
||||
await _deviceManager.UpdateDevice(device).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
else
|
||||
|
|
|
@ -81,6 +81,12 @@ public class TrickplayManager : ITrickplayManager
|
|||
_logger.LogDebug("Trickplay refresh for {ItemId} (replace existing: {Replace})", video.Id, replace);
|
||||
|
||||
var options = _config.Configuration.TrickplayOptions;
|
||||
if (options.Interval < 1000)
|
||||
{
|
||||
_logger.LogWarning("Trickplay image interval {Interval} is too small, reset to the minimum valid value of 1000", options.Interval);
|
||||
options.Interval = 1000;
|
||||
}
|
||||
|
||||
foreach (var width in options.WidthResolutions)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
@ -121,6 +127,13 @@ public class TrickplayManager : ITrickplayManager
|
|||
return;
|
||||
}
|
||||
|
||||
var mediaPath = mediaSource.Path;
|
||||
if (!File.Exists(mediaPath))
|
||||
{
|
||||
_logger.LogWarning("Media source {MediaSourceId} not found at {Path} for item {ItemID}", mediaSource.Id, mediaPath, video.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
// The width has to be even, otherwise a lot of filters will not be able to sample it
|
||||
var actualWidth = 2 * (width / 2);
|
||||
|
||||
|
@ -139,7 +152,6 @@ public class TrickplayManager : ITrickplayManager
|
|||
return;
|
||||
}
|
||||
|
||||
var mediaPath = mediaSource.Path;
|
||||
var mediaStream = mediaSource.VideoStream;
|
||||
var container = mediaSource.Container;
|
||||
|
||||
|
@ -261,7 +273,7 @@ public class TrickplayManager : ITrickplayManager
|
|||
}
|
||||
|
||||
// Update bitrate
|
||||
var bitrate = (int)Math.Ceiling((decimal)new FileInfo(tilePath).Length * 8 / trickplayInfo.TileWidth / trickplayInfo.TileHeight / (trickplayInfo.Interval / 1000));
|
||||
var bitrate = (int)Math.Ceiling(new FileInfo(tilePath).Length * 8m / trickplayInfo.TileWidth / trickplayInfo.TileHeight / (trickplayInfo.Interval / 1000m));
|
||||
trickplayInfo.Bandwidth = Math.Max(trickplayInfo.Bandwidth, bitrate);
|
||||
}
|
||||
|
||||
|
|
|
@ -60,10 +60,10 @@ public sealed class DeviceAccessHost : IHostedService
|
|||
|
||||
private async Task UpdateDeviceAccess(User user)
|
||||
{
|
||||
var existing = (await _deviceManager.GetDevices(new DeviceQuery
|
||||
var existing = _deviceManager.GetDevices(new DeviceQuery
|
||||
{
|
||||
UserId = user.Id
|
||||
}).ConfigureAwait(false)).Items;
|
||||
}).Items;
|
||||
|
||||
foreach (var device in existing)
|
||||
{
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
#pragma warning disable CA1307
|
||||
#pragma warning disable CA1309 // Use ordinal string comparison - EF can't translate this
|
||||
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
|
@ -47,6 +47,8 @@ namespace Jellyfin.Server.Implementations.Users
|
|||
private readonly DefaultPasswordResetProvider _defaultPasswordResetProvider;
|
||||
private readonly IServerConfigurationManager _serverConfigurationManager;
|
||||
|
||||
private readonly IDictionary<Guid, User> _users;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="UserManager"/> class.
|
||||
/// </summary>
|
||||
|
@ -84,30 +86,29 @@ namespace Jellyfin.Server.Implementations.Users
|
|||
_invalidAuthProvider = _authenticationProviders.OfType<InvalidAuthProvider>().First();
|
||||
_defaultAuthenticationProvider = _authenticationProviders.OfType<DefaultAuthenticationProvider>().First();
|
||||
_defaultPasswordResetProvider = _passwordResetProviders.OfType<DefaultPasswordResetProvider>().First();
|
||||
|
||||
_users = new ConcurrentDictionary<Guid, User>();
|
||||
using var dbContext = _dbProvider.CreateDbContext();
|
||||
foreach (var user in dbContext.Users
|
||||
.AsSplitQuery()
|
||||
.Include(user => user.Permissions)
|
||||
.Include(user => user.Preferences)
|
||||
.Include(user => user.AccessSchedules)
|
||||
.Include(user => user.ProfileImage)
|
||||
.AsEnumerable())
|
||||
{
|
||||
_users.Add(user.Id, user);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public event EventHandler<GenericEventArgs<User>>? OnUserUpdated;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IEnumerable<User> Users
|
||||
{
|
||||
get
|
||||
{
|
||||
using var dbContext = _dbProvider.CreateDbContext();
|
||||
return GetUsersInternal(dbContext).ToList();
|
||||
}
|
||||
}
|
||||
public IEnumerable<User> Users => _users.Values;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IEnumerable<Guid> UsersIds
|
||||
{
|
||||
get
|
||||
{
|
||||
using var dbContext = _dbProvider.CreateDbContext();
|
||||
return dbContext.Users.Select(u => u.Id).ToList();
|
||||
}
|
||||
}
|
||||
public IEnumerable<Guid> UsersIds => _users.Keys;
|
||||
|
||||
// This is some regex that matches only on unicode "word" characters, as well as -, _ and @
|
||||
// In theory this will cut out most if not all 'control' characters which should help minimize any weirdness
|
||||
|
@ -123,8 +124,8 @@ namespace Jellyfin.Server.Implementations.Users
|
|||
throw new ArgumentException("Guid can't be empty", nameof(id));
|
||||
}
|
||||
|
||||
using var dbContext = _dbProvider.CreateDbContext();
|
||||
return GetUsersInternal(dbContext).FirstOrDefault(u => u.Id.Equals(id));
|
||||
_users.TryGetValue(id, out var user);
|
||||
return user;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
|
@ -135,9 +136,7 @@ namespace Jellyfin.Server.Implementations.Users
|
|||
throw new ArgumentException("Invalid username", nameof(name));
|
||||
}
|
||||
|
||||
using var dbContext = _dbProvider.CreateDbContext();
|
||||
return GetUsersInternal(dbContext)
|
||||
.FirstOrDefault(u => string.Equals(u.Username, name));
|
||||
return _users.Values.FirstOrDefault(u => string.Equals(u.Username, name, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
|
@ -202,6 +201,8 @@ namespace Jellyfin.Server.Implementations.Users
|
|||
user.AddDefaultPermissions();
|
||||
user.AddDefaultPreferences();
|
||||
|
||||
_users.Add(user.Id, user);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
|
@ -236,46 +237,40 @@ namespace Jellyfin.Server.Implementations.Users
|
|||
/// <inheritdoc/>
|
||||
public async Task DeleteUserAsync(Guid userId)
|
||||
{
|
||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||
if (!_users.TryGetValue(userId, out var user))
|
||||
{
|
||||
throw new ResourceNotFoundException(nameof(userId));
|
||||
}
|
||||
|
||||
if (_users.Count == 1)
|
||||
{
|
||||
throw new InvalidOperationException(string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"The user '{0}' cannot be deleted because there must be at least one user in the system.",
|
||||
user.Username));
|
||||
}
|
||||
|
||||
if (user.HasPermission(PermissionKind.IsAdministrator)
|
||||
&& Users.Count(i => i.HasPermission(PermissionKind.IsAdministrator)) == 1)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"The user '{0}' cannot be deleted because there must be at least one admin user in the system.",
|
||||
user.Username),
|
||||
nameof(userId));
|
||||
}
|
||||
|
||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
var user = await dbContext.Users
|
||||
.AsSingleQuery()
|
||||
.Include(u => u.Permissions)
|
||||
.FirstOrDefaultAsync(u => u.Id.Equals(userId))
|
||||
.ConfigureAwait(false);
|
||||
if (user is null)
|
||||
{
|
||||
throw new ResourceNotFoundException(nameof(userId));
|
||||
}
|
||||
|
||||
if (await dbContext.Users.CountAsync().ConfigureAwait(false) == 1)
|
||||
{
|
||||
throw new InvalidOperationException(string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"The user '{0}' cannot be deleted because there must be at least one user in the system.",
|
||||
user.Username));
|
||||
}
|
||||
|
||||
if (user.HasPermission(PermissionKind.IsAdministrator)
|
||||
&& await dbContext.Users
|
||||
.CountAsync(u => u.Permissions.Any(p => p.Kind == PermissionKind.IsAdministrator && p.Value))
|
||||
.ConfigureAwait(false) == 1)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"The user '{0}' cannot be deleted because there must be at least one admin user in the system.",
|
||||
user.Username),
|
||||
nameof(userId));
|
||||
}
|
||||
|
||||
dbContext.Users.Remove(user);
|
||||
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
||||
|
||||
await _eventManager.PublishAsync(new UserDeletedEventArgs(user)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_users.Remove(userId);
|
||||
|
||||
await _eventManager.PublishAsync(new UserDeletedEventArgs(user)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
|
@ -542,23 +537,23 @@ namespace Jellyfin.Server.Implementations.Users
|
|||
/// <inheritdoc />
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
// TODO: Refactor the startup wizard so that it doesn't require a user to already exist.
|
||||
if (_users.Any())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var defaultName = Environment.UserName;
|
||||
if (string.IsNullOrWhiteSpace(defaultName) || !ValidUsernameRegex().IsMatch(defaultName))
|
||||
{
|
||||
defaultName = "MyJellyfinUser";
|
||||
}
|
||||
|
||||
_logger.LogWarning("No users, creating one with username {UserName}", defaultName);
|
||||
|
||||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
// TODO: Refactor the startup wizard so that it doesn't require a user to already exist.
|
||||
if (await dbContext.Users.AnyAsync().ConfigureAwait(false))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var defaultName = Environment.UserName;
|
||||
if (string.IsNullOrWhiteSpace(defaultName) || !ValidUsernameRegex().IsMatch(defaultName))
|
||||
{
|
||||
defaultName = "MyJellyfinUser";
|
||||
}
|
||||
|
||||
_logger.LogWarning("No users, creating one with username {UserName}", defaultName);
|
||||
|
||||
var newUser = await CreateUserInternalAsync(defaultName, dbContext).ConfigureAwait(false);
|
||||
newUser.SetPermission(PermissionKind.IsAdministrator, true);
|
||||
newUser.SetPermission(PermissionKind.EnableContentDeletion, true);
|
||||
|
@ -605,9 +600,12 @@ namespace Jellyfin.Server.Implementations.Users
|
|||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
var user = await GetUsersInternal(dbContext)
|
||||
.FirstOrDefaultAsync(u => u.Id.Equals(userId))
|
||||
.ConfigureAwait(false)
|
||||
var user = dbContext.Users
|
||||
.Include(u => u.Permissions)
|
||||
.Include(u => u.Preferences)
|
||||
.Include(u => u.AccessSchedules)
|
||||
.Include(u => u.ProfileImage)
|
||||
.FirstOrDefault(u => u.Id.Equals(userId))
|
||||
?? throw new ArgumentException("No user exists with given Id!");
|
||||
|
||||
user.SubtitleMode = config.SubtitleMode;
|
||||
|
@ -635,6 +633,7 @@ namespace Jellyfin.Server.Implementations.Users
|
|||
user.SetPreference(PreferenceKind.LatestItemExcludes, config.LatestItemsExcludes);
|
||||
|
||||
dbContext.Update(user);
|
||||
_users[user.Id] = user;
|
||||
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
@ -645,9 +644,12 @@ namespace Jellyfin.Server.Implementations.Users
|
|||
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
var user = await GetUsersInternal(dbContext)
|
||||
.FirstOrDefaultAsync(u => u.Id.Equals(userId))
|
||||
.ConfigureAwait(false)
|
||||
var user = dbContext.Users
|
||||
.Include(u => u.Permissions)
|
||||
.Include(u => u.Preferences)
|
||||
.Include(u => u.AccessSchedules)
|
||||
.Include(u => u.ProfileImage)
|
||||
.FirstOrDefault(u => u.Id.Equals(userId))
|
||||
?? throw new ArgumentException("No user exists with given Id!");
|
||||
|
||||
// The default number of login attempts is 3, but for some god forsaken reason it's sent to the server as "0"
|
||||
|
@ -708,6 +710,7 @@ namespace Jellyfin.Server.Implementations.Users
|
|||
user.SetPreference(PreferenceKind.EnableContentDeletionFromFolders, policy.EnableContentDeletionFromFolders);
|
||||
|
||||
dbContext.Update(user);
|
||||
_users[user.Id] = user;
|
||||
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
@ -728,6 +731,7 @@ namespace Jellyfin.Server.Implementations.Users
|
|||
}
|
||||
|
||||
user.ProfileImage = null;
|
||||
_users[user.Id] = user;
|
||||
}
|
||||
|
||||
internal static void ThrowIfInvalidUsername(string name)
|
||||
|
@ -874,15 +878,8 @@ namespace Jellyfin.Server.Implementations.Users
|
|||
private async Task UpdateUserInternalAsync(JellyfinDbContext dbContext, User user)
|
||||
{
|
||||
dbContext.Users.Update(user);
|
||||
_users[user.Id] = user;
|
||||
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private IQueryable<User> GetUsersInternal(JellyfinDbContext dbContext)
|
||||
=> dbContext.Users
|
||||
.AsSplitQuery()
|
||||
.Include(user => user.Permissions)
|
||||
.Include(user => user.Preferences)
|
||||
.Include(user => user.AccessSchedules)
|
||||
.Include(user => user.ProfileImage);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -35,17 +35,18 @@ public static class WebHostBuilderExtensions
|
|||
return builder
|
||||
.UseKestrel((builderContext, options) =>
|
||||
{
|
||||
var addresses = appHost.NetManager.GetAllBindInterfaces(true);
|
||||
var addresses = appHost.NetManager.GetAllBindInterfaces(false);
|
||||
|
||||
bool flagged = false;
|
||||
foreach (var netAdd in addresses)
|
||||
{
|
||||
logger.LogInformation("Kestrel is listening on {Address}", IPAddress.IPv6Any.Equals(netAdd.Address) ? "All IPv6 addresses" : netAdd.Address);
|
||||
var address = netAdd.Address;
|
||||
logger.LogInformation("Kestrel is listening on {Address}", address.Equals(IPAddress.IPv6Any) ? "all interfaces" : address);
|
||||
options.Listen(netAdd.Address, appHost.HttpPort);
|
||||
if (appHost.ListenWithHttps)
|
||||
{
|
||||
options.Listen(
|
||||
netAdd.Address,
|
||||
address,
|
||||
appHost.HttpsPort,
|
||||
listenOptions => listenOptions.UseHttps(appHost.Certificate));
|
||||
}
|
||||
|
@ -54,7 +55,7 @@ public static class WebHostBuilderExtensions
|
|||
try
|
||||
{
|
||||
options.Listen(
|
||||
netAdd.Address,
|
||||
address,
|
||||
appHost.HttpsPort,
|
||||
listenOptions => listenOptions.UseHttps());
|
||||
}
|
||||
|
|
|
@ -44,7 +44,8 @@ namespace Jellyfin.Server.Migrations
|
|||
typeof(Routines.FixPlaylistOwner),
|
||||
typeof(Routines.MigrateRatingLevels),
|
||||
typeof(Routines.AddDefaultCastReceivers),
|
||||
typeof(Routines.UpdateDefaultPluginRepository)
|
||||
typeof(Routines.UpdateDefaultPluginRepository),
|
||||
typeof(Routines.FixAudioData),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
|
|
106
Jellyfin.Server/Migrations/Routines/FixAudioData.cs
Normal file
106
Jellyfin.Server/Migrations/Routines/FixAudioData.cs
Normal file
|
@ -0,0 +1,106 @@
|
|||
using System;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using Jellyfin.Data.Enums;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.Audio;
|
||||
using MediaBrowser.Controller.Persistence;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Server.Migrations.Routines
|
||||
{
|
||||
/// <summary>
|
||||
/// Fixes the data column of audio types to be deserializable.
|
||||
/// </summary>
|
||||
internal class FixAudioData : IMigrationRoutine
|
||||
{
|
||||
private const string DbFilename = "library.db";
|
||||
private readonly ILogger<FixAudioData> _logger;
|
||||
private readonly IServerApplicationPaths _applicationPaths;
|
||||
private readonly IItemRepository _itemRepository;
|
||||
|
||||
public FixAudioData(
|
||||
IServerApplicationPaths applicationPaths,
|
||||
ILoggerFactory loggerFactory,
|
||||
IItemRepository itemRepository)
|
||||
{
|
||||
_applicationPaths = applicationPaths;
|
||||
_itemRepository = itemRepository;
|
||||
_logger = loggerFactory.CreateLogger<FixAudioData>();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Guid Id => Guid.Parse("{CF6FABC2-9FBE-4933-84A5-FFE52EF22A58}");
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string Name => "FixAudioData";
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool PerformOnNewInstall => false;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Perform()
|
||||
{
|
||||
var dbPath = Path.Combine(_applicationPaths.DataPath, DbFilename);
|
||||
|
||||
// Back up the database before modifying any entries
|
||||
for (int i = 1; ; i++)
|
||||
{
|
||||
var bakPath = string.Format(CultureInfo.InvariantCulture, "{0}.bak{1}", dbPath, i);
|
||||
if (!File.Exists(bakPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Backing up {Library} to {BackupPath}", DbFilename, bakPath);
|
||||
File.Copy(dbPath, bakPath);
|
||||
_logger.LogInformation("{Library} backed up to {BackupPath}", DbFilename, bakPath);
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Cannot make a backup of {Library} at path {BackupPath}", DbFilename, bakPath);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Backfilling audio lyrics data to database.");
|
||||
var startIndex = 0;
|
||||
var records = _itemRepository.GetCount(new InternalItemsQuery
|
||||
{
|
||||
IncludeItemTypes = [BaseItemKind.Audio],
|
||||
});
|
||||
|
||||
while (startIndex < records)
|
||||
{
|
||||
var results = _itemRepository.GetItemList(new InternalItemsQuery
|
||||
{
|
||||
IncludeItemTypes = [BaseItemKind.Audio],
|
||||
StartIndex = startIndex,
|
||||
Limit = 5000,
|
||||
SkipDeserialization = true
|
||||
})
|
||||
.Cast<Audio>()
|
||||
.ToList();
|
||||
|
||||
foreach (var audio in results)
|
||||
{
|
||||
var lyricMediaStreams = audio.GetMediaStreams().Where(s => s.Type == MediaStreamType.Lyric).Select(s => s.Path).ToList();
|
||||
if (lyricMediaStreams.Count > 0)
|
||||
{
|
||||
audio.HasLyrics = true;
|
||||
audio.LyricFiles = lyricMediaStreams;
|
||||
}
|
||||
}
|
||||
|
||||
_itemRepository.SaveItems(results, CancellationToken.None);
|
||||
startIndex += results.Count;
|
||||
_logger.LogInformation("Backfilled data for {UpdatedRecords} of {TotalRecords} audio records", startIndex, records);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -67,7 +67,7 @@ namespace Jellyfin.Server.Migrations.Routines
|
|||
using (var connection = new SqliteConnection($"Filename={Path.Combine(dataPath, DbFilename)}"))
|
||||
{
|
||||
connection.Open();
|
||||
var dbContext = _provider.CreateDbContext();
|
||||
using var dbContext = _provider.CreateDbContext();
|
||||
|
||||
var queryResult = connection.Query("SELECT * FROM LocalUsersv2");
|
||||
|
||||
|
|
|
@ -185,6 +185,7 @@ namespace Jellyfin.Server
|
|||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_restartOnShutdown = false;
|
||||
_logger.LogCritical(ex, "Error while starting server");
|
||||
}
|
||||
finally
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
<PropertyGroup>
|
||||
<Authors>Jellyfin Contributors</Authors>
|
||||
<PackageId>Jellyfin.Common</PackageId>
|
||||
<VersionPrefix>10.9.0</VersionPrefix>
|
||||
<VersionPrefix>10.9.11</VersionPrefix>
|
||||
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
||||
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
||||
</PropertyGroup>
|
||||
|
|
|
@ -58,6 +58,11 @@ public static class NetworkConstants
|
|||
/// </summary>
|
||||
public static readonly IPNetwork IPv4RFC1918PrivateClassC = new IPNetwork(IPAddress.Parse("192.168.0.0"), 16);
|
||||
|
||||
/// <summary>
|
||||
/// IPv4 Link-Local as defined in RFC 3927.
|
||||
/// </summary>
|
||||
public static readonly IPNetwork IPv4RFC3927LinkLocal = new IPNetwork(IPAddress.Parse("169.254.0.0"), 16);
|
||||
|
||||
/// <summary>
|
||||
/// IPv6 loopback as defined in RFC 4291.
|
||||
/// </summary>
|
||||
|
|
|
@ -19,5 +19,18 @@ namespace MediaBrowser.Controller.Configuration
|
|||
/// </summary>
|
||||
/// <value>The configuration.</value>
|
||||
ServerConfiguration Configuration { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the folder priority.
|
||||
/// </summary>
|
||||
/// <param name="path">The path to test.</param>
|
||||
/// <returns>The priority int value (higher comes first).</returns>
|
||||
int FetchFolderPriority(string path);
|
||||
|
||||
/// <summary>
|
||||
/// Logs a message.
|
||||
/// </summary>
|
||||
/// <param name="message">The message to log.</param>
|
||||
void LogMessage(string message);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -44,26 +44,28 @@ namespace MediaBrowser.Controller.Devices
|
|||
/// </summary>
|
||||
/// <param name="id">The identifier.</param>
|
||||
/// <returns>DeviceInfo.</returns>
|
||||
Task<DeviceInfo> GetDevice(string id);
|
||||
DeviceInfo GetDevice(string id);
|
||||
|
||||
/// <summary>
|
||||
/// Gets devices based on the provided query.
|
||||
/// </summary>
|
||||
/// <param name="query">The device query.</param>
|
||||
/// <returns>A <see cref="Task{QueryResult}"/> representing the retrieval of the devices.</returns>
|
||||
Task<QueryResult<Device>> GetDevices(DeviceQuery query);
|
||||
QueryResult<Device> GetDevices(DeviceQuery query);
|
||||
|
||||
Task<QueryResult<DeviceInfo>> GetDeviceInfos(DeviceQuery query);
|
||||
QueryResult<DeviceInfo> GetDeviceInfos(DeviceQuery query);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the devices.
|
||||
/// </summary>
|
||||
/// <param name="userId">The user's id, or <c>null</c>.</param>
|
||||
/// <returns>IEnumerable<DeviceInfo>.</returns>
|
||||
Task<QueryResult<DeviceInfo>> GetDevicesForUser(Guid? userId);
|
||||
QueryResult<DeviceInfo> GetDevicesForUser(Guid? userId);
|
||||
|
||||
Task DeleteDevice(Device device);
|
||||
|
||||
Task UpdateDevice(Device device);
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether this instance [can access device] the specified user identifier.
|
||||
/// </summary>
|
||||
|
@ -74,6 +76,6 @@ namespace MediaBrowser.Controller.Devices
|
|||
|
||||
Task UpdateDeviceOptions(string deviceId, string deviceName);
|
||||
|
||||
Task<DeviceOptions> GetDeviceOptions(string deviceId);
|
||||
DeviceOptions GetDeviceOptions(string deviceId);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -169,8 +169,7 @@ namespace MediaBrowser.Controller.Entities.Audio
|
|||
|
||||
var childUpdateType = ItemUpdateType.None;
|
||||
|
||||
// Refresh songs only and not m3u files in album folder
|
||||
foreach (var item in items.OfType<Audio>())
|
||||
foreach (var item in items)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
|
|
|
@ -751,9 +751,6 @@ namespace MediaBrowser.Controller.Entities
|
|||
[JsonIgnore]
|
||||
public virtual bool SupportsAncestors => true;
|
||||
|
||||
[JsonIgnore]
|
||||
public virtual bool StopRefreshIfLocalMetadataFound => true;
|
||||
|
||||
[JsonIgnore]
|
||||
protected virtual bool SupportsOwnedItems => !ParentId.IsEmpty() && IsFileProtocol;
|
||||
|
||||
|
@ -1183,28 +1180,29 @@ namespace MediaBrowser.Controller.Entities
|
|||
return info;
|
||||
}
|
||||
|
||||
private string GetMediaSourceName(BaseItem item)
|
||||
internal string GetMediaSourceName(BaseItem item)
|
||||
{
|
||||
var terms = new List<string>();
|
||||
|
||||
var path = item.Path;
|
||||
if (item.IsFileProtocol && !string.IsNullOrEmpty(path))
|
||||
{
|
||||
var displayName = System.IO.Path.GetFileNameWithoutExtension(path);
|
||||
if (HasLocalAlternateVersions)
|
||||
{
|
||||
var displayName = System.IO.Path.GetFileNameWithoutExtension(path)
|
||||
.Replace(System.IO.Path.GetFileName(ContainingFolderPath), string.Empty, StringComparison.OrdinalIgnoreCase)
|
||||
.TrimStart(new char[] { ' ', '-' });
|
||||
|
||||
if (!string.IsNullOrEmpty(displayName))
|
||||
var containingFolderName = System.IO.Path.GetFileName(ContainingFolderPath);
|
||||
if (displayName.Length > containingFolderName.Length && displayName.StartsWith(containingFolderName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
terms.Add(displayName);
|
||||
var name = displayName.AsSpan(containingFolderName.Length).TrimStart([' ', '-']);
|
||||
if (!name.IsWhiteSpace())
|
||||
{
|
||||
terms.Add(name.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (terms.Count == 0)
|
||||
{
|
||||
var displayName = System.IO.Path.GetFileNameWithoutExtension(path);
|
||||
terms.Add(displayName);
|
||||
}
|
||||
}
|
||||
|
@ -1952,14 +1950,15 @@ namespace MediaBrowser.Controller.Entities
|
|||
return;
|
||||
}
|
||||
|
||||
// Remove it from the item
|
||||
RemoveImage(info);
|
||||
|
||||
// Remove from file system
|
||||
if (info.IsLocalFile)
|
||||
{
|
||||
FileSystem.DeleteFile(info.Path);
|
||||
}
|
||||
|
||||
// Remove from item
|
||||
RemoveImage(info);
|
||||
|
||||
await UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
|
|
|
@ -6,6 +6,7 @@ using System;
|
|||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
@ -364,15 +365,23 @@ namespace MediaBrowser.Controller.Entities
|
|||
|
||||
if (IsFileProtocol)
|
||||
{
|
||||
IEnumerable<BaseItem> nonCachedChildren;
|
||||
IEnumerable<BaseItem> nonCachedChildren = [];
|
||||
|
||||
try
|
||||
{
|
||||
nonCachedChildren = GetNonCachedChildren(directoryService);
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
Logger.LogError(ex, "Error retrieving children from file system");
|
||||
}
|
||||
catch (SecurityException ex)
|
||||
{
|
||||
Logger.LogError(ex, "Error retrieving children from file system");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Error retrieving children folder");
|
||||
Logger.LogError(ex, "Error retrieving children");
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -594,7 +603,7 @@ namespace MediaBrowser.Controller.Entities
|
|||
}
|
||||
|
||||
var fanoutConcurrency = ConfigurationManager.Configuration.LibraryScanFanoutConcurrency;
|
||||
var parallelism = fanoutConcurrency > 0 ? fanoutConcurrency : 2 * Environment.ProcessorCount;
|
||||
var parallelism = fanoutConcurrency > 0 ? fanoutConcurrency : Environment.ProcessorCount;
|
||||
|
||||
var actionBlock = new ActionBlock<int>(
|
||||
async i =>
|
||||
|
|
|
@ -51,6 +51,7 @@ namespace MediaBrowser.Controller.Entities
|
|||
TrailerTypes = Array.Empty<TrailerType>();
|
||||
VideoTypes = Array.Empty<VideoType>();
|
||||
Years = Array.Empty<int>();
|
||||
SkipDeserialization = false;
|
||||
}
|
||||
|
||||
public InternalItemsQuery(User? user)
|
||||
|
@ -358,6 +359,8 @@ namespace MediaBrowser.Controller.Entities
|
|||
|
||||
public string? SeriesTimerId { get; set; }
|
||||
|
||||
public bool SkipDeserialization { get; set; }
|
||||
|
||||
public void SetUser(User user)
|
||||
{
|
||||
MaxParentalRating = user.MaxParentalAgeRating;
|
||||
|
|
|
@ -45,9 +45,6 @@ namespace MediaBrowser.Controller.Entities.Movies
|
|||
set => TmdbCollectionName = value;
|
||||
}
|
||||
|
||||
[JsonIgnore]
|
||||
public override bool StopRefreshIfLocalMetadataFound => false;
|
||||
|
||||
public override double GetDefaultPrimaryImageAspectRatio()
|
||||
{
|
||||
// hack for tv plugins
|
||||
|
|
|
@ -180,10 +180,7 @@ namespace MediaBrowser.Controller.Entities.TV
|
|||
}
|
||||
|
||||
public string FindSeriesPresentationUniqueKey()
|
||||
{
|
||||
var series = Series;
|
||||
return series is null ? null : series.PresentationUniqueKey;
|
||||
}
|
||||
=> Series?.PresentationUniqueKey;
|
||||
|
||||
public string FindSeasonName()
|
||||
{
|
||||
|
|
|
@ -159,7 +159,7 @@ namespace MediaBrowser.Controller.Entities.TV
|
|||
|
||||
Func<BaseItem, bool> filter = i => UserViewBuilder.Filter(i, user, query, UserDataManager, LibraryManager);
|
||||
|
||||
var items = GetEpisodes(user, query.DtoOptions).Where(filter);
|
||||
var items = GetEpisodes(user, query.DtoOptions, true).Where(filter);
|
||||
|
||||
return PostFilterAndSort(items, query, false);
|
||||
}
|
||||
|
@ -169,30 +169,31 @@ namespace MediaBrowser.Controller.Entities.TV
|
|||
/// </summary>
|
||||
/// <param name="user">The user.</param>
|
||||
/// <param name="options">The options to use.</param>
|
||||
/// <param name="shouldIncludeMissingEpisodes">If missing episodes should be included.</param>
|
||||
/// <returns>Set of episodes.</returns>
|
||||
public List<BaseItem> GetEpisodes(User user, DtoOptions options)
|
||||
public List<BaseItem> GetEpisodes(User user, DtoOptions options, bool shouldIncludeMissingEpisodes)
|
||||
{
|
||||
return GetEpisodes(Series, user, options);
|
||||
return GetEpisodes(Series, user, options, shouldIncludeMissingEpisodes);
|
||||
}
|
||||
|
||||
public List<BaseItem> GetEpisodes(Series series, User user, DtoOptions options)
|
||||
public List<BaseItem> GetEpisodes(Series series, User user, DtoOptions options, bool shouldIncludeMissingEpisodes)
|
||||
{
|
||||
return GetEpisodes(series, user, null, options);
|
||||
return GetEpisodes(series, user, null, options, shouldIncludeMissingEpisodes);
|
||||
}
|
||||
|
||||
public List<BaseItem> GetEpisodes(Series series, User user, IEnumerable<Episode> allSeriesEpisodes, DtoOptions options)
|
||||
public List<BaseItem> GetEpisodes(Series series, User user, IEnumerable<Episode> allSeriesEpisodes, DtoOptions options, bool shouldIncludeMissingEpisodes)
|
||||
{
|
||||
return series.GetSeasonEpisodes(this, user, allSeriesEpisodes, options);
|
||||
return series.GetSeasonEpisodes(this, user, allSeriesEpisodes, options, shouldIncludeMissingEpisodes);
|
||||
}
|
||||
|
||||
public List<BaseItem> GetEpisodes()
|
||||
{
|
||||
return Series.GetSeasonEpisodes(this, null, null, new DtoOptions(true));
|
||||
return Series.GetSeasonEpisodes(this, null, null, new DtoOptions(true), true);
|
||||
}
|
||||
|
||||
public override List<BaseItem> GetChildren(User user, bool includeLinkedChildren, InternalItemsQuery query)
|
||||
{
|
||||
return GetEpisodes(user, new DtoOptions(true));
|
||||
return GetEpisodes(user, new DtoOptions(true), true);
|
||||
}
|
||||
|
||||
protected override bool GetBlockUnratedValue(User user)
|
||||
|
|
|
@ -28,16 +28,12 @@ namespace MediaBrowser.Controller.Entities.TV
|
|||
public Series()
|
||||
{
|
||||
AirDays = Array.Empty<DayOfWeek>();
|
||||
SeasonNames = new Dictionary<int, string>();
|
||||
}
|
||||
|
||||
public DayOfWeek[] AirDays { get; set; }
|
||||
|
||||
public string AirTime { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public Dictionary<int, string> SeasonNames { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public override bool SupportsAddingToPlaylist => true;
|
||||
|
||||
|
@ -73,9 +69,6 @@ namespace MediaBrowser.Controller.Entities.TV
|
|||
/// <value>The status.</value>
|
||||
public SeriesStatus? Status { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public override bool StopRefreshIfLocalMetadataFound => false;
|
||||
|
||||
public override double GetDefaultPrimaryImageAspectRatio()
|
||||
{
|
||||
double value = 2;
|
||||
|
@ -257,7 +250,7 @@ namespace MediaBrowser.Controller.Entities.TV
|
|||
return LibraryManager.GetItemsResult(query);
|
||||
}
|
||||
|
||||
public IEnumerable<BaseItem> GetEpisodes(User user, DtoOptions options)
|
||||
public IEnumerable<BaseItem> GetEpisodes(User user, DtoOptions options, bool shouldIncludeMissingEpisodes)
|
||||
{
|
||||
var seriesKey = GetUniqueSeriesKey(this);
|
||||
|
||||
|
@ -267,10 +260,10 @@ namespace MediaBrowser.Controller.Entities.TV
|
|||
SeriesPresentationUniqueKey = seriesKey,
|
||||
IncludeItemTypes = new[] { BaseItemKind.Episode, BaseItemKind.Season },
|
||||
OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) },
|
||||
DtoOptions = options
|
||||
DtoOptions = options,
|
||||
};
|
||||
|
||||
if (user is null || !user.DisplayMissingEpisodes)
|
||||
if (!shouldIncludeMissingEpisodes)
|
||||
{
|
||||
query.IsMissing = false;
|
||||
}
|
||||
|
@ -280,7 +273,7 @@ namespace MediaBrowser.Controller.Entities.TV
|
|||
var allSeriesEpisodes = allItems.OfType<Episode>().ToList();
|
||||
|
||||
var allEpisodes = allItems.OfType<Season>()
|
||||
.SelectMany(i => i.GetEpisodes(this, user, allSeriesEpisodes, options))
|
||||
.SelectMany(i => i.GetEpisodes(this, user, allSeriesEpisodes, options, shouldIncludeMissingEpisodes))
|
||||
.Reverse();
|
||||
|
||||
// Specials could appear twice based on above - once in season 0, once in the aired season
|
||||
|
@ -292,8 +285,7 @@ namespace MediaBrowser.Controller.Entities.TV
|
|||
|
||||
public async Task RefreshAllMetadata(MetadataRefreshOptions refreshOptions, IProgress<double> progress, CancellationToken cancellationToken)
|
||||
{
|
||||
// Refresh bottom up, children first, then the boxset
|
||||
// By then hopefully the movies within will have Tmdb collection values
|
||||
// Refresh bottom up, seasons and episodes first, then the series
|
||||
var items = GetRecursiveChildren();
|
||||
|
||||
var totalItems = items.Count;
|
||||
|
@ -356,7 +348,7 @@ namespace MediaBrowser.Controller.Entities.TV
|
|||
await ProviderManager.RefreshSingleItem(this, refreshOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public List<BaseItem> GetSeasonEpisodes(Season parentSeason, User user, DtoOptions options)
|
||||
public List<BaseItem> GetSeasonEpisodes(Season parentSeason, User user, DtoOptions options, bool shouldIncludeMissingEpisodes)
|
||||
{
|
||||
var queryFromSeries = ConfigurationManager.Configuration.DisplaySpecialsWithinSeasons;
|
||||
|
||||
|
@ -373,24 +365,22 @@ namespace MediaBrowser.Controller.Entities.TV
|
|||
OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) },
|
||||
DtoOptions = options
|
||||
};
|
||||
if (user is not null)
|
||||
|
||||
if (!shouldIncludeMissingEpisodes)
|
||||
{
|
||||
if (!user.DisplayMissingEpisodes)
|
||||
{
|
||||
query.IsMissing = false;
|
||||
}
|
||||
query.IsMissing = false;
|
||||
}
|
||||
|
||||
var allItems = LibraryManager.GetItemList(query);
|
||||
|
||||
return GetSeasonEpisodes(parentSeason, user, allItems, options);
|
||||
return GetSeasonEpisodes(parentSeason, user, allItems, options, shouldIncludeMissingEpisodes);
|
||||
}
|
||||
|
||||
public List<BaseItem> GetSeasonEpisodes(Season parentSeason, User user, IEnumerable<BaseItem> allSeriesEpisodes, DtoOptions options)
|
||||
public List<BaseItem> GetSeasonEpisodes(Season parentSeason, User user, IEnumerable<BaseItem> allSeriesEpisodes, DtoOptions options, bool shouldIncludeMissingEpisodes)
|
||||
{
|
||||
if (allSeriesEpisodes is null)
|
||||
{
|
||||
return GetSeasonEpisodes(parentSeason, user, options);
|
||||
return GetSeasonEpisodes(parentSeason, user, options, shouldIncludeMissingEpisodes);
|
||||
}
|
||||
|
||||
var episodes = FilterEpisodesBySeason(allSeriesEpisodes, parentSeason, ConfigurationManager.Configuration.DisplaySpecialsWithinSeasons);
|
||||
|
|
|
@ -23,9 +23,6 @@ namespace MediaBrowser.Controller.Entities
|
|||
TrailerTypes = Array.Empty<TrailerType>();
|
||||
}
|
||||
|
||||
[JsonIgnore]
|
||||
public override bool StopRefreshIfLocalMetadataFound => false;
|
||||
|
||||
public TrailerType[] TrailerTypes { get; set; }
|
||||
|
||||
public override double GetDefaultPrimaryImageAspectRatio()
|
||||
|
|
|
@ -430,8 +430,6 @@ namespace MediaBrowser.Controller.Entities
|
|||
InternalItemsQuery query,
|
||||
ILibraryManager libraryManager)
|
||||
{
|
||||
var user = query.User;
|
||||
|
||||
// This must be the last filter
|
||||
if (!query.AdjacentTo.IsNullOrEmpty())
|
||||
{
|
||||
|
|
|
@ -148,22 +148,7 @@ namespace MediaBrowser.Controller.Entities
|
|||
public override bool SupportsAddingToPlaylist => true;
|
||||
|
||||
[JsonIgnore]
|
||||
public int MediaSourceCount
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!string.IsNullOrEmpty(PrimaryVersionId))
|
||||
{
|
||||
var item = LibraryManager.GetItemById(PrimaryVersionId);
|
||||
if (item is Video video)
|
||||
{
|
||||
return video.MediaSourceCount;
|
||||
}
|
||||
}
|
||||
|
||||
return LinkedAlternateVersions.Length + LocalAlternateVersions.Length + 1;
|
||||
}
|
||||
}
|
||||
public int MediaSourceCount => GetMediaSourceCount((message) => LibraryManager.LogMessage(message));
|
||||
|
||||
[JsonIgnore]
|
||||
public bool IsStacked => AdditionalParts.Length > 0;
|
||||
|
@ -550,5 +535,44 @@ namespace MediaBrowser.Controller.Entities
|
|||
|
||||
return list;
|
||||
}
|
||||
|
||||
/// <summary> Gets hte media source count.</summary>
|
||||
/// <param name="logMessage">Logs a message.</param>
|
||||
/// <returns>The mediasourcecount.</returns>
|
||||
public int GetMediaSourceCount(Action<string> logMessage)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(PrimaryVersionId))
|
||||
{
|
||||
logMessage($"Got a PrimaryVersionId: {PrimaryVersionId}, {GetContainingFolderPath()}");
|
||||
var item = LibraryManager.GetItemById(PrimaryVersionId);
|
||||
if (item is Video video)
|
||||
{
|
||||
logMessage($"Entered the recursion!!!");
|
||||
return video.MediaSourceCount;
|
||||
}
|
||||
}
|
||||
|
||||
return LinkedAlternateVersions.Length + LocalAlternateVersions.Length + 1;
|
||||
}
|
||||
|
||||
/// <summary> Gets the containing folder path.</summary>
|
||||
/// <returns>The path to the containing folder path.</returns>
|
||||
public string GetContainingFolderPath()
|
||||
{
|
||||
if (IsStacked)
|
||||
{
|
||||
return System.IO.Path.GetDirectoryName(Path);
|
||||
}
|
||||
|
||||
if (!IsPlaceHolder)
|
||||
{
|
||||
if (VideoType == VideoType.BluRay || VideoType == VideoType.Dvd)
|
||||
{
|
||||
return Path;
|
||||
}
|
||||
}
|
||||
|
||||
return base.ContainingFolderPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
64
MediaBrowser.Controller/IO/FileSystemHelper.cs
Normal file
64
MediaBrowser.Controller/IO/FileSystemHelper.cs
Normal file
|
@ -0,0 +1,64 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using MediaBrowser.Model.IO;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace MediaBrowser.Controller.IO;
|
||||
|
||||
/// <summary>
|
||||
/// Helper methods for file system management.
|
||||
/// </summary>
|
||||
public static class FileSystemHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Deletes the file.
|
||||
/// </summary>
|
||||
/// <param name="fileSystem">The fileSystem.</param>
|
||||
/// <param name="path">The path.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public static void DeleteFile(IFileSystem fileSystem, string path, ILogger logger)
|
||||
{
|
||||
try
|
||||
{
|
||||
fileSystem.DeleteFile(path);
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
logger.LogError(ex, "Error deleting file {Path}", path);
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
logger.LogError(ex, "Error deleting file {Path}", path);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recursively delete empty folders.
|
||||
/// </summary>
|
||||
/// <param name="fileSystem">The fileSystem.</param>
|
||||
/// <param name="path">The path.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public static void DeleteEmptyFolders(IFileSystem fileSystem, string path, ILogger logger)
|
||||
{
|
||||
foreach (var directory in fileSystem.GetDirectoryPaths(path))
|
||||
{
|
||||
DeleteEmptyFolders(fileSystem, directory, logger);
|
||||
if (!fileSystem.GetFileSystemEntryPaths(directory).Any())
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.Delete(directory, false);
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
logger.LogError(ex, "Error deleting directory {Path}", directory);
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
logger.LogError(ex, "Error deleting directory {Path}", directory);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -149,6 +149,14 @@ namespace MediaBrowser.Controller.Library
|
|||
/// <returns>Task.</returns>
|
||||
Task ValidateMediaLibrary(IProgress<double> progress, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Reloads the root media folder.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">The cancellation token.</param>
|
||||
/// <param name="removeRoot">Is remove the library itself allowed.</param>
|
||||
/// <returns>Task.</returns>
|
||||
Task ValidateTopLibraryFolders(CancellationToken cancellationToken, bool removeRoot = false);
|
||||
|
||||
Task UpdateImagesAsync(BaseItem item, bool forceUpdate = false);
|
||||
|
||||
/// <summary>
|
||||
|
@ -613,5 +621,11 @@ namespace MediaBrowser.Controller.Library
|
|||
/// This exists so plugins can trigger a library scan.
|
||||
/// </remarks>
|
||||
void QueueLibraryScan();
|
||||
|
||||
/// <summary>
|
||||
/// Logs a message.
|
||||
/// </summary>
|
||||
/// <param name="message">The message to log.</param>
|
||||
void LogMessage(string message);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
<PropertyGroup>
|
||||
<Authors>Jellyfin Contributors</Authors>
|
||||
<PackageId>Jellyfin.Controller</PackageId>
|
||||
<VersionPrefix>10.9.0</VersionPrefix>
|
||||
<VersionPrefix>10.9.11</VersionPrefix>
|
||||
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
||||
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
||||
</PropertyGroup>
|
||||
|
|
|
@ -55,6 +55,7 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||
private readonly Version _minKerneli915Hang = new Version(5, 18);
|
||||
private readonly Version _maxKerneli915Hang = new Version(6, 1, 3);
|
||||
private readonly Version _minFixedKernel60i915Hang = new Version(6, 0, 18);
|
||||
private readonly Version _minKernelVersionAmdVkFmtModifier = new Version(5, 15);
|
||||
|
||||
private readonly Version _minFFmpegImplictHwaccel = new Version(6, 0);
|
||||
private readonly Version _minFFmpegHwaUnsafeOutput = new Version(6, 0);
|
||||
|
@ -680,16 +681,6 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||
return -1;
|
||||
}
|
||||
|
||||
public string GetInputPathArgument(EncodingJobInfo state)
|
||||
{
|
||||
return state.MediaSource.VideoType switch
|
||||
{
|
||||
VideoType.Dvd => _mediaEncoder.GetInputArgument(_mediaEncoder.GetPrimaryPlaylistVobFiles(state.MediaPath, null).ToList(), state.MediaSource),
|
||||
VideoType.BluRay => _mediaEncoder.GetInputArgument(_mediaEncoder.GetPrimaryPlaylistM2tsFiles(state.MediaPath).ToList(), state.MediaSource),
|
||||
_ => _mediaEncoder.GetInputArgument(state.MediaPath, state.MediaSource)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the audio encoder.
|
||||
/// </summary>
|
||||
|
@ -1005,7 +996,8 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||
Environment.SetEnvironmentVariable("AMD_DEBUG", "noefc");
|
||||
|
||||
if (IsVulkanFullSupported()
|
||||
&& _mediaEncoder.IsVaapiDeviceSupportVulkanDrmInterop)
|
||||
&& _mediaEncoder.IsVaapiDeviceSupportVulkanDrmInterop
|
||||
&& Environment.OSVersion.Version >= _minKernelVersionAmdVkFmtModifier)
|
||||
{
|
||||
args.Append(GetDrmDeviceArgs(options.VaapiDevice, DrmAlias));
|
||||
args.Append(GetVaapiDeviceArgs(null, null, null, DrmAlias, VaapiAlias));
|
||||
|
@ -1197,13 +1189,14 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||
{
|
||||
var tmpConcatPath = Path.Join(_configurationManager.GetTranscodePath(), state.MediaSource.Id + ".concat");
|
||||
_mediaEncoder.GenerateConcatConfig(state.MediaSource, tmpConcatPath);
|
||||
arg.Append(" -f concat -safe 0 -i ")
|
||||
.Append(tmpConcatPath);
|
||||
arg.Append(" -f concat -safe 0 -i \"")
|
||||
.Append(tmpConcatPath)
|
||||
.Append("\" ");
|
||||
}
|
||||
else
|
||||
{
|
||||
arg.Append(" -i ")
|
||||
.Append(GetInputPathArgument(state));
|
||||
.Append(_mediaEncoder.GetInputPathArgument(state));
|
||||
}
|
||||
|
||||
// sub2video for external graphical subtitles
|
||||
|
@ -1215,8 +1208,8 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||
var subtitlePath = state.SubtitleStream.Path;
|
||||
var subtitleExtension = Path.GetExtension(subtitlePath.AsSpan());
|
||||
|
||||
if (subtitleExtension.Equals(".sub", StringComparison.OrdinalIgnoreCase)
|
||||
|| subtitleExtension.Equals(".sup", StringComparison.OrdinalIgnoreCase))
|
||||
// dvdsub/vobsub graphical subtitles use .sub+.idx pairs
|
||||
if (subtitleExtension.Equals(".sub", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var idxFile = Path.ChangeExtension(subtitlePath, ".idx");
|
||||
if (File.Exists(idxFile))
|
||||
|
@ -1320,7 +1313,7 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||
|
||||
// Apply aac_adtstoasc bitstream filter when media source is in mpegts.
|
||||
if (string.Equals(segmentFormat, "mp4", StringComparison.OrdinalIgnoreCase)
|
||||
&& (string.Equals(mediaSourceContainer, "mpegts", StringComparison.OrdinalIgnoreCase)
|
||||
&& (string.Equals(mediaSourceContainer, "ts", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(mediaSourceContainer, "aac", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(mediaSourceContainer, "hls", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
|
@ -2012,7 +2005,26 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||
}
|
||||
|
||||
var profile = state.GetRequestedProfiles(targetVideoCodec).FirstOrDefault() ?? string.Empty;
|
||||
profile = WhiteSpaceRegex().Replace(profile, string.Empty);
|
||||
profile = WhiteSpaceRegex().Replace(profile, string.Empty).ToLowerInvariant();
|
||||
|
||||
var videoProfiles = Array.Empty<string>();
|
||||
if (string.Equals("h264", targetVideoCodec, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
videoProfiles = _videoProfilesH264;
|
||||
}
|
||||
else if (string.Equals("hevc", targetVideoCodec, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
videoProfiles = _videoProfilesH265;
|
||||
}
|
||||
else if (string.Equals("av1", targetVideoCodec, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
videoProfiles = _videoProfilesAv1;
|
||||
}
|
||||
|
||||
if (!videoProfiles.Contains(profile, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
profile = string.Empty;
|
||||
}
|
||||
|
||||
// We only transcode to HEVC 8-bit for now, force Main Profile.
|
||||
if (profile.Contains("main10", StringComparison.OrdinalIgnoreCase)
|
||||
|
@ -2083,6 +2095,18 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||
profile = "constrained_high";
|
||||
}
|
||||
|
||||
if (string.Equals(videoEncoder, "h264_videotoolbox", StringComparison.OrdinalIgnoreCase)
|
||||
&& profile.Contains("constrainedbaseline", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
profile = "constrained_baseline";
|
||||
}
|
||||
|
||||
if (string.Equals(videoEncoder, "h264_videotoolbox", StringComparison.OrdinalIgnoreCase)
|
||||
&& profile.Contains("constrainedhigh", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
profile = "constrained_high";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(profile))
|
||||
{
|
||||
// Currently there's no profile option in av1_nvenc encoder
|
||||
|
@ -2316,7 +2340,11 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||
if (request.VideoBitRate.HasValue
|
||||
&& (!videoStream.BitRate.HasValue || videoStream.BitRate.Value > request.VideoBitRate.Value))
|
||||
{
|
||||
return false;
|
||||
// For LiveTV that has no bitrate, let's try copy if other conditions are met
|
||||
if (string.IsNullOrWhiteSpace(request.LiveStreamId) || videoStream.BitRate.HasValue)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
var maxBitDepth = state.GetRequestedVideoBitDepth(videoStream.Codec);
|
||||
|
@ -2629,10 +2657,14 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||
&& state.AudioStream.Channels.HasValue
|
||||
&& state.AudioStream.Channels.Value == 6)
|
||||
{
|
||||
if (!encodingOptions.DownMixAudioBoost.Equals(1))
|
||||
{
|
||||
filters.Add("volume=" + encodingOptions.DownMixAudioBoost.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
switch (encodingOptions.DownMixStereoAlgorithm)
|
||||
{
|
||||
case DownMixStereoAlgorithms.Dave750:
|
||||
filters.Add("volume=4.25");
|
||||
filters.Add("pan=stereo|c0=0.5*c2+0.707*c0+0.707*c4+0.5*c3|c1=0.5*c2+0.707*c1+0.707*c5+0.5*c3");
|
||||
break;
|
||||
case DownMixStereoAlgorithms.NightmodeDialogue:
|
||||
|
@ -2640,11 +2672,6 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||
break;
|
||||
case DownMixStereoAlgorithms.None:
|
||||
default:
|
||||
if (!encodingOptions.DownMixAudioBoost.Equals(1))
|
||||
{
|
||||
filters.Add("volume=" + encodingOptions.DownMixAudioBoost.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
@ -2771,7 +2798,13 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||
|
||||
if (time > 0)
|
||||
{
|
||||
seekParam += string.Format(CultureInfo.InvariantCulture, "-ss {0}", _mediaEncoder.GetTimeParameter(time));
|
||||
// For direct streaming/remuxing, we seek at the exact position of the keyframe
|
||||
// However, ffmpeg will seek to previous keyframe when the exact time is the input
|
||||
// Workaround this by adding 0.5s offset to the seeking time to get the exact keyframe on most videos.
|
||||
// This will help subtitle syncing.
|
||||
var isHlsRemuxing = state.IsVideoRequest && state.TranscodingType is TranscodingJobType.Hls && IsCopyCodec(state.OutputVideoCodec);
|
||||
var seekTick = isHlsRemuxing ? time + 5000000L : time;
|
||||
seekParam += string.Format(CultureInfo.InvariantCulture, "-ss {0}", _mediaEncoder.GetTimeParameter(seekTick));
|
||||
|
||||
if (state.IsVideoRequest)
|
||||
{
|
||||
|
@ -3155,7 +3188,9 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||
int? requestedMaxHeight)
|
||||
{
|
||||
var isV4l2 = string.Equals(videoEncoder, "h264_v4l2m2m", StringComparison.OrdinalIgnoreCase);
|
||||
var isMjpeg = videoEncoder is not null && videoEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase);
|
||||
var scaleVal = isV4l2 ? 64 : 2;
|
||||
var targetAr = isMjpeg ? "(a*sar)" : "a"; // manually calculate AR when using mjpeg encoder
|
||||
|
||||
// If fixed dimensions were supplied
|
||||
if (requestedWidth.HasValue && requestedHeight.HasValue)
|
||||
|
@ -3184,10 +3219,11 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||
|
||||
return string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
@"scale=trunc(min(max(iw\,ih*a)\,min({0}\,{1}*a))/{2})*{2}:trunc(min(max(iw/a\,ih)\,min({0}/a\,{1}))/2)*2",
|
||||
@"scale=trunc(min(max(iw\,ih*{3})\,min({0}\,{1}*{3}))/{2})*{2}:trunc(min(max(iw/{3}\,ih)\,min({0}/{3}\,{1}))/2)*2",
|
||||
maxWidthParam,
|
||||
maxHeightParam,
|
||||
scaleVal);
|
||||
scaleVal,
|
||||
targetAr);
|
||||
}
|
||||
|
||||
// If a fixed width was requested
|
||||
|
@ -3203,8 +3239,9 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||
|
||||
return string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"scale={0}:trunc(ow/a/2)*2",
|
||||
widthParam);
|
||||
"scale={0}:trunc(ow/{1}/2)*2",
|
||||
widthParam,
|
||||
targetAr);
|
||||
}
|
||||
|
||||
// If a fixed height was requested
|
||||
|
@ -3214,9 +3251,10 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||
|
||||
return string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"scale=trunc(oh*a/{1})*{1}:{0}",
|
||||
"scale=trunc(oh*{2}/{1})*{1}:{0}",
|
||||
heightParam,
|
||||
scaleVal);
|
||||
scaleVal,
|
||||
targetAr);
|
||||
}
|
||||
|
||||
// If a max width was requested
|
||||
|
@ -3226,9 +3264,10 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||
|
||||
return string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
@"scale=trunc(min(max(iw\,ih*a)\,{0})/{1})*{1}:trunc(ow/a/2)*2",
|
||||
@"scale=trunc(min(max(iw\,ih*{2})\,{0})/{1})*{1}:trunc(ow/{2}/2)*2",
|
||||
maxWidthParam,
|
||||
scaleVal);
|
||||
scaleVal,
|
||||
targetAr);
|
||||
}
|
||||
|
||||
// If a max height was requested
|
||||
|
@ -3238,9 +3277,10 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||
|
||||
return string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
@"scale=trunc(oh*a/{1})*{1}:min(max(iw/a\,ih)\,{0})",
|
||||
@"scale=trunc(oh*{2}/{1})*{1}:min(max(iw/{2}\,ih)\,{0})",
|
||||
maxHeightParam,
|
||||
scaleVal);
|
||||
scaleVal,
|
||||
targetAr);
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
|
@ -4285,6 +4325,7 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||
{
|
||||
// map from qsv to vaapi.
|
||||
mainFilters.Add("hwmap=derive_device=vaapi");
|
||||
mainFilters.Add("format=vaapi");
|
||||
}
|
||||
|
||||
var tonemapFilter = GetHwTonemapFilter(options, "vaapi", "nv12");
|
||||
|
@ -4294,6 +4335,7 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||
{
|
||||
// map from vaapi to qsv.
|
||||
mainFilters.Add("hwmap=derive_device=qsv");
|
||||
mainFilters.Add("format=qsv");
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -4468,7 +4510,8 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||
// prefered vaapi + vulkan filters pipeline
|
||||
if (_mediaEncoder.IsVaapiDeviceAmd
|
||||
&& isVaapiVkSupported
|
||||
&& _mediaEncoder.IsVaapiDeviceSupportVulkanDrmInterop)
|
||||
&& _mediaEncoder.IsVaapiDeviceSupportVulkanDrmInterop
|
||||
&& Environment.OSVersion.Version >= _minKernelVersionAmdVkFmtModifier)
|
||||
{
|
||||
// AMD radeonsi path(targeting Polaris/gfx8+), with extra vulkan tonemap and overlay support.
|
||||
return GetAmdVaapiFullVidFiltersPrefered(state, options, vidDecoder, vidEncoder);
|
||||
|
|
|
@ -245,6 +245,21 @@ namespace MediaBrowser.Controller.MediaEncoding
|
|||
/// <returns>A playlist.</returns>
|
||||
IReadOnlyList<string> GetPrimaryPlaylistM2tsFiles(string path);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the input path argument from <see cref="EncodingJobInfo"/>.
|
||||
/// </summary>
|
||||
/// <param name="state">The <see cref="EncodingJobInfo"/>.</param>
|
||||
/// <returns>The input path argument.</returns>
|
||||
string GetInputPathArgument(EncodingJobInfo state);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the input path argument.
|
||||
/// </summary>
|
||||
/// <param name="path">The item path.</param>
|
||||
/// <param name="mediaSource">The <see cref="MediaSourceInfo"/>.</param>
|
||||
/// <returns>The input path argument.</returns>
|
||||
string GetInputPathArgument(string path, MediaSourceInfo mediaSource);
|
||||
|
||||
/// <summary>
|
||||
/// Generates a FFmpeg concat config for the source.
|
||||
/// </summary>
|
||||
|
|
|
@ -166,7 +166,7 @@ namespace MediaBrowser.Controller.Playlists
|
|||
return base.GetChildren(user, true, query);
|
||||
}
|
||||
|
||||
public static IReadOnlyList<BaseItem> GetPlaylistItems(MediaType playlistMediaType, IEnumerable<BaseItem> inputItems, User user, DtoOptions options)
|
||||
public static IReadOnlyList<BaseItem> GetPlaylistItems(IEnumerable<BaseItem> inputItems, User user, DtoOptions options)
|
||||
{
|
||||
if (user is not null)
|
||||
{
|
||||
|
@ -177,14 +177,14 @@ namespace MediaBrowser.Controller.Playlists
|
|||
|
||||
foreach (var item in inputItems)
|
||||
{
|
||||
var playlistItems = GetPlaylistItems(item, user, playlistMediaType, options);
|
||||
var playlistItems = GetPlaylistItems(item, user, options);
|
||||
list.AddRange(playlistItems);
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
private static IEnumerable<BaseItem> GetPlaylistItems(BaseItem item, User user, MediaType mediaType, DtoOptions options)
|
||||
private static IEnumerable<BaseItem> GetPlaylistItems(BaseItem item, User user, DtoOptions options)
|
||||
{
|
||||
if (item is MusicGenre musicGenre)
|
||||
{
|
||||
|
@ -216,7 +216,7 @@ namespace MediaBrowser.Controller.Playlists
|
|||
{
|
||||
Recursive = true,
|
||||
IsFolder = false,
|
||||
MediaTypes = [mediaType],
|
||||
MediaTypes = [MediaType.Audio, MediaType.Video],
|
||||
EnableTotalRecordCount = false,
|
||||
DtoOptions = options
|
||||
};
|
||||
|
|
|
@ -28,6 +28,22 @@ namespace MediaBrowser.Controller.Providers
|
|||
return _cache.GetOrAdd(path, static (p, fileSystem) => fileSystem.GetFileSystemEntries(p).ToArray(), _fileSystem);
|
||||
}
|
||||
|
||||
public List<FileSystemMetadata> GetDirectories(string path)
|
||||
{
|
||||
var list = new List<FileSystemMetadata>();
|
||||
var items = GetFileSystemEntries(path);
|
||||
for (var i = 0; i < items.Length; i++)
|
||||
{
|
||||
var item = items[i];
|
||||
if (item.IsDirectory)
|
||||
{
|
||||
list.Add(item);
|
||||
}
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
public List<FileSystemMetadata> GetFiles(string path)
|
||||
{
|
||||
var list = new List<FileSystemMetadata>();
|
||||
|
|
|
@ -9,6 +9,8 @@ namespace MediaBrowser.Controller.Providers
|
|||
{
|
||||
FileSystemMetadata[] GetFileSystemEntries(string path);
|
||||
|
||||
List<FileSystemMetadata> GetDirectories(string path);
|
||||
|
||||
List<FileSystemMetadata> GetFiles(string path);
|
||||
|
||||
FileSystemMetadata? GetFile(string path);
|
||||
|
|
|
@ -140,6 +140,14 @@ namespace MediaBrowser.Controller.Providers
|
|||
IEnumerable<IMetadataProvider<T>> GetMetadataProviders<T>(BaseItem item, LibraryOptions libraryOptions)
|
||||
where T : BaseItem;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the metadata savers for the provided item.
|
||||
/// </summary>
|
||||
/// <param name="item">The item.</param>
|
||||
/// <param name="libraryOptions">The library options.</param>
|
||||
/// <returns>The metadata savers.</returns>
|
||||
IEnumerable<IMetadataSaver> GetMetadataSavers(BaseItem item, LibraryOptions libraryOptions);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all metadata plugins.
|
||||
/// </summary>
|
||||
|
|
|
@ -11,6 +11,8 @@ namespace MediaBrowser.Controller.Providers
|
|||
public ItemInfo(BaseItem item)
|
||||
{
|
||||
Path = item.Path;
|
||||
ParentId = item.ParentId;
|
||||
IndexNumber = item.IndexNumber;
|
||||
ContainingFolderPath = item.ContainingFolderPath;
|
||||
IsInMixedFolder = item.IsInMixedFolder;
|
||||
|
||||
|
@ -27,6 +29,10 @@ namespace MediaBrowser.Controller.Providers
|
|||
|
||||
public string Path { get; set; }
|
||||
|
||||
public Guid ParentId { get; set; }
|
||||
|
||||
public int? IndexNumber { get; set; }
|
||||
|
||||
public string ContainingFolderPath { get; set; }
|
||||
|
||||
public VideoType VideoType { get; set; }
|
||||
|
|
|
@ -38,19 +38,26 @@ namespace MediaBrowser.LocalMetadata.Images
|
|||
}
|
||||
|
||||
var parentPathFiles = directoryService.GetFiles(parentPath);
|
||||
var nameWithoutExtension = Path.GetFileNameWithoutExtension(item.Path.AsSpan()).ToString();
|
||||
|
||||
var nameWithoutExtension = Path.GetFileNameWithoutExtension(item.Path.AsSpan());
|
||||
var images = GetImageFilesFromFolder(nameWithoutExtension, parentPathFiles);
|
||||
|
||||
return GetFilesFromParentFolder(nameWithoutExtension, parentPathFiles);
|
||||
var metadataSubDir = directoryService.GetDirectories(parentPath).FirstOrDefault(d => d.Name.Equals("metadata", StringComparison.Ordinal));
|
||||
if (metadataSubDir is not null)
|
||||
{
|
||||
var files = directoryService.GetFiles(metadataSubDir.FullName);
|
||||
images.AddRange(GetImageFilesFromFolder(nameWithoutExtension, files));
|
||||
}
|
||||
|
||||
return images;
|
||||
}
|
||||
|
||||
private List<LocalImageInfo> GetFilesFromParentFolder(ReadOnlySpan<char> filenameWithoutExtension, List<FileSystemMetadata> parentPathFiles)
|
||||
private List<LocalImageInfo> GetImageFilesFromFolder(ReadOnlySpan<char> filenameWithoutExtension, List<FileSystemMetadata> filePaths)
|
||||
{
|
||||
var list = new List<LocalImageInfo>(1);
|
||||
var thumbName = string.Concat(filenameWithoutExtension, "-thumb");
|
||||
|
||||
var list = new List<LocalImageInfo>(1);
|
||||
|
||||
foreach (var i in parentPathFiles)
|
||||
foreach (var i in filePaths)
|
||||
{
|
||||
if (i.IsDirectory)
|
||||
{
|
||||
|
|
|
@ -89,15 +89,29 @@ namespace MediaBrowser.MediaEncoding.Attachments
|
|||
string outputPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false))
|
||||
var shouldExtractOneByOne = mediaSource.MediaAttachments.Any(a => !string.IsNullOrEmpty(a.FileName)
|
||||
&& (a.FileName.Contains('/', StringComparison.OrdinalIgnoreCase) || a.FileName.Contains('\\', StringComparison.OrdinalIgnoreCase)));
|
||||
if (shouldExtractOneByOne)
|
||||
{
|
||||
if (!Directory.Exists(outputPath))
|
||||
var attachmentIndexes = mediaSource.MediaAttachments.Select(a => a.Index);
|
||||
foreach (var i in attachmentIndexes)
|
||||
{
|
||||
await ExtractAllAttachmentsInternal(
|
||||
_mediaEncoder.GetInputArgument(inputFile, mediaSource),
|
||||
outputPath,
|
||||
false,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
var newName = Path.Join(outputPath, i.ToString(CultureInfo.InvariantCulture));
|
||||
await ExtractAttachment(inputFile, mediaSource, i, newName, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
if (!Directory.Exists(outputPath))
|
||||
{
|
||||
await ExtractAllAttachmentsInternal(
|
||||
_mediaEncoder.GetInputArgument(inputFile, mediaSource),
|
||||
outputPath,
|
||||
false,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -270,7 +284,7 @@ namespace MediaBrowser.MediaEncoding.Attachments
|
|||
|
||||
if (extractableAttachmentIds.Count > 0)
|
||||
{
|
||||
await CacheAllAttachmentsInternal(mediaPath, inputFile, mediaSource, extractableAttachmentIds, cancellationToken).ConfigureAwait(false);
|
||||
await CacheAllAttachmentsInternal(mediaPath, _mediaEncoder.GetInputArgument(inputFile, mediaSource), mediaSource, extractableAttachmentIds, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
@ -309,7 +323,7 @@ namespace MediaBrowser.MediaEncoding.Attachments
|
|||
|
||||
processArgs += string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
" -i \"{0}\" -t 0 -f null null",
|
||||
" -i {0} -t 0 -f null null",
|
||||
inputFile);
|
||||
|
||||
int exitCode;
|
||||
|
|
|
@ -168,6 +168,8 @@ namespace MediaBrowser.MediaEncoding.Encoder
|
|||
|
||||
private readonly string _encoderPath;
|
||||
|
||||
private readonly Version _minFFmpegMultiThreadedCli = new Version(7, 0);
|
||||
|
||||
public EncoderValidator(ILogger logger, string encoderPath)
|
||||
{
|
||||
_logger = logger;
|
||||
|
@ -477,7 +479,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
|
|||
return false;
|
||||
}
|
||||
|
||||
public bool CheckSupportedRuntimeKey(string keyDesc)
|
||||
public bool CheckSupportedRuntimeKey(string keyDesc, Version? ffmpegVersion)
|
||||
{
|
||||
if (string.IsNullOrEmpty(keyDesc))
|
||||
{
|
||||
|
@ -487,7 +489,9 @@ namespace MediaBrowser.MediaEncoding.Encoder
|
|||
string output;
|
||||
try
|
||||
{
|
||||
output = GetProcessOutput(_encoderPath, "-hide_banner -f lavfi -i nullsrc=s=1x1:d=500 -f null -", true, "?");
|
||||
// With multi-threaded cli support, FFmpeg 7 is less sensitive to keyboard input
|
||||
var duration = ffmpegVersion >= _minFFmpegMultiThreadedCli ? 10000 : 1000;
|
||||
output = GetProcessOutput(_encoderPath, $"-hide_banner -f lavfi -i nullsrc=s=1x1:d={duration} -f null -", true, "?");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
|
|
@ -30,10 +30,8 @@ using MediaBrowser.Model.Entities;
|
|||
using MediaBrowser.Model.Globalization;
|
||||
using MediaBrowser.Model.IO;
|
||||
using MediaBrowser.Model.MediaInfo;
|
||||
using Microsoft.AspNetCore.Components.Forms;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static Nikse.SubtitleEdit.Core.Common.IfoParser;
|
||||
|
||||
namespace MediaBrowser.MediaEncoding.Encoder
|
||||
{
|
||||
|
@ -195,7 +193,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
|
|||
|
||||
_threads = EncodingHelper.GetNumberOfThreads(null, options, null);
|
||||
|
||||
_isPkeyPauseSupported = validator.CheckSupportedRuntimeKey("p pause transcoding");
|
||||
_isPkeyPauseSupported = validator.CheckSupportedRuntimeKey("p pause transcoding", _ffmpegVersion);
|
||||
|
||||
// Check the Vaapi device vendor
|
||||
if (OperatingSystem.IsLinux()
|
||||
|
@ -458,9 +456,9 @@ namespace MediaBrowser.MediaEncoding.Encoder
|
|||
extraArgs += " -probesize " + ffmpegProbeSize;
|
||||
}
|
||||
|
||||
if (request.MediaSource.RequiredHttpHeaders.TryGetValue("user_agent", out var userAgent))
|
||||
if (request.MediaSource.RequiredHttpHeaders.TryGetValue("User-Agent", out var userAgent))
|
||||
{
|
||||
extraArgs += " -user_agent " + userAgent;
|
||||
extraArgs += $" -user_agent \"{userAgent}\"";
|
||||
}
|
||||
|
||||
if (request.MediaSource.Protocol == MediaProtocol.Rtsp)
|
||||
|
@ -621,13 +619,13 @@ namespace MediaBrowser.MediaEncoding.Encoder
|
|||
ImageFormat? targetFormat,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var inputArgument = GetInputArgument(inputFile, mediaSource);
|
||||
var inputArgument = GetInputPathArgument(inputFile, mediaSource);
|
||||
|
||||
if (!isAudio)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await ExtractImageInternal(inputArgument, container, videoStream, imageStreamIndex, threedFormat, offset, true, targetFormat, cancellationToken).ConfigureAwait(false);
|
||||
return await ExtractImageInternal(inputArgument, container, videoStream, imageStreamIndex, threedFormat, offset, true, targetFormat, false, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
|
@ -639,7 +637,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
|
|||
}
|
||||
}
|
||||
|
||||
return await ExtractImageInternal(inputArgument, container, videoStream, imageStreamIndex, threedFormat, offset, false, targetFormat, cancellationToken).ConfigureAwait(false);
|
||||
return await ExtractImageInternal(inputArgument, container, videoStream, imageStreamIndex, threedFormat, offset, false, targetFormat, isAudio, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private string GetImageResolutionParameter()
|
||||
|
@ -665,7 +663,17 @@ namespace MediaBrowser.MediaEncoding.Encoder
|
|||
return imageResolutionParameter;
|
||||
}
|
||||
|
||||
private async Task<string> ExtractImageInternal(string inputPath, string container, MediaStream videoStream, int? imageStreamIndex, Video3DFormat? threedFormat, TimeSpan? offset, bool useIFrame, ImageFormat? targetFormat, CancellationToken cancellationToken)
|
||||
private async Task<string> ExtractImageInternal(
|
||||
string inputPath,
|
||||
string container,
|
||||
MediaStream videoStream,
|
||||
int? imageStreamIndex,
|
||||
Video3DFormat? threedFormat,
|
||||
TimeSpan? offset,
|
||||
bool useIFrame,
|
||||
ImageFormat? targetFormat,
|
||||
bool isAudio,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(inputPath);
|
||||
|
||||
|
@ -724,7 +732,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
|
|||
|
||||
var vf = string.Join(',', filters);
|
||||
var mapArg = imageStreamIndex.HasValue ? (" -map 0:" + imageStreamIndex.Value.ToString(CultureInfo.InvariantCulture)) : string.Empty;
|
||||
var args = string.Format(CultureInfo.InvariantCulture, "-i {0}{3} -threads {4} -v quiet -vframes 1 -vf {2}{5} -f image2 \"{1}\"", inputPath, tempExtractPath, vf, mapArg, _threads, GetImageResolutionParameter());
|
||||
var args = string.Format(CultureInfo.InvariantCulture, "-i {0}{3} -threads {4} -v quiet -vframes 1 -vf {2}{5} -f image2 \"{1}\"", inputPath, tempExtractPath, vf, mapArg, _threads, isAudio ? string.Empty : GetImageResolutionParameter());
|
||||
|
||||
if (offset.HasValue)
|
||||
{
|
||||
|
@ -824,6 +832,22 @@ namespace MediaBrowser.MediaEncoding.Encoder
|
|||
options.EnableTonemapping = false;
|
||||
}
|
||||
|
||||
if (imageStream.Width is not null && imageStream.Height is not null && !string.IsNullOrEmpty(imageStream.AspectRatio))
|
||||
{
|
||||
// For hardware trickplay encoders, we need to re-calculate the size because they used fixed scale dimensions
|
||||
var darParts = imageStream.AspectRatio.Split(':');
|
||||
var (wa, ha) = (double.Parse(darParts[0], CultureInfo.InvariantCulture), double.Parse(darParts[1], CultureInfo.InvariantCulture));
|
||||
// When dimension / DAR does not equal to 1:1, then the frames are most likely stored stretched.
|
||||
// Note: this might be incorrect for 3D videos as the SAR stored might be per eye instead of per video, but we really can do little about it.
|
||||
var shouldResetHeight = Math.Abs((imageStream.Width.Value * ha) - (imageStream.Height.Value * wa)) > .05;
|
||||
if (shouldResetHeight)
|
||||
{
|
||||
// SAR = DAR * Height / Width
|
||||
// RealHeight = Height / SAR = Height / (DAR * Height / Width) = Width / DAR
|
||||
imageStream.Height = Convert.ToInt32(imageStream.Width.Value * ha / wa);
|
||||
}
|
||||
}
|
||||
|
||||
var baseRequest = new BaseEncodingJobOptions { MaxWidth = maxWidth, MaxFramerate = (float)(1.0 / interval.TotalSeconds) };
|
||||
var jobState = new EncodingJobInfo(TranscodingJobType.Progressive)
|
||||
{
|
||||
|
@ -871,6 +895,15 @@ namespace MediaBrowser.MediaEncoding.Encoder
|
|||
throw new InvalidOperationException("Empty or invalid input argument.");
|
||||
}
|
||||
|
||||
float? encoderQuality = qualityScale;
|
||||
if (vidEncoder.Contains("vaapi", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// vaapi's mjpeg encoder uses jpeg quality divided by QP2LAMBDA (118) as input, instead of ffmpeg defined qscale
|
||||
// ffmpeg qscale is a value from 1-31, with 1 being best quality and 31 being worst
|
||||
// jpeg quality is a value from 0-100, with 0 being worst quality and 100 being best
|
||||
encoderQuality = (100 - ((qualityScale - 1) * (100 / 30))) / 118;
|
||||
}
|
||||
|
||||
// Output arguments
|
||||
var targetDirectory = Path.Combine(_configurationManager.ApplicationPaths.TempDirectory, Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(targetDirectory);
|
||||
|
@ -884,7 +917,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
|
|||
filterParam,
|
||||
outputThreads.GetValueOrDefault(_threads),
|
||||
vidEncoder,
|
||||
qualityScale.HasValue ? "-qscale:v " + qualityScale.Value.ToString(CultureInfo.InvariantCulture) + " " : string.Empty,
|
||||
qualityScale.HasValue ? "-qscale:v " + encoderQuality.Value.ToString(CultureInfo.InvariantCulture) + " " : string.Empty,
|
||||
"image2",
|
||||
outputPath);
|
||||
|
||||
|
@ -936,7 +969,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
|
|||
var timeoutMs = _configurationManager.Configuration.ImageExtractionTimeoutMs;
|
||||
timeoutMs = timeoutMs <= 0 ? DefaultHdrImageExtractionTimeout : timeoutMs;
|
||||
|
||||
while (isResponsive)
|
||||
while (isResponsive && !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
|
@ -950,8 +983,6 @@ namespace MediaBrowser.MediaEncoding.Encoder
|
|||
// We don't actually expect the process to be finished in one timeout span, just that one image has been generated.
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var jpegCount = _fileSystem.GetFilePaths(targetDirectory).Count();
|
||||
|
||||
isResponsive = jpegCount > lastCount;
|
||||
|
@ -960,7 +991,12 @@ namespace MediaBrowser.MediaEncoding.Encoder
|
|||
|
||||
if (!ranToCompletion)
|
||||
{
|
||||
_logger.LogInformation("Stopping trickplay extraction due to process inactivity.");
|
||||
if (!isResponsive)
|
||||
{
|
||||
_logger.LogInformation("Trickplay process unresponsive.");
|
||||
}
|
||||
|
||||
_logger.LogInformation("Stopping trickplay extraction.");
|
||||
StopProcess(processWrapper, 1000);
|
||||
}
|
||||
}
|
||||
|
@ -1128,16 +1164,29 @@ namespace MediaBrowser.MediaEncoding.Encoder
|
|||
var validPlaybackFiles = _blurayExaminer.GetDiscInfo(path).Files;
|
||||
|
||||
// Get all files from the BDMV/STREAMING directory
|
||||
var directoryFiles = _fileSystem.GetFiles(Path.Join(path, "BDMV", "STREAM"));
|
||||
|
||||
// Only return playable local .m2ts files
|
||||
return directoryFiles
|
||||
.Where(f => validPlaybackFiles.Contains(f.Name, StringComparer.OrdinalIgnoreCase))
|
||||
.Select(f => f.FullName)
|
||||
.Order()
|
||||
var files = _fileSystem.GetFiles(Path.Join(path, "BDMV", "STREAM")).ToList();
|
||||
return validPlaybackFiles
|
||||
.Select(validFile => files.FirstOrDefault(f => Path.GetFileName(f.FullName.AsSpan()).Equals(validFile, StringComparison.OrdinalIgnoreCase))?.FullName)
|
||||
.Where(f => f is not null)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GetInputPathArgument(EncodingJobInfo state)
|
||||
=> GetInputPathArgument(state.MediaPath, state.MediaSource);
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GetInputPathArgument(string path, MediaSourceInfo mediaSource)
|
||||
{
|
||||
return mediaSource.VideoType switch
|
||||
{
|
||||
VideoType.Dvd => GetInputArgument(GetPrimaryPlaylistVobFiles(path, null).ToList(), mediaSource),
|
||||
VideoType.BluRay => GetInputArgument(GetPrimaryPlaylistM2tsFiles(path).ToList(), mediaSource),
|
||||
_ => GetInputArgument(path, mediaSource)
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void GenerateConcatConfig(MediaSourceInfo source, string concatFilePath)
|
||||
{
|
||||
|
@ -1158,7 +1207,8 @@ namespace MediaBrowser.MediaEncoding.Encoder
|
|||
}
|
||||
|
||||
// Generate concat configuration entries for each file and write to file
|
||||
using StreamWriter sw = new StreamWriter(concatFilePath);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(concatFilePath));
|
||||
using var sw = new FormattingStreamWriter(concatFilePath, CultureInfo.InvariantCulture);
|
||||
foreach (var path in files)
|
||||
{
|
||||
var mediaInfoResult = GetMediaInfo(
|
||||
|
@ -1177,7 +1227,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
|
|||
var duration = TimeSpan.FromTicks(mediaInfoResult.RunTimeTicks.Value).TotalSeconds;
|
||||
|
||||
// Add file path stanza to concat configuration
|
||||
sw.WriteLine("file '{0}'", path);
|
||||
sw.WriteLine("file '{0}'", path.Replace("'", @"'\''", StringComparison.Ordinal));
|
||||
|
||||
// Add duration stanza to concat configuration
|
||||
sw.WriteLine("duration {0}", duration);
|
||||
|
|
|
@ -280,8 +280,8 @@ namespace MediaBrowser.MediaEncoding.Probing
|
|||
splitFormat[i] = "mpeg";
|
||||
}
|
||||
|
||||
// Handle MPEG-2 container
|
||||
else if (string.Equals(splitFormat[i], "mpeg", StringComparison.OrdinalIgnoreCase))
|
||||
// Handle MPEG-TS container
|
||||
else if (string.Equals(splitFormat[i], "mpegts", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
splitFormat[i] = "ts";
|
||||
}
|
||||
|
@ -624,15 +624,19 @@ namespace MediaBrowser.MediaEncoding.Probing
|
|||
{
|
||||
if (string.Equals(codec, "dvb_subtitle", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
codec = "dvbsub";
|
||||
codec = "DVBSUB";
|
||||
}
|
||||
else if ((codec ?? string.Empty).Contains("PGS", StringComparison.OrdinalIgnoreCase))
|
||||
else if (string.Equals(codec, "dvb_teletext", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
codec = "PGSSUB";
|
||||
codec = "DVBTXT";
|
||||
}
|
||||
else if ((codec ?? string.Empty).Contains("DVD", StringComparison.OrdinalIgnoreCase))
|
||||
else if (string.Equals(codec, "dvd_subtitle", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
codec = "DVDSUB";
|
||||
codec = "DVDSUB"; // .sub+.idx
|
||||
}
|
||||
else if (string.Equals(codec, "hdmv_pgs_subtitle", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
codec = "PGSSUB"; // .sup
|
||||
}
|
||||
|
||||
return codec;
|
||||
|
@ -717,6 +721,8 @@ namespace MediaBrowser.MediaEncoding.Probing
|
|||
if (streamInfo.CodecType == CodecType.Audio)
|
||||
{
|
||||
stream.Type = MediaStreamType.Audio;
|
||||
stream.LocalizedDefault = _localization.GetLocalizedString("Default");
|
||||
stream.LocalizedExternal = _localization.GetLocalizedString("External");
|
||||
|
||||
stream.Channels = streamInfo.Channels;
|
||||
|
||||
|
@ -779,11 +785,10 @@ namespace MediaBrowser.MediaEncoding.Probing
|
|||
&& !string.Equals(streamInfo.FieldOrder, "progressive", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (isAudio
|
||||
&& (string.Equals(stream.Codec, "bmp", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(stream.Codec, "gif", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(stream.Codec, "mjpeg", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(stream.Codec, "png", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(stream.Codec, "webp", StringComparison.OrdinalIgnoreCase)))
|
||||
|| string.Equals(stream.Codec, "bmp", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(stream.Codec, "gif", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(stream.Codec, "png", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(stream.Codec, "webp", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
stream.Type = MediaStreamType.EmbeddedImage;
|
||||
}
|
||||
|
|
|
@ -501,11 +501,11 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
|||
List<MediaStream> subtitleStreams,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var inputPath = mediaSource.Path;
|
||||
var inputPath = _mediaEncoder.GetInputArgument(mediaSource.Path, mediaSource);
|
||||
var outputPaths = new List<string>();
|
||||
var args = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"-i \"{0}\" -copyts",
|
||||
"-i {0} -copyts",
|
||||
inputPath);
|
||||
|
||||
foreach (var subtitleStream in subtitleStreams)
|
||||
|
@ -676,7 +676,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
|
|||
|
||||
var processArgs = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"-i \"{0}\" -copyts -map 0:{1} -an -vn -c:s {2} \"{3}\"",
|
||||
"-i {0} -copyts -map 0:{1} -an -vn -c:s {2} \"{3}\"",
|
||||
inputPath,
|
||||
subtitleStreamIndex,
|
||||
outputCodec,
|
||||
|
|
|
@ -51,6 +51,8 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable
|
|||
o.PoolInitialFill = 1;
|
||||
});
|
||||
|
||||
private readonly Version _maxFFmpegCkeyPauseSupported = new Version(6, 1);
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TranscodeManager"/> class.
|
||||
/// </summary>
|
||||
|
@ -559,7 +561,9 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable
|
|||
|
||||
private void StartThrottler(StreamState state, TranscodingJob transcodingJob)
|
||||
{
|
||||
if (EnableThrottling(state))
|
||||
if (EnableThrottling(state)
|
||||
&& (_mediaEncoder.IsPkeyPauseSupported
|
||||
|| _mediaEncoder.EncoderVersion <= _maxFFmpegCkeyPauseSupported))
|
||||
{
|
||||
transcodingJob.TranscodingThrottler = new TranscodingThrottler(transcodingJob, _loggerFactory.CreateLogger<TranscodingThrottler>(), _serverConfigurationManager, _fileSystem, _mediaEncoder);
|
||||
transcodingJob.TranscodingThrottler.Start();
|
||||
|
|
|
@ -858,7 +858,7 @@ namespace MediaBrowser.Model.Dlna
|
|||
{
|
||||
audioStream = directAudioStream;
|
||||
playlistItem.AudioStreamIndex = audioStream.Index;
|
||||
playlistItem.AudioCodecs = new[] { audioStream.Codec };
|
||||
playlistItem.AudioCodecs = audioCodecs = new[] { audioStream.Codec };
|
||||
|
||||
// Copy matching audio codec options
|
||||
playlistItem.AudioSampleRate = audioStream.SampleRate;
|
||||
|
@ -897,18 +897,18 @@ namespace MediaBrowser.Model.Dlna
|
|||
|
||||
var appliedVideoConditions = options.Profile.CodecProfiles
|
||||
.Where(i => i.Type == CodecType.Video &&
|
||||
i.ContainsAnyCodec(videoStream?.Codec, container) &&
|
||||
i.ApplyConditions.All(applyCondition => ConditionProcessor.IsVideoConditionSatisfied(applyCondition, width, height, bitDepth, videoBitrate, videoProfile, videoRangeType, videoLevel, videoFramerate, packetLength, timestamp, isAnamorphic, isInterlaced, refFrames, numVideoStreams, numAudioStreams, videoCodecTag, isAvc)));
|
||||
var isFirstAppliedCodecProfile = true;
|
||||
i.ContainsAnyCodec(videoCodecs, container) &&
|
||||
i.ApplyConditions.All(applyCondition => ConditionProcessor.IsVideoConditionSatisfied(applyCondition, width, height, bitDepth, videoBitrate, videoProfile, videoRangeType, videoLevel, videoFramerate, packetLength, timestamp, isAnamorphic, isInterlaced, refFrames, numVideoStreams, numAudioStreams, videoCodecTag, isAvc)))
|
||||
// Reverse codec profiles for backward compatibility - first codec profile has higher priority
|
||||
.Reverse();
|
||||
|
||||
foreach (var i in appliedVideoConditions)
|
||||
{
|
||||
var transcodingVideoCodecs = ContainerProfile.SplitValue(videoCodec);
|
||||
foreach (var transcodingVideoCodec in transcodingVideoCodecs)
|
||||
foreach (var transcodingVideoCodec in videoCodecs)
|
||||
{
|
||||
if (i.ContainsAnyCodec(transcodingVideoCodec, container))
|
||||
{
|
||||
ApplyTranscodingConditions(playlistItem, i.Conditions, transcodingVideoCodec, true, isFirstAppliedCodecProfile);
|
||||
isFirstAppliedCodecProfile = false;
|
||||
ApplyTranscodingConditions(playlistItem, i.Conditions, transcodingVideoCodec, true, true);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
@ -929,18 +929,18 @@ namespace MediaBrowser.Model.Dlna
|
|||
|
||||
var appliedAudioConditions = options.Profile.CodecProfiles
|
||||
.Where(i => i.Type == CodecType.VideoAudio &&
|
||||
i.ContainsAnyCodec(audioStream?.Codec, container) &&
|
||||
i.ApplyConditions.All(applyCondition => ConditionProcessor.IsVideoAudioConditionSatisfied(applyCondition, audioChannels, inputAudioBitrate, inputAudioSampleRate, inputAudioBitDepth, audioProfile, isSecondaryAudio)));
|
||||
isFirstAppliedCodecProfile = true;
|
||||
i.ContainsAnyCodec(audioCodecs, container) &&
|
||||
i.ApplyConditions.All(applyCondition => ConditionProcessor.IsVideoAudioConditionSatisfied(applyCondition, audioChannels, inputAudioBitrate, inputAudioSampleRate, inputAudioBitDepth, audioProfile, isSecondaryAudio)))
|
||||
// Reverse codec profiles for backward compatibility - first codec profile has higher priority
|
||||
.Reverse();
|
||||
|
||||
foreach (var codecProfile in appliedAudioConditions)
|
||||
{
|
||||
var transcodingAudioCodecs = ContainerProfile.SplitValue(audioCodec);
|
||||
foreach (var transcodingAudioCodec in transcodingAudioCodecs)
|
||||
foreach (var transcodingAudioCodec in audioCodecs)
|
||||
{
|
||||
if (codecProfile.ContainsAnyCodec(transcodingAudioCodec, container))
|
||||
{
|
||||
ApplyTranscodingConditions(playlistItem, codecProfile.Conditions, transcodingAudioCodec, true, isFirstAppliedCodecProfile);
|
||||
isFirstAppliedCodecProfile = false;
|
||||
ApplyTranscodingConditions(playlistItem, codecProfile.Conditions, transcodingAudioCodec, true, true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -656,14 +656,14 @@ namespace MediaBrowser.Model.Entities
|
|||
{
|
||||
string codec = format ?? string.Empty;
|
||||
|
||||
// sub = external .sub file
|
||||
// microdvd and dvdsub/vobsub share the ".sub" file extension, but it's text-based.
|
||||
|
||||
return !codec.Contains("pgs", StringComparison.OrdinalIgnoreCase)
|
||||
&& !codec.Contains("dvd", StringComparison.OrdinalIgnoreCase)
|
||||
&& !codec.Contains("dvbsub", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(codec, "sub", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(codec, "sup", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(codec, "dvb_subtitle", StringComparison.OrdinalIgnoreCase);
|
||||
return codec.Contains("microdvd", StringComparison.OrdinalIgnoreCase)
|
||||
|| (!codec.Contains("pgs", StringComparison.OrdinalIgnoreCase)
|
||||
&& !codec.Contains("dvdsub", StringComparison.OrdinalIgnoreCase)
|
||||
&& !codec.Contains("dvbsub", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(codec, "sup", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(codec, "sub", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
public bool SupportsSubtitleConversionTo(string toCodec)
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
<PropertyGroup>
|
||||
<Authors>Jellyfin Contributors</Authors>
|
||||
<PackageId>Jellyfin.Model</PackageId>
|
||||
<VersionPrefix>10.9.0</VersionPrefix>
|
||||
<VersionPrefix>10.9.11</VersionPrefix>
|
||||
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
||||
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
||||
</PropertyGroup>
|
||||
|
|
|
@ -43,7 +43,7 @@ namespace MediaBrowser.Model.Search
|
|||
/// Gets or sets the matched term.
|
||||
/// </summary>
|
||||
/// <value>The matched term.</value>
|
||||
public string MatchedTerm { get; set; }
|
||||
public string? MatchedTerm { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the index number.
|
||||
|
|
|
@ -14,6 +14,7 @@ using MediaBrowser.Common.Configuration;
|
|||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.Audio;
|
||||
using MediaBrowser.Controller.IO;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Model.Configuration;
|
||||
using MediaBrowser.Model.Entities;
|
||||
|
@ -100,8 +101,8 @@ namespace MediaBrowser.Providers.Manager
|
|||
{
|
||||
saveLocally = false;
|
||||
|
||||
// If season is virtual under a physical series, save locally if using compatible convention
|
||||
if (item is Season season && _config.Configuration.ImageSavingConvention == ImageSavingConvention.Compatible)
|
||||
// If season is virtual under a physical series, save locally
|
||||
if (item is Season season)
|
||||
{
|
||||
var series = season.Series;
|
||||
|
||||
|
@ -126,7 +127,11 @@ namespace MediaBrowser.Providers.Manager
|
|||
|
||||
var paths = GetSavePaths(item, type, imageIndex, mimeType, saveLocally);
|
||||
|
||||
var retryPaths = GetSavePaths(item, type, imageIndex, mimeType, false);
|
||||
string[] retryPaths = [];
|
||||
if (saveLocally)
|
||||
{
|
||||
retryPaths = GetSavePaths(item, type, imageIndex, mimeType, false);
|
||||
}
|
||||
|
||||
// If there are more than one output paths, the stream will need to be seekable
|
||||
if (paths.Length > 1 && !source.CanSeek)
|
||||
|
@ -183,6 +188,29 @@ namespace MediaBrowser.Providers.Manager
|
|||
try
|
||||
{
|
||||
_fileSystem.DeleteFile(currentPath);
|
||||
|
||||
// Remove local episode metadata directory if it exists and is empty
|
||||
var directory = Path.GetDirectoryName(currentPath);
|
||||
if (item is Episode && directory.Equals("metadata", StringComparison.Ordinal))
|
||||
{
|
||||
var parentDirectoryPath = Directory.GetParent(currentPath).FullName;
|
||||
if (_fileSystem.DirectoryExists(parentDirectoryPath) && !_fileSystem.GetFiles(parentDirectoryPath).Any())
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Deleting empty local metadata folder {Folder}", parentDirectoryPath);
|
||||
Directory.Delete(parentDirectoryPath);
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error deleting directory {Path}", parentDirectoryPath);
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error deleting directory {Path}", parentDirectoryPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
|
@ -374,6 +402,47 @@ namespace MediaBrowser.Providers.Manager
|
|||
throw new ArgumentException(string.Format(CultureInfo.InvariantCulture, "Unable to determine image file extension from mime type {0}", mimeType));
|
||||
}
|
||||
|
||||
if (string.Equals(extension, ".jpeg", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
extension = ".jpg";
|
||||
}
|
||||
|
||||
extension = extension.ToLowerInvariant();
|
||||
|
||||
if (type == ImageType.Primary && saveLocally)
|
||||
{
|
||||
if (season is not null && season.IndexNumber.HasValue)
|
||||
{
|
||||
var seriesFolder = season.SeriesPath;
|
||||
|
||||
var seasonMarker = season.IndexNumber.Value == 0
|
||||
? "-specials"
|
||||
: season.IndexNumber.Value.ToString("00", CultureInfo.InvariantCulture);
|
||||
|
||||
var imageFilename = "season" + seasonMarker + "-poster" + extension;
|
||||
|
||||
return Path.Combine(seriesFolder, imageFilename);
|
||||
}
|
||||
}
|
||||
|
||||
if (type == ImageType.Backdrop && saveLocally)
|
||||
{
|
||||
if (season is not null
|
||||
&& season.IndexNumber.HasValue
|
||||
&& (imageIndex is null || imageIndex == 0))
|
||||
{
|
||||
var seriesFolder = season.SeriesPath;
|
||||
|
||||
var seasonMarker = season.IndexNumber.Value == 0
|
||||
? "-specials"
|
||||
: season.IndexNumber.Value.ToString("00", CultureInfo.InvariantCulture);
|
||||
|
||||
var imageFilename = "season" + seasonMarker + "-fanart" + extension;
|
||||
|
||||
return Path.Combine(seriesFolder, imageFilename);
|
||||
}
|
||||
}
|
||||
|
||||
if (type == ImageType.Thumb && saveLocally)
|
||||
{
|
||||
if (season is not null && season.IndexNumber.HasValue)
|
||||
|
@ -447,20 +516,12 @@ namespace MediaBrowser.Providers.Manager
|
|||
break;
|
||||
}
|
||||
|
||||
if (string.Equals(extension, ".jpeg", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
extension = ".jpg";
|
||||
}
|
||||
|
||||
extension = extension.ToLowerInvariant();
|
||||
|
||||
string path = null;
|
||||
|
||||
if (saveLocally)
|
||||
{
|
||||
if (type == ImageType.Primary && item is Episode)
|
||||
{
|
||||
path = Path.Combine(Path.GetDirectoryName(item.Path), "metadata", filename + extension);
|
||||
path = Path.Combine(Path.GetDirectoryName(item.Path), filename + "-thumb" + extension);
|
||||
}
|
||||
else if (item.IsInMixedFolder)
|
||||
{
|
||||
|
|
|
@ -10,6 +10,7 @@ using System.Threading;
|
|||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.Audio;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.LiveTv;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
|
@ -96,7 +97,7 @@ namespace MediaBrowser.Providers.Manager
|
|||
public bool ValidateImages(BaseItem item, IEnumerable<IImageProvider> providers, ImageRefreshOptions refreshOptions)
|
||||
{
|
||||
var hasChanges = false;
|
||||
IDirectoryService directoryService = refreshOptions?.DirectoryService;
|
||||
var directoryService = refreshOptions?.DirectoryService;
|
||||
|
||||
if (item is not Photo)
|
||||
{
|
||||
|
@ -158,7 +159,7 @@ namespace MediaBrowser.Providers.Manager
|
|||
}
|
||||
}
|
||||
|
||||
// only delete existing multi-images if new ones were added
|
||||
// Only delete existing multi-images if new ones were added
|
||||
if (oldBackdropImages.Length > 0 && oldBackdropImages.Length < item.GetImages(ImageType.Backdrop).Count())
|
||||
{
|
||||
PruneImages(item, oldBackdropImages);
|
||||
|
@ -359,10 +360,8 @@ namespace MediaBrowser.Providers.Manager
|
|||
|
||||
private void PruneImages(BaseItem item, IReadOnlyList<ItemImageInfo> images)
|
||||
{
|
||||
for (var i = 0; i < images.Count; i++)
|
||||
foreach (var image in images)
|
||||
{
|
||||
var image = images[i];
|
||||
|
||||
if (image.IsLocalFile)
|
||||
{
|
||||
try
|
||||
|
@ -371,7 +370,7 @@ namespace MediaBrowser.Providers.Manager
|
|||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
// nothing to do, already gone
|
||||
// Nothing to do, already gone
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
|
@ -381,6 +380,16 @@ namespace MediaBrowser.Providers.Manager
|
|||
}
|
||||
|
||||
item.RemoveImages(images);
|
||||
|
||||
// Cleanup old metadata directory for episodes if empty
|
||||
if (item is Episode)
|
||||
{
|
||||
var oldLocalMetadataDirectory = Path.Combine(item.ContainingFolderPath, "metadata");
|
||||
if (_fileSystem.DirectoryExists(oldLocalMetadataDirectory) && !_fileSystem.GetFiles(oldLocalMetadataDirectory).Any())
|
||||
{
|
||||
Directory.Delete(oldLocalMetadataDirectory);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
@ -413,12 +422,10 @@ namespace MediaBrowser.Providers.Manager
|
|||
{
|
||||
var changed = item.ValidateImages();
|
||||
var foundImageTypes = new List<ImageType>();
|
||||
|
||||
for (var i = 0; i < _singularImages.Length; i++)
|
||||
{
|
||||
var type = _singularImages[i];
|
||||
var image = GetFirstLocalImageInfoByType(images, type);
|
||||
|
||||
if (image is not null)
|
||||
{
|
||||
var currentImage = item.GetImageInfo(type, 0);
|
||||
|
|
|
@ -8,7 +8,6 @@ using System.Linq;
|
|||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
|
@ -93,10 +92,6 @@ namespace MediaBrowser.Providers.Manager
|
|||
}
|
||||
}
|
||||
|
||||
var localImagesFailed = false;
|
||||
|
||||
var allImageProviders = ProviderManager.GetImageProviders(item, refreshOptions).ToList();
|
||||
|
||||
if (refreshOptions.RemoveOldMetadata && refreshOptions.ReplaceAllImages)
|
||||
{
|
||||
if (ImageProvider.RemoveImages(item))
|
||||
|
@ -105,24 +100,35 @@ namespace MediaBrowser.Providers.Manager
|
|||
}
|
||||
}
|
||||
|
||||
// Start by validating images
|
||||
try
|
||||
var localImagesFailed = false;
|
||||
var allImageProviders = ProviderManager.GetImageProviders(item, refreshOptions).ToList();
|
||||
|
||||
// Only validate already registered images if we are replacing and saving locally
|
||||
if (item.IsSaveLocalMetadataEnabled() && refreshOptions.ReplaceAllImages)
|
||||
{
|
||||
// Always validate images and check for new locally stored ones.
|
||||
if (ImageProvider.ValidateImages(item, allImageProviders.OfType<ILocalImageProvider>(), refreshOptions))
|
||||
{
|
||||
updateType |= ItemUpdateType.ImageUpdate;
|
||||
}
|
||||
item.ValidateImages();
|
||||
}
|
||||
catch (Exception ex)
|
||||
else
|
||||
{
|
||||
localImagesFailed = true;
|
||||
Logger.LogError(ex, "Error validating images for {Item}", item.Path ?? item.Name ?? "Unknown name");
|
||||
// Run full image validation and register new local images
|
||||
try
|
||||
{
|
||||
if (ImageProvider.ValidateImages(item, allImageProviders.OfType<ILocalImageProvider>(), refreshOptions))
|
||||
{
|
||||
updateType |= ItemUpdateType.ImageUpdate;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
localImagesFailed = true;
|
||||
Logger.LogError(ex, "Error validating images for {Item}", item.Path ?? item.Name ?? "Unknown name");
|
||||
}
|
||||
}
|
||||
|
||||
var metadataResult = new MetadataResult<TItemType>
|
||||
{
|
||||
Item = itemOfType
|
||||
Item = itemOfType,
|
||||
People = LibraryManager.GetPeople(item)
|
||||
};
|
||||
|
||||
bool hasRefreshedMetadata = true;
|
||||
|
@ -154,7 +160,8 @@ namespace MediaBrowser.Providers.Manager
|
|||
|
||||
id.IsAutomated = refreshOptions.IsAutomated;
|
||||
|
||||
var result = await RefreshWithProviders(metadataResult, id, refreshOptions, providers, ImageProvider, cancellationToken).ConfigureAwait(false);
|
||||
var hasMetadataSavers = ProviderManager.GetMetadataSavers(item, libraryOptions).Any();
|
||||
var result = await RefreshWithProviders(metadataResult, id, refreshOptions, providers, ImageProvider, hasMetadataSavers, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
updateType |= result.UpdateType;
|
||||
if (result.Failures > 0)
|
||||
|
@ -165,7 +172,7 @@ namespace MediaBrowser.Providers.Manager
|
|||
}
|
||||
|
||||
// Next run remote image providers, but only if local image providers didn't throw an exception
|
||||
if (!localImagesFailed && refreshOptions.ImageRefreshMode != MetadataRefreshMode.ValidationOnly)
|
||||
if (!localImagesFailed && refreshOptions.ImageRefreshMode > MetadataRefreshMode.ValidationOnly)
|
||||
{
|
||||
var providers = GetNonLocalImageProviders(item, allImageProviders, refreshOptions).ToList();
|
||||
|
||||
|
@ -243,7 +250,7 @@ namespace MediaBrowser.Providers.Manager
|
|||
|
||||
protected async Task SaveItemAsync(MetadataResult<TItemType> result, ItemUpdateType reason, CancellationToken cancellationToken)
|
||||
{
|
||||
if (result.Item.SupportsPeople && result.People is not null)
|
||||
if (result.Item.SupportsPeople)
|
||||
{
|
||||
var baseItem = result.Item;
|
||||
|
||||
|
@ -399,7 +406,8 @@ namespace MediaBrowser.Providers.Manager
|
|||
|
||||
foreach (var child in children)
|
||||
{
|
||||
if (!child.IsFolder)
|
||||
// Exclude any folders and virtual items since they are only placeholders
|
||||
if (!child.IsFolder && !child.IsVirtualItem)
|
||||
{
|
||||
var childDateCreated = child.DateCreated;
|
||||
if (childDateCreated > dateLastMediaAdded)
|
||||
|
@ -638,6 +646,7 @@ namespace MediaBrowser.Providers.Manager
|
|||
MetadataRefreshOptions options,
|
||||
ICollection<IMetadataProvider> providers,
|
||||
ItemImageProvider imageService,
|
||||
bool isSavingMetadata,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var refreshResult = new RefreshResult
|
||||
|
@ -655,102 +664,96 @@ namespace MediaBrowser.Providers.Manager
|
|||
await RunCustomProvider(provider, item, logName, options, refreshResult, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (item.IsLocked)
|
||||
{
|
||||
return refreshResult;
|
||||
}
|
||||
|
||||
var temp = new MetadataResult<TItemType>
|
||||
{
|
||||
Item = CreateNew()
|
||||
};
|
||||
temp.Item.Path = item.Path;
|
||||
temp.Item.Id = item.Id;
|
||||
temp.Item.PreferredMetadataCountryCode = item.PreferredMetadataCountryCode;
|
||||
temp.Item.PreferredMetadataLanguage = item.PreferredMetadataLanguage;
|
||||
|
||||
// If replacing all metadata, run internet providers first
|
||||
if (options.ReplaceAllMetadata)
|
||||
{
|
||||
var remoteResult = await ExecuteRemoteProviders(temp, logName, id, providers.OfType<IRemoteMetadataProvider<TItemType, TIdType>>(), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
refreshResult.UpdateType |= remoteResult.UpdateType;
|
||||
refreshResult.ErrorMessage = remoteResult.ErrorMessage;
|
||||
refreshResult.Failures += remoteResult.Failures;
|
||||
}
|
||||
|
||||
var hasLocalMetadata = false;
|
||||
var foundImageTypes = new List<ImageType>();
|
||||
|
||||
foreach (var provider in providers.OfType<ILocalMetadataProvider<TItemType>>())
|
||||
// Do not execute local providers if we are identifying or replacing with local metadata saving enabled
|
||||
if (options.SearchResult is null && !(isSavingMetadata && options.ReplaceAllMetadata))
|
||||
{
|
||||
var providerName = provider.GetType().Name;
|
||||
Logger.LogDebug("Running {Provider} for {Item}", providerName, logName);
|
||||
|
||||
var itemInfo = new ItemInfo(item);
|
||||
|
||||
try
|
||||
foreach (var provider in providers.OfType<ILocalMetadataProvider<TItemType>>())
|
||||
{
|
||||
var localItem = await provider.GetMetadata(itemInfo, options.DirectoryService, cancellationToken).ConfigureAwait(false);
|
||||
var providerName = provider.GetType().Name;
|
||||
Logger.LogDebug("Running {Provider} for {Item}", providerName, logName);
|
||||
|
||||
if (localItem.HasMetadata)
|
||||
var itemInfo = new ItemInfo(item);
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var remoteImage in localItem.RemoteImages)
|
||||
var localItem = await provider.GetMetadata(itemInfo, options.DirectoryService, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (localItem.HasMetadata)
|
||||
{
|
||||
try
|
||||
foreach (var remoteImage in localItem.RemoteImages)
|
||||
{
|
||||
if (item.ImageInfos.Any(x => x.Type == remoteImage.Type)
|
||||
&& !options.IsReplacingImage(remoteImage.Type))
|
||||
try
|
||||
{
|
||||
continue;
|
||||
if (item.ImageInfos.Any(x => x.Type == remoteImage.Type)
|
||||
&& !options.IsReplacingImage(remoteImage.Type))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
await ProviderManager.SaveImage(item, remoteImage.Url, remoteImage.Type, null, cancellationToken).ConfigureAwait(false);
|
||||
refreshResult.UpdateType |= ItemUpdateType.ImageUpdate;
|
||||
|
||||
// remember imagetype that has just been downloaded
|
||||
foundImageTypes.Add(remoteImage.Type);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
Logger.LogError(ex, "Could not save {ImageType} image: {Url}", Enum.GetName(remoteImage.Type), remoteImage.Url);
|
||||
}
|
||||
|
||||
await ProviderManager.SaveImage(item, remoteImage.Url, remoteImage.Type, null, cancellationToken).ConfigureAwait(false);
|
||||
refreshResult.UpdateType |= ItemUpdateType.ImageUpdate;
|
||||
|
||||
// remember imagetype that has just been downloaded
|
||||
foundImageTypes.Add(remoteImage.Type);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
|
||||
if (foundImageTypes.Count > 0)
|
||||
{
|
||||
Logger.LogError(ex, "Could not save {ImageType} image: {Url}", Enum.GetName(remoteImage.Type), remoteImage.Url);
|
||||
imageService.UpdateReplaceImages(options, foundImageTypes);
|
||||
}
|
||||
|
||||
if (imageService.MergeImages(item, localItem.Images, options))
|
||||
{
|
||||
refreshResult.UpdateType |= ItemUpdateType.ImageUpdate;
|
||||
}
|
||||
|
||||
MergeData(localItem, temp, Array.Empty<MetadataField>(), false, true);
|
||||
refreshResult.UpdateType |= ItemUpdateType.MetadataImport;
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
if (foundImageTypes.Count > 0)
|
||||
{
|
||||
imageService.UpdateReplaceImages(options, foundImageTypes);
|
||||
}
|
||||
|
||||
if (imageService.MergeImages(item, localItem.Images, options))
|
||||
{
|
||||
refreshResult.UpdateType |= ItemUpdateType.ImageUpdate;
|
||||
}
|
||||
|
||||
MergeData(localItem, temp, Array.Empty<MetadataField>(), options.ReplaceAllMetadata, true);
|
||||
refreshResult.UpdateType |= ItemUpdateType.MetadataImport;
|
||||
|
||||
// Only one local provider allowed per item
|
||||
if (item.IsLocked || localItem.Item.IsLocked || IsFullLocalMetadata(localItem.Item))
|
||||
{
|
||||
hasLocalMetadata = true;
|
||||
}
|
||||
|
||||
break;
|
||||
Logger.LogDebug("{Provider} returned no metadata for {Item}", providerName, logName);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Error in {Provider}", provider.Name);
|
||||
|
||||
Logger.LogDebug("{Provider} returned no metadata for {Item}", providerName, logName);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Error in {Provider}", provider.Name);
|
||||
|
||||
// If a local provider fails, consider that a failure
|
||||
refreshResult.ErrorMessage = ex.Message;
|
||||
// If a local provider fails, consider that a failure
|
||||
refreshResult.ErrorMessage = ex.Message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Local metadata is king - if any is found don't run remote providers
|
||||
if (!options.ReplaceAllMetadata && (!hasLocalMetadata || options.MetadataRefreshMode == MetadataRefreshMode.FullRefresh || !item.StopRefreshIfLocalMetadataFound))
|
||||
var isLocalLocked = temp.Item.IsLocked;
|
||||
if (!isLocalLocked && (options.ReplaceAllMetadata || options.MetadataRefreshMode > MetadataRefreshMode.ValidationOnly))
|
||||
{
|
||||
var remoteResult = await ExecuteRemoteProviders(temp, logName, id, providers.OfType<IRemoteMetadataProvider<TItemType, TIdType>>(), cancellationToken)
|
||||
var remoteResult = await ExecuteRemoteProviders(temp, logName, false, id, providers.OfType<IRemoteMetadataProvider<TItemType, TIdType>>(), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
refreshResult.UpdateType |= remoteResult.UpdateType;
|
||||
|
@ -762,19 +765,20 @@ namespace MediaBrowser.Providers.Manager
|
|||
{
|
||||
if (refreshResult.UpdateType > ItemUpdateType.None)
|
||||
{
|
||||
if (hasLocalMetadata)
|
||||
if (!options.RemoveOldMetadata)
|
||||
{
|
||||
// Add existing metadata to provider result if it does not exist there
|
||||
MergeData(metadata, temp, Array.Empty<MetadataField>(), false, false);
|
||||
}
|
||||
|
||||
if (isLocalLocked)
|
||||
{
|
||||
MergeData(temp, metadata, item.LockedFields, true, true);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!options.RemoveOldMetadata)
|
||||
{
|
||||
MergeData(metadata, temp, Array.Empty<MetadataField>(), false, false);
|
||||
}
|
||||
|
||||
// Will always replace all metadata when Scan for new and updated files is used. Else, follow the options.
|
||||
MergeData(temp, metadata, item.LockedFields, options.MetadataRefreshMode == MetadataRefreshMode.Default || options.ReplaceAllMetadata, false);
|
||||
var shouldReplace = options.MetadataRefreshMode > MetadataRefreshMode.ValidationOnly || options.ReplaceAllMetadata;
|
||||
MergeData(temp, metadata, item.LockedFields, shouldReplace, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -787,16 +791,6 @@ namespace MediaBrowser.Providers.Manager
|
|||
return refreshResult;
|
||||
}
|
||||
|
||||
protected virtual bool IsFullLocalMetadata(TItemType item)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(item.Name))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task RunCustomProvider(ICustomMetadataProvider<TItemType> provider, TItemType item, string logName, MetadataRefreshOptions options, RefreshResult refreshResult, CancellationToken cancellationToken)
|
||||
{
|
||||
Logger.LogDebug("Running {Provider} for {Item}", provider.GetType().Name, logName);
|
||||
|
@ -821,23 +815,20 @@ namespace MediaBrowser.Providers.Manager
|
|||
return new TItemType();
|
||||
}
|
||||
|
||||
private async Task<RefreshResult> ExecuteRemoteProviders(MetadataResult<TItemType> temp, string logName, TIdType id, IEnumerable<IRemoteMetadataProvider<TItemType, TIdType>> providers, CancellationToken cancellationToken)
|
||||
private async Task<RefreshResult> ExecuteRemoteProviders(MetadataResult<TItemType> temp, string logName, bool replaceData, TIdType id, IEnumerable<IRemoteMetadataProvider<TItemType, TIdType>> providers, CancellationToken cancellationToken)
|
||||
{
|
||||
var refreshResult = new RefreshResult();
|
||||
|
||||
var tmpDataMerged = false;
|
||||
if (id is not null)
|
||||
{
|
||||
MergeNewData(temp.Item, id);
|
||||
}
|
||||
|
||||
foreach (var provider in providers)
|
||||
{
|
||||
var providerName = provider.GetType().Name;
|
||||
Logger.LogDebug("Running {Provider} for {Item}", providerName, logName);
|
||||
|
||||
if (id is not null && !tmpDataMerged)
|
||||
{
|
||||
MergeNewData(temp.Item, id);
|
||||
tmpDataMerged = true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await provider.GetMetadata(id, cancellationToken).ConfigureAwait(false);
|
||||
|
@ -846,7 +837,7 @@ namespace MediaBrowser.Providers.Manager
|
|||
{
|
||||
result.Provider = provider.Name;
|
||||
|
||||
MergeData(result, temp, Array.Empty<MetadataField>(), false, false);
|
||||
MergeData(result, temp, Array.Empty<MetadataField>(), replaceData, false);
|
||||
MergeNewData(temp.Item, id);
|
||||
|
||||
refreshResult.UpdateType |= ItemUpdateType.MetadataDownload;
|
||||
|
@ -949,11 +940,7 @@ namespace MediaBrowser.Providers.Manager
|
|||
|
||||
if (replaceData || string.IsNullOrEmpty(target.OriginalTitle))
|
||||
{
|
||||
// Safeguard against incoming data having an empty name
|
||||
if (!string.IsNullOrWhiteSpace(source.OriginalTitle))
|
||||
{
|
||||
target.OriginalTitle = source.OriginalTitle;
|
||||
}
|
||||
target.OriginalTitle = source.OriginalTitle;
|
||||
}
|
||||
|
||||
if (replaceData || !target.CommunityRating.HasValue)
|
||||
|
@ -1016,7 +1003,7 @@ namespace MediaBrowser.Providers.Manager
|
|||
{
|
||||
targetResult.People = sourceResult.People;
|
||||
}
|
||||
else if (targetResult.People is not null && sourceResult.People is not null)
|
||||
else if (sourceResult.People is not null && sourceResult.People.Count >= 0)
|
||||
{
|
||||
MergePeople(sourceResult.People, targetResult.People);
|
||||
}
|
||||
|
@ -1049,6 +1036,10 @@ namespace MediaBrowser.Providers.Manager
|
|||
{
|
||||
target.Studios = source.Studios;
|
||||
}
|
||||
else
|
||||
{
|
||||
target.Studios = target.Studios.Concat(source.Studios).Distinct().ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
if (!lockedFields.Contains(MetadataField.Tags))
|
||||
|
@ -1057,6 +1048,10 @@ namespace MediaBrowser.Providers.Manager
|
|||
{
|
||||
target.Tags = source.Tags;
|
||||
}
|
||||
else
|
||||
{
|
||||
target.Tags = target.Tags.Concat(source.Tags).Distinct().ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
if (!lockedFields.Contains(MetadataField.ProductionLocations))
|
||||
|
@ -1065,6 +1060,10 @@ namespace MediaBrowser.Providers.Manager
|
|||
{
|
||||
target.ProductionLocations = source.ProductionLocations;
|
||||
}
|
||||
else
|
||||
{
|
||||
target.ProductionLocations = target.ProductionLocations.Concat(source.ProductionLocations).Distinct().ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var id in source.ProviderIds)
|
||||
|
@ -1082,17 +1081,28 @@ namespace MediaBrowser.Providers.Manager
|
|||
}
|
||||
}
|
||||
|
||||
if (replaceData || !target.CriticRating.HasValue)
|
||||
{
|
||||
target.CriticRating = source.CriticRating;
|
||||
}
|
||||
|
||||
if (replaceData || target.RemoteTrailers.Count == 0)
|
||||
{
|
||||
target.RemoteTrailers = source.RemoteTrailers;
|
||||
}
|
||||
else
|
||||
{
|
||||
target.RemoteTrailers = target.RemoteTrailers.Concat(source.RemoteTrailers).DistinctBy(t => t.Url).ToArray();
|
||||
}
|
||||
|
||||
MergeAlbumArtist(source, target, replaceData);
|
||||
MergeCriticRating(source, target, replaceData);
|
||||
MergeTrailers(source, target, replaceData);
|
||||
MergeVideoInfo(source, target, replaceData);
|
||||
MergeDisplayOrder(source, target, replaceData);
|
||||
|
||||
if (replaceData || string.IsNullOrEmpty(target.ForcedSortName))
|
||||
{
|
||||
var forcedSortName = source.ForcedSortName;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(forcedSortName))
|
||||
if (!string.IsNullOrEmpty(forcedSortName))
|
||||
{
|
||||
target.ForcedSortName = forcedSortName;
|
||||
}
|
||||
|
@ -1100,22 +1110,44 @@ namespace MediaBrowser.Providers.Manager
|
|||
|
||||
if (mergeMetadataSettings)
|
||||
{
|
||||
target.LockedFields = source.LockedFields;
|
||||
target.IsLocked = source.IsLocked;
|
||||
if (replaceData || !target.IsLocked)
|
||||
{
|
||||
target.IsLocked = target.IsLocked || source.IsLocked;
|
||||
}
|
||||
|
||||
if (target.LockedFields.Length == 0)
|
||||
{
|
||||
target.LockedFields = source.LockedFields;
|
||||
}
|
||||
else
|
||||
{
|
||||
target.LockedFields = target.LockedFields.Concat(source.LockedFields).Distinct().ToArray();
|
||||
}
|
||||
|
||||
// Grab the value if it's there, but if not then don't overwrite with the default
|
||||
if (source.DateCreated != default)
|
||||
{
|
||||
target.DateCreated = source.DateCreated;
|
||||
}
|
||||
|
||||
target.PreferredMetadataCountryCode = source.PreferredMetadataCountryCode;
|
||||
target.PreferredMetadataLanguage = source.PreferredMetadataLanguage;
|
||||
if (replaceData || string.IsNullOrEmpty(target.PreferredMetadataCountryCode))
|
||||
{
|
||||
target.PreferredMetadataCountryCode = source.PreferredMetadataCountryCode;
|
||||
}
|
||||
|
||||
if (replaceData || string.IsNullOrEmpty(target.PreferredMetadataLanguage))
|
||||
{
|
||||
target.PreferredMetadataLanguage = source.PreferredMetadataLanguage;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void MergePeople(List<PersonInfo> source, List<PersonInfo> target)
|
||||
{
|
||||
if (target is null)
|
||||
{
|
||||
target = new List<PersonInfo>();
|
||||
}
|
||||
|
||||
foreach (var person in target)
|
||||
{
|
||||
var normalizedName = person.Name.RemoveDiacritics();
|
||||
|
@ -1144,7 +1176,6 @@ namespace MediaBrowser.Providers.Manager
|
|||
if (replaceData || string.IsNullOrEmpty(targetHasDisplayOrder.DisplayOrder))
|
||||
{
|
||||
var displayOrder = sourceHasDisplayOrder.DisplayOrder;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(displayOrder))
|
||||
{
|
||||
targetHasDisplayOrder.DisplayOrder = displayOrder;
|
||||
|
@ -1162,22 +1193,10 @@ namespace MediaBrowser.Providers.Manager
|
|||
{
|
||||
targetHasAlbumArtist.AlbumArtists = sourceHasAlbumArtist.AlbumArtists;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void MergeCriticRating(BaseItem source, BaseItem target, bool replaceData)
|
||||
{
|
||||
if (replaceData || !target.CriticRating.HasValue)
|
||||
{
|
||||
target.CriticRating = source.CriticRating;
|
||||
}
|
||||
}
|
||||
|
||||
private static void MergeTrailers(BaseItem source, BaseItem target, bool replaceData)
|
||||
{
|
||||
if (replaceData || target.RemoteTrailers.Count == 0)
|
||||
{
|
||||
target.RemoteTrailers = source.RemoteTrailers;
|
||||
else if (sourceHasAlbumArtist.AlbumArtists.Count >= 0)
|
||||
{
|
||||
targetHasAlbumArtist.AlbumArtists = targetHasAlbumArtist.AlbumArtists.Concat(sourceHasAlbumArtist.AlbumArtists).Distinct().ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1185,7 +1204,7 @@ namespace MediaBrowser.Providers.Manager
|
|||
{
|
||||
if (source is Video sourceCast && target is Video targetCast)
|
||||
{
|
||||
if (replaceData || targetCast.Video3DFormat is null)
|
||||
if (replaceData || !targetCast.Video3DFormat.HasValue)
|
||||
{
|
||||
targetCast.Video3DFormat = sourceCast.Video3DFormat;
|
||||
}
|
||||
|
|
|
@ -418,6 +418,12 @@ namespace MediaBrowser.Providers.Manager
|
|||
return GetMetadataProvidersInternal<T>(item, libraryOptions, globalMetadataOptions, false, false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<IMetadataSaver> GetMetadataSavers(BaseItem item, LibraryOptions libraryOptions)
|
||||
{
|
||||
return _savers.Where(i => IsSaverEnabledForItem(i, item, libraryOptions, ItemUpdateType.MetadataEdit, false));
|
||||
}
|
||||
|
||||
private IEnumerable<IMetadataProvider<T>> GetMetadataProvidersInternal<T>(BaseItem item, LibraryOptions libraryOptions, MetadataOptions globalMetadataOptions, bool includeDisabled, bool forceEnableInternetMetadata)
|
||||
where T : BaseItem
|
||||
{
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user