mirror of
https://github.com/jellyfin/jellyfin.git
synced 2025-12-09 18:43:05 +03:00
Compare commits
266 Commits
renovate/m
...
v10.9.9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 }}
|
||||
|
||||
|
||||
@@ -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>
|
||||
</Project>
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
<PropertyGroup>
|
||||
<Authors>Jellyfin Contributors</Authors>
|
||||
<PackageId>Jellyfin.Naming</PackageId>
|
||||
<VersionPrefix>10.9.0</VersionPrefix>
|
||||
<VersionPrefix>10.9.9</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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -91,7 +91,7 @@ namespace Emby.Server.Implementations.TV
|
||||
}
|
||||
|
||||
string? presentationUniqueKey = null;
|
||||
int? limit = null;
|
||||
int? limit = request.Limit;
|
||||
if (!request.SeriesId.IsNullOrEmpty())
|
||||
{
|
||||
if (_libraryManager.GetItemById(request.SeriesId.Value) is Series series)
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.9</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.9</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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1952,14 +1949,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
|
||||
|
||||
@@ -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()
|
||||
|
||||
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>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<PropertyGroup>
|
||||
<Authors>Jellyfin Contributors</Authors>
|
||||
<PackageId>Jellyfin.Controller</PackageId>
|
||||
<VersionPrefix>10.9.0</VersionPrefix>
|
||||
<VersionPrefix>10.9.9</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))
|
||||
@@ -2083,6 +2076,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 +2321,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 +2638,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 +2653,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 +2779,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 +3169,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 +3200,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 +3220,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 +3232,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 +3245,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 +3258,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 +4306,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 +4316,7 @@ namespace MediaBrowser.Controller.MediaEncoding
|
||||
{
|
||||
// map from vaapi to qsv.
|
||||
mainFilters.Add("hwmap=derive_device=qsv");
|
||||
mainFilters.Add("format=qsv");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4468,7 +4491,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,28 @@ namespace MediaBrowser.MediaEncoding.Attachments
|
||||
string outputPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false))
|
||||
var shouldExtractOneByOne = mediaSource.MediaAttachments.Any(a => 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -477,7 +477,9 @@ namespace MediaBrowser.MediaEncoding.Encoder
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool CheckSupportedRuntimeKey(string keyDesc)
|
||||
private readonly Version _minFFmpegMultiThreadedCli = new Version(7, 0);
|
||||
|
||||
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,7 +619,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
|
||||
ImageFormat? targetFormat,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var inputArgument = GetInputArgument(inputFile, mediaSource);
|
||||
var inputArgument = GetInputPathArgument(inputFile, mediaSource);
|
||||
|
||||
if (!isAudio)
|
||||
{
|
||||
@@ -824,6 +822,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 +885,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 +907,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 +959,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
|
||||
var timeoutMs = _configurationManager.Configuration.ImageExtractionTimeoutMs;
|
||||
timeoutMs = timeoutMs <= 0 ? DefaultHdrImageExtractionTimeout : timeoutMs;
|
||||
|
||||
while (isResponsive)
|
||||
while (isResponsive && !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -950,8 +973,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 +981,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 +1154,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)
|
||||
{
|
||||
@@ -1177,7 +1216,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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.9</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
|
||||
{
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.Audio;
|
||||
using MediaBrowser.Controller.Library;
|
||||
@@ -15,6 +17,7 @@ using MediaBrowser.Model.Dlna;
|
||||
using MediaBrowser.Model.Dto;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.MediaInfo;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TagLib;
|
||||
|
||||
namespace MediaBrowser.Providers.MediaInfo
|
||||
@@ -27,6 +30,7 @@ namespace MediaBrowser.Providers.MediaInfo
|
||||
private readonly IMediaEncoder _mediaEncoder;
|
||||
private readonly IItemRepository _itemRepo;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly ILogger<AudioFileProber> _logger;
|
||||
private readonly IMediaSourceManager _mediaSourceManager;
|
||||
private readonly LyricResolver _lyricResolver;
|
||||
private readonly ILyricManager _lyricManager;
|
||||
@@ -34,6 +38,7 @@ namespace MediaBrowser.Providers.MediaInfo
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AudioFileProber"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
|
||||
/// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
|
||||
/// <param name="mediaEncoder">Instance of the <see cref="IMediaEncoder"/> interface.</param>
|
||||
/// <param name="itemRepo">Instance of the <see cref="IItemRepository"/> interface.</param>
|
||||
@@ -41,6 +46,7 @@ namespace MediaBrowser.Providers.MediaInfo
|
||||
/// <param name="lyricResolver">Instance of the <see cref="LyricResolver"/> interface.</param>
|
||||
/// <param name="lyricManager">Instance of the <see cref="ILyricManager"/> interface.</param>
|
||||
public AudioFileProber(
|
||||
ILogger<AudioFileProber> logger,
|
||||
IMediaSourceManager mediaSourceManager,
|
||||
IMediaEncoder mediaEncoder,
|
||||
IItemRepository itemRepo,
|
||||
@@ -51,6 +57,7 @@ namespace MediaBrowser.Providers.MediaInfo
|
||||
_mediaEncoder = mediaEncoder;
|
||||
_itemRepo = itemRepo;
|
||||
_libraryManager = libraryManager;
|
||||
_logger = logger;
|
||||
_mediaSourceManager = mediaSourceManager;
|
||||
_lyricResolver = lyricResolver;
|
||||
_lyricManager = lyricManager;
|
||||
@@ -130,6 +137,10 @@ namespace MediaBrowser.Providers.MediaInfo
|
||||
if (!audio.IsLocked)
|
||||
{
|
||||
await FetchDataFromTags(audio, mediaInfo, options, tryExtractEmbeddedLyrics).ConfigureAwait(false);
|
||||
if (tryExtractEmbeddedLyrics)
|
||||
{
|
||||
AddExternalLyrics(audio, mediaStreams, options);
|
||||
}
|
||||
}
|
||||
|
||||
audio.HasLyrics = mediaStreams.Any(s => s.Type == MediaStreamType.Lyric);
|
||||
@@ -146,191 +157,212 @@ namespace MediaBrowser.Providers.MediaInfo
|
||||
/// <param name="tryExtractEmbeddedLyrics">Whether to extract embedded lyrics to lrc file. </param>
|
||||
private async Task FetchDataFromTags(Audio audio, Model.MediaInfo.MediaInfo mediaInfo, MetadataRefreshOptions options, bool tryExtractEmbeddedLyrics)
|
||||
{
|
||||
using var file = TagLib.File.Create(audio.Path);
|
||||
var tagTypes = file.TagTypesOnDisk;
|
||||
Tag? tags = null;
|
||||
try
|
||||
{
|
||||
using var file = TagLib.File.Create(audio.Path);
|
||||
var tagTypes = file.TagTypesOnDisk;
|
||||
|
||||
if (tagTypes.HasFlag(TagTypes.Id3v2))
|
||||
{
|
||||
tags = file.GetTag(TagTypes.Id3v2);
|
||||
}
|
||||
else if (tagTypes.HasFlag(TagTypes.Ape))
|
||||
{
|
||||
tags = file.GetTag(TagTypes.Ape);
|
||||
}
|
||||
else if (tagTypes.HasFlag(TagTypes.FlacMetadata))
|
||||
{
|
||||
tags = file.GetTag(TagTypes.FlacMetadata);
|
||||
}
|
||||
else if (tagTypes.HasFlag(TagTypes.Apple))
|
||||
{
|
||||
tags = file.GetTag(TagTypes.Apple);
|
||||
}
|
||||
else if (tagTypes.HasFlag(TagTypes.Xiph))
|
||||
{
|
||||
tags = file.GetTag(TagTypes.Xiph);
|
||||
}
|
||||
else if (tagTypes.HasFlag(TagTypes.AudibleMetadata))
|
||||
{
|
||||
tags = file.GetTag(TagTypes.AudibleMetadata);
|
||||
}
|
||||
else if (tagTypes.HasFlag(TagTypes.Id3v1))
|
||||
{
|
||||
tags = file.GetTag(TagTypes.Id3v1);
|
||||
}
|
||||
|
||||
if (tags is not null)
|
||||
{
|
||||
if (audio.SupportsPeople && !audio.LockedFields.Contains(MetadataField.Cast))
|
||||
if (tagTypes.HasFlag(TagTypes.Id3v2))
|
||||
{
|
||||
var people = new List<PersonInfo>();
|
||||
var albumArtists = tags.AlbumArtists;
|
||||
foreach (var albumArtist in albumArtists)
|
||||
tags = file.GetTag(TagTypes.Id3v2);
|
||||
}
|
||||
else if (tagTypes.HasFlag(TagTypes.Ape))
|
||||
{
|
||||
tags = file.GetTag(TagTypes.Ape);
|
||||
}
|
||||
else if (tagTypes.HasFlag(TagTypes.FlacMetadata))
|
||||
{
|
||||
tags = file.GetTag(TagTypes.FlacMetadata);
|
||||
}
|
||||
else if (tagTypes.HasFlag(TagTypes.Apple))
|
||||
{
|
||||
tags = file.GetTag(TagTypes.Apple);
|
||||
}
|
||||
else if (tagTypes.HasFlag(TagTypes.Xiph))
|
||||
{
|
||||
tags = file.GetTag(TagTypes.Xiph);
|
||||
}
|
||||
else if (tagTypes.HasFlag(TagTypes.AudibleMetadata))
|
||||
{
|
||||
tags = file.GetTag(TagTypes.AudibleMetadata);
|
||||
}
|
||||
else if (tagTypes.HasFlag(TagTypes.Id3v1))
|
||||
{
|
||||
tags = file.GetTag(TagTypes.Id3v1);
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogWarning(e, "TagLib-Sharp does not support this audio");
|
||||
}
|
||||
|
||||
tags ??= new TagLib.Id3v2.Tag();
|
||||
tags.AlbumArtists = tags.AlbumArtists.Length == 0 ? mediaInfo.AlbumArtists : tags.AlbumArtists;
|
||||
tags.Album ??= mediaInfo.Album;
|
||||
tags.Title ??= mediaInfo.Name;
|
||||
tags.Year = tags.Year == 0U ? Convert.ToUInt32(mediaInfo.ProductionYear, CultureInfo.InvariantCulture) : tags.Year;
|
||||
tags.Performers = tags.Performers.Length == 0 ? mediaInfo.Artists : tags.Performers;
|
||||
tags.Genres ??= mediaInfo.Genres;
|
||||
tags.Track = tags.Track == 0U ? Convert.ToUInt32(mediaInfo.IndexNumber, CultureInfo.InvariantCulture) : tags.Track;
|
||||
tags.Disc = tags.Disc == 0U ? Convert.ToUInt32(mediaInfo.ParentIndexNumber, CultureInfo.InvariantCulture) : tags.Disc;
|
||||
|
||||
if (audio.SupportsPeople && !audio.LockedFields.Contains(MetadataField.Cast))
|
||||
{
|
||||
var people = new List<PersonInfo>();
|
||||
var albumArtists = tags.AlbumArtists;
|
||||
foreach (var albumArtist in albumArtists)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(albumArtist))
|
||||
{
|
||||
if (!string.IsNullOrEmpty(albumArtist))
|
||||
PeopleHelper.AddPerson(people, new PersonInfo
|
||||
{
|
||||
PeopleHelper.AddPerson(people, new PersonInfo
|
||||
{
|
||||
Name = albumArtist,
|
||||
Type = PersonKind.AlbumArtist
|
||||
});
|
||||
}
|
||||
Name = albumArtist,
|
||||
Type = PersonKind.AlbumArtist
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var performers = tags.Performers;
|
||||
foreach (var performer in performers)
|
||||
var performers = tags.Performers;
|
||||
foreach (var performer in performers)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(performer))
|
||||
{
|
||||
if (!string.IsNullOrEmpty(performer))
|
||||
PeopleHelper.AddPerson(people, new PersonInfo
|
||||
{
|
||||
PeopleHelper.AddPerson(people, new PersonInfo
|
||||
{
|
||||
Name = performer,
|
||||
Type = PersonKind.Artist
|
||||
});
|
||||
}
|
||||
Name = performer,
|
||||
Type = PersonKind.Artist
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var composer in tags.Composers)
|
||||
foreach (var composer in tags.Composers)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(composer))
|
||||
{
|
||||
if (!string.IsNullOrEmpty(composer))
|
||||
PeopleHelper.AddPerson(people, new PersonInfo
|
||||
{
|
||||
PeopleHelper.AddPerson(people, new PersonInfo
|
||||
{
|
||||
Name = composer,
|
||||
Type = PersonKind.Composer
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_libraryManager.UpdatePeople(audio, people);
|
||||
|
||||
if (options.ReplaceAllMetadata && performers.Length != 0)
|
||||
{
|
||||
audio.Artists = performers;
|
||||
}
|
||||
else if (!options.ReplaceAllMetadata
|
||||
&& (audio.Artists is null || audio.Artists.Count == 0))
|
||||
{
|
||||
audio.Artists = performers;
|
||||
}
|
||||
|
||||
if (albumArtists.Length == 0)
|
||||
{
|
||||
// Album artists not provided, fall back to performers (artists).
|
||||
albumArtists = performers;
|
||||
}
|
||||
|
||||
if (options.ReplaceAllMetadata && albumArtists.Length != 0)
|
||||
{
|
||||
audio.AlbumArtists = albumArtists;
|
||||
}
|
||||
else if (!options.ReplaceAllMetadata
|
||||
&& (audio.AlbumArtists is null || audio.AlbumArtists.Count == 0))
|
||||
{
|
||||
audio.AlbumArtists = albumArtists;
|
||||
Name = composer,
|
||||
Type = PersonKind.Composer
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!audio.LockedFields.Contains(MetadataField.Name) && !string.IsNullOrEmpty(tags.Title))
|
||||
_libraryManager.UpdatePeople(audio, people);
|
||||
|
||||
if (options.ReplaceAllMetadata && performers.Length != 0)
|
||||
{
|
||||
audio.Name = tags.Title;
|
||||
audio.Artists = performers;
|
||||
}
|
||||
else if (!options.ReplaceAllMetadata
|
||||
&& (audio.Artists is null || audio.Artists.Count == 0))
|
||||
{
|
||||
audio.Artists = performers;
|
||||
}
|
||||
|
||||
if (options.ReplaceAllMetadata)
|
||||
if (albumArtists.Length == 0)
|
||||
{
|
||||
audio.Album = tags.Album;
|
||||
audio.IndexNumber = Convert.ToInt32(tags.Track);
|
||||
audio.ParentIndexNumber = Convert.ToInt32(tags.Disc);
|
||||
}
|
||||
else
|
||||
{
|
||||
audio.Album ??= tags.Album;
|
||||
audio.IndexNumber ??= Convert.ToInt32(tags.Track);
|
||||
audio.ParentIndexNumber ??= Convert.ToInt32(tags.Disc);
|
||||
// Album artists not provided, fall back to performers (artists).
|
||||
albumArtists = performers;
|
||||
}
|
||||
|
||||
if (tags.Year != 0)
|
||||
if (options.ReplaceAllMetadata && albumArtists.Length != 0)
|
||||
{
|
||||
var year = Convert.ToInt32(tags.Year);
|
||||
audio.ProductionYear = year;
|
||||
audio.AlbumArtists = albumArtists;
|
||||
}
|
||||
else if (!options.ReplaceAllMetadata
|
||||
&& (audio.AlbumArtists is null || audio.AlbumArtists.Count == 0))
|
||||
{
|
||||
audio.AlbumArtists = albumArtists;
|
||||
}
|
||||
}
|
||||
|
||||
if (!audio.PremiereDate.HasValue)
|
||||
if (!audio.LockedFields.Contains(MetadataField.Name) && !string.IsNullOrEmpty(tags.Title))
|
||||
{
|
||||
audio.Name = tags.Title;
|
||||
}
|
||||
|
||||
if (options.ReplaceAllMetadata)
|
||||
{
|
||||
audio.Album = tags.Album;
|
||||
audio.IndexNumber = Convert.ToInt32(tags.Track);
|
||||
audio.ParentIndexNumber = Convert.ToInt32(tags.Disc);
|
||||
}
|
||||
else
|
||||
{
|
||||
audio.Album ??= tags.Album;
|
||||
audio.IndexNumber ??= Convert.ToInt32(tags.Track);
|
||||
audio.ParentIndexNumber ??= Convert.ToInt32(tags.Disc);
|
||||
}
|
||||
|
||||
if (tags.Year != 0)
|
||||
{
|
||||
var year = Convert.ToInt32(tags.Year);
|
||||
audio.ProductionYear = year;
|
||||
|
||||
if (!audio.PremiereDate.HasValue)
|
||||
{
|
||||
try
|
||||
{
|
||||
audio.PremiereDate = new DateTime(year, 01, 01);
|
||||
}
|
||||
}
|
||||
|
||||
if (!audio.LockedFields.Contains(MetadataField.Genres))
|
||||
{
|
||||
audio.Genres = options.ReplaceAllMetadata || audio.Genres == null || audio.Genres.Length == 0
|
||||
? tags.Genres.Distinct(StringComparer.OrdinalIgnoreCase).ToArray()
|
||||
: audio.Genres;
|
||||
}
|
||||
|
||||
if (!double.IsNaN(tags.ReplayGainTrackGain))
|
||||
{
|
||||
audio.NormalizationGain = (float)tags.ReplayGainTrackGain;
|
||||
}
|
||||
|
||||
if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzArtist, out _))
|
||||
{
|
||||
audio.SetProviderId(MetadataProvider.MusicBrainzArtist, tags.MusicBrainzArtistId);
|
||||
}
|
||||
|
||||
if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzAlbumArtist, out _))
|
||||
{
|
||||
audio.SetProviderId(MetadataProvider.MusicBrainzAlbumArtist, tags.MusicBrainzReleaseArtistId);
|
||||
}
|
||||
|
||||
if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzAlbum, out _))
|
||||
{
|
||||
audio.SetProviderId(MetadataProvider.MusicBrainzAlbum, tags.MusicBrainzReleaseId);
|
||||
}
|
||||
|
||||
if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzReleaseGroup, out _))
|
||||
{
|
||||
audio.SetProviderId(MetadataProvider.MusicBrainzReleaseGroup, tags.MusicBrainzReleaseGroupId);
|
||||
}
|
||||
|
||||
if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzTrack, out _))
|
||||
{
|
||||
// Fallback to ffprobe as TagLib incorrectly provides recording MBID in `tags.MusicBrainzTrackId`.
|
||||
// See https://github.com/mono/taglib-sharp/issues/304
|
||||
var trackMbId = mediaInfo.GetProviderId(MetadataProvider.MusicBrainzTrack);
|
||||
if (trackMbId is not null)
|
||||
catch (ArgumentOutOfRangeException ex)
|
||||
{
|
||||
audio.SetProviderId(MetadataProvider.MusicBrainzTrack, trackMbId);
|
||||
_logger.LogError(ex, "Error parsing YEAR tag in {File}. '{TagValue}' is an invalid year", audio.Path, tags.Year);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save extracted lyrics if they exist,
|
||||
// and if the audio doesn't yet have lyrics.
|
||||
if (!string.IsNullOrWhiteSpace(tags.Lyrics)
|
||||
&& tryExtractEmbeddedLyrics)
|
||||
if (!audio.LockedFields.Contains(MetadataField.Genres))
|
||||
{
|
||||
audio.Genres = options.ReplaceAllMetadata || audio.Genres == null || audio.Genres.Length == 0
|
||||
? tags.Genres.Distinct(StringComparer.OrdinalIgnoreCase).ToArray()
|
||||
: audio.Genres;
|
||||
}
|
||||
|
||||
if (!double.IsNaN(tags.ReplayGainTrackGain))
|
||||
{
|
||||
audio.NormalizationGain = (float)tags.ReplayGainTrackGain;
|
||||
}
|
||||
|
||||
if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzArtist, out _))
|
||||
{
|
||||
audio.SetProviderId(MetadataProvider.MusicBrainzArtist, tags.MusicBrainzArtistId);
|
||||
}
|
||||
|
||||
if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzAlbumArtist, out _))
|
||||
{
|
||||
audio.SetProviderId(MetadataProvider.MusicBrainzAlbumArtist, tags.MusicBrainzReleaseArtistId);
|
||||
}
|
||||
|
||||
if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzAlbum, out _))
|
||||
{
|
||||
audio.SetProviderId(MetadataProvider.MusicBrainzAlbum, tags.MusicBrainzReleaseId);
|
||||
}
|
||||
|
||||
if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzReleaseGroup, out _))
|
||||
{
|
||||
audio.SetProviderId(MetadataProvider.MusicBrainzReleaseGroup, tags.MusicBrainzReleaseGroupId);
|
||||
}
|
||||
|
||||
if (options.ReplaceAllMetadata || !audio.TryGetProviderId(MetadataProvider.MusicBrainzTrack, out _))
|
||||
{
|
||||
// Fallback to ffprobe as TagLib incorrectly provides recording MBID in `tags.MusicBrainzTrackId`.
|
||||
// See https://github.com/mono/taglib-sharp/issues/304
|
||||
var trackMbId = mediaInfo.GetProviderId(MetadataProvider.MusicBrainzTrack);
|
||||
if (trackMbId is not null)
|
||||
{
|
||||
await _lyricManager.SaveLyricAsync(audio, "lrc", tags.Lyrics).ConfigureAwait(false);
|
||||
audio.SetProviderId(MetadataProvider.MusicBrainzTrack, trackMbId);
|
||||
}
|
||||
}
|
||||
|
||||
// Save extracted lyrics if they exist,
|
||||
// and if the audio doesn't yet have lyrics.
|
||||
if (!string.IsNullOrWhiteSpace(tags.Lyrics)
|
||||
&& tryExtractEmbeddedLyrics)
|
||||
{
|
||||
await _lyricManager.SaveLyricAsync(audio, "lrc", tags.Lyrics).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private void AddExternalLyrics(
|
||||
@@ -342,7 +374,10 @@ namespace MediaBrowser.Providers.MediaInfo
|
||||
var externalLyricFiles = _lyricResolver.GetExternalStreams(audio, startIndex, options.DirectoryService, false);
|
||||
|
||||
audio.LyricFiles = externalLyricFiles.Select(i => i.Path).Distinct().ToArray();
|
||||
currentStreams.AddRange(externalLyricFiles);
|
||||
if (externalLyricFiles.Count > 0)
|
||||
{
|
||||
currentStreams.Add(externalLyricFiles[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -358,6 +358,10 @@ namespace MediaBrowser.Providers.MediaInfo
|
||||
blurayVideoStream.BitRate = blurayVideoStream.BitRate.GetValueOrDefault() == 0 ? ffmpegVideoStream.BitRate : blurayVideoStream.BitRate;
|
||||
blurayVideoStream.Width = blurayVideoStream.Width.GetValueOrDefault() == 0 ? ffmpegVideoStream.Width : blurayVideoStream.Width;
|
||||
blurayVideoStream.Height = blurayVideoStream.Height.GetValueOrDefault() == 0 ? ffmpegVideoStream.Width : blurayVideoStream.Height;
|
||||
blurayVideoStream.ColorRange = ffmpegVideoStream.ColorRange;
|
||||
blurayVideoStream.ColorSpace = ffmpegVideoStream.ColorSpace;
|
||||
blurayVideoStream.ColorTransfer = ffmpegVideoStream.ColorTransfer;
|
||||
blurayVideoStream.ColorPrimaries = ffmpegVideoStream.ColorPrimaries;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#nullable disable
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
@@ -103,6 +104,7 @@ namespace MediaBrowser.Providers.MediaInfo
|
||||
_subtitleResolver);
|
||||
|
||||
_audioProber = new AudioFileProber(
|
||||
loggerFactory.CreateLogger<AudioFileProber>(),
|
||||
mediaSourceManager,
|
||||
mediaEncoder,
|
||||
itemRepo,
|
||||
@@ -140,19 +142,15 @@ namespace MediaBrowser.Providers.MediaInfo
|
||||
&& item.SupportsLocalMetadata
|
||||
&& !video.IsPlaceHolder)
|
||||
{
|
||||
if (!video.SubtitleFiles.SequenceEqual(
|
||||
_subtitleResolver.GetExternalFiles(video, directoryService, false)
|
||||
.Select(info => info.Path).ToList(),
|
||||
StringComparer.Ordinal))
|
||||
var externalFiles = new HashSet<string>(_subtitleResolver.GetExternalFiles(video, directoryService, false).Select(info => info.Path), StringComparer.OrdinalIgnoreCase);
|
||||
if (!new HashSet<string>(video.SubtitleFiles, StringComparer.Ordinal).SetEquals(externalFiles))
|
||||
{
|
||||
_logger.LogDebug("Refreshing {ItemPath} due to external subtitles change.", item.Path);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!video.AudioFiles.SequenceEqual(
|
||||
_audioResolver.GetExternalFiles(video, directoryService, false)
|
||||
.Select(info => info.Path).ToList(),
|
||||
StringComparer.Ordinal))
|
||||
externalFiles = new HashSet<string>(_audioResolver.GetExternalFiles(video, directoryService, false).Select(info => info.Path), StringComparer.OrdinalIgnoreCase);
|
||||
if (!new HashSet<string>(video.AudioFiles, StringComparer.Ordinal).SetEquals(externalFiles))
|
||||
{
|
||||
_logger.LogDebug("Refreshing {ItemPath} due to external audio change.", item.Path);
|
||||
return true;
|
||||
@@ -160,14 +158,14 @@ namespace MediaBrowser.Providers.MediaInfo
|
||||
}
|
||||
|
||||
if (item is Audio audio
|
||||
&& item.SupportsLocalMetadata
|
||||
&& !audio.LyricFiles.SequenceEqual(
|
||||
_lyricResolver.GetExternalFiles(audio, directoryService, false)
|
||||
.Select(info => info.Path).ToList(),
|
||||
StringComparer.Ordinal))
|
||||
&& item.SupportsLocalMetadata)
|
||||
{
|
||||
_logger.LogDebug("Refreshing {ItemPath} due to external lyrics change.", item.Path);
|
||||
return true;
|
||||
var externalFiles = new HashSet<string>(_lyricResolver.GetExternalFiles(audio, directoryService, false).Select(info => info.Path), StringComparer.OrdinalIgnoreCase);
|
||||
if (!new HashSet<string>(audio.LyricFiles, StringComparer.Ordinal).SetEquals(externalFiles))
|
||||
{
|
||||
_logger.LogDebug("Refreshing {ItemPath} due to external lyrics change.", item.Path);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
@@ -23,22 +23,6 @@ namespace MediaBrowser.Providers.Movies
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override bool IsFullLocalMetadata(Movie item)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(item.Overview))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!item.ProductionYear.HasValue)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return base.IsFullLocalMetadata(item);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void MergeData(MetadataResult<Movie> source, MetadataResult<Movie> target, MetadataField[] lockedFields, bool replaceData, bool mergeMetadataSettings)
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma warning disable CS1591
|
||||
|
||||
using System.Linq;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
@@ -23,22 +24,6 @@ namespace MediaBrowser.Providers.Movies
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override bool IsFullLocalMetadata(Trailer item)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(item.Overview))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!item.ProductionYear.HasValue)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return base.IsFullLocalMetadata(item);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void MergeData(MetadataResult<Trailer> source, MetadataResult<Trailer> target, MetadataField[] lockedFields, bool replaceData, bool mergeMetadataSettings)
|
||||
{
|
||||
@@ -48,6 +33,10 @@ namespace MediaBrowser.Providers.Movies
|
||||
{
|
||||
target.Item.TrailerTypes = source.Item.TrailerTypes;
|
||||
}
|
||||
else
|
||||
{
|
||||
target.Item.TrailerTypes = target.Item.TrailerTypes.Concat(source.Item.TrailerTypes).Distinct().ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,6 +225,10 @@ namespace MediaBrowser.Providers.Music
|
||||
{
|
||||
targetItem.Artists = sourceItem.Artists;
|
||||
}
|
||||
else
|
||||
{
|
||||
targetItem.Artists = targetItem.Artists.Concat(sourceItem.Artists).Distinct().ToArray();
|
||||
}
|
||||
|
||||
if (replaceData || string.IsNullOrEmpty(targetItem.GetProviderId(MetadataProvider.MusicBrainzAlbumArtist)))
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Entities.Audio;
|
||||
using MediaBrowser.Controller.Library;
|
||||
@@ -60,6 +61,10 @@ namespace MediaBrowser.Providers.Music
|
||||
{
|
||||
targetItem.Artists = sourceItem.Artists;
|
||||
}
|
||||
else
|
||||
{
|
||||
targetItem.Artists = targetItem.Artists.Concat(sourceItem.Artists).Distinct().ToArray();
|
||||
}
|
||||
|
||||
if (replaceData || string.IsNullOrEmpty(targetItem.Album))
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma warning disable CS1591
|
||||
|
||||
using System.Linq;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
@@ -45,6 +46,10 @@ namespace MediaBrowser.Providers.Music
|
||||
{
|
||||
targetItem.Artists = sourceItem.Artists;
|
||||
}
|
||||
else
|
||||
{
|
||||
targetItem.Artists = targetItem.Artists.Concat(sourceItem.Artists).Distinct().ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,184 +1,227 @@
|
||||
#nullable disable
|
||||
|
||||
#pragma warning disable CS1591
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Playlists;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Model.IO;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PlaylistsNET.Content;
|
||||
|
||||
namespace MediaBrowser.Providers.Playlists
|
||||
namespace MediaBrowser.Providers.Playlists;
|
||||
|
||||
/// <summary>
|
||||
/// Local playlist provider.
|
||||
/// </summary>
|
||||
public class PlaylistItemsProvider : ILocalMetadataProvider<Playlist>,
|
||||
IHasOrder,
|
||||
IForcedProvider,
|
||||
IHasItemChangeMonitor
|
||||
{
|
||||
public class PlaylistItemsProvider : ICustomMetadataProvider<Playlist>,
|
||||
IHasOrder,
|
||||
IForcedProvider,
|
||||
IPreRefreshProvider,
|
||||
IHasItemChangeMonitor
|
||||
private readonly IFileSystem _fileSystem;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly ILogger<PlaylistItemsProvider> _logger;
|
||||
private readonly CollectionType[] _ignoredCollections = [CollectionType.livetv, CollectionType.boxsets, CollectionType.playlists];
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PlaylistItemsProvider"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">Instance of the <see cref="ILogger{PlaylistItemsProvider}"/> interface.</param>
|
||||
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
|
||||
/// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
|
||||
public PlaylistItemsProvider(ILogger<PlaylistItemsProvider> logger, ILibraryManager libraryManager, IFileSystem fileSystem)
|
||||
{
|
||||
private readonly ILogger<PlaylistItemsProvider> _logger;
|
||||
_logger = logger;
|
||||
_libraryManager = libraryManager;
|
||||
_fileSystem = fileSystem;
|
||||
}
|
||||
|
||||
public PlaylistItemsProvider(ILogger<PlaylistItemsProvider> logger)
|
||||
/// <inheritdoc />
|
||||
public string Name => "Playlist Item Provider";
|
||||
|
||||
/// <inheritdoc />
|
||||
public int Order => 100;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<MetadataResult<Playlist>> GetMetadata(
|
||||
ItemInfo info,
|
||||
IDirectoryService directoryService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = new MetadataResult<Playlist>()
|
||||
{
|
||||
_logger = logger;
|
||||
Item = new Playlist
|
||||
{
|
||||
Path = info.Path
|
||||
}
|
||||
};
|
||||
Fetch(result);
|
||||
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
private void Fetch(MetadataResult<Playlist> result)
|
||||
{
|
||||
var item = result.Item;
|
||||
var path = item.Path;
|
||||
if (!Playlist.IsPlaylistFile(path))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
public string Name => "Playlist Reader";
|
||||
|
||||
// Run last
|
||||
public int Order => 100;
|
||||
|
||||
public Task<ItemUpdateType> FetchAsync(Playlist item, MetadataRefreshOptions options, CancellationToken cancellationToken)
|
||||
var extension = Path.GetExtension(path);
|
||||
if (!Playlist.SupportedExtensions.Contains(extension ?? string.Empty, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var path = item.Path;
|
||||
if (!Playlist.IsPlaylistFile(path))
|
||||
{
|
||||
return Task.FromResult(ItemUpdateType.None);
|
||||
}
|
||||
|
||||
var extension = Path.GetExtension(path);
|
||||
if (!Playlist.SupportedExtensions.Contains(extension ?? string.Empty, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Task.FromResult(ItemUpdateType.None);
|
||||
}
|
||||
|
||||
var items = GetItems(path, extension).ToArray();
|
||||
return;
|
||||
}
|
||||
|
||||
var items = GetItems(path, extension).ToArray();
|
||||
if (items.Length > 0)
|
||||
{
|
||||
result.HasMetadata = true;
|
||||
item.LinkedChildren = items;
|
||||
|
||||
return Task.FromResult(ItemUpdateType.None);
|
||||
}
|
||||
|
||||
private IEnumerable<LinkedChild> GetItems(string path, string extension)
|
||||
return;
|
||||
}
|
||||
|
||||
private IEnumerable<LinkedChild> GetItems(string path, string extension)
|
||||
{
|
||||
var libraryRoots = _libraryManager.GetUserRootFolder().Children
|
||||
.OfType<CollectionFolder>()
|
||||
.Where(f => f.CollectionType.HasValue && !_ignoredCollections.Contains(f.CollectionType.Value))
|
||||
.SelectMany(f => f.PhysicalLocations)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
using (var stream = File.OpenRead(path))
|
||||
{
|
||||
using (var stream = File.OpenRead(path))
|
||||
if (string.Equals(".wpl", extension, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (string.Equals(".wpl", extension, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return GetWplItems(stream, path);
|
||||
}
|
||||
|
||||
if (string.Equals(".zpl", extension, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return GetZplItems(stream, path);
|
||||
}
|
||||
|
||||
if (string.Equals(".m3u", extension, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return GetM3uItems(stream, path);
|
||||
}
|
||||
|
||||
if (string.Equals(".m3u8", extension, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return GetM3u8Items(stream, path);
|
||||
}
|
||||
|
||||
if (string.Equals(".pls", extension, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return GetPlsItems(stream, path);
|
||||
}
|
||||
return GetWplItems(stream, path, libraryRoots);
|
||||
}
|
||||
|
||||
return Enumerable.Empty<LinkedChild>();
|
||||
}
|
||||
|
||||
private IEnumerable<LinkedChild> GetPlsItems(Stream stream, string path)
|
||||
{
|
||||
var content = new PlsContent();
|
||||
var playlist = content.GetFromStream(stream);
|
||||
|
||||
return playlist.PlaylistEntries.Select(i => new LinkedChild
|
||||
if (string.Equals(".zpl", extension, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Path = GetPlaylistItemPath(i.Path, path),
|
||||
Type = LinkedChildType.Manual
|
||||
});
|
||||
}
|
||||
|
||||
private IEnumerable<LinkedChild> GetM3u8Items(Stream stream, string path)
|
||||
{
|
||||
var content = new M3uContent();
|
||||
var playlist = content.GetFromStream(stream);
|
||||
|
||||
return playlist.PlaylistEntries.Select(i => new LinkedChild
|
||||
{
|
||||
Path = GetPlaylistItemPath(i.Path, path),
|
||||
Type = LinkedChildType.Manual
|
||||
});
|
||||
}
|
||||
|
||||
private IEnumerable<LinkedChild> GetM3uItems(Stream stream, string path)
|
||||
{
|
||||
var content = new M3uContent();
|
||||
var playlist = content.GetFromStream(stream);
|
||||
|
||||
return playlist.PlaylistEntries.Select(i => new LinkedChild
|
||||
{
|
||||
Path = GetPlaylistItemPath(i.Path, path),
|
||||
Type = LinkedChildType.Manual
|
||||
});
|
||||
}
|
||||
|
||||
private IEnumerable<LinkedChild> GetZplItems(Stream stream, string path)
|
||||
{
|
||||
var content = new ZplContent();
|
||||
var playlist = content.GetFromStream(stream);
|
||||
|
||||
return playlist.PlaylistEntries.Select(i => new LinkedChild
|
||||
{
|
||||
Path = GetPlaylistItemPath(i.Path, path),
|
||||
Type = LinkedChildType.Manual
|
||||
});
|
||||
}
|
||||
|
||||
private IEnumerable<LinkedChild> GetWplItems(Stream stream, string path)
|
||||
{
|
||||
var content = new WplContent();
|
||||
var playlist = content.GetFromStream(stream);
|
||||
|
||||
return playlist.PlaylistEntries.Select(i => new LinkedChild
|
||||
{
|
||||
Path = GetPlaylistItemPath(i.Path, path),
|
||||
Type = LinkedChildType.Manual
|
||||
});
|
||||
}
|
||||
|
||||
private string GetPlaylistItemPath(string itemPath, string containingPlaylistFolder)
|
||||
{
|
||||
if (!File.Exists(itemPath))
|
||||
{
|
||||
var path = Path.Combine(Path.GetDirectoryName(containingPlaylistFolder), itemPath);
|
||||
if (File.Exists(path))
|
||||
{
|
||||
return path;
|
||||
}
|
||||
return GetZplItems(stream, path, libraryRoots);
|
||||
}
|
||||
|
||||
return itemPath;
|
||||
}
|
||||
|
||||
public bool HasChanged(BaseItem item, IDirectoryService directoryService)
|
||||
{
|
||||
var path = item.Path;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(path) && item.IsFileProtocol)
|
||||
if (string.Equals(".m3u", extension, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var file = directoryService.GetFile(path);
|
||||
if (file is not null && file.LastWriteTimeUtc != item.DateModified)
|
||||
{
|
||||
_logger.LogDebug("Refreshing {Path} due to date modified timestamp change.", path);
|
||||
return true;
|
||||
}
|
||||
return GetM3uItems(stream, path, libraryRoots);
|
||||
}
|
||||
|
||||
if (string.Equals(".m3u8", extension, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return GetM3uItems(stream, path, libraryRoots);
|
||||
}
|
||||
|
||||
if (string.Equals(".pls", extension, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return GetPlsItems(stream, path, libraryRoots);
|
||||
}
|
||||
}
|
||||
|
||||
return Enumerable.Empty<LinkedChild>();
|
||||
}
|
||||
|
||||
private IEnumerable<LinkedChild> GetPlsItems(Stream stream, string playlistPath, List<string> libraryRoots)
|
||||
{
|
||||
var content = new PlsContent();
|
||||
var playlist = content.GetFromStream(stream);
|
||||
|
||||
return playlist.PlaylistEntries
|
||||
.Select(i => GetLinkedChild(i.Path, playlistPath, libraryRoots))
|
||||
.Where(i => i is not null);
|
||||
}
|
||||
|
||||
private IEnumerable<LinkedChild> GetM3uItems(Stream stream, string playlistPath, List<string> libraryRoots)
|
||||
{
|
||||
var content = new M3uContent();
|
||||
var playlist = content.GetFromStream(stream);
|
||||
|
||||
return playlist.PlaylistEntries
|
||||
.Select(i => GetLinkedChild(i.Path, playlistPath, libraryRoots))
|
||||
.Where(i => i is not null);
|
||||
}
|
||||
|
||||
private IEnumerable<LinkedChild> GetZplItems(Stream stream, string playlistPath, List<string> libraryRoots)
|
||||
{
|
||||
var content = new ZplContent();
|
||||
var playlist = content.GetFromStream(stream);
|
||||
|
||||
return playlist.PlaylistEntries
|
||||
.Select(i => GetLinkedChild(i.Path, playlistPath, libraryRoots))
|
||||
.Where(i => i is not null);
|
||||
}
|
||||
|
||||
private IEnumerable<LinkedChild> GetWplItems(Stream stream, string playlistPath, List<string> libraryRoots)
|
||||
{
|
||||
var content = new WplContent();
|
||||
var playlist = content.GetFromStream(stream);
|
||||
|
||||
return playlist.PlaylistEntries
|
||||
.Select(i => GetLinkedChild(i.Path, playlistPath, libraryRoots))
|
||||
.Where(i => i is not null);
|
||||
}
|
||||
|
||||
private LinkedChild GetLinkedChild(string itemPath, string playlistPath, List<string> libraryRoots)
|
||||
{
|
||||
if (TryGetPlaylistItemPath(itemPath, playlistPath, libraryRoots, out var parsedPath))
|
||||
{
|
||||
return new LinkedChild
|
||||
{
|
||||
Path = parsedPath,
|
||||
Type = LinkedChildType.Manual
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private bool TryGetPlaylistItemPath(string itemPath, string playlistPath, List<string> libraryPaths, out string path)
|
||||
{
|
||||
path = null;
|
||||
string pathToCheck = _fileSystem.MakeAbsolutePath(Path.GetDirectoryName(playlistPath), itemPath);
|
||||
if (!File.Exists(pathToCheck))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var libraryPath in libraryPaths)
|
||||
{
|
||||
if (pathToCheck.StartsWith(libraryPath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
path = pathToCheck;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool HasChanged(BaseItem item, IDirectoryService directoryService)
|
||||
{
|
||||
var path = item.Path;
|
||||
if (!string.IsNullOrWhiteSpace(path) && item.IsFileProtocol)
|
||||
{
|
||||
var file = directoryService.GetFile(path);
|
||||
if (file is not null && file.LastWriteTimeUtc != item.DateModified)
|
||||
{
|
||||
_logger.LogDebug("Refreshing {Path} due to date modified timestamp change.", path);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma warning disable CS1591
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using MediaBrowser.Controller.Configuration;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
@@ -49,8 +50,24 @@ namespace MediaBrowser.Providers.Playlists
|
||||
if (mergeMetadataSettings)
|
||||
{
|
||||
targetItem.PlaylistMediaType = sourceItem.PlaylistMediaType;
|
||||
targetItem.LinkedChildren = sourceItem.LinkedChildren;
|
||||
targetItem.Shares = sourceItem.Shares;
|
||||
|
||||
if (replaceData || targetItem.LinkedChildren.Length == 0)
|
||||
{
|
||||
targetItem.LinkedChildren = sourceItem.LinkedChildren;
|
||||
}
|
||||
else
|
||||
{
|
||||
targetItem.LinkedChildren = sourceItem.LinkedChildren.Concat(targetItem.LinkedChildren).Distinct().ToArray();
|
||||
}
|
||||
|
||||
if (replaceData || targetItem.Shares.Count == 0)
|
||||
{
|
||||
targetItem.Shares = sourceItem.Shares;
|
||||
}
|
||||
else
|
||||
{
|
||||
targetItem.Shares = sourceItem.Shares.Concat(targetItem.Shares).DistinctBy(s => s.UserId).ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,7 +250,7 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu
|
||||
// If we have a release ID but not a release group ID, lookup the release group
|
||||
if (!string.IsNullOrWhiteSpace(releaseId) && string.IsNullOrWhiteSpace(releaseGroupId))
|
||||
{
|
||||
var release = await _musicBrainzQuery.LookupReleaseAsync(new Guid(releaseId), Include.Releases, cancellationToken).ConfigureAwait(false);
|
||||
var release = await _musicBrainzQuery.LookupReleaseAsync(new Guid(releaseId), Include.ReleaseGroups, cancellationToken).ConfigureAwait(false);
|
||||
releaseGroupId = release.ReleaseGroup?.Id.ToString();
|
||||
result.HasMetadata = true;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user