mirror of
https://github.com/jellyfin/jellyfin.git
synced 2026-01-16 08:08:16 +00:00
Compare commits
38 Commits
renovate/m
...
v10.11.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4187c6f620 | ||
|
|
e7dbb3afec | ||
|
|
f994dd6211 | ||
|
|
da254ee968 | ||
|
|
4ad3141875 | ||
|
|
b5f0199a25 | ||
|
|
6bf88c049e | ||
|
|
40a33da2a5 | ||
|
|
3596fc0693 | ||
|
|
93824dad97 | ||
|
|
e5656af1f2 | ||
|
|
c127c10458 | ||
|
|
7d1824ea27 | ||
|
|
2966d27c97 | ||
|
|
618ec4543e | ||
|
|
0e4031ae52 | ||
|
|
442af96ed9 | ||
|
|
a305204cfa | ||
|
|
75f472e6a7 | ||
|
|
cc32e8f7cb | ||
|
|
14b3085ff1 | ||
|
|
5691eee4f1 | ||
|
|
1520a697ad | ||
|
|
81b8b0ca4a | ||
|
|
ac3fa3c376 | ||
|
|
7a1c1cd342 | ||
|
|
70c32a26fa | ||
|
|
2b94bb54aa | ||
|
|
0a6e8146be | ||
|
|
305b0fdca3 | ||
|
|
d738386fe2 | ||
|
|
ca830d5be7 | ||
|
|
a5bc4524d8 | ||
|
|
175ee12bbc | ||
|
|
a725220c21 | ||
|
|
a245605152 | ||
|
|
f4a53209f4 | ||
|
|
877251bcae |
@@ -3,7 +3,7 @@
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"dotnet-ef": {
|
||||
"version": "9.0.11",
|
||||
"version": "9.0.10",
|
||||
"commands": [
|
||||
"dotnet-ef"
|
||||
]
|
||||
|
||||
15
.github/CODEOWNERS
vendored
15
.github/CODEOWNERS
vendored
@@ -1,11 +1,4 @@
|
||||
# Joshua must review all changes to bump_version and any files it touches
|
||||
bump_version @joshuaboniface
|
||||
.github/ISSUE_TEMPLATE @joshuaboniface
|
||||
MediaBrowser.Common/MediaBrowser.Common.csproj @joshuaboniface
|
||||
Jellyfin.Data/Jellyfin.Data.csproj @joshuaboniface
|
||||
MediaBrowser.Controller/MediaBrowser.Controller.csproj @joshuaboniface
|
||||
MediaBrowser.Model/MediaBrowser.Model.csproj @joshuaboniface
|
||||
Emby.Naming/Emby.Naming.csproj @joshuaboniface
|
||||
src/Jellyfin.Extensions/Jellyfin.Extensions.csproj @joshuaboniface
|
||||
# Core must approve all changes within the repo config
|
||||
.github/ @jellyfin/core
|
||||
# Joshua must review all changes to deployment and build.sh
|
||||
.ci/* @joshuaboniface
|
||||
deployment/* @joshuaboniface
|
||||
build.sh @joshuaboniface
|
||||
|
||||
17
.github/ISSUE_TEMPLATE/issue report.yml
vendored
17
.github/ISSUE_TEMPLATE/issue report.yml
vendored
@@ -87,12 +87,7 @@ body:
|
||||
label: Jellyfin Server version
|
||||
description: What version of Jellyfin are you using?
|
||||
options:
|
||||
- 10.11.5
|
||||
- 10.11.4
|
||||
- 10.11.3
|
||||
- 10.11.2
|
||||
- 10.11.1
|
||||
- 10.11.0
|
||||
- 10.10.0+
|
||||
- Master
|
||||
- Unstable
|
||||
- Older*
|
||||
@@ -141,14 +136,13 @@ body:
|
||||
- **FFmpeg Version**: [e.g. 5.1.2-Jellyfin]
|
||||
- **Playback**: [Direct Play, Remux, Direct Stream, Transcode]
|
||||
- **Hardware Acceleration**: [e.g. none, VAAPI, NVENC, etc.]
|
||||
- **CPU Model**: [e.g. AMD Ryzen 5 9600X, Intel Core i7-8565U, etc.]
|
||||
- **GPU Model**: [e.g. none, UHD630, GTX1050, etc.]
|
||||
- **Installed Plugins**: [e.g. none, Fanart, Anime, etc.]
|
||||
- **Reverse Proxy**: [e.g. none, nginx, apache, etc.]
|
||||
- **Base URL**: [e.g. none, yes: /example]
|
||||
- **Networking**: [e.g. Host, Bridge/NAT]
|
||||
- **Jellyfin Data Storage & Filesystem**: [e.g. local SATA SSD - ext4, local HDD - NTFS]
|
||||
- **Media Storage & Filesystem**: [e.g. Local HDD - ext4, SMB Share]
|
||||
- **Jellyfin Data Storage**: [e.g. local SATA SSD, local HDD]
|
||||
- **Media Storage**: [e.g. Local HDD, SMB Share]
|
||||
- **External Integrations**: [e.g. Jellystat, Jellyseerr]
|
||||
value: |
|
||||
- OS:
|
||||
@@ -159,14 +153,13 @@ body:
|
||||
- FFmpeg Version:
|
||||
- Playback Method:
|
||||
- Hardware Acceleration:
|
||||
- CPU Model:
|
||||
- GPU Model:
|
||||
- Plugins:
|
||||
- Reverse Proxy:
|
||||
- Base URL:
|
||||
- Networking:
|
||||
- Jellyfin Data Storage & Filesystem:
|
||||
- Media Storage & Filesystem:
|
||||
- Jellyfin Data Storage:
|
||||
- Media Storage:
|
||||
- External Integrations:
|
||||
render: markdown
|
||||
validations:
|
||||
|
||||
13
.github/workflows/ci-codeql-analysis.yml
vendored
13
.github/workflows/ci-codeql-analysis.yml
vendored
@@ -20,21 +20,18 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
|
||||
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
|
||||
with:
|
||||
dotnet-version: '9.0.x'
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
|
||||
uses: github/codeql-action/init@16140ae1a102900babc80a33c44059580f687047 # v4.30.9
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
queries: +security-extended
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
|
||||
|
||||
uses: github/codeql-action/autobuild@16140ae1a102900babc80a33c44059580f687047 # v4.30.9
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
|
||||
uses: github/codeql-action/analyze@16140ae1a102900babc80a33c44059580f687047 # v4.30.9
|
||||
|
||||
18
.github/workflows/ci-compat.yml
vendored
18
.github/workflows/ci-compat.yml
vendored
@@ -11,13 +11,13 @@ jobs:
|
||||
permissions: read-all
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
|
||||
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
|
||||
with:
|
||||
dotnet-version: '9.0.x'
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
dotnet build Jellyfin.Server -o ./out
|
||||
|
||||
- name: Upload Head
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: abi-head
|
||||
retention-days: 14
|
||||
@@ -40,14 +40,14 @@ jobs:
|
||||
permissions: read-all
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
|
||||
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
|
||||
with:
|
||||
dotnet-version: '9.0.x'
|
||||
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
dotnet build Jellyfin.Server -o ./out
|
||||
|
||||
- name: Upload Head
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: abi-base
|
||||
retention-days: 14
|
||||
@@ -85,13 +85,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Download abi-head
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
with:
|
||||
name: abi-head
|
||||
path: abi-head
|
||||
|
||||
- name: Download abi-base
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
with:
|
||||
name: abi-base
|
||||
path: abi-base
|
||||
@@ -106,7 +106,7 @@ jobs:
|
||||
{
|
||||
echo 'body<<EOF'
|
||||
for file in Jellyfin.Data.dll MediaBrowser.Common.dll MediaBrowser.Controller.dll MediaBrowser.Model.dll Emby.Naming.dll Jellyfin.Extensions.dll Jellyfin.MediaEncoding.Keyframes.dll Jellyfin.Database.Implementations.dll; do
|
||||
COMPAT_OUTPUT="$( { apicompat --left ./abi-base/${file} --right ./abi-head/${file}; } 2>&1 || true )"
|
||||
COMPAT_OUTPUT="$( { apicompat --left ./abi-base/${file} --right ./abi-head/${file}; } 2>&1 )"
|
||||
if [ "APICompat ran successfully without finding any breaking changes." != "${COMPAT_OUTPUT}" ]; then
|
||||
printf "\n${file}\n${COMPAT_OUTPUT}\n"
|
||||
fi
|
||||
|
||||
99
.github/workflows/ci-openapi.yml
vendored
99
.github/workflows/ci-openapi.yml
vendored
@@ -16,21 +16,18 @@ jobs:
|
||||
permissions: read-all
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
|
||||
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
|
||||
with:
|
||||
dotnet-version: '9.0.x'
|
||||
|
||||
- name: Generate openapi.json
|
||||
run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests"
|
||||
|
||||
- name: Upload openapi.json
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: openapi-head
|
||||
retention-days: 14
|
||||
@@ -44,12 +41,11 @@ jobs:
|
||||
permissions: read-all
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Checkout common ancestor
|
||||
env:
|
||||
HEAD_REF: ${{ github.head_ref }}
|
||||
@@ -58,17 +54,14 @@ jobs:
|
||||
git -c protocol.version=2 fetch --prune --progress --no-recurse-submodules upstream +refs/heads/*:refs/remotes/upstream/* +refs/tags/*:refs/tags/*
|
||||
ANCESTOR_REF=$(git merge-base upstream/${{ github.base_ref }} origin/$HEAD_REF)
|
||||
git checkout --progress --force $ANCESTOR_REF
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
|
||||
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
|
||||
with:
|
||||
dotnet-version: '9.0.x'
|
||||
|
||||
- name: Generate openapi.json
|
||||
run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests"
|
||||
|
||||
- name: Upload openapi.json
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: openapi-base
|
||||
retention-days: 14
|
||||
@@ -77,7 +70,7 @@ jobs:
|
||||
|
||||
openapi-diff:
|
||||
permissions:
|
||||
pull-requests: write
|
||||
pull-requests: write # to create or update comment (peter-evans/create-or-update-comment)
|
||||
|
||||
name: OpenAPI - Difference
|
||||
if: ${{ github.event_name == 'pull_request_target' }}
|
||||
@@ -87,27 +80,71 @@ jobs:
|
||||
- openapi-base
|
||||
steps:
|
||||
- name: Download openapi-head
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
with:
|
||||
name: openapi-head
|
||||
path: openapi-head
|
||||
|
||||
- name: Download openapi-base
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
with:
|
||||
name: openapi-base
|
||||
path: openapi-base
|
||||
|
||||
- name: Detect OpenAPI changes
|
||||
id: openapi-diff
|
||||
uses: jellyfin/openapi-diff-action@9274f6bda9d01ab091942a4a8334baa53692e8a4 # v1.0.0
|
||||
- name: Workaround openapi-diff issue
|
||||
run: |
|
||||
sed -i 's/"allOf"/"oneOf"/g' openapi-head/openapi.json
|
||||
sed -i 's/"allOf"/"oneOf"/g' openapi-base/openapi.json
|
||||
- name: Calculate OpenAPI difference
|
||||
uses: docker://openapitools/openapi-diff
|
||||
continue-on-error: true
|
||||
with:
|
||||
old-spec: openapi-base/openapi.json
|
||||
new-spec: openapi-head/openapi.json
|
||||
markdown: openapi-changelog.md
|
||||
add-pr-comment: true
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
args: --fail-on-changed --markdown openapi-changes.md openapi-base/openapi.json openapi-head/openapi.json
|
||||
- id: read-diff
|
||||
name: Read openapi-diff output
|
||||
run: |
|
||||
# Read and fix markdown
|
||||
body=$(cat openapi-changes.md)
|
||||
# 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@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4.0.0
|
||||
id: find-comment
|
||||
with:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
direction: last
|
||||
body-includes: openapi-diff-workflow-comment
|
||||
- name: Reply or edit difference comment (changed)
|
||||
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
|
||||
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-path: openapi-changes-reply.md
|
||||
- name: Edit difference comment (unchanged)
|
||||
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
|
||||
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 }}
|
||||
edit-mode: replace
|
||||
body: |
|
||||
<!--openapi-diff-workflow-comment-->
|
||||
|
||||
No changes to OpenAPI specification found. See history of this comment for previous changes.
|
||||
|
||||
publish-unstable:
|
||||
name: OpenAPI - Publish Unstable Spec
|
||||
@@ -121,7 +158,7 @@ jobs:
|
||||
run: |-
|
||||
echo "JELLYFIN_VERSION=$(date +'%Y%m%d%H%M%S')" >> $GITHUB_ENV
|
||||
- name: Download openapi-head
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
with:
|
||||
name: openapi-head
|
||||
path: openapi-head
|
||||
@@ -135,12 +172,13 @@ jobs:
|
||||
strip_components: 1
|
||||
target: "/srv/incoming/openapi/unstable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}"
|
||||
- name: Move openapi.json (unstable) into place
|
||||
uses: appleboy/ssh-action@823bd89e131d8d508129f9443cad5855e9ba96f0 # v1.2.4
|
||||
uses: appleboy/ssh-action@2ead5e36573f08b82fbfce1504f1a4b05a647c6f # v1.2.2
|
||||
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
|
||||
@@ -182,7 +220,7 @@ jobs:
|
||||
run: |-
|
||||
echo "JELLYFIN_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
|
||||
- name: Download openapi-head
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
|
||||
with:
|
||||
name: openapi-head
|
||||
path: openapi-head
|
||||
@@ -196,12 +234,13 @@ jobs:
|
||||
strip_components: 1
|
||||
target: "/srv/incoming/openapi/stable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}"
|
||||
- name: Move openapi.json (stable) into place
|
||||
uses: appleboy/ssh-action@823bd89e131d8d508129f9443cad5855e9ba96f0 # v1.2.4
|
||||
uses: appleboy/ssh-action@2ead5e36573f08b82fbfce1504f1a4b05a647c6f # v1.2.2
|
||||
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
|
||||
|
||||
6
.github/workflows/ci-tests.yml
vendored
6
.github/workflows/ci-tests.yml
vendored
@@ -20,9 +20,9 @@ jobs:
|
||||
|
||||
runs-on: "${{ matrix.os }}"
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
|
||||
- uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
|
||||
with:
|
||||
dotnet-version: ${{ env.SDK_VERSION }}
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
--verbosity minimal
|
||||
|
||||
- name: Merge code coverage results
|
||||
uses: danielpalme/ReportGenerator-GitHub-Action@ee0ae774f6d3afedcbd1683c1ab21b83670bdf8e # v5.5.1
|
||||
uses: danielpalme/ReportGenerator-GitHub-Action@9870ed167742d546b99962ff815fcc1098355ed8 # v5.4.17
|
||||
with:
|
||||
reports: "**/coverage.cobertura.xml"
|
||||
targetdir: "merged/"
|
||||
|
||||
9
.github/workflows/commands.yml
vendored
9
.github/workflows/commands.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
||||
reactions: '+1'
|
||||
|
||||
- name: Checkout the latest code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
fetch-depth: 0
|
||||
@@ -40,19 +40,16 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: pull in script
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
repository: jellyfin/jellyfin-triage-script
|
||||
|
||||
- name: install python
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
with:
|
||||
python-version: '3.14'
|
||||
cache: 'pip'
|
||||
|
||||
- name: install python packages
|
||||
run: pip install -r rename/requirements.txt
|
||||
|
||||
- name: run rename script
|
||||
run: python3 rename.py
|
||||
working-directory: ./rename
|
||||
|
||||
2
.github/workflows/issue-stale.yml
vendored
2
.github/workflows/issue-stale.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ contains(github.repository, 'jellyfin/') }}
|
||||
steps:
|
||||
- uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
|
||||
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
|
||||
with:
|
||||
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
ascending: true
|
||||
|
||||
7
.github/workflows/issue-template-check.yml
vendored
7
.github/workflows/issue-template-check.yml
vendored
@@ -10,19 +10,16 @@ jobs:
|
||||
issues: write
|
||||
steps:
|
||||
- name: pull in script
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
repository: jellyfin/jellyfin-triage-script
|
||||
|
||||
- name: install python
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
with:
|
||||
python-version: '3.14'
|
||||
cache: 'pip'
|
||||
|
||||
- name: install python packages
|
||||
run: pip install -r main-repo-triage/requirements.txt
|
||||
|
||||
- name: check and comment issue
|
||||
working-directory: ./main-repo-triage
|
||||
run: python3 single_issue_gha.py
|
||||
|
||||
1
.github/workflows/project-automation.yml
vendored
1
.github/workflows/project-automation.yml
vendored
@@ -21,7 +21,6 @@ jobs:
|
||||
with:
|
||||
project: Current Release
|
||||
action: delete
|
||||
column: In progress
|
||||
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
|
||||
- name: Add to 'Release Next' project
|
||||
|
||||
2
.github/workflows/pull-request-stale.yaml
vendored
2
.github/workflows/pull-request-stale.yaml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ contains(github.repository, 'jellyfin/') }}
|
||||
steps:
|
||||
- uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
|
||||
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
|
||||
with:
|
||||
repo-token: ${{ secrets.JF_BOT_TOKEN }}
|
||||
ascending: true
|
||||
|
||||
4
.github/workflows/release-bump-version.yaml
vendored
4
.github/workflows/release-bump-version.yaml
vendored
@@ -33,7 +33,7 @@ jobs:
|
||||
yq-version: v4.9.8
|
||||
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
ref: ${{ env.TAG_BRANCH }}
|
||||
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
NEXT_VERSION: ${{ github.event.inputs.NEXT_VERSION }}
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
ref: ${{ env.TAG_BRANCH }}
|
||||
|
||||
|
||||
@@ -117,7 +117,6 @@
|
||||
- [sachk](https://github.com/sachk)
|
||||
- [sammyrc34](https://github.com/sammyrc34)
|
||||
- [samuel9554](https://github.com/samuel9554)
|
||||
- [SapientGuardian](https://github.com/SapientGuardian)
|
||||
- [scheidleon](https://github.com/scheidleon)
|
||||
- [sebPomme](https://github.com/sebPomme)
|
||||
- [SegiH](https://github.com/SegiH)
|
||||
@@ -206,10 +205,6 @@
|
||||
- [theshoeshiner](https://github.com/theshoeshiner)
|
||||
- [TokerX](https://github.com/TokerX)
|
||||
- [GeneMarks](https://github.com/GeneMarks)
|
||||
- [Kirill Nikiforov](https://github.com/allmazz)
|
||||
- [bjorntp](https://github.com/bjorntp)
|
||||
- [martenumberto](https://github.com/martenumberto)
|
||||
- [ZeusCraft10](https://github.com/ZeusCraft10)
|
||||
|
||||
# Emby Contributors
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
</PropertyGroup>
|
||||
<!-- Run "dotnet list package (dash,dash)outdated" to see the latest versions of each package.-->
|
||||
<ItemGroup Label="Package Dependencies">
|
||||
<PackageVersion Include="AsyncKeyedLock" Version="7.1.8" />
|
||||
<PackageVersion Include="AsyncKeyedLock" Version="7.1.7" />
|
||||
<PackageVersion Include="AutoFixture.AutoMoq" Version="4.18.1" />
|
||||
<PackageVersion Include="AutoFixture.Xunit2" Version="4.18.1" />
|
||||
<PackageVersion Include="AutoFixture" Version="4.18.1" />
|
||||
@@ -17,7 +17,7 @@
|
||||
<PackageVersion Include="Diacritics" Version="4.0.17" />
|
||||
<PackageVersion Include="DiscUtils.Udf" Version="0.16.13" />
|
||||
<PackageVersion Include="DotNet.Glob" Version="3.1.3" />
|
||||
<PackageVersion Include="FsCheck.Xunit" Version="3.3.2" />
|
||||
<PackageVersion Include="FsCheck.Xunit" Version="3.3.1" />
|
||||
<PackageVersion Include="HarfBuzzSharp.NativeAssets.Linux" Version="8.3.1.1" />
|
||||
<PackageVersion Include="ICU4N.Transliterator" Version="60.1.0-alpha.356" />
|
||||
<PackageVersion Include="IDisposableAnalyzers" Version="4.0.8" />
|
||||
@@ -25,34 +25,34 @@
|
||||
<PackageVersion Include="Jellyfin.XmlTv" Version="10.8.0" />
|
||||
<PackageVersion Include="libse" Version="4.0.12" />
|
||||
<PackageVersion Include="LrcParser" Version="2025.623.0" />
|
||||
<PackageVersion Include="MetaBrainz.MusicBrainz" Version="7.0.0" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.2" />
|
||||
<PackageVersion Include="MetaBrainz.MusicBrainz" Version="6.1.0" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="4.14.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="5.0.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.0.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="4.14.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" />
|
||||
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||
<PackageVersion Include="Microsoft.Data.Sqlite" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Options" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.0" />
|
||||
<PackageVersion Include="MimeTypes" Version="2.5.2" />
|
||||
<PackageVersion Include="Morestachio" Version="5.0.1.631" />
|
||||
<PackageVersion Include="Moq" Version="4.18.4" />
|
||||
@@ -62,38 +62,38 @@
|
||||
<PackageVersion Include="prometheus-net.AspNetCore" Version="8.2.1" />
|
||||
<PackageVersion Include="prometheus-net.DotNetRuntime" Version="4.4.1" />
|
||||
<PackageVersion Include="prometheus-net" Version="8.2.1" />
|
||||
<PackageVersion Include="Polly" Version="8.6.5" />
|
||||
<PackageVersion Include="Polly" Version="8.6.4" />
|
||||
<PackageVersion Include="Serilog.AspNetCore" Version="9.0.0" />
|
||||
<PackageVersion Include="Serilog.Enrichers.Thread" Version="4.0.0" />
|
||||
<PackageVersion Include="Serilog.Expressions" Version="5.0.0" />
|
||||
<PackageVersion Include="Serilog.Settings.Configuration" Version="9.0.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.Async" Version="2.1.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||
<PackageVersion Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||
<PackageVersion Include="Serilog.Sinks.Graylog" Version="3.1.1" />
|
||||
<PackageVersion Include="SerilogAnalyzer" Version="0.15.0" />
|
||||
<PackageVersion Include="SharpFuzz" Version="2.2.0" />
|
||||
<!-- Pinned to 3.116.1 because https://github.com/jellyfin/jellyfin/pull/14255 -->
|
||||
<PackageVersion Include="SkiaSharp" Version="[3.116.1]" />
|
||||
<PackageVersion Include="SkiaSharp.HarfBuzz" Version="[3.116.1]" />
|
||||
<PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="[3.116.1]" />
|
||||
<PackageVersion Include="SkiaSharp" Version="3.116.1" />
|
||||
<PackageVersion Include="SkiaSharp.HarfBuzz" Version="3.116.1" />
|
||||
<PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="3.116.1" />
|
||||
<PackageVersion Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" />
|
||||
<PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" />
|
||||
<PackageVersion Include="Svg.Skia" Version="3.2.1" />
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="6.9.0" />
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore" Version="6.9.0" />
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="6.5.0" />
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore" Version="6.2.3" />
|
||||
<PackageVersion Include="System.Globalization" Version="4.3.0" />
|
||||
<PackageVersion Include="System.Linq.Async" Version="7.0.0" />
|
||||
<PackageVersion Include="System.Text.Encoding.CodePages" Version="10.0.2" />
|
||||
<PackageVersion Include="System.Text.Json" Version="10.0.2" />
|
||||
<PackageVersion Include="System.Threading.Tasks.Dataflow" Version="10.0.2" />
|
||||
<PackageVersion Include="System.Linq.Async" Version="6.0.3" />
|
||||
<PackageVersion Include="System.Text.Encoding.CodePages" Version="9.0.10" />
|
||||
<PackageVersion Include="System.Text.Json" Version="9.0.10" />
|
||||
<PackageVersion Include="System.Threading.Tasks.Dataflow" Version="9.0.10" />
|
||||
<PackageVersion Include="TagLibSharp" Version="2.3.0" />
|
||||
<PackageVersion Include="z440.atl.core" Version="7.10.0" />
|
||||
<PackageVersion Include="z440.atl.core" Version="7.6.0" />
|
||||
<PackageVersion Include="TMDbLib" Version="2.3.0" />
|
||||
<PackageVersion Include="UTF.Unknown" Version="2.6.0" />
|
||||
<PackageVersion Include="Xunit.Priority" Version="1.1.6" />
|
||||
<PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageVersion Include="Xunit.SkippableFact" Version="1.5.61" />
|
||||
<PackageVersion Include="Xunit.SkippableFact" Version="1.5.23" />
|
||||
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Emby.Naming.Book
|
||||
{
|
||||
/// <summary>
|
||||
/// Helper class to retrieve basic metadata from a book filename.
|
||||
/// </summary>
|
||||
public static class BookFileNameParser
|
||||
{
|
||||
private const string NameMatchGroup = "name";
|
||||
private const string IndexMatchGroup = "index";
|
||||
private const string YearMatchGroup = "year";
|
||||
private const string SeriesNameMatchGroup = "seriesName";
|
||||
|
||||
private static readonly Regex[] _nameMatches =
|
||||
[
|
||||
// seriesName (seriesYear) #index (of count) (year) where only seriesName and index are required
|
||||
new Regex(@"^(?<seriesName>.+?)((\s\((?<seriesYear>[0-9]{4})\))?)\s#(?<index>[0-9]+)((\s\(of\s(?<count>[0-9]+)\))?)((\s\((?<year>[0-9]{4})\))?)$"),
|
||||
new Regex(@"^(?<name>.+?)\s\((?<seriesName>.+?),\s#(?<index>[0-9]+)\)((\s\((?<year>[0-9]{4})\))?)$"),
|
||||
new Regex(@"^(?<index>[0-9]+)\s\-\s(?<name>.+?)((\s\((?<year>[0-9]{4})\))?)$"),
|
||||
new Regex(@"(?<name>.*)\((?<year>[0-9]{4})\)"),
|
||||
// last resort matches the whole string as the name
|
||||
new Regex(@"(?<name>.*)")
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Parse a filename name to retrieve the book name, series name, index, and year.
|
||||
/// </summary>
|
||||
/// <param name="name">Book filename to parse for information.</param>
|
||||
/// <returns>Returns <see cref="BookFileNameParserResult"/> object.</returns>
|
||||
public static BookFileNameParserResult Parse(string? name)
|
||||
{
|
||||
var result = new BookFileNameParserResult();
|
||||
|
||||
if (name == null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
foreach (var regex in _nameMatches)
|
||||
{
|
||||
var match = regex.Match(name);
|
||||
|
||||
if (!match.Success)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (match.Groups.TryGetValue(NameMatchGroup, out Group? nameGroup) && nameGroup.Success)
|
||||
{
|
||||
result.Name = nameGroup.Value.Trim();
|
||||
}
|
||||
|
||||
if (match.Groups.TryGetValue(IndexMatchGroup, out Group? indexGroup) && indexGroup.Success && int.TryParse(indexGroup.Value, out var index))
|
||||
{
|
||||
result.Index = index;
|
||||
}
|
||||
|
||||
if (match.Groups.TryGetValue(YearMatchGroup, out Group? yearGroup) && yearGroup.Success && int.TryParse(yearGroup.Value, out var year))
|
||||
{
|
||||
result.Year = year;
|
||||
}
|
||||
|
||||
if (match.Groups.TryGetValue(SeriesNameMatchGroup, out Group? seriesGroup) && seriesGroup.Success)
|
||||
{
|
||||
result.SeriesName = seriesGroup.Value.Trim();
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
using System;
|
||||
|
||||
namespace Emby.Naming.Book
|
||||
{
|
||||
/// <summary>
|
||||
/// Data object used to pass metadata parsed from a book filename.
|
||||
/// </summary>
|
||||
public class BookFileNameParserResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="BookFileNameParserResult"/> class.
|
||||
/// </summary>
|
||||
public BookFileNameParserResult()
|
||||
{
|
||||
Name = null;
|
||||
Index = null;
|
||||
Year = null;
|
||||
SeriesName = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the name of the book.
|
||||
/// </summary>
|
||||
public string? Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the book index.
|
||||
/// </summary>
|
||||
public int? Index { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the publication year.
|
||||
/// </summary>
|
||||
public int? Year { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the series name.
|
||||
/// </summary>
|
||||
public string? SeriesName { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,7 @@
|
||||
<PropertyGroup>
|
||||
<Authors>Jellyfin Contributors</Authors>
|
||||
<PackageId>Jellyfin.Naming</PackageId>
|
||||
<VersionPrefix>10.12.0</VersionPrefix>
|
||||
<VersionPrefix>10.11.2</VersionPrefix>
|
||||
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
||||
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -10,17 +10,12 @@ namespace Emby.Naming.TV
|
||||
/// </summary>
|
||||
public static partial class SeasonPathParser
|
||||
{
|
||||
private static readonly Regex CleanNameRegex = new(@"[ ._\-\[\]]", RegexOptions.Compiled);
|
||||
|
||||
[GeneratedRegex(@"^\s*((?<seasonnumber>(?>\d+))(?:st|nd|rd|th|\.)*(?!\s*[Ee]\d+))\s*(?:[[시즌]*|[シーズン]*|[sS](?:eason|æson|aison|taffel|eries|tagione|äsong|eizoen|easong|ezon|ezona|ezóna|ezonul)*|[tT](?:emporada)*|[kK](?:ausi)*|[Сс](?:езон)*)\s*(?<rightpart>.*)$", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex ProcessPre();
|
||||
|
||||
[GeneratedRegex(@"^\s*(?:[[시즌]*|[シーズン]*|[sS](?:eason|æson|aison|taffel|eries|tagione|äsong|eizoen|easong|ezon|ezona|ezóna|ezonul)*|[tT](?:emporada)*|[kK](?:ausi)*|[Сс](?:езон)*)\s*(?<seasonnumber>\d+?)(?=\d{3,4}p|[^\d]|$)(?!\s*[Ee]\d)(?<rightpart>.*)$", RegexOptions.IgnoreCase)]
|
||||
[GeneratedRegex(@"^\s*(?:[[시즌]*|[シーズン]*|[sS](?:eason|æson|aison|taffel|eries|tagione|äsong|eizoen|easong|ezon|ezona|ezóna|ezonul)*|[tT](?:emporada)*|[kK](?:ausi)*|[Сс](?:езон)*)\s*(?<seasonnumber>(?>\d+)(?!\s*[Ee]\d+))(?<rightpart>.*)$", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex ProcessPost();
|
||||
|
||||
[GeneratedRegex(@"[sS](\d{1,4})(?!\d|[eE]\d)(?=\.|_|-|\[|\]|\s|$)", RegexOptions.None)]
|
||||
private static partial Regex SeasonPrefix();
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to parse season number from path.
|
||||
/// </summary>
|
||||
@@ -61,34 +56,44 @@ namespace Emby.Naming.TV
|
||||
bool supportSpecialAliases,
|
||||
bool supportNumericSeasonFolders)
|
||||
{
|
||||
var fileName = Path.GetFileName(path);
|
||||
|
||||
var seasonPrefixMatch = SeasonPrefix().Match(fileName);
|
||||
if (seasonPrefixMatch.Success &&
|
||||
int.TryParse(seasonPrefixMatch.Groups[1].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var val))
|
||||
{
|
||||
return (val, true);
|
||||
}
|
||||
|
||||
string filename = CleanNameRegex.Replace(fileName, string.Empty);
|
||||
string filename = Path.GetFileName(path);
|
||||
filename = Regex.Replace(filename, "[ ._-]", string.Empty);
|
||||
|
||||
if (parentFolderName is not null)
|
||||
{
|
||||
var cleanParent = CleanNameRegex.Replace(parentFolderName, string.Empty);
|
||||
filename = filename.Replace(cleanParent, string.Empty, StringComparison.OrdinalIgnoreCase);
|
||||
parentFolderName = Regex.Replace(parentFolderName, "[ ._-]", string.Empty);
|
||||
filename = filename.Replace(parentFolderName, string.Empty, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
if (supportSpecialAliases &&
|
||||
(filename.Equals("specials", StringComparison.OrdinalIgnoreCase) ||
|
||||
filename.Equals("extras", StringComparison.OrdinalIgnoreCase)))
|
||||
if (supportSpecialAliases)
|
||||
{
|
||||
return (0, true);
|
||||
if (string.Equals(filename, "specials", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return (0, true);
|
||||
}
|
||||
|
||||
if (string.Equals(filename, "extras", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return (0, true);
|
||||
}
|
||||
}
|
||||
|
||||
if (supportNumericSeasonFolders &&
|
||||
int.TryParse(filename, NumberStyles.Integer, CultureInfo.InvariantCulture, out val))
|
||||
if (supportNumericSeasonFolders)
|
||||
{
|
||||
return (val, true);
|
||||
if (int.TryParse(filename, NumberStyles.Integer, CultureInfo.InvariantCulture, out var val))
|
||||
{
|
||||
return (val, true);
|
||||
}
|
||||
}
|
||||
|
||||
if (filename.Length > 0 && (filename[0] == 'S' || filename[0] == 's'))
|
||||
{
|
||||
var testFilename = filename.AsSpan()[1..];
|
||||
|
||||
if (int.TryParse(testFilename, NumberStyles.Integer, CultureInfo.InvariantCulture, out var val))
|
||||
{
|
||||
return (val, true);
|
||||
}
|
||||
}
|
||||
|
||||
var preMatch = ProcessPre().Match(filename);
|
||||
|
||||
@@ -17,13 +17,6 @@ namespace Emby.Naming.TV
|
||||
[GeneratedRegex(@"((?<a>[^\._]{2,})[\._]*)|([\._](?<b>[^\._]{2,}))")]
|
||||
private static partial Regex SeriesNameRegex();
|
||||
|
||||
/// <summary>
|
||||
/// Regex that matches titles with year in parentheses. Captures the title (which may be
|
||||
/// numeric) before the year, i.e. turns "1923 (2022)" into "1923".
|
||||
/// </summary>
|
||||
[GeneratedRegex(@"(?<title>.+?)\s*\(\d{4}\)")]
|
||||
private static partial Regex TitleWithYearRegex();
|
||||
|
||||
/// <summary>
|
||||
/// Resolve information about series from path.
|
||||
/// </summary>
|
||||
@@ -34,20 +27,6 @@ namespace Emby.Naming.TV
|
||||
{
|
||||
string seriesName = Path.GetFileName(path);
|
||||
|
||||
// First check if the filename matches a title with year pattern (handles numeric titles)
|
||||
if (!string.IsNullOrEmpty(seriesName))
|
||||
{
|
||||
var titleWithYearMatch = TitleWithYearRegex().Match(seriesName);
|
||||
if (titleWithYearMatch.Success)
|
||||
{
|
||||
seriesName = titleWithYearMatch.Groups["title"].Value.Trim();
|
||||
return new SeriesInfo(path)
|
||||
{
|
||||
Name = seriesName
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
SeriesPathParserResult result = SeriesPathParser.Parse(options, path);
|
||||
if (result.Success)
|
||||
{
|
||||
|
||||
@@ -107,20 +107,10 @@ namespace Emby.Server.Implementations.AppBase
|
||||
|
||||
private void CheckOrCreateMarker(string path, string markerName, bool recursive = false)
|
||||
{
|
||||
string? otherMarkers = null;
|
||||
try
|
||||
{
|
||||
otherMarkers = GetMarkers(path, recursive).FirstOrDefault(e => !Path.GetFileName(e.AsSpan()).Equals(markerName, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Error while checking for marker files, assume none exist and keep going
|
||||
// TODO: add some logging
|
||||
}
|
||||
|
||||
var otherMarkers = GetMarkers(path, recursive).FirstOrDefault(e => Path.GetFileName(e) != markerName);
|
||||
if (otherMarkers is not null)
|
||||
{
|
||||
throw new InvalidOperationException($"Expected to find only {markerName} but found marker for {otherMarkers}.");
|
||||
throw new InvalidOperationException($"Exepected to find only {markerName} but found marker for {otherMarkers}.");
|
||||
}
|
||||
|
||||
var markerPath = Path.Combine(path, markerName);
|
||||
|
||||
@@ -39,24 +39,22 @@ namespace Emby.Server.Implementations.Cryptography
|
||||
{
|
||||
if (string.Equals(hash.Id, "PBKDF2", StringComparison.Ordinal))
|
||||
{
|
||||
var iterations = GetIterationsParameter(hash);
|
||||
return hash.Hash.SequenceEqual(
|
||||
Rfc2898DeriveBytes.Pbkdf2(
|
||||
password,
|
||||
hash.Salt,
|
||||
iterations,
|
||||
int.Parse(hash.Parameters["iterations"], CultureInfo.InvariantCulture),
|
||||
HashAlgorithmName.SHA1,
|
||||
32));
|
||||
}
|
||||
|
||||
if (string.Equals(hash.Id, "PBKDF2-SHA512", StringComparison.Ordinal))
|
||||
{
|
||||
var iterations = GetIterationsParameter(hash);
|
||||
return hash.Hash.SequenceEqual(
|
||||
Rfc2898DeriveBytes.Pbkdf2(
|
||||
password,
|
||||
hash.Salt,
|
||||
iterations,
|
||||
int.Parse(hash.Parameters["iterations"], CultureInfo.InvariantCulture),
|
||||
HashAlgorithmName.SHA512,
|
||||
DefaultOutputLength));
|
||||
}
|
||||
@@ -64,27 +62,6 @@ namespace Emby.Server.Implementations.Cryptography
|
||||
throw new NotSupportedException($"Can't verify hash with id: {hash.Id}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts and validates the iterations parameter from a password hash.
|
||||
/// </summary>
|
||||
/// <param name="hash">The password hash containing parameters.</param>
|
||||
/// <returns>The number of iterations.</returns>
|
||||
/// <exception cref="FormatException">Thrown when iterations parameter is missing or invalid.</exception>
|
||||
private static int GetIterationsParameter(PasswordHash hash)
|
||||
{
|
||||
if (!hash.Parameters.TryGetValue("iterations", out var iterationsStr))
|
||||
{
|
||||
throw new FormatException($"Password hash with id '{hash.Id}' is missing required 'iterations' parameter.");
|
||||
}
|
||||
|
||||
if (!int.TryParse(iterationsStr, CultureInfo.InvariantCulture, out var iterations))
|
||||
{
|
||||
throw new FormatException($"Password hash with id '{hash.Id}' has invalid 'iterations' parameter: '{iterationsStr}'.");
|
||||
}
|
||||
|
||||
return iterations;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public byte[] GenerateSalt()
|
||||
=> GenerateSalt(DefaultSaltLength);
|
||||
|
||||
@@ -6,7 +6,6 @@ using System.Linq;
|
||||
using System.Security;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Controller.IO;
|
||||
using MediaBrowser.Model.IO;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -261,7 +260,7 @@ namespace Emby.Server.Implementations.IO
|
||||
{
|
||||
try
|
||||
{
|
||||
var targetFileInfo = FileSystemHelper.ResolveLinkTarget(fileInfo, returnFinalTarget: true);
|
||||
var targetFileInfo = (FileInfo?)fileInfo.ResolveLinkTarget(returnFinalTarget: true);
|
||||
if (targetFileInfo is not null)
|
||||
{
|
||||
result.Exists = targetFileInfo.Exists;
|
||||
@@ -497,17 +496,8 @@ namespace Emby.Server.Implementations.IO
|
||||
/// <inheritdoc />
|
||||
public virtual bool AreEqual(string path1, string path2)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path1) || string.IsNullOrWhiteSpace(path2))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var normalized1 = Path.TrimEndingDirectorySeparator(path1);
|
||||
var normalized2 = Path.TrimEndingDirectorySeparator(path2);
|
||||
|
||||
return string.Equals(
|
||||
normalized1,
|
||||
normalized2,
|
||||
return Path.TrimEndingDirectorySeparator(path1).Equals(
|
||||
Path.TrimEndingDirectorySeparator(path2),
|
||||
_isEnvironmentCaseInsensitive ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text.RegularExpressions;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.IO;
|
||||
using MediaBrowser.Controller.Resolvers;
|
||||
using MediaBrowser.Model.IO;
|
||||
|
||||
@@ -13,24 +11,28 @@ namespace Emby.Server.Implementations.Library;
|
||||
/// </summary>
|
||||
public class DotIgnoreIgnoreRule : IResolverIgnoreRule
|
||||
{
|
||||
private static readonly bool IsWindows = OperatingSystem.IsWindows();
|
||||
|
||||
private static FileInfo? FindIgnoreFile(DirectoryInfo directory)
|
||||
{
|
||||
for (var current = directory; current is not null; current = current.Parent)
|
||||
var ignoreFile = new FileInfo(Path.Join(directory.FullName, ".ignore"));
|
||||
if (ignoreFile.Exists)
|
||||
{
|
||||
var ignorePath = Path.Join(current.FullName, ".ignore");
|
||||
if (File.Exists(ignorePath))
|
||||
{
|
||||
return new FileInfo(ignorePath);
|
||||
}
|
||||
return ignoreFile;
|
||||
}
|
||||
|
||||
return null;
|
||||
var parentDir = directory.Parent;
|
||||
if (parentDir is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return FindIgnoreFile(parentDir);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool ShouldIgnore(FileSystemMetadata fileInfo, BaseItem? parent) => IsIgnored(fileInfo, parent);
|
||||
public bool ShouldIgnore(FileSystemMetadata fileInfo, BaseItem? parent)
|
||||
{
|
||||
return IsIgnored(fileInfo, parent);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether or not the file is ignored.
|
||||
@@ -40,101 +42,65 @@ public class DotIgnoreIgnoreRule : IResolverIgnoreRule
|
||||
/// <returns>True if the file should be ignored.</returns>
|
||||
public static bool IsIgnored(FileSystemMetadata fileInfo, BaseItem? parent)
|
||||
{
|
||||
var searchDirectory = fileInfo.IsDirectory
|
||||
? new DirectoryInfo(fileInfo.FullName)
|
||||
: new DirectoryInfo(Path.GetDirectoryName(fileInfo.FullName) ?? string.Empty);
|
||||
if (fileInfo.IsDirectory)
|
||||
{
|
||||
var dirIgnoreFile = FindIgnoreFile(new DirectoryInfo(fileInfo.FullName));
|
||||
if (dirIgnoreFile is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(searchDirectory.FullName))
|
||||
// Fast path in case the ignore files isn't a symlink and is empty
|
||||
if (dirIgnoreFile.LinkTarget is null && dirIgnoreFile.Length == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// ignore the directory only if the .ignore file is empty
|
||||
// evaluate individual files otherwise
|
||||
return string.IsNullOrWhiteSpace(GetFileContent(dirIgnoreFile));
|
||||
}
|
||||
|
||||
var parentDirPath = Path.GetDirectoryName(fileInfo.FullName);
|
||||
if (string.IsNullOrEmpty(parentDirPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var ignoreFile = FindIgnoreFile(searchDirectory);
|
||||
var folder = new DirectoryInfo(parentDirPath);
|
||||
var ignoreFile = FindIgnoreFile(folder);
|
||||
if (ignoreFile is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Fast path in case the ignore files isn't a symlink and is empty
|
||||
if (ignoreFile.LinkTarget is null && ignoreFile.Length == 0)
|
||||
string ignoreFileString = GetFileContent(ignoreFile);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ignoreFileString))
|
||||
{
|
||||
// Ignore directory if we just have the file
|
||||
return true;
|
||||
}
|
||||
|
||||
var content = GetFileContent(ignoreFile);
|
||||
return string.IsNullOrWhiteSpace(content)
|
||||
|| CheckIgnoreRules(fileInfo.FullName, content, fileInfo.IsDirectory);
|
||||
}
|
||||
|
||||
private static bool CheckIgnoreRules(string path, string ignoreFileContent, bool isDirectory)
|
||||
{
|
||||
// If file has content, base ignoring off the content .gitignore-style rules
|
||||
var rules = ignoreFileContent.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
return CheckIgnoreRules(path, rules, isDirectory);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a path should be ignored based on an array of ignore rules.
|
||||
/// </summary>
|
||||
/// <param name="path">The path to check.</param>
|
||||
/// <param name="rules">The array of ignore rules.</param>
|
||||
/// <param name="isDirectory">Whether the path is a directory.</param>
|
||||
/// <returns>True if the path should be ignored.</returns>
|
||||
internal static bool CheckIgnoreRules(string path, string[] rules, bool isDirectory)
|
||||
=> CheckIgnoreRules(path, rules, isDirectory, IsWindows);
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a path should be ignored based on an array of ignore rules.
|
||||
/// </summary>
|
||||
/// <param name="path">The path to check.</param>
|
||||
/// <param name="rules">The array of ignore rules.</param>
|
||||
/// <param name="isDirectory">Whether the path is a directory.</param>
|
||||
/// <param name="normalizePath">Whether to normalize backslashes to forward slashes (for Windows paths).</param>
|
||||
/// <returns>True if the path should be ignored.</returns>
|
||||
internal static bool CheckIgnoreRules(string path, string[] rules, bool isDirectory, bool normalizePath)
|
||||
{
|
||||
var ignoreRules = ignoreFileString.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
var ignore = new Ignore.Ignore();
|
||||
ignore.Add(ignoreRules);
|
||||
|
||||
// Add each rule individually to catch and skip invalid patterns
|
||||
var validRulesAdded = 0;
|
||||
foreach (var rule in rules)
|
||||
{
|
||||
try
|
||||
{
|
||||
ignore.Add(rule);
|
||||
validRulesAdded++;
|
||||
}
|
||||
catch (RegexParseException)
|
||||
{
|
||||
// Ignore invalid patterns
|
||||
}
|
||||
}
|
||||
|
||||
// If no valid rules were added, fall back to ignoring everything (like an empty .ignore file)
|
||||
if (validRulesAdded == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Mitigate the problem of the Ignore library not handling Windows paths correctly.
|
||||
// See https://github.com/jellyfin/jellyfin/issues/15484
|
||||
var pathToCheck = normalizePath ? path.NormalizePath('/') : path;
|
||||
|
||||
// Add trailing slash for directories to match "folder/"
|
||||
if (isDirectory)
|
||||
{
|
||||
pathToCheck = string.Concat(pathToCheck.AsSpan().TrimEnd('/'), "/");
|
||||
}
|
||||
|
||||
return ignore.IsIgnored(pathToCheck);
|
||||
return ignore.IsIgnored(fileInfo.FullName);
|
||||
}
|
||||
|
||||
private static string GetFileContent(FileInfo ignoreFile)
|
||||
private static string GetFileContent(FileInfo dirIgnoreFile)
|
||||
{
|
||||
ignoreFile = FileSystemHelper.ResolveLinkTarget(ignoreFile, returnFinalTarget: true) ?? ignoreFile;
|
||||
return ignoreFile.Exists
|
||||
? File.ReadAllText(ignoreFile.FullName)
|
||||
: string.Empty;
|
||||
dirIgnoreFile = (FileInfo?)dirIgnoreFile.ResolveLinkTarget(returnFinalTarget: true) ?? dirIgnoreFile;
|
||||
if (!dirIgnoreFile.Exists)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
using (var reader = dirIgnoreFile.OpenText())
|
||||
{
|
||||
return reader.ReadToEnd();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -457,12 +457,6 @@ namespace Emby.Server.Implementations.Library
|
||||
_cache.TryRemove(child.Id, out _);
|
||||
}
|
||||
|
||||
if (parent is Folder folder)
|
||||
{
|
||||
folder.Children = null;
|
||||
folder.UserData = null;
|
||||
}
|
||||
|
||||
ReportItemRemoved(item, parent);
|
||||
}
|
||||
|
||||
@@ -1058,7 +1052,6 @@ namespace Emby.Server.Implementations.Library
|
||||
{
|
||||
IncludeItemTypes = [BaseItemKind.MusicArtist],
|
||||
Name = name,
|
||||
UseRawName = true,
|
||||
DtoOptions = options
|
||||
}).Cast<MusicArtist>()
|
||||
.OrderBy(i => i.IsAccessedByName ? 1 : 0)
|
||||
@@ -2000,12 +1993,6 @@ namespace Emby.Server.Implementations.Library
|
||||
RegisterItem(item);
|
||||
}
|
||||
|
||||
if (parent is Folder folder)
|
||||
{
|
||||
folder.Children = null;
|
||||
folder.UserData = null;
|
||||
}
|
||||
|
||||
if (ItemAdded is not null)
|
||||
{
|
||||
foreach (var item in items)
|
||||
@@ -2144,7 +2131,7 @@ namespace Emby.Server.Implementations.Library
|
||||
|
||||
item.ValidateImages();
|
||||
|
||||
await _itemRepository.SaveImagesAsync(item).ConfigureAwait(false);
|
||||
_itemRepository.SaveImages(item);
|
||||
|
||||
RegisterItem(item);
|
||||
}
|
||||
@@ -2163,12 +2150,6 @@ namespace Emby.Server.Implementations.Library
|
||||
|
||||
_itemRepository.SaveItems(items, cancellationToken);
|
||||
|
||||
if (parent is Folder folder)
|
||||
{
|
||||
folder.Children = null;
|
||||
folder.UserData = null;
|
||||
}
|
||||
|
||||
if (ItemUpdated is not null)
|
||||
{
|
||||
foreach (var item in items)
|
||||
|
||||
@@ -226,11 +226,6 @@ namespace Emby.Server.Implementations.Library
|
||||
/// <inheritdoc />>
|
||||
public MediaProtocol GetPathProtocol(string path)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path))
|
||||
{
|
||||
return MediaProtocol.File;
|
||||
}
|
||||
|
||||
if (path.StartsWith("Rtsp", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return MediaProtocol.Rtsp;
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Emby.Naming.Book;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Resolvers;
|
||||
using MediaBrowser.Model.Entities;
|
||||
|
||||
namespace Emby.Server.Implementations.Library.Resolvers.Books
|
||||
{
|
||||
@@ -35,22 +35,17 @@ namespace Emby.Server.Implementations.Library.Resolvers.Books
|
||||
|
||||
var extension = Path.GetExtension(args.Path.AsSpan());
|
||||
|
||||
if (!_validExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase))
|
||||
if (_validExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
// It's a book
|
||||
return new Book
|
||||
{
|
||||
Path = args.Path,
|
||||
IsInMixedFolder = true
|
||||
};
|
||||
}
|
||||
|
||||
var result = BookFileNameParser.Parse(Path.GetFileNameWithoutExtension(args.Path));
|
||||
|
||||
return new Book
|
||||
{
|
||||
Path = args.Path,
|
||||
Name = result.Name ?? string.Empty,
|
||||
IndexNumber = result.Index,
|
||||
ProductionYear = result.Year,
|
||||
SeriesName = result.SeriesName ?? Path.GetFileName(Path.GetDirectoryName(args.Path)),
|
||||
IsInMixedFolder = true,
|
||||
};
|
||||
return null;
|
||||
}
|
||||
|
||||
private Book GetBook(ItemResolveArgs args)
|
||||
@@ -64,22 +59,15 @@ namespace Emby.Server.Implementations.Library.Resolvers.Books
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
}).ToList();
|
||||
|
||||
// directory is only considered a book when it contains exactly one supported file
|
||||
// other library structures with multiple books to a directory will get picked up as individual files
|
||||
// Don't return a Book if there is more (or less) than one document in the directory
|
||||
if (bookFiles.Count != 1)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var result = BookFileNameParser.Parse(Path.GetFileName(args.Path));
|
||||
|
||||
return new Book
|
||||
{
|
||||
Path = bookFiles[0].FullName,
|
||||
Name = result.Name ?? string.Empty,
|
||||
IndexNumber = result.Index,
|
||||
ProductionYear = result.Year,
|
||||
SeriesName = result.SeriesName ?? string.Empty,
|
||||
Path = bookFiles[0].FullName
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -369,16 +369,13 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies
|
||||
// We need to only look at the name of this actual item (not parents)
|
||||
var justName = item.IsInMixedFolder ? Path.GetFileName(item.Path.AsSpan()) : Path.GetFileName(item.ContainingFolderPath.AsSpan());
|
||||
|
||||
var tmdbid = justName.GetAttributeValue("tmdbid");
|
||||
|
||||
// If not in a mixed folder and ID not found in folder path, check filename
|
||||
if (string.IsNullOrEmpty(tmdbid) && !item.IsInMixedFolder)
|
||||
if (!justName.IsEmpty)
|
||||
{
|
||||
tmdbid = Path.GetFileName(item.Path.AsSpan()).GetAttributeValue("tmdbid");
|
||||
// Check for TMDb id
|
||||
var tmdbid = justName.GetAttributeValue("tmdbid");
|
||||
item.TrySetProviderId(MetadataProvider.Tmdb, tmdbid);
|
||||
}
|
||||
|
||||
item.TrySetProviderId(MetadataProvider.Tmdb, tmdbid);
|
||||
|
||||
if (!string.IsNullOrEmpty(item.Path))
|
||||
{
|
||||
// Check for IMDb id - we use full media path, as we can assume that this will match in any use case (whether id in parent dir or in file name)
|
||||
|
||||
@@ -42,7 +42,7 @@ namespace Emby.Server.Implementations.Library
|
||||
results = results.GetRange(query.StartIndex.Value, totalRecordCount - query.StartIndex.Value);
|
||||
}
|
||||
|
||||
if (query.Limit.HasValue && query.Limit.Value > 0)
|
||||
if (query.Limit.HasValue)
|
||||
{
|
||||
results = results.GetRange(0, Math.Min(query.Limit.Value, results.Count));
|
||||
}
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
"Albums": "ألبومات",
|
||||
"AppDeviceValues": "تطبيق: {0}, جهاز: {1}",
|
||||
"Application": "تطبيق",
|
||||
"Artists": "فنانون",
|
||||
"Artists": "الفنانون",
|
||||
"AuthenticationSucceededWithUserName": "نجحت عملية التوثيق بـ {0}",
|
||||
"Books": "الكتب",
|
||||
"CameraImageUploadedFrom": "رُفعت صورة الكاميرا الجديدة من {0}",
|
||||
"Channels": "القنوات",
|
||||
"ChapterNameValue": "الفصل {0}",
|
||||
"Collections": "مجموعات",
|
||||
"Collections": "المجموعات",
|
||||
"DeviceOfflineWithName": "قُطِع الاتصال ب{0}",
|
||||
"DeviceOnlineWithName": "{0} متصل",
|
||||
"FailedLoginAttemptWithUserName": "محاولة تسجيل الدخول فاشلة من {0}",
|
||||
@@ -16,7 +16,7 @@
|
||||
"Folders": "المجلدات",
|
||||
"Genres": "التصنيفات",
|
||||
"HeaderAlbumArtists": "فناني الألبوم",
|
||||
"HeaderContinueWatching": "متابعة المشاهدة",
|
||||
"HeaderContinueWatching": "إستئناف المشاهدة",
|
||||
"HeaderFavoriteAlbums": "الألبومات المفضلة",
|
||||
"HeaderFavoriteArtists": "الفنانون المفضلون",
|
||||
"HeaderFavoriteEpisodes": "الحلقات المفضلة",
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"Collections": "Калекцыі",
|
||||
"Default": "Па змаўчанні",
|
||||
"FailedLoginAttemptWithUserName": "Няўдалая спроба ўваходу з {0}",
|
||||
"Folders": "Папкі",
|
||||
"Folders": "Тэчкі",
|
||||
"Favorites": "Абранае",
|
||||
"External": "Знешні",
|
||||
"Genres": "Жанры",
|
||||
@@ -95,7 +95,7 @@
|
||||
"ServerNameNeedsToBeRestarted": "{0} патрабуе перазапуску",
|
||||
"Shows": "Шоу",
|
||||
"StartupEmbyServerIsLoading": "Jellyfin Server загружаецца. Калі ласка, паўтарыце спробу крыху пазней.",
|
||||
"SubtitleDownloadFailureFromForItem": "Субцітры для {1} не ўдалося спампаваць з {0}",
|
||||
"SubtitleDownloadFailureFromForItem": "Не атрымалася спампаваць субтытры з {0} для {1}",
|
||||
"TvShows": "Тэлепраграма",
|
||||
"Undefined": "Нявызначана",
|
||||
"UserLockedOutWithName": "Карыстальнік {0} быў заблакіраваны",
|
||||
@@ -104,7 +104,7 @@
|
||||
"UserStartedPlayingItemWithValues": "{0} прайграваецца {1} на {2}",
|
||||
"UserStoppedPlayingItemWithValues": "{0} скончыў прайграванне {1} на {2}",
|
||||
"ValueHasBeenAddedToLibrary": "{0} быў дададзены ў вашу медыятэку",
|
||||
"ValueSpecialEpisodeName": "Спецвыпуск - {0}",
|
||||
"ValueSpecialEpisodeName": "Спецэпізод - {0}",
|
||||
"VersionNumber": "Версія {0}",
|
||||
"TasksMaintenanceCategory": "Абслугоўванне",
|
||||
"TasksLibraryCategory": "Бібліятэка",
|
||||
@@ -114,7 +114,7 @@
|
||||
"TaskCleanCacheDescription": "Выдаляе файлы кэша, якія больш не патрэбныя сістэме.",
|
||||
"TaskRefreshChapterImages": "Вынуць выявы раздзелаў",
|
||||
"TaskRefreshLibrary": "Сканаваць бібліятэку",
|
||||
"TaskRefreshLibraryDescription": "Сканіруе вашу медыятэку на наяўнасць новых файлаў і абнаўляе метаданыя.",
|
||||
"TaskRefreshLibraryDescription": "Скануе вашу медыятэку на наяўнасць новых файлаў і абнаўляе метададзеныя.",
|
||||
"TaskCleanLogs": "Ачысціць журнал",
|
||||
"TaskRefreshPeople": "Абнавіць выканаўцаў",
|
||||
"TaskRefreshPeopleDescription": "Абнаўленне метаданых для акцёраў і рэжысёраў у вашай медыятэцы.",
|
||||
@@ -137,5 +137,5 @@
|
||||
"TaskExtractMediaSegments": "Сканіраванне медыя-сегмента",
|
||||
"TaskMoveTrickplayImages": "Перанесці месцазнаходжанне выявы Trickplay",
|
||||
"CleanupUserDataTask": "Задача па ачыстцы дадзеных карыстальніка",
|
||||
"CleanupUserDataTaskDescription": "Ачышчае ўсе даныя карыстальніка (стан прагляду, абранае і г.д.) для медыяфайлаў, што адсутнічаюць больш за 90 дзён."
|
||||
"CleanupUserDataTaskDescription": "Ачысьціць усе дадзеныя карыстальніка (стан прагляду, абранае і г.д.) для медыяфайлаў, што адсутнічаюць больш за 90 дзён."
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"CameraImageUploadedFrom": "Mae delwedd camera newydd wedi'i lanlwytho o {0}",
|
||||
"Books": "Llyfrau",
|
||||
"AuthenticationSucceededWithUserName": "{0} wedi’i ddilysu’n llwyddiannus",
|
||||
"Artists": "Crewyr",
|
||||
"Artists": "Artistiaid",
|
||||
"AppDeviceValues": "Ap: {0}, Dyfais: {1}",
|
||||
"Albums": "Albwmau",
|
||||
"Genres": "Genres",
|
||||
@@ -67,7 +67,7 @@
|
||||
"NotificationOptionAudioPlayback": "Dechreuwyd chwarae sain",
|
||||
"MessageServerConfigurationUpdated": "Mae gosodiadau gweinydd wedi'i ddiweddaru",
|
||||
"MessageNamedServerConfigurationUpdatedWithValue": "Mae adran gosodiadau gweinydd {0} wedi'i diweddaru",
|
||||
"FailedLoginAttemptWithUserName": "Cais mewngofnodi wedi methu o {0}",
|
||||
"FailedLoginAttemptWithUserName": "Cais mewngofnodi wedi methu gan {0}",
|
||||
"ValueHasBeenAddedToLibrary": "{0} wedi'i hychwanegu at eich llyfrgell gyfryngau",
|
||||
"UserStoppedPlayingItemWithValues": "{0} wedi gorffen chwarae {1} ar {2}",
|
||||
"UserStartedPlayingItemWithValues": "{0} yn chwarae {1} ar {2}",
|
||||
@@ -123,14 +123,5 @@
|
||||
"TaskRefreshChapterImages": "Echdynnu Lluniau Pennod",
|
||||
"TaskCleanCacheDescription": "Dileu ffeiliau cache nad oes eu hangen ar y system mwyach.",
|
||||
"TaskCleanCache": "Gwaghau Ffolder Cache",
|
||||
"HearingImpaired": "Nam ar y clyw",
|
||||
"TaskAudioNormalization": "Gwastatau Sain",
|
||||
"TaskAudioNormalizationDescription": "Yn sganio ffeiliau am ddata gwastatau sain.",
|
||||
"TaskRefreshTrickplayImages": "Creuwch lluniau Trickplay",
|
||||
"TaskRefreshTrickplayImagesDescription": "Creu rhagolygon Trickplay ar gyfer fideos mewn llyfrgelloedd gweithredol.",
|
||||
"TaskDownloadMissingLyrics": "Lawrlwytho geiriau coll",
|
||||
"TaskDownloadMissingLyricsDescription": "Lawrlwytho geiriau caneuon",
|
||||
"TaskCleanCollectionsAndPlaylists": "Glanhau casgliadau a rhestrau chwarae",
|
||||
"TaskCleanCollectionsAndPlaylistsDescription": "Dileu eitemau o gasgliadau a rhestrau chwarae sydd ddim yn bodoli bellach.",
|
||||
"TaskExtractMediaSegments": "Sganio Darnau Cyfryngau"
|
||||
"HearingImpaired": "Nam ar y clyw"
|
||||
}
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
"Channels": "Kanäle",
|
||||
"ChapterNameValue": "Kapitel {0}",
|
||||
"Collections": "Sammlungen",
|
||||
"DeviceOfflineWithName": "{0} ist offline",
|
||||
"DeviceOnlineWithName": "{0} ist online",
|
||||
"FailedLoginAttemptWithUserName": "Anmeldung von {0} fehlgeschlagen",
|
||||
"DeviceOfflineWithName": "{0} hat die Verbindung getrennt",
|
||||
"DeviceOnlineWithName": "{0} ist verbunden",
|
||||
"FailedLoginAttemptWithUserName": "Fehlgeschlagener Anmeldeversuch von {0}",
|
||||
"Favorites": "Favoriten",
|
||||
"Folders": "Verzeichnisse",
|
||||
"Genres": "Genres",
|
||||
@@ -21,7 +21,7 @@
|
||||
"HeaderFavoriteArtists": "Lieblingsinterpreten",
|
||||
"HeaderFavoriteEpisodes": "Lieblingsepisoden",
|
||||
"HeaderFavoriteShows": "Lieblingsserien",
|
||||
"HeaderFavoriteSongs": "Lieblingssongs",
|
||||
"HeaderFavoriteSongs": "Lieblingslieder",
|
||||
"HeaderLiveTV": "Live TV",
|
||||
"HeaderNextUp": "Als Nächstes",
|
||||
"HeaderRecordingGroups": "Aufnahme-Gruppen",
|
||||
@@ -46,7 +46,7 @@
|
||||
"NewVersionIsAvailable": "Eine neue Jellyfin-Serverversion steht zum Download bereit.",
|
||||
"NotificationOptionApplicationUpdateAvailable": "Anwendungsaktualisierung verfügbar",
|
||||
"NotificationOptionApplicationUpdateInstalled": "Anwendungsaktualisierung installiert",
|
||||
"NotificationOptionAudioPlayback": "Audio wird abgespielt",
|
||||
"NotificationOptionAudioPlayback": "Audiowiedergabe gestartet",
|
||||
"NotificationOptionAudioPlaybackStopped": "Audiowiedergabe gestoppt",
|
||||
"NotificationOptionCameraImageUploaded": "Foto hochgeladen",
|
||||
"NotificationOptionInstallationFailed": "Installation fehlgeschlagen",
|
||||
@@ -57,8 +57,8 @@
|
||||
"NotificationOptionPluginUpdateInstalled": "Pluginaktualisierung installiert",
|
||||
"NotificationOptionServerRestartRequired": "Serverneustart notwendig",
|
||||
"NotificationOptionTaskFailed": "Geplante Aufgabe fehlgeschlagen",
|
||||
"NotificationOptionUserLockedOut": "Benutzer gesperrt",
|
||||
"NotificationOptionVideoPlayback": "Video wird abgespielt",
|
||||
"NotificationOptionUserLockedOut": "Benutzer ausgeschlossen",
|
||||
"NotificationOptionVideoPlayback": "Videowiedergabe gestartet",
|
||||
"NotificationOptionVideoPlaybackStopped": "Videowiedergabe gestoppt",
|
||||
"Photos": "Fotos",
|
||||
"Playlists": "Wiedergabelisten",
|
||||
@@ -82,7 +82,7 @@
|
||||
"UserCreatedWithName": "Benutzer {0} wurde erstellt",
|
||||
"UserDeletedWithName": "Benutzer {0} wurde gelöscht",
|
||||
"UserDownloadingItemWithValues": "{0} lädt {1} herunter",
|
||||
"UserLockedOutWithName": "Benutzer {0} wurde gesperrt",
|
||||
"UserLockedOutWithName": "Benutzer {0} wurde ausgeschlossen",
|
||||
"UserOfflineFromDevice": "{0} wurde getrennt von {1}",
|
||||
"UserOnlineFromDevice": "{0} ist online von {1}",
|
||||
"UserPasswordChangedWithName": "Das Passwort für Benutzer {0} wurde geändert",
|
||||
@@ -97,25 +97,25 @@
|
||||
"TaskRefreshChannelsDescription": "Aktualisiert Internet-Kanal-Informationen.",
|
||||
"TaskRefreshChannels": "Kanäle aktualisieren",
|
||||
"TaskCleanTranscodeDescription": "Löscht Transkodierungsdateien, die älter als einen Tag sind.",
|
||||
"TaskCleanTranscode": "Transkodierungsverzeichnis leeren",
|
||||
"TaskCleanTranscode": "Transkodierungs-Verzeichnis aufräumen",
|
||||
"TaskUpdatePluginsDescription": "Lädt Updates für Plugins herunter, welche für automatische Updates konfiguriert sind und installiert diese.",
|
||||
"TaskUpdatePlugins": "Plugins aktualisieren",
|
||||
"TaskRefreshPeopleDescription": "Aktualisiert Metadaten für Schauspieler und Regisseure in deinen Bibliotheken.",
|
||||
"TaskRefreshPeople": "Personen aktualisieren",
|
||||
"TaskCleanLogsDescription": "Lösche Log-Dateien, die älter als {0} Tage sind.",
|
||||
"TaskCleanLogs": "Protokollverzeichnis leeren",
|
||||
"TaskRefreshLibraryDescription": "Durchsucht deine Medienbibliothek nach neuen Dateien und aktualisiert Metadaten.",
|
||||
"TaskCleanLogs": "Log-Verzeichnis aufräumen",
|
||||
"TaskRefreshLibraryDescription": "Durchsucht alle Bibliotheken nach neu hinzugefügten Dateien und aktualisiert Metadaten.",
|
||||
"TaskRefreshLibrary": "Medien-Bibliothek scannen",
|
||||
"TaskRefreshChapterImagesDescription": "Erstellt Vorschaubilder für Videokapitel.",
|
||||
"TaskRefreshChapterImages": "Kapitelvorschauen erstellen",
|
||||
"TaskCleanCacheDescription": "Löscht Cache-Dateien, die vom System nicht mehr benötigt werden.",
|
||||
"TaskCleanCache": "Cache-Verzeichnis leeren",
|
||||
"TaskRefreshChapterImagesDescription": "Erstellt Vorschaubilder für Videos, die Kapitel besitzen.",
|
||||
"TaskRefreshChapterImages": "Kapitel-Bilder extrahieren",
|
||||
"TaskCleanCacheDescription": "Löscht vom System nicht mehr benötigte Zwischenspeicherdateien.",
|
||||
"TaskCleanCache": "Zwischenspeicher-Verzeichnis aufräumen",
|
||||
"TasksChannelsCategory": "Internet-Kanäle",
|
||||
"TasksApplicationCategory": "Anwendung",
|
||||
"TasksLibraryCategory": "Bibliothek",
|
||||
"TasksMaintenanceCategory": "Wartung",
|
||||
"TaskCleanActivityLogDescription": "Löscht Aktivitätsprotokolleinträge, die älter als das konfigurierte Alter sind.",
|
||||
"TaskCleanActivityLog": "Aktivitätsverlauf bereinigen",
|
||||
"TaskCleanActivityLog": "Aktivitätsprotokolle aufräumen",
|
||||
"Undefined": "Undefiniert",
|
||||
"Forced": "Erzwungen",
|
||||
"Default": "Standard",
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
"ScheduledTaskFailedWithName": "{0} falló",
|
||||
"ScheduledTaskStartedWithName": "{0} iniciado",
|
||||
"ServerNameNeedsToBeRestarted": "{0} necesita ser reiniciado",
|
||||
"Shows": "Series",
|
||||
"Shows": "Programas",
|
||||
"Songs": "Canciones",
|
||||
"StartupEmbyServerIsLoading": "El servidor Jellyfin se está cargando. Vuelve a intentarlo en breve.",
|
||||
"SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}",
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"ItemAddedWithName": "{0} fue agregado a la biblioteca",
|
||||
"ItemRemovedWithName": "{0} fue removido de la biblioteca",
|
||||
"LabelIpAddressValue": "Dirección IP: {0}",
|
||||
"LabelRunningTimeValue": "Tiempo corriendo: {0}",
|
||||
"LabelRunningTimeValue": "Tiempo de reproducción: {0}",
|
||||
"Latest": "Recientes",
|
||||
"MessageApplicationUpdated": "El servidor Jellyfin ha sido actualizado",
|
||||
"MessageApplicationUpdatedTo": "El servidor Jellyfin ha sido actualizado a {0}",
|
||||
|
||||
@@ -137,5 +137,5 @@
|
||||
"TaskExtractMediaSegmentsDescription": "Eraldab või võtab meediasegmendid MediaSegment'i lubavatest pluginatest.",
|
||||
"TaskMoveTrickplayImages": "Muuda trickplay piltide asukoht",
|
||||
"CleanupUserDataTask": "Puhasta kasutajaandmed",
|
||||
"CleanupUserDataTaskDescription": "Puhastab kõik kasutajaandmed (vaatamise olek, lemmikute olek jne) meediast, mida pole enam vähemalt 90 päeva saadaval olnud."
|
||||
"CleanupUserDataTaskDescription": "Puhastab kõik kasutajaandmed (vaatamise olek, lemmikute olek jne) meediast, mis pole enam vähemalt 90 päeva saadaval olnud."
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"Collections": "Sammlungen",
|
||||
"DeviceOfflineWithName": "{0} wurde getrennt",
|
||||
"DeviceOnlineWithName": "{0} ist verbunden",
|
||||
"FailedLoginAttemptWithUserName": "Fählgschlagene Ameldeversuech vo {0}",
|
||||
"FailedLoginAttemptWithUserName": "Fehlgeschlagener Anmeldeversuch von {0}",
|
||||
"Favorites": "Favorite",
|
||||
"Folders": "Ordner",
|
||||
"Genres": "Genre",
|
||||
|
||||
@@ -129,12 +129,5 @@
|
||||
"TaskAudioNormalization": "श्रव्य सामान्यीकरण",
|
||||
"TaskAudioNormalizationDescription": "श्रव्य सामान्यीकरण के लिए फाइलें अन्वेषण करें",
|
||||
"TaskDownloadMissingLyrics": "लापता गानों के बोल डाउनलोड करेँ",
|
||||
"TaskDownloadMissingLyricsDescription": "गानों के बोल डाउनलोड करता है",
|
||||
"TaskExtractMediaSegments": "मीडिया सेगमेंट स्कैन",
|
||||
"TaskExtractMediaSegmentsDescription": "मीडियासेगमेंट सक्षम प्लगइन्स से मीडिया सेगमेंट निकालता है या प्राप्त करता है।",
|
||||
"TaskMoveTrickplayImages": "ट्रिकप्ले छवि स्थान माइग्रेट करें",
|
||||
"TaskMoveTrickplayImagesDescription": "लाइब्रेरी सेटिंग्स के अनुसार मौजूदा ट्रिकप्ले फ़ाइलों को स्थानांतरित करता है।",
|
||||
"TaskCleanCollectionsAndPlaylistsDescription": "संग्रहों और प्लेलिस्टों से उन आइटमों को हटाता है जो अब मौजूद नहीं हैं।",
|
||||
"TaskCleanCollectionsAndPlaylists": "संग्रह और प्लेलिस्ट साफ़ करें",
|
||||
"CleanupUserDataTask": "यूज़र डेटा की सफाई करता है।"
|
||||
"TaskDownloadMissingLyricsDescription": "गानों के बोल डाउनलोड करता है"
|
||||
}
|
||||
|
||||
@@ -136,7 +136,5 @@
|
||||
"TaskMoveTrickplayImages": "트릭플레이 이미지 위치 마이그레이션",
|
||||
"TaskMoveTrickplayImagesDescription": "추출된 트릭플레이 이미지를 라이브러리 설정에 따라 이동합니다.",
|
||||
"TaskDownloadMissingLyrics": "누락된 가사 다운로드",
|
||||
"TaskDownloadMissingLyricsDescription": "가사 다운로드",
|
||||
"CleanupUserDataTask": "사용자 데이터 정리 작업",
|
||||
"CleanupUserDataTaskDescription": "최소 90일 이상 존재하지 않는 미디어에 대한 사용자 데이터(시청 상태, 즐겨찾기 등)를 정리합니다."
|
||||
"TaskDownloadMissingLyricsDescription": "가사 다운로드"
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"Albums": "Pukaemi",
|
||||
"AppDeviceValues": "Taupānga: {0}, Pūrere: {1}",
|
||||
"Application": "Taupānga",
|
||||
"Artists": "Kaiwaiata",
|
||||
"AuthenticationSucceededWithUserName": "{0} has been successfully authenticated",
|
||||
"Books": "Ngā pukapuka",
|
||||
"CameraImageUploadedFrom": "Kua tuku ake he whakaahua kāmera hou mai i {0}"
|
||||
}
|
||||
@@ -2,12 +2,12 @@
|
||||
"AppDeviceValues": "അപ്ലിക്കേഷൻ: {0}, ഉപകരണം: {1}",
|
||||
"Application": "അപ്ലിക്കേഷൻ",
|
||||
"AuthenticationSucceededWithUserName": "{0} വിജയകരമായി പ്രാമാണീകരിച്ചു",
|
||||
"CameraImageUploadedFrom": "{0} എന്നതിൽ നിന്ന് ഒരു പുതിയ ക്യാമറ ചിത്രം അപ്ലോഡുചെയ്തു",
|
||||
"CameraImageUploadedFrom": "Camera 0 from എന്നതിൽ നിന്ന് ഒരു പുതിയ ക്യാമറ ചിത്രം അപ്ലോഡുചെയ്തു",
|
||||
"ChapterNameValue": "അധ്യായം {0}",
|
||||
"DeviceOfflineWithName": "{0} വിച്ഛേദിച്ചു",
|
||||
"DeviceOnlineWithName": "{0} ബന്ധിപ്പിച്ചു",
|
||||
"FailedLoginAttemptWithUserName": "{0}ൽ നിന്നുള്ള പ്രവേശന ശ്രമം പരാജയപ്പെട്ടു",
|
||||
"Forced": "നിർബന്ധിതമായി",
|
||||
"Forced": "നിർബന്ധിച്ചു",
|
||||
"HeaderFavoriteAlbums": "പ്രിയപ്പെട്ട ആൽബങ്ങൾ",
|
||||
"HeaderFavoriteArtists": "പ്രിയപ്പെട്ട കലാകാരന്മാർ",
|
||||
"HeaderFavoriteEpisodes": "പ്രിയപ്പെട്ട എപ്പിസോഡുകൾ",
|
||||
@@ -114,7 +114,7 @@
|
||||
"Artists": "കലാകാരന്മാർ",
|
||||
"Shows": "ഷോകൾ",
|
||||
"Default": "സ്ഥിരസ്ഥിതി",
|
||||
"Favorites": "പ്രിയപ്പെട്ടവ",
|
||||
"Favorites": "പ്രിയങ്കരങ്ങൾ",
|
||||
"Books": "പുസ്തകങ്ങൾ",
|
||||
"Genres": "വിഭാഗങ്ങൾ",
|
||||
"Channels": "ചാനലുകൾ",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"HeaderNextUp": "Дараа нь",
|
||||
"HeaderContinueWatching": "Үргэлжлүүлэн үзэх",
|
||||
"Songs": "Дуунууд",
|
||||
"Playlists": "Тоглуулах жагсаалтууд",
|
||||
"Playlists": "Playlist-ууд",
|
||||
"Movies": "Кинонууд",
|
||||
"Latest": "Сүүлийн үеийн",
|
||||
"Genres": "Төрлүүд",
|
||||
@@ -71,7 +71,7 @@
|
||||
"Forced": "Хүчээр",
|
||||
"HeaderAlbumArtists": "Цомгийн уран бүтээлчид",
|
||||
"HeaderFavoriteAlbums": "Дуртай цомгууд",
|
||||
"HeaderLiveTV": "Шууд ТВ",
|
||||
"HeaderLiveTV": "Шууд",
|
||||
"HeaderRecordingGroups": "Бичлэгийн бүлгүүд",
|
||||
"HearingImpaired": "Сонсголын бэрхшээлтэй",
|
||||
"HomeVideos": "Үндсэн дүрсүүд",
|
||||
@@ -109,7 +109,7 @@
|
||||
"ScheduledTaskStartedWithName": "{0}-г эхлүүлэв",
|
||||
"ServerNameNeedsToBeRestarted": "{0}-г дахин асаана уу",
|
||||
"Shows": "Шоу",
|
||||
"Sync": "Синхрончлох",
|
||||
"Sync": "Дахин",
|
||||
"System": "Систем",
|
||||
"TvShows": "ТВ нэвтрүүлгүүд",
|
||||
"Undefined": "Танисангүй",
|
||||
|
||||
@@ -132,10 +132,5 @@
|
||||
"TaskDownloadMissingLyrics": "उपलब्ध नसलेली गीतपट्टी (Lyrics) डाउनलोड करा",
|
||||
"TaskAudioNormalization": "ऑडिओ सामान्यीकरण",
|
||||
"TaskAudioNormalizationDescription": "ऑडिओ सामान्यीकरणाचा डाटा स्कॅन करतो.",
|
||||
"TaskDownloadMissingLyricsDescription": "गाण्यांची गीतपट्टी (Lyrics) डाउनलोड करतो",
|
||||
"TaskExtractMediaSegmentsDescription": "सक्रिय असलेल्या प्लगिनमधून मीडिया विभाग प्राप्त करते.",
|
||||
"TaskMoveTrickplayImagesDescription": "लायब्ररीच्या सेटिंग्जप्रमाणे आधीपासून अस्तित्वात असलेल्या ट्रिकप्ले फाइल्सचे स्थान बदलते.",
|
||||
"TaskCleanCollectionsAndPlaylistsDescription": "जे संग्रह आणि प्लेलिस्ट आता अस्तित्वात नाहीत, त्यांमधील घटक हटवते.",
|
||||
"CleanupUserDataTask": "वापरकर्ता डेटाची स्वच्छता प्रक्रिया",
|
||||
"CleanupUserDataTaskDescription": "९० दिवसांहून अधिक काळ अनुपस्थित असलेल्या माध्यमांवरील सर्व वापरकर्ता माहिती (जसे पाहण्याची स्थिती, आवडी इ.) हटवते."
|
||||
"TaskDownloadMissingLyricsDescription": "गाण्यांची गीतपट्टी (Lyrics) डाउनलोड करतो"
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -134,8 +134,6 @@
|
||||
"TaskCleanCollectionsAndPlaylistsDescription": "ਕਲੈਕਸ਼ਨਾਂ ਅਤੇ ਪਲੇਲਿਸਟਾਂ ਵਿੱਚੋਂ ਉਹ ਆਈਟਮ ਹਟਾਉਂਦਾ ਹੈ ਜੋ ਹੁਣ ਮੌਜੂਦ ਨਹੀਂ ਹਨ।",
|
||||
"TaskCleanCollectionsAndPlaylists": "ਕਲੈਕਸ਼ਨਾਂ ਅਤੇ ਪਲੇਲਿਸਟਾਂ ਨੂੰ ਸਾਫ ਕਰੋ",
|
||||
"TaskAudioNormalization": "ਆਵਾਜ਼ ਸਧਾਰਣੀਕਰਨ",
|
||||
"TaskRefreshTrickplayImagesDescription": "ਵੀਡੀਓ ਲਈ ਟ੍ਰਿਕਪਲੇ ਪ੍ਰੀਵਿਊ ਬਣਾਉਂਦਾ ਹੈ (ਜੇ ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚ ਚੁਣਿਆ ਗਿਆ ਹੈ)।",
|
||||
"TaskKeyframeExtractorDescription": "ਕੀ-ਫ੍ਰੇਮਜ਼ ਨੂੰ ਵੀਡੀਓ ਫਾਈਲਾਂ ਵਿੱਚੋਂ ਨਿਕਾਲਦਾ ਹੈ ਤਾਂ ਜੋ ਹੋਰ ਜ਼ਿਆਦਾ ਸਟਿਕ ਹੋਣ ਵਾਲੀਆਂ HLS ਪਲੇਲਿਸਟਾਂ ਬਣਾਈਆਂ ਜਾ ਸਕਣ। ਇਹ ਕੰਮ ਲੰਬੇ ਸਮੇਂ ਤੱਕ ਚੱਲ ਸਕਦਾ ਹੈ।",
|
||||
"CleanupUserDataTaskDescription": "ਘੱਟੋ-ਘੱਟ 90 ਦਿਨਾਂ ਤੋਂ ਮੌਜੂਦ ਨਾ ਹੋਣ ਵਾਲੇ ਮੀਡੀਆ ਤੋਂ ਸਾਰੇ ਉਪਭੋਗਤਾ ਡੇਟਾ (ਵਾਚ ਸਟੇਟ, ਮਨਪਸੰਦ ਸਟੇਟਸ ਆਦਿ) ਨੂੰ ਸਾਫ਼ ਕਰਦਾ ਹੈ।",
|
||||
"CleanupUserDataTask": "ਯੂਜ਼ਰ ਡਾਟਾ ਸਾਫ਼ ਕਰਨ ਦਾ ਕੰਮ"
|
||||
"TaskRefreshTrickplayImagesDescription": "ਚਲ ਰਹੀ ਲਾਇਬ੍ਰੇਰੀਆਂ ਵਿੱਚ ਵੀਡੀਓਜ਼ ਲਈ ਟ੍ਰਿਕਪਲੇ ਪ੍ਰੀਵਿਊ ਬਣਾਉਂਦਾ ਹੈ।",
|
||||
"TaskKeyframeExtractorDescription": "ਕੀ-ਫ੍ਰੇਮਜ਼ ਨੂੰ ਵੀਡੀਓ ਫਾਈਲਾਂ ਵਿੱਚੋਂ ਨਿਕਾਲਦਾ ਹੈ ਤਾਂ ਜੋ ਹੋਰ ਜ਼ਿਆਦਾ ਸਟਿਕ ਹੋਣ ਵਾਲੀਆਂ HLS ਪਲੇਲਿਸਟਾਂ ਬਣਾਈਆਂ ਜਾ ਸਕਣ। ਇਹ ਕੰਮ ਲੰਬੇ ਸਮੇਂ ਤੱਕ ਚੱਲ ਸਕਦਾ ਹੈ।"
|
||||
}
|
||||
|
||||
@@ -125,8 +125,8 @@
|
||||
"TaskKeyframeExtractorDescription": "Wyodrębnia klatki kluczowe z plików wideo w celu utworzenia bardziej precyzyjnych list odtwarzania HLS. To zadanie może trwać przez długi czas.",
|
||||
"TaskKeyframeExtractor": "Ekstraktor klatek kluczowych",
|
||||
"HearingImpaired": "Niedosłyszący",
|
||||
"TaskRefreshTrickplayImages": "Generuj obrazy Trickplay",
|
||||
"TaskRefreshTrickplayImagesDescription": "Tworzy podglądy Trickplay dla filmów we włączonych bibliotekach.",
|
||||
"TaskRefreshTrickplayImages": "Generuj obrazy trickplay",
|
||||
"TaskRefreshTrickplayImagesDescription": "Tworzy podglądy trickplay dla filmów we włączonych bibliotekach.",
|
||||
"TaskCleanCollectionsAndPlaylistsDescription": "Usuwa elementy z kolekcji i list odtwarzania, które już nie istnieją.",
|
||||
"TaskCleanCollectionsAndPlaylists": "Oczyść kolekcje i listy odtwarzania",
|
||||
"TaskAudioNormalization": "Normalizacja dźwięku",
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"Collections": "Barrels",
|
||||
"ItemAddedWithName": "{0} is now with yer treasure",
|
||||
"Default": "Normal-like",
|
||||
"FailedLoginAttemptWithUserName": "Ye failed to enter from {0}",
|
||||
"FailedLoginAttemptWithUserName": "Ye failed to get in, try from {0}",
|
||||
"Favorites": "Finest Loot",
|
||||
"ItemRemovedWithName": "{0} was taken from yer treasure",
|
||||
"LabelIpAddressValue": "Ship's coordinates: {0}",
|
||||
@@ -113,10 +113,5 @@
|
||||
"TaskCleanCache": "Sweep the Cache Chest",
|
||||
"TaskRefreshChapterImages": "Claim chapter portraits",
|
||||
"TaskRefreshChapterImagesDescription": "Paints wee portraits fer videos that own chapters.",
|
||||
"TaskRefreshLibrary": "Scan the Treasure Trove",
|
||||
"TasksChannelsCategory": "Channels o' thy Internet",
|
||||
"TaskRefreshTrickplayImages": "Summon the picture tricks",
|
||||
"TaskRefreshTrickplayImagesDescription": "Summons picture trick previews for videos in ye enabled book roost",
|
||||
"TaskUpdatePlugins": "Resummon yer Plugins",
|
||||
"TaskCleanTranscode": "Swab Ye Transcode Directory"
|
||||
"TaskRefreshLibrary": "Scan the Treasure Trove"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"Artists": "Artistas",
|
||||
"AuthenticationSucceededWithUserName": "{0} autenticado com sucesso",
|
||||
"Books": "Livros",
|
||||
"CameraImageUploadedFrom": "Uma nova imagem da câmara foi enviada a partir de {0}",
|
||||
"CameraImageUploadedFrom": "Uma nova imagem de câmara foi enviada a partir de {0}",
|
||||
"Channels": "Canais",
|
||||
"ChapterNameValue": "Capítulo {0}",
|
||||
"Collections": "Coleções",
|
||||
@@ -125,8 +125,8 @@
|
||||
"TaskKeyframeExtractor": "Extrator de Quadros-chave",
|
||||
"External": "Externo",
|
||||
"HearingImpaired": "Surdo",
|
||||
"TaskRefreshTrickplayImages": "Gerar Imagens de Trickplay",
|
||||
"TaskRefreshTrickplayImagesDescription": "Cria ficheiros de trickplay para vídeos nas bibliotecas ativas.",
|
||||
"TaskRefreshTrickplayImages": "Gerar imagens de truques",
|
||||
"TaskRefreshTrickplayImagesDescription": "Cria vizualizações de truques para videos nas librarias ativas.",
|
||||
"TaskCleanCollectionsAndPlaylistsDescription": "Remove itens de coleções e listas de reprodução que já não existem.",
|
||||
"TaskCleanCollectionsAndPlaylists": "Limpar coleções e listas de reprodução",
|
||||
"TaskAudioNormalizationDescription": "Analisa os ficheiros para obter dados de normalização de áudio.",
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -135,7 +135,5 @@
|
||||
"TaskExtractMediaSegments": "การสแกนส่วนของสื่อมีเดีย",
|
||||
"TaskMoveTrickplayImagesDescription": "ย้ายไฟล์ Trickplay ตามการตั้งค่าของไลบรารี",
|
||||
"TaskExtractMediaSegmentsDescription": "แยกหรือดึงส่วนของสื่อจากปลั๊กอินที่เปิดใช้งาน MediaSegment",
|
||||
"TaskMoveTrickplayImages": "ย้ายตำแหน่งเก็บภาพตัวอย่าง Trickplay",
|
||||
"CleanupUserDataTask": "ส่วนงานล้างข้อมูลผู้ใช้",
|
||||
"CleanupUserDataTaskDescription": "ล้างข้อมูลผู้ใช้ทั้งหมด (สถานะการรับชม สถานะรายการโปรด ฯลฯ) จากสื่อที่ไม่ได้ใช้งานแล้วอย่างน้อย 90 วัน"
|
||||
"TaskMoveTrickplayImages": "ย้ายตำแหน่งเก็บภาพตัวอย่าง Trickplay"
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
"TasksMaintenanceCategory": "Bảo Trì",
|
||||
"VersionNumber": "Phiên Bản {0}",
|
||||
"ValueHasBeenAddedToLibrary": "{0} đã được thêm vào thư viện của bạn",
|
||||
"UserStoppedPlayingItemWithValues": "{0} đã kết thúc phát {1} trên {2}",
|
||||
"UserStoppedPlayingItemWithValues": "{0} đã phát xong {1} trên {2}",
|
||||
"UserStartedPlayingItemWithValues": "{0} đang phát {1} trên {2}",
|
||||
"UserPolicyUpdatedWithName": "Chính sách người dùng đã được cập nhật cho {0}",
|
||||
"UserPasswordChangedWithName": "Mật khẩu đã được thay đổi cho người dùng {0}",
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"HeaderFavoriteShows": "最愛的節目",
|
||||
"HeaderFavoriteSongs": "最愛的歌曲",
|
||||
"HeaderLiveTV": "電視直播",
|
||||
"HeaderNextUp": "繼續觀看",
|
||||
"HeaderNextUp": "接著播放",
|
||||
"HeaderRecordingGroups": "錄製組",
|
||||
"HomeVideos": "家庭影片",
|
||||
"Inherit": "繼承",
|
||||
@@ -127,8 +127,8 @@
|
||||
"HearingImpaired": "聽力障礙",
|
||||
"TaskRefreshTrickplayImages": "建立 Trickplay 圖像",
|
||||
"TaskRefreshTrickplayImagesDescription": "為已啟用 Trickplay 的媒體庫內的影片建立 Trickplay 預覽圖。",
|
||||
"TaskExtractMediaSegments": "掃描媒體分段資訊",
|
||||
"TaskExtractMediaSegmentsDescription": "從允許MediaSegment 功能的插件中獲取媒體片段。",
|
||||
"TaskExtractMediaSegments": "掃描媒體段落",
|
||||
"TaskExtractMediaSegmentsDescription": "從MediaSegment中被允許的插件獲取媒體段落。",
|
||||
"TaskDownloadMissingLyrics": "下載欠缺歌詞",
|
||||
"TaskDownloadMissingLyricsDescription": "下載歌詞",
|
||||
"TaskCleanCollectionsAndPlaylists": "整理媒體與播放清單",
|
||||
@@ -137,6 +137,5 @@
|
||||
"TaskCleanCollectionsAndPlaylistsDescription": "從資料庫及播放清單中移除已不存在的項目。",
|
||||
"TaskMoveTrickplayImagesDescription": "根據媒體庫設定移動現有的 Trickplay 檔案。",
|
||||
"TaskMoveTrickplayImages": "轉移 Trickplay 影像位置",
|
||||
"CleanupUserDataTask": "用戶資料清理工作",
|
||||
"CleanupUserDataTaskDescription": "從用戶數據中清除已經被刪除超過 90 日的媒體相關資料。"
|
||||
"CleanupUserDataTask": "用戶資料清理工作"
|
||||
}
|
||||
|
||||
@@ -38,7 +38,6 @@ namespace Emby.Server.Implementations.Localization
|
||||
|
||||
private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
|
||||
|
||||
private readonly ConcurrentDictionary<string, CultureDto?> _cultureCache = new(StringComparer.OrdinalIgnoreCase);
|
||||
private List<CultureDto> _cultures = [];
|
||||
|
||||
private FrozenDictionary<string, string> _iso6392BtoT = null!;
|
||||
@@ -162,7 +161,6 @@ namespace Emby.Server.Implementations.Localization
|
||||
list.Add(new CultureDto(name, displayname, twoCharName, threeLetterNames));
|
||||
}
|
||||
|
||||
_cultureCache.Clear();
|
||||
_cultures = list;
|
||||
_iso6392BtoT = iso6392BtoTdict.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
@@ -171,31 +169,20 @@ namespace Emby.Server.Implementations.Localization
|
||||
/// <inheritdoc />
|
||||
public CultureDto? FindLanguageInfo(string language)
|
||||
{
|
||||
if (string.IsNullOrEmpty(language))
|
||||
// TODO language should ideally be a ReadOnlySpan but moq cannot mock ref structs
|
||||
for (var i = 0; i < _cultures.Count; i++)
|
||||
{
|
||||
return null;
|
||||
var culture = _cultures[i];
|
||||
if (language.Equals(culture.DisplayName, StringComparison.OrdinalIgnoreCase)
|
||||
|| language.Equals(culture.Name, StringComparison.OrdinalIgnoreCase)
|
||||
|| culture.ThreeLetterISOLanguageNames.Contains(language, StringComparison.OrdinalIgnoreCase)
|
||||
|| language.Equals(culture.TwoLetterISOLanguageName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return culture;
|
||||
}
|
||||
}
|
||||
|
||||
return _cultureCache.GetOrAdd(
|
||||
language,
|
||||
static (lang, cultures) =>
|
||||
{
|
||||
// TODO language should ideally be a ReadOnlySpan but moq cannot mock ref structs
|
||||
for (var i = 0; i < cultures.Count; i++)
|
||||
{
|
||||
var culture = cultures[i];
|
||||
if (lang.Equals(culture.DisplayName, StringComparison.OrdinalIgnoreCase)
|
||||
|| lang.Equals(culture.Name, StringComparison.OrdinalIgnoreCase)
|
||||
|| culture.ThreeLetterISOLanguageNames.Contains(lang, StringComparison.OrdinalIgnoreCase)
|
||||
|| lang.Equals(culture.TwoLetterISOLanguageName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return culture;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
_cultures);
|
||||
return default;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -324,19 +311,15 @@ namespace Emby.Server.Implementations.Localization
|
||||
else
|
||||
{
|
||||
// Fall back to server default language for ratings check
|
||||
var ratingsDictionary = GetParentalRatingsDictionary();
|
||||
// If it has no ratings, use the US ratings
|
||||
var ratingsDictionary = GetParentalRatingsDictionary() ?? GetParentalRatingsDictionary("us");
|
||||
if (ratingsDictionary is not null && ratingsDictionary.TryGetValue(rating, out ParentalRatingScore? value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
// If we don't find anything, check all ratings systems, starting with US
|
||||
if (_allParentalRatings.TryGetValue("us", out var usRatings) && usRatings.TryGetValue(rating, out var usValue))
|
||||
{
|
||||
return usValue;
|
||||
}
|
||||
|
||||
// If we don't find anything, check all ratings systems
|
||||
foreach (var dictionary in _allParentalRatings.Values)
|
||||
{
|
||||
if (dictionary.TryGetValue(rating, out var value))
|
||||
|
||||
@@ -244,7 +244,6 @@ namespace Emby.Server.Implementations.Playlists
|
||||
|
||||
// Update the playlist in the repository
|
||||
playlist.LinkedChildren = [.. playlist.LinkedChildren, .. childrenToAdd];
|
||||
playlist.DateLastMediaAdded = DateTime.UtcNow;
|
||||
|
||||
await UpdatePlaylistInternal(playlist).ConfigureAwait(false);
|
||||
|
||||
|
||||
@@ -266,7 +266,7 @@ namespace Emby.Server.Implementations.TV
|
||||
items = items.Skip(query.StartIndex.Value);
|
||||
}
|
||||
|
||||
if (query.Limit.HasValue && query.Limit.Value > 0)
|
||||
if (query.Limit.HasValue)
|
||||
{
|
||||
items = items.Take(query.Limit.Value);
|
||||
}
|
||||
|
||||
@@ -156,11 +156,6 @@ namespace Emby.Server.Implementations.Updates
|
||||
_logger.LogError(ex, "The URL configured for the plugin repository manifest URL is not valid: {Manifest}", manifest);
|
||||
return Array.Empty<PackageInfo>();
|
||||
}
|
||||
catch (NotSupportedException ex)
|
||||
{
|
||||
_logger.LogError(ex, "The URL scheme configured for the plugin repository is not supported: {Manifest}", manifest);
|
||||
return Array.Empty<PackageInfo>();
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "An error occurred while accessing the plugin manifest: {Manifest}", manifest);
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Api.Constants;
|
||||
using Jellyfin.Data.Queries;
|
||||
using Jellyfin.Database.Implementations.Enums;
|
||||
using MediaBrowser.Common.Api;
|
||||
using MediaBrowser.Model.Activity;
|
||||
using MediaBrowser.Model.Querying;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Api.Controllers;
|
||||
|
||||
@@ -35,20 +32,10 @@ public class ActivityLogController : BaseJellyfinApiController
|
||||
/// <summary>
|
||||
/// Gets activity log entries.
|
||||
/// </summary>
|
||||
/// <param name="startIndex">The record index to start at. All items with a lower index will be dropped from the results.</param>
|
||||
/// <param name="limit">The maximum number of records to return.</param>
|
||||
/// <param name="minDate">The minimum date.</param>
|
||||
/// <param name="maxDate">The maximum date.</param>
|
||||
/// <param name="hasUserId">Filter log entries if it has user id, or not.</param>
|
||||
/// <param name="name">Filter by name.</param>
|
||||
/// <param name="overview">Filter by overview.</param>
|
||||
/// <param name="shortOverview">Filter by short overview.</param>
|
||||
/// <param name="type">Filter by type.</param>
|
||||
/// <param name="itemId">Filter by item id.</param>
|
||||
/// <param name="username">Filter by username.</param>
|
||||
/// <param name="severity">Filter by log severity.</param>
|
||||
/// <param name="sortBy">Specify one or more sort orders. Format: SortBy=Name,Type.</param>
|
||||
/// <param name="sortOrder">Sort Order..</param>
|
||||
/// <param name="startIndex">Optional. The record index to start at. All items with a lower index will be dropped from the results.</param>
|
||||
/// <param name="limit">Optional. The maximum number of records to return.</param>
|
||||
/// <param name="minDate">Optional. The minimum date. Format = ISO.</param>
|
||||
/// <param name="hasUserId">Optional. Filter log entries if it has user id, or not.</param>
|
||||
/// <response code="200">Activity log returned.</response>
|
||||
/// <returns>A <see cref="QueryResult{ActivityLogEntry}"/> containing the log entries.</returns>
|
||||
[HttpGet("Entries")]
|
||||
@@ -57,62 +44,14 @@ public class ActivityLogController : BaseJellyfinApiController
|
||||
[FromQuery] int? startIndex,
|
||||
[FromQuery] int? limit,
|
||||
[FromQuery] DateTime? minDate,
|
||||
[FromQuery] DateTime? maxDate,
|
||||
[FromQuery] bool? hasUserId,
|
||||
[FromQuery] string? name,
|
||||
[FromQuery] string? overview,
|
||||
[FromQuery] string? shortOverview,
|
||||
[FromQuery] string? type,
|
||||
[FromQuery] Guid? itemId,
|
||||
[FromQuery] string? username,
|
||||
[FromQuery] LogLevel? severity,
|
||||
[FromQuery] ActivityLogSortBy[]? sortBy,
|
||||
[FromQuery] SortOrder[]? sortOrder)
|
||||
[FromQuery] bool? hasUserId)
|
||||
{
|
||||
var query = new ActivityLogQuery
|
||||
return await _activityManager.GetPagedResultAsync(new ActivityLogQuery
|
||||
{
|
||||
Skip = startIndex,
|
||||
Limit = limit,
|
||||
MinDate = minDate,
|
||||
MaxDate = maxDate,
|
||||
HasUserId = hasUserId,
|
||||
Name = name,
|
||||
Overview = overview,
|
||||
ShortOverview = shortOverview,
|
||||
Type = type,
|
||||
ItemId = itemId,
|
||||
Username = username,
|
||||
Severity = severity,
|
||||
OrderBy = GetOrderBy(sortBy ?? [], sortOrder ?? []),
|
||||
};
|
||||
|
||||
return await _activityManager.GetPagedResultAsync(query).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static (ActivityLogSortBy SortBy, SortOrder SortOrder)[] GetOrderBy(
|
||||
IReadOnlyList<ActivityLogSortBy> sortBy,
|
||||
IReadOnlyList<SortOrder> requestedSortOrder)
|
||||
{
|
||||
if (sortBy.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var result = new (ActivityLogSortBy, SortOrder)[sortBy.Count];
|
||||
var i = 0;
|
||||
for (; i < requestedSortOrder.Count; i++)
|
||||
{
|
||||
result[i] = (sortBy[i], requestedSortOrder[i]);
|
||||
}
|
||||
|
||||
// Add remaining elements with the first specified SortOrder
|
||||
// or the default one if no SortOrders are specified
|
||||
var order = requestedSortOrder.Count > 0 ? requestedSortOrder[0] : SortOrder.Ascending;
|
||||
for (; i < sortBy.Count; i++)
|
||||
{
|
||||
result[i] = (sortBy[i], order);
|
||||
}
|
||||
|
||||
return result;
|
||||
HasUserId = hasUserId
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +122,7 @@ public class ArtistsController : BaseJellyfinApiController
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var dtoOptions = new DtoOptions { Fields = fields }
|
||||
.AddClientFields(User)
|
||||
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
|
||||
|
||||
User? user = null;
|
||||
@@ -325,6 +326,7 @@ public class ArtistsController : BaseJellyfinApiController
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var dtoOptions = new DtoOptions { Fields = fields }
|
||||
.AddClientFields(User)
|
||||
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
|
||||
|
||||
User? user = null;
|
||||
@@ -465,7 +467,7 @@ public class ArtistsController : BaseJellyfinApiController
|
||||
public ActionResult<BaseItemDto> GetArtistByName([FromRoute, Required] string name, [FromQuery] Guid? userId)
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var dtoOptions = new DtoOptions();
|
||||
var dtoOptions = new DtoOptions().AddClientFields(User);
|
||||
|
||||
var item = _libraryManager.GetArtist(name, dtoOptions);
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ public class CollectionController : BaseJellyfinApiController
|
||||
UserIds = new[] { userId }
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
var dtoOptions = new DtoOptions();
|
||||
var dtoOptions = new DtoOptions().AddClientFields(User);
|
||||
|
||||
var dto = _dtoService.GetBaseItemDto(item, dtoOptions);
|
||||
|
||||
|
||||
@@ -1839,9 +1839,8 @@ public class DynamicHlsController : BaseJellyfinApiController
|
||||
{
|
||||
if (isActualOutputVideoCodecHevc)
|
||||
{
|
||||
// Use hvc1 for 8.4. This is what Dolby uses for its official sample streams. Tagging with dvh1 would break some players with strict tag checking like Apple Safari.
|
||||
var codecTag = state.VideoStream.VideoRangeType == VideoRangeType.DOVIWithHLG ? "hvc1" : "dvh1";
|
||||
args += $" -tag:v:0 {codecTag} -strict -2";
|
||||
// Prefer dvh1 to dvhe
|
||||
args += " -tag:v:0 dvh1 -strict -2";
|
||||
}
|
||||
else if (isActualOutputVideoCodecAv1)
|
||||
{
|
||||
|
||||
@@ -94,6 +94,7 @@ public class GenresController : BaseJellyfinApiController
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var dtoOptions = new DtoOptions { Fields = fields }
|
||||
.AddClientFields(User)
|
||||
.AddAdditionalDtoOptions(enableImages, false, imageTypeLimit, enableImageTypes);
|
||||
|
||||
User? user = userId.IsNullOrEmpty()
|
||||
@@ -158,7 +159,8 @@ public class GenresController : BaseJellyfinApiController
|
||||
public ActionResult<BaseItemDto> GetGenre([FromRoute, Required] string genreName, [FromQuery] Guid? userId)
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var dtoOptions = new DtoOptions();
|
||||
var dtoOptions = new DtoOptions()
|
||||
.AddClientFields(User);
|
||||
|
||||
Genre? item;
|
||||
if (genreName.Contains(BaseItem.SlugChar, StringComparison.OrdinalIgnoreCase))
|
||||
|
||||
@@ -90,6 +90,7 @@ public class InstantMixController : BaseJellyfinApiController
|
||||
}
|
||||
|
||||
var dtoOptions = new DtoOptions { Fields = fields }
|
||||
.AddClientFields(User)
|
||||
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
|
||||
var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions);
|
||||
return GetResult(items, user, limit, dtoOptions);
|
||||
@@ -133,6 +134,7 @@ public class InstantMixController : BaseJellyfinApiController
|
||||
}
|
||||
|
||||
var dtoOptions = new DtoOptions { Fields = fields }
|
||||
.AddClientFields(User)
|
||||
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
|
||||
var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions);
|
||||
return GetResult(items, user, limit, dtoOptions);
|
||||
@@ -176,6 +178,7 @@ public class InstantMixController : BaseJellyfinApiController
|
||||
}
|
||||
|
||||
var dtoOptions = new DtoOptions { Fields = fields }
|
||||
.AddClientFields(User)
|
||||
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
|
||||
var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions);
|
||||
return GetResult(items, user, limit, dtoOptions);
|
||||
@@ -211,6 +214,7 @@ public class InstantMixController : BaseJellyfinApiController
|
||||
? null
|
||||
: _userManager.GetUserById(userId.Value);
|
||||
var dtoOptions = new DtoOptions { Fields = fields }
|
||||
.AddClientFields(User)
|
||||
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
|
||||
var items = _musicManager.GetInstantMixFromGenres(new[] { name }, user, dtoOptions);
|
||||
return GetResult(items, user, limit, dtoOptions);
|
||||
@@ -254,6 +258,7 @@ public class InstantMixController : BaseJellyfinApiController
|
||||
}
|
||||
|
||||
var dtoOptions = new DtoOptions { Fields = fields }
|
||||
.AddClientFields(User)
|
||||
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
|
||||
var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions);
|
||||
return GetResult(items, user, limit, dtoOptions);
|
||||
@@ -297,6 +302,7 @@ public class InstantMixController : BaseJellyfinApiController
|
||||
}
|
||||
|
||||
var dtoOptions = new DtoOptions { Fields = fields }
|
||||
.AddClientFields(User)
|
||||
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
|
||||
var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions);
|
||||
return GetResult(items, user, limit, dtoOptions);
|
||||
@@ -379,6 +385,7 @@ public class InstantMixController : BaseJellyfinApiController
|
||||
}
|
||||
|
||||
var dtoOptions = new DtoOptions { Fields = fields }
|
||||
.AddClientFields(User)
|
||||
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
|
||||
var items = _musicManager.GetInstantMixFromItem(item, user, dtoOptions);
|
||||
return GetResult(items, user, limit, dtoOptions);
|
||||
|
||||
@@ -180,14 +180,11 @@ public class ItemUpdateController : BaseJellyfinApiController
|
||||
info.ContentTypeOptions = GetContentTypeOptions(true).ToArray();
|
||||
info.ContentType = configuredContentType;
|
||||
|
||||
if (inheritedContentType is null
|
||||
|| inheritedContentType == CollectionType.tvshows
|
||||
|| inheritedContentType == CollectionType.movies)
|
||||
if (inheritedContentType is null || inheritedContentType == CollectionType.tvshows)
|
||||
{
|
||||
info.ContentTypeOptions = info.ContentTypeOptions
|
||||
.Where(i => string.IsNullOrWhiteSpace(i.Value)
|
||||
|| string.Equals(i.Value, "TvShows", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(i.Value, "Movies", StringComparison.OrdinalIgnoreCase))
|
||||
|| string.Equals(i.Value, "TvShows", StringComparison.OrdinalIgnoreCase))
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,6 +268,7 @@ public class ItemsController : BaseJellyfinApiController
|
||||
}
|
||||
|
||||
var dtoOptions = new DtoOptions { Fields = fields }
|
||||
.AddClientFields(User)
|
||||
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
|
||||
|
||||
if (includeItemTypes.Length == 1
|
||||
@@ -848,6 +849,7 @@ public class ItemsController : BaseJellyfinApiController
|
||||
|
||||
var parentIdGuid = parentId ?? Guid.Empty;
|
||||
var dtoOptions = new DtoOptions { Fields = fields }
|
||||
.AddClientFields(User)
|
||||
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
|
||||
|
||||
var ancestorIds = Array.Empty<Guid>();
|
||||
|
||||
@@ -23,7 +23,6 @@ using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.Audio;
|
||||
using MediaBrowser.Controller.Entities.Movies;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
using MediaBrowser.Controller.IO;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Model.Activity;
|
||||
@@ -188,7 +187,7 @@ public class LibraryController : BaseJellyfinApiController
|
||||
item = parent;
|
||||
}
|
||||
|
||||
var dtoOptions = new DtoOptions();
|
||||
var dtoOptions = new DtoOptions().AddClientFields(User);
|
||||
var items = themeItems
|
||||
.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item))
|
||||
.ToArray();
|
||||
@@ -261,7 +260,7 @@ public class LibraryController : BaseJellyfinApiController
|
||||
item = parent;
|
||||
}
|
||||
|
||||
var dtoOptions = new DtoOptions();
|
||||
var dtoOptions = new DtoOptions().AddClientFields(User);
|
||||
var items = themeItems
|
||||
.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user, item))
|
||||
.ToArray();
|
||||
@@ -497,7 +496,7 @@ public class LibraryController : BaseJellyfinApiController
|
||||
|
||||
var baseItemDtos = new List<BaseItemDto>();
|
||||
|
||||
var dtoOptions = new DtoOptions();
|
||||
var dtoOptions = new DtoOptions().AddClientFields(User);
|
||||
BaseItem? parent = item.GetParent();
|
||||
|
||||
while (parent is not null)
|
||||
@@ -557,7 +556,7 @@ public class LibraryController : BaseJellyfinApiController
|
||||
items = items.Where(i => i.IsHidden == val).ToList();
|
||||
}
|
||||
|
||||
var dtoOptions = new DtoOptions();
|
||||
var dtoOptions = new DtoOptions().AddClientFields(User);
|
||||
var resultArray = _dtoService.GetBaseItemDtos(items, dtoOptions);
|
||||
return new QueryResult<BaseItemDto>(resultArray);
|
||||
}
|
||||
@@ -701,18 +700,7 @@ public class LibraryController : BaseJellyfinApiController
|
||||
// Quotes are valid in linux. They'll possibly cause issues here.
|
||||
var filename = Path.GetFileName(item.Path)?.Replace("\"", string.Empty, StringComparison.Ordinal);
|
||||
|
||||
var filePath = item.Path;
|
||||
if (item.IsFileProtocol)
|
||||
{
|
||||
// PhysicalFile does not work well with symlinks at the moment.
|
||||
var resolved = FileSystemHelper.ResolveLinkTarget(filePath, returnFinalTarget: true);
|
||||
if (resolved is not null && resolved.Exists)
|
||||
{
|
||||
filePath = resolved.FullName;
|
||||
}
|
||||
}
|
||||
|
||||
return PhysicalFile(filePath, MimeTypes.GetMimeType(filePath), filename, true);
|
||||
return PhysicalFile(item.Path, MimeTypes.GetMimeType(item.Path), filename, true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -759,7 +747,8 @@ public class LibraryController : BaseJellyfinApiController
|
||||
return new QueryResult<BaseItemDto>();
|
||||
}
|
||||
|
||||
var dtoOptions = new DtoOptions { Fields = fields };
|
||||
var dtoOptions = new DtoOptions { Fields = fields }
|
||||
.AddClientFields(User);
|
||||
|
||||
var program = item as IHasProgramAttributes;
|
||||
bool? isMovie = item is Movie || (program is not null && program.IsMovie) || item is Trailer;
|
||||
|
||||
@@ -170,6 +170,7 @@ public class LiveTvController : BaseJellyfinApiController
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var dtoOptions = new DtoOptions { Fields = fields }
|
||||
.AddClientFields(User)
|
||||
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
|
||||
|
||||
var channelResult = _liveTvManager.GetInternalChannels(
|
||||
@@ -241,7 +242,8 @@ public class LiveTvController : BaseJellyfinApiController
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var dtoOptions = new DtoOptions();
|
||||
var dtoOptions = new DtoOptions()
|
||||
.AddClientFields(User);
|
||||
return _dtoService.GetBaseItemDto(item, dtoOptions, user);
|
||||
}
|
||||
|
||||
@@ -295,6 +297,7 @@ public class LiveTvController : BaseJellyfinApiController
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var dtoOptions = new DtoOptions { Fields = fields }
|
||||
.AddClientFields(User)
|
||||
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
|
||||
|
||||
return await _liveTvManager.GetRecordingsAsync(
|
||||
@@ -441,7 +444,8 @@ public class LiveTvController : BaseJellyfinApiController
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var dtoOptions = new DtoOptions();
|
||||
var dtoOptions = new DtoOptions()
|
||||
.AddClientFields(User);
|
||||
|
||||
return _dtoService.GetBaseItemDto(item, dtoOptions, user);
|
||||
}
|
||||
@@ -631,6 +635,7 @@ public class LiveTvController : BaseJellyfinApiController
|
||||
}
|
||||
|
||||
var dtoOptions = new DtoOptions { Fields = fields }
|
||||
.AddClientFields(User)
|
||||
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
|
||||
return await _liveTvManager.GetPrograms(query, dtoOptions, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
@@ -685,6 +690,7 @@ public class LiveTvController : BaseJellyfinApiController
|
||||
}
|
||||
|
||||
var dtoOptions = new DtoOptions { Fields = body.Fields ?? [] }
|
||||
.AddClientFields(User)
|
||||
.AddAdditionalDtoOptions(body.EnableImages, body.EnableUserData, body.ImageTypeLimit, body.EnableImageTypes ?? []);
|
||||
return await _liveTvManager.GetPrograms(query, dtoOptions, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
@@ -754,6 +760,7 @@ public class LiveTvController : BaseJellyfinApiController
|
||||
};
|
||||
|
||||
var dtoOptions = new DtoOptions { Fields = fields }
|
||||
.AddClientFields(User)
|
||||
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
|
||||
return await _liveTvManager.GetRecommendedProgramsAsync(query, dtoOptions, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -74,7 +74,8 @@ public class MoviesController : BaseJellyfinApiController
|
||||
var user = userId.IsNullOrEmpty()
|
||||
? null
|
||||
: _userManager.GetUserById(userId.Value);
|
||||
var dtoOptions = new DtoOptions { Fields = fields };
|
||||
var dtoOptions = new DtoOptions { Fields = fields }
|
||||
.AddClientFields(User);
|
||||
|
||||
var categories = new List<RecommendationDto>();
|
||||
|
||||
|
||||
@@ -94,6 +94,7 @@ public class MusicGenresController : BaseJellyfinApiController
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var dtoOptions = new DtoOptions { Fields = fields }
|
||||
.AddClientFields(User)
|
||||
.AddAdditionalDtoOptions(enableImages, false, imageTypeLimit, enableImageTypes);
|
||||
|
||||
User? user = userId.IsNullOrEmpty()
|
||||
@@ -147,7 +148,7 @@ public class MusicGenresController : BaseJellyfinApiController
|
||||
public ActionResult<BaseItemDto> GetMusicGenre([FromRoute, Required] string genreName, [FromQuery] Guid? userId)
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var dtoOptions = new DtoOptions();
|
||||
var dtoOptions = new DtoOptions().AddClientFields(User);
|
||||
|
||||
MusicGenre? item;
|
||||
|
||||
|
||||
@@ -81,6 +81,7 @@ public class PersonsController : BaseJellyfinApiController
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var dtoOptions = new DtoOptions { Fields = fields }
|
||||
.AddClientFields(User)
|
||||
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
|
||||
|
||||
User? user = userId.IsNullOrEmpty()
|
||||
@@ -120,7 +121,8 @@ public class PersonsController : BaseJellyfinApiController
|
||||
public ActionResult<BaseItemDto> GetPerson([FromRoute, Required] string name, [FromQuery] Guid? userId)
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var dtoOptions = new DtoOptions();
|
||||
var dtoOptions = new DtoOptions()
|
||||
.AddClientFields(User);
|
||||
|
||||
var item = _libraryManager.GetPerson(name);
|
||||
if (item is null)
|
||||
|
||||
@@ -548,6 +548,7 @@ public class PlaylistsController : BaseJellyfinApiController
|
||||
}
|
||||
|
||||
var dtoOptions = new DtoOptions { Fields = fields }
|
||||
.AddClientFields(User)
|
||||
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
|
||||
|
||||
var dtos = _dtoService.GetBaseItemDtos(items.Select(i => i.Item2).ToList(), dtoOptions, user);
|
||||
|
||||
@@ -89,6 +89,7 @@ public class StudiosController : BaseJellyfinApiController
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var dtoOptions = new DtoOptions { Fields = fields }
|
||||
.AddClientFields(User)
|
||||
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
|
||||
|
||||
User? user = userId.IsNullOrEmpty()
|
||||
@@ -141,7 +142,7 @@ public class StudiosController : BaseJellyfinApiController
|
||||
public ActionResult<BaseItemDto> GetStudio([FromRoute, Required] string name, [FromQuery] Guid? userId)
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var dtoOptions = new DtoOptions();
|
||||
var dtoOptions = new DtoOptions().AddClientFields(User);
|
||||
|
||||
var item = _libraryManager.GetStudio(name);
|
||||
if (!userId.IsNullOrEmpty())
|
||||
|
||||
@@ -77,7 +77,7 @@ public class SuggestionsController : BaseJellyfinApiController
|
||||
user = _userManager.GetUserById(requestUserId);
|
||||
}
|
||||
|
||||
var dtoOptions = new DtoOptions();
|
||||
var dtoOptions = new DtoOptions().AddClientFields(User);
|
||||
var result = _libraryManager.GetItemsResult(new InternalItemsQuery(user)
|
||||
{
|
||||
OrderBy = new[] { (ItemSortBy.Random, SortOrder.Descending) },
|
||||
|
||||
@@ -86,7 +86,7 @@ public class TrickplayController : BaseJellyfinApiController
|
||||
[FromRoute, Required] int index,
|
||||
[FromQuery] Guid? mediaSourceId)
|
||||
{
|
||||
var item = _libraryManager.GetItemById<BaseItem>(mediaSourceId ?? itemId, User.GetUserId());
|
||||
var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId());
|
||||
if (item is null)
|
||||
{
|
||||
return NotFound();
|
||||
|
||||
@@ -99,6 +99,7 @@ public class TvShowsController : BaseJellyfinApiController
|
||||
}
|
||||
|
||||
var options = new DtoOptions { Fields = fields }
|
||||
.AddClientFields(User)
|
||||
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
|
||||
|
||||
var result = _tvSeriesManager.GetNextUp(
|
||||
@@ -160,6 +161,7 @@ public class TvShowsController : BaseJellyfinApiController
|
||||
var parentIdGuid = parentId ?? Guid.Empty;
|
||||
|
||||
var options = new DtoOptions { Fields = fields }
|
||||
.AddClientFields(User)
|
||||
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
|
||||
|
||||
var itemsResult = _libraryManager.GetItemList(new InternalItemsQuery(user)
|
||||
@@ -229,6 +231,7 @@ public class TvShowsController : BaseJellyfinApiController
|
||||
List<BaseItem> episodes;
|
||||
|
||||
var dtoOptions = new DtoOptions { Fields = fields }
|
||||
.AddClientFields(User)
|
||||
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
|
||||
var shouldIncludeMissingEpisodes = (user is not null && user.DisplayMissingEpisodes) || User.GetIsApiKey();
|
||||
|
||||
@@ -357,6 +360,7 @@ public class TvShowsController : BaseJellyfinApiController
|
||||
});
|
||||
|
||||
var dtoOptions = new DtoOptions { Fields = fields }
|
||||
.AddClientFields(User)
|
||||
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
|
||||
|
||||
var returnItems = _dtoService.GetBaseItemDtos(seasons, dtoOptions, user);
|
||||
|
||||
@@ -13,7 +13,6 @@ using Jellyfin.Extensions;
|
||||
using MediaBrowser.Controller.Dto;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.Audio;
|
||||
using MediaBrowser.Controller.Entities.TV;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Providers;
|
||||
using MediaBrowser.Model.Dto;
|
||||
@@ -95,7 +94,7 @@ public class UserLibraryController : BaseJellyfinApiController
|
||||
|
||||
await RefreshItemOnDemandIfNeeded(item).ConfigureAwait(false);
|
||||
|
||||
var dtoOptions = new DtoOptions();
|
||||
var dtoOptions = new DtoOptions().AddClientFields(User);
|
||||
|
||||
return _dtoService.GetBaseItemDto(item, dtoOptions, user);
|
||||
}
|
||||
@@ -134,7 +133,7 @@ public class UserLibraryController : BaseJellyfinApiController
|
||||
}
|
||||
|
||||
var item = _libraryManager.GetUserRootFolder();
|
||||
var dtoOptions = new DtoOptions();
|
||||
var dtoOptions = new DtoOptions().AddClientFields(User);
|
||||
return _dtoService.GetBaseItemDto(item, dtoOptions, user);
|
||||
}
|
||||
|
||||
@@ -181,7 +180,7 @@ public class UserLibraryController : BaseJellyfinApiController
|
||||
}
|
||||
|
||||
var items = await _libraryManager.GetIntros(item, user).ConfigureAwait(false);
|
||||
var dtoOptions = new DtoOptions();
|
||||
var dtoOptions = new DtoOptions().AddClientFields(User);
|
||||
var dtos = items.Select(i => _dtoService.GetBaseItemDto(i, dtoOptions, user)).ToArray();
|
||||
|
||||
return new QueryResult<BaseItemDto>(dtos);
|
||||
@@ -423,7 +422,7 @@ public class UserLibraryController : BaseJellyfinApiController
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var dtoOptions = new DtoOptions();
|
||||
var dtoOptions = new DtoOptions().AddClientFields(User);
|
||||
if (item is IHasTrailers hasTrailers)
|
||||
{
|
||||
var trailers = hasTrailers.LocalTrailers;
|
||||
@@ -479,7 +478,7 @@ public class UserLibraryController : BaseJellyfinApiController
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var dtoOptions = new DtoOptions();
|
||||
var dtoOptions = new DtoOptions().AddClientFields(User);
|
||||
|
||||
return Ok(item
|
||||
.GetExtras()
|
||||
@@ -550,6 +549,7 @@ public class UserLibraryController : BaseJellyfinApiController
|
||||
}
|
||||
|
||||
var dtoOptions = new DtoOptions { Fields = fields }
|
||||
.AddClientFields(User)
|
||||
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
|
||||
|
||||
var list = _userViewManager.GetLatestItems(
|
||||
@@ -569,7 +569,7 @@ public class UserLibraryController : BaseJellyfinApiController
|
||||
var item = i.Item2[0];
|
||||
var childCount = 0;
|
||||
|
||||
if (i.Item1 is not null && (i.Item2.Count > 1 || i.Item1 is MusicAlbum || i.Item1 is Series ))
|
||||
if (i.Item1 is not null && (i.Item2.Count > 1 || i.Item1 is MusicAlbum))
|
||||
{
|
||||
item = i.Item1;
|
||||
childCount = i.Item2.Count;
|
||||
|
||||
@@ -86,7 +86,7 @@ public class UserViewsController : BaseJellyfinApiController
|
||||
|
||||
var folders = _userViewManager.GetUserViews(query);
|
||||
|
||||
var dtoOptions = new DtoOptions();
|
||||
var dtoOptions = new DtoOptions().AddClientFields(User);
|
||||
dtoOptions.Fields = [..dtoOptions.Fields, ItemFields.PrimaryImageAspectRatio, ItemFields.DisplayPreferencesId];
|
||||
|
||||
var dtos = Array.ConvertAll(folders, i => _dtoService.GetBaseItemDto(i, dtoOptions, user));
|
||||
|
||||
@@ -111,6 +111,7 @@ public class VideosController : BaseJellyfinApiController
|
||||
}
|
||||
|
||||
var dtoOptions = new DtoOptions();
|
||||
dtoOptions = dtoOptions.AddClientFields(User);
|
||||
|
||||
BaseItemDto[] items;
|
||||
if (item is Video video)
|
||||
|
||||
@@ -89,6 +89,7 @@ public class YearsController : BaseJellyfinApiController
|
||||
{
|
||||
userId = RequestHelpers.GetUserId(User, userId);
|
||||
var dtoOptions = new DtoOptions { Fields = fields }
|
||||
.AddClientFields(User)
|
||||
.AddAdditionalDtoOptions(enableImages, enableUserData, imageTypeLimit, enableImageTypes);
|
||||
|
||||
User? user = userId.IsNullOrEmpty()
|
||||
@@ -181,7 +182,8 @@ public class YearsController : BaseJellyfinApiController
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
var dtoOptions = new DtoOptions();
|
||||
var dtoOptions = new DtoOptions()
|
||||
.AddClientFields(User);
|
||||
|
||||
if (!userId.IsNullOrEmpty())
|
||||
{
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Claims;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Controller.Dto;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Querying;
|
||||
|
||||
namespace Jellyfin.Api.Extensions;
|
||||
|
||||
@@ -9,6 +13,55 @@ namespace Jellyfin.Api.Extensions;
|
||||
/// </summary>
|
||||
public static class DtoExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Add additional fields depending on client.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Use in place of GetDtoOptions.
|
||||
/// Legacy order: 2.
|
||||
/// </remarks>
|
||||
/// <param name="dtoOptions">DtoOptions object.</param>
|
||||
/// <param name="user">Current claims principal.</param>
|
||||
/// <returns>Modified DtoOptions object.</returns>
|
||||
internal static DtoOptions AddClientFields(
|
||||
this DtoOptions dtoOptions, ClaimsPrincipal user)
|
||||
{
|
||||
string? client = user.GetClient();
|
||||
|
||||
// No client in claim
|
||||
if (string.IsNullOrEmpty(client))
|
||||
{
|
||||
return dtoOptions;
|
||||
}
|
||||
|
||||
if (!dtoOptions.ContainsField(ItemFields.RecursiveItemCount))
|
||||
{
|
||||
if (client.Contains("kodi", StringComparison.OrdinalIgnoreCase) ||
|
||||
client.Contains("wmc", StringComparison.OrdinalIgnoreCase) ||
|
||||
client.Contains("media center", StringComparison.OrdinalIgnoreCase) ||
|
||||
client.Contains("classic", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
dtoOptions.Fields = [..dtoOptions.Fields, ItemFields.RecursiveItemCount];
|
||||
}
|
||||
}
|
||||
|
||||
if (!dtoOptions.ContainsField(ItemFields.ChildCount))
|
||||
{
|
||||
if (client.Contains("kodi", StringComparison.OrdinalIgnoreCase) ||
|
||||
client.Contains("wmc", StringComparison.OrdinalIgnoreCase) ||
|
||||
client.Contains("media center", StringComparison.OrdinalIgnoreCase) ||
|
||||
client.Contains("classic", StringComparison.OrdinalIgnoreCase) ||
|
||||
client.Contains("roku", StringComparison.OrdinalIgnoreCase) ||
|
||||
client.Contains("samsung", StringComparison.OrdinalIgnoreCase) ||
|
||||
client.Contains("androidtv", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
dtoOptions.Fields = [..dtoOptions.Fields, ItemFields.ChildCount];
|
||||
}
|
||||
}
|
||||
|
||||
return dtoOptions;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add additional DtoOptions.
|
||||
/// </summary>
|
||||
|
||||
@@ -154,7 +154,7 @@ public class DynamicHlsHelper
|
||||
// from universal audio service, need to override the AudioCodec when the actual request differs from original query
|
||||
if (!string.Equals(state.OutputAudioCodec, _httpContextAccessor.HttpContext.Request.Query["AudioCodec"].ToString(), StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var newQuery = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(queryString);
|
||||
var newQuery = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(_httpContextAccessor.HttpContext.Request.QueryString.ToString());
|
||||
newQuery["AudioCodec"] = state.OutputAudioCodec;
|
||||
queryString = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(string.Empty, newQuery);
|
||||
}
|
||||
@@ -173,21 +173,10 @@ public class DynamicHlsHelper
|
||||
queryString += "&TranscodeReasons=" + state.Request.TranscodeReasons;
|
||||
}
|
||||
|
||||
// Video rotation metadata is only supported in fMP4 remuxing
|
||||
if (state.VideoStream is not null
|
||||
&& state.VideoRequest is not null
|
||||
&& (state.VideoStream?.Rotation ?? 0) != 0
|
||||
&& EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
|
||||
&& !string.IsNullOrWhiteSpace(state.Request.SegmentContainer)
|
||||
&& !string.Equals(state.Request.SegmentContainer, "mp4", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
queryString += "&AllowVideoStreamCopy=false";
|
||||
}
|
||||
|
||||
// Main stream
|
||||
var baseUrl = isLiveStream ? "live.m3u8" : "main.m3u8";
|
||||
var playlistUrl = baseUrl + queryString;
|
||||
var playlistQuery = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(queryString);
|
||||
var playlistUrl = isLiveStream ? "live.m3u8" : "main.m3u8";
|
||||
|
||||
playlistUrl += queryString;
|
||||
|
||||
var subtitleStreams = state.MediaSource
|
||||
.MediaStreams
|
||||
@@ -209,36 +198,37 @@ public class DynamicHlsHelper
|
||||
AddSubtitles(state, subtitleStreams, builder, _httpContextAccessor.HttpContext.User);
|
||||
}
|
||||
|
||||
// Video rotation metadata is only supported in fMP4 remuxing
|
||||
if (state.VideoStream is not null
|
||||
&& state.VideoRequest is not null
|
||||
&& (state.VideoStream?.Rotation ?? 0) != 0
|
||||
&& EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
|
||||
&& !string.IsNullOrWhiteSpace(state.Request.SegmentContainer)
|
||||
&& !string.Equals(state.Request.SegmentContainer, "mp4", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
playlistUrl += "&AllowVideoStreamCopy=false";
|
||||
}
|
||||
|
||||
var basicPlaylist = AppendPlaylist(builder, state, playlistUrl, totalBitrate, subtitleGroup);
|
||||
|
||||
if (state.VideoStream is not null && state.VideoRequest is not null)
|
||||
{
|
||||
var encodingOptions = _serverConfigurationManager.GetEncodingOptions();
|
||||
|
||||
// Provide AV1 and HEVC SDR entrances for backward compatibility.
|
||||
foreach (var sdrVideoCodec in new[] { "av1", "hevc" })
|
||||
// Provide SDR HEVC entrance for backward compatibility.
|
||||
if (encodingOptions.AllowHevcEncoding
|
||||
&& !encodingOptions.AllowAv1Encoding
|
||||
&& EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
|
||||
&& state.VideoStream.VideoRange == VideoRange.HDR
|
||||
&& string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var isAv1EncodingAllowed = encodingOptions.AllowAv1Encoding
|
||||
&& string.Equals(sdrVideoCodec, "av1", StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(state.ActualOutputVideoCodec, "av1", StringComparison.OrdinalIgnoreCase);
|
||||
var isHevcEncodingAllowed = encodingOptions.AllowHevcEncoding
|
||||
&& string.Equals(sdrVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase);
|
||||
var isEncodingAllowed = isAv1EncodingAllowed || isHevcEncodingAllowed;
|
||||
|
||||
if (isEncodingAllowed
|
||||
&& EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
|
||||
&& state.VideoStream.VideoRange == VideoRange.HDR)
|
||||
var requestedVideoProfiles = state.GetRequestedProfiles("hevc");
|
||||
if (requestedVideoProfiles is not null && requestedVideoProfiles.Length > 0)
|
||||
{
|
||||
// Force AV1 and HEVC Main Profile and disable video stream copy.
|
||||
state.OutputVideoCodec = sdrVideoCodec;
|
||||
|
||||
var sdrPlaylistQuery = playlistQuery;
|
||||
sdrPlaylistQuery["VideoCodec"] = sdrVideoCodec;
|
||||
sdrPlaylistQuery[sdrVideoCodec + "-profile"] = "main";
|
||||
sdrPlaylistQuery["AllowVideoStreamCopy"] = "false";
|
||||
|
||||
var sdrVideoUrl = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(baseUrl, sdrPlaylistQuery);
|
||||
// Force HEVC Main Profile and disable video stream copy.
|
||||
state.OutputVideoCodec = "hevc";
|
||||
var sdrVideoUrl = ReplaceProfile(playlistUrl, "hevc", string.Join(',', requestedVideoProfiles), "main");
|
||||
sdrVideoUrl += "&AllowVideoStreamCopy=false";
|
||||
|
||||
// HACK: Use the same bitrate so that the client can choose by other attributes, such as color range.
|
||||
AppendPlaylist(builder, state, sdrVideoUrl, totalBitrate, subtitleGroup);
|
||||
@@ -248,30 +238,12 @@ public class DynamicHlsHelper
|
||||
}
|
||||
}
|
||||
|
||||
// Provide H.264 SDR entrance for backward compatibility.
|
||||
if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
|
||||
&& state.VideoStream.VideoRange == VideoRange.HDR)
|
||||
{
|
||||
// Force H.264 and disable video stream copy.
|
||||
state.OutputVideoCodec = "h264";
|
||||
|
||||
var sdrPlaylistQuery = playlistQuery;
|
||||
sdrPlaylistQuery["VideoCodec"] = "h264";
|
||||
sdrPlaylistQuery["AllowVideoStreamCopy"] = "false";
|
||||
|
||||
var sdrVideoUrl = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(baseUrl, sdrPlaylistQuery);
|
||||
|
||||
// HACK: Use the same bitrate so that the client can choose by other attributes, such as color range.
|
||||
AppendPlaylist(builder, state, sdrVideoUrl, totalBitrate, subtitleGroup);
|
||||
|
||||
// Restore the video codec
|
||||
state.OutputVideoCodec = "copy";
|
||||
}
|
||||
|
||||
// Provide Level 5.0 entrance for backward compatibility.
|
||||
// e.g. Apple A10 chips refuse the master playlist containing SDR HEVC Main Level 5.1 video,
|
||||
// but in fact it is capable of playing videos up to Level 6.1.
|
||||
if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
|
||||
if (encodingOptions.AllowHevcEncoding
|
||||
&& !encodingOptions.AllowAv1Encoding
|
||||
&& EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
|
||||
&& state.VideoStream.Level.HasValue
|
||||
&& state.VideoStream.Level > 150
|
||||
&& state.VideoStream.VideoRange == VideoRange.SDR
|
||||
@@ -301,15 +273,12 @@ public class DynamicHlsHelper
|
||||
var variation = GetBitrateVariation(totalBitrate);
|
||||
|
||||
var newBitrate = totalBitrate - variation;
|
||||
var variantQuery = playlistQuery;
|
||||
variantQuery["VideoBitrate"] = (requestedVideoBitrate - variation).ToString(CultureInfo.InvariantCulture);
|
||||
var variantUrl = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(baseUrl, variantQuery);
|
||||
var variantUrl = ReplaceVideoBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation);
|
||||
AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup);
|
||||
|
||||
variation *= 2;
|
||||
newBitrate = totalBitrate - variation;
|
||||
variantQuery["VideoBitrate"] = (requestedVideoBitrate - variation).ToString(CultureInfo.InvariantCulture);
|
||||
variantUrl = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(baseUrl, variantQuery);
|
||||
variantUrl = ReplaceVideoBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation);
|
||||
AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup);
|
||||
}
|
||||
|
||||
@@ -754,9 +723,7 @@ public class DynamicHlsHelper
|
||||
{
|
||||
if (string.Equals(state.ActualOutputAudioCodec, "aac", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string? profile = EncodingHelper.IsCopyCodec(state.OutputAudioCodec)
|
||||
? state.AudioStream?.Profile : state.GetRequestedProfiles("aac").FirstOrDefault();
|
||||
|
||||
string? profile = state.GetRequestedProfiles("aac").FirstOrDefault();
|
||||
return HlsCodecStringHelpers.GetAACString(profile);
|
||||
}
|
||||
|
||||
@@ -790,19 +757,6 @@ public class DynamicHlsHelper
|
||||
return HlsCodecStringHelpers.GetOPUSString();
|
||||
}
|
||||
|
||||
if (string.Equals(state.ActualOutputAudioCodec, "truehd", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return HlsCodecStringHelpers.GetTRUEHDString();
|
||||
}
|
||||
|
||||
if (string.Equals(state.ActualOutputAudioCodec, "dts", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// lavc only support encoding DTS core profile
|
||||
string? profile = EncodingHelper.IsCopyCodec(state.OutputAudioCodec) ? state.AudioStream?.Profile : "DTS";
|
||||
|
||||
return HlsCodecStringHelpers.GetDTSString(profile);
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
@@ -909,6 +863,23 @@ public class DynamicHlsHelper
|
||||
return variation;
|
||||
}
|
||||
|
||||
private string ReplaceVideoBitrate(string url, int oldValue, int newValue)
|
||||
{
|
||||
return url.Replace(
|
||||
"videobitrate=" + oldValue.ToString(CultureInfo.InvariantCulture),
|
||||
"videobitrate=" + newValue.ToString(CultureInfo.InvariantCulture),
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private string ReplaceProfile(string url, string codec, string oldValue, string newValue)
|
||||
{
|
||||
string profileStr = codec + "-profile=";
|
||||
return url.Replace(
|
||||
profileStr + oldValue,
|
||||
profileStr + newValue,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private string ReplacePlaylistCodecsField(StringBuilder playlist, StringBuilder oldValue, StringBuilder newValue)
|
||||
{
|
||||
var oldPlaylist = playlist.ToString();
|
||||
|
||||
@@ -41,11 +41,6 @@ public static class HlsCodecStringHelpers
|
||||
/// </summary>
|
||||
public const string OPUS = "Opus";
|
||||
|
||||
/// <summary>
|
||||
/// Codec name for TRUEHD.
|
||||
/// </summary>
|
||||
public const string TRUEHD = "mlpa";
|
||||
|
||||
/// <summary>
|
||||
/// Gets a MP3 codec string.
|
||||
/// </summary>
|
||||
@@ -64,7 +59,7 @@ public static class HlsCodecStringHelpers
|
||||
{
|
||||
StringBuilder result = new StringBuilder("mp4a", 9);
|
||||
|
||||
if (string.Equals(profile, "HE-AAC", StringComparison.OrdinalIgnoreCase))
|
||||
if (string.Equals(profile, "HE", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
result.Append(".40.5");
|
||||
}
|
||||
@@ -122,46 +117,6 @@ public static class HlsCodecStringHelpers
|
||||
return OPUS;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an TRUEHD codec string.
|
||||
/// </summary>
|
||||
/// <returns>TRUEHD codec string.</returns>
|
||||
public static string GetTRUEHDString()
|
||||
{
|
||||
return TRUEHD;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an DTS codec string.
|
||||
/// </summary>
|
||||
/// <param name="profile">DTS profile.</param>
|
||||
/// <returns>DTS codec string.</returns>
|
||||
public static string GetDTSString(string? profile)
|
||||
{
|
||||
if (string.Equals(profile, "DTS", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(profile, "DTS-ES", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(profile, "DTS 96/24", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "dtsc";
|
||||
}
|
||||
|
||||
if (string.Equals(profile, "DTS-HD HRA", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(profile, "DTS-HD MA", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(profile, "DTS-HD MA + DTS:X", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(profile, "DTS-HD MA + DTS:X IMAX", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "dtsh";
|
||||
}
|
||||
|
||||
if (string.Equals(profile, "DTS Express", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "dtse";
|
||||
}
|
||||
|
||||
// Default to DTS core if profile is invalid
|
||||
return "dtsc";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a H.264 codec string.
|
||||
/// </summary>
|
||||
|
||||
@@ -159,13 +159,6 @@ public static class StreamingHelpers
|
||||
|
||||
string? containerInternal = Path.GetExtension(state.RequestedUrl);
|
||||
|
||||
if (string.IsNullOrEmpty(containerInternal)
|
||||
&& (!string.IsNullOrWhiteSpace(streamingRequest.LiveStreamId)
|
||||
|| (mediaSource != null && mediaSource.IsInfiniteStream)))
|
||||
{
|
||||
containerInternal = ".ts";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(streamingRequest.Container))
|
||||
{
|
||||
containerInternal = streamingRequest.Container;
|
||||
|
||||
53
Jellyfin.Api/Middleware/LegacyEmbyRouteRewriteMiddleware.cs
Normal file
53
Jellyfin.Api/Middleware/LegacyEmbyRouteRewriteMiddleware.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Api.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// Removes /emby and /mediabrowser from requested route.
|
||||
/// </summary>
|
||||
public class LegacyEmbyRouteRewriteMiddleware
|
||||
{
|
||||
private const string EmbyPath = "/emby";
|
||||
private const string MediabrowserPath = "/mediabrowser";
|
||||
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly ILogger<LegacyEmbyRouteRewriteMiddleware> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="LegacyEmbyRouteRewriteMiddleware"/> class.
|
||||
/// </summary>
|
||||
/// <param name="next">The next delegate in the pipeline.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public LegacyEmbyRouteRewriteMiddleware(
|
||||
RequestDelegate next,
|
||||
ILogger<LegacyEmbyRouteRewriteMiddleware> logger)
|
||||
{
|
||||
_next = next;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes the middleware action.
|
||||
/// </summary>
|
||||
/// <param name="httpContext">The current HTTP context.</param>
|
||||
/// <returns>The async task.</returns>
|
||||
public async Task Invoke(HttpContext httpContext)
|
||||
{
|
||||
var localPath = httpContext.Request.Path.ToString();
|
||||
if (localPath.StartsWith(EmbyPath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
httpContext.Request.Path = localPath[EmbyPath.Length..];
|
||||
_logger.LogDebug("Removing {EmbyPath} from route.", EmbyPath);
|
||||
}
|
||||
else if (localPath.StartsWith(MediabrowserPath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
httpContext.Request.Path = localPath[MediabrowserPath.Length..];
|
||||
_logger.LogDebug("Removing {MediabrowserPath} from route.", MediabrowserPath);
|
||||
}
|
||||
|
||||
await _next(httpContext).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
namespace Jellyfin.Data.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Activity log sorting options.
|
||||
/// </summary>
|
||||
public enum ActivityLogSortBy
|
||||
{
|
||||
/// <summary>
|
||||
/// Sort by name.
|
||||
/// </summary>
|
||||
Name = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Sort by overview.
|
||||
/// </summary>
|
||||
Overiew = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Sort by short overview.
|
||||
/// </summary>
|
||||
ShortOverview = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Sort by type.
|
||||
/// </summary>
|
||||
Type = 3,
|
||||
|
||||
/*
|
||||
/// <summary>
|
||||
/// Sort by item name.
|
||||
/// </summary>
|
||||
Item = 4,
|
||||
*/
|
||||
|
||||
/// <summary>
|
||||
/// Sort by date.
|
||||
/// </summary>
|
||||
DateCreated = 5,
|
||||
|
||||
/// <summary>
|
||||
/// Sort by username.
|
||||
/// </summary>
|
||||
Username = 6,
|
||||
|
||||
/// <summary>
|
||||
/// Sort by severity.
|
||||
/// </summary>
|
||||
LogSeverity = 7
|
||||
}
|
||||
@@ -18,7 +18,7 @@
|
||||
<PropertyGroup>
|
||||
<Authors>Jellyfin Contributors</Authors>
|
||||
<PackageId>Jellyfin.Data</PackageId>
|
||||
<VersionPrefix>10.12.0</VersionPrefix>
|
||||
<VersionPrefix>10.11.2</VersionPrefix>
|
||||
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
|
||||
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -1,68 +1,20 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations.Enums;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Data.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// A class representing a query to the activity logs.
|
||||
/// </summary>
|
||||
public class ActivityLogQuery : PaginatedQuery
|
||||
namespace Jellyfin.Data.Queries
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to take entries with a user id.
|
||||
/// A class representing a query to the activity logs.
|
||||
/// </summary>
|
||||
public bool? HasUserId { get; set; }
|
||||
public class ActivityLogQuery : PaginatedQuery
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to take entries with a user id.
|
||||
/// </summary>
|
||||
public bool? HasUserId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the minimum date to query for.
|
||||
/// </summary>
|
||||
public DateTime? MinDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum date to query for.
|
||||
/// </summary>
|
||||
public DateTime? MaxDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the name filter.
|
||||
/// </summary>
|
||||
public string? Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the overview filter.
|
||||
/// </summary>
|
||||
public string? Overview { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the short overview filter.
|
||||
/// </summary>
|
||||
public string? ShortOverview { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the type filter.
|
||||
/// </summary>
|
||||
public string? Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the item filter.
|
||||
/// </summary>
|
||||
public Guid? ItemId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the username filter.
|
||||
/// </summary>
|
||||
public string? Username { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the log level filter.
|
||||
/// </summary>
|
||||
public LogLevel? Severity { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the result ordering.
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<(ActivityLogSortBy, SortOrder)>? OrderBy { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets the minimum date to query for.
|
||||
/// </summary>
|
||||
public DateTime? MinDate { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,213 +1,103 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Linq.Expressions;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Data.Events;
|
||||
using Jellyfin.Data.Queries;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using Jellyfin.Database.Implementations.Enums;
|
||||
using Jellyfin.Extensions;
|
||||
using MediaBrowser.Model.Activity;
|
||||
using MediaBrowser.Model.Querying;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Jellyfin.Server.Implementations.Activity;
|
||||
|
||||
/// <summary>
|
||||
/// Manages the storage and retrieval of <see cref="ActivityLog"/> instances.
|
||||
/// </summary>
|
||||
public class ActivityManager : IActivityManager
|
||||
namespace Jellyfin.Server.Implementations.Activity
|
||||
{
|
||||
private readonly IDbContextFactory<JellyfinDbContext> _provider;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ActivityManager"/> class.
|
||||
/// Manages the storage and retrieval of <see cref="ActivityLog"/> instances.
|
||||
/// </summary>
|
||||
/// <param name="provider">The Jellyfin database provider.</param>
|
||||
public ActivityManager(IDbContextFactory<JellyfinDbContext> provider)
|
||||
public class ActivityManager : IActivityManager
|
||||
{
|
||||
_provider = provider;
|
||||
}
|
||||
private readonly IDbContextFactory<JellyfinDbContext> _provider;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public event EventHandler<GenericEventArgs<ActivityLogEntry>>? EntryCreated;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task CreateAsync(ActivityLog entry)
|
||||
{
|
||||
var dbContext = await _provider.CreateDbContextAsync().ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ActivityManager"/> class.
|
||||
/// </summary>
|
||||
/// <param name="provider">The Jellyfin database provider.</param>
|
||||
public ActivityManager(IDbContextFactory<JellyfinDbContext> provider)
|
||||
{
|
||||
dbContext.ActivityLogs.Add(entry);
|
||||
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
||||
_provider = provider;
|
||||
}
|
||||
|
||||
EntryCreated?.Invoke(this, new GenericEventArgs<ActivityLogEntry>(ConvertToOldModel(entry)));
|
||||
}
|
||||
/// <inheritdoc/>
|
||||
public event EventHandler<GenericEventArgs<ActivityLogEntry>>? EntryCreated;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<QueryResult<ActivityLogEntry>> GetPagedResultAsync(ActivityLogQuery query)
|
||||
{
|
||||
// TODO allow sorting and filtering by item id. Currently not possible because ActivityLog stores the item id as a string.
|
||||
|
||||
var dbContext = await _provider.CreateDbContextAsync().ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
/// <inheritdoc/>
|
||||
public async Task CreateAsync(ActivityLog entry)
|
||||
{
|
||||
// TODO switch to LeftJoin in .NET 10.
|
||||
var entries = from a in dbContext.ActivityLogs
|
||||
join u in dbContext.Users on a.UserId equals u.Id into ugj
|
||||
from u in ugj.DefaultIfEmpty()
|
||||
select new ExpandedActivityLog { ActivityLog = a, Username = u.Username };
|
||||
|
||||
if (query.HasUserId is not null)
|
||||
var dbContext = await _provider.CreateDbContextAsync().ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
entries = entries.Where(e => e.ActivityLog.UserId.Equals(default) != query.HasUserId.Value);
|
||||
dbContext.ActivityLogs.Add(entry);
|
||||
await dbContext.SaveChangesAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (query.MinDate is not null)
|
||||
{
|
||||
entries = entries.Where(e => e.ActivityLog.DateCreated >= query.MinDate.Value);
|
||||
}
|
||||
|
||||
if (query.MaxDate is not null)
|
||||
{
|
||||
entries = entries.Where(e => e.ActivityLog.DateCreated <= query.MaxDate.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(query.Name))
|
||||
{
|
||||
entries = entries.Where(e => EF.Functions.Like(e.ActivityLog.Name, $"%{query.Name}%"));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(query.Overview))
|
||||
{
|
||||
entries = entries.Where(e => EF.Functions.Like(e.ActivityLog.Overview, $"%{query.Overview}%"));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(query.ShortOverview))
|
||||
{
|
||||
entries = entries.Where(e => EF.Functions.Like(e.ActivityLog.ShortOverview, $"%{query.ShortOverview}%"));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(query.Type))
|
||||
{
|
||||
entries = entries.Where(e => EF.Functions.Like(e.ActivityLog.Type, $"%{query.Type}%"));
|
||||
}
|
||||
|
||||
if (!query.ItemId.IsNullOrEmpty())
|
||||
{
|
||||
var itemId = query.ItemId.Value.ToString("N");
|
||||
entries = entries.Where(e => e.ActivityLog.ItemId == itemId);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(query.Username))
|
||||
{
|
||||
entries = entries.Where(e => EF.Functions.Like(e.Username, $"%{query.Username}%"));
|
||||
}
|
||||
|
||||
if (query.Severity is not null)
|
||||
{
|
||||
entries = entries.Where(e => e.ActivityLog.LogSeverity == query.Severity);
|
||||
}
|
||||
|
||||
return new QueryResult<ActivityLogEntry>(
|
||||
query.Skip,
|
||||
await entries.CountAsync().ConfigureAwait(false),
|
||||
await ApplyOrdering(entries, query.OrderBy)
|
||||
.Skip(query.Skip ?? 0)
|
||||
.Take(query.Limit ?? 100)
|
||||
.Select(entity => new ActivityLogEntry(entity.ActivityLog.Name, entity.ActivityLog.Type, entity.ActivityLog.UserId)
|
||||
{
|
||||
Id = entity.ActivityLog.Id,
|
||||
Overview = entity.ActivityLog.Overview,
|
||||
ShortOverview = entity.ActivityLog.ShortOverview,
|
||||
ItemId = entity.ActivityLog.ItemId,
|
||||
Date = entity.ActivityLog.DateCreated,
|
||||
Severity = entity.ActivityLog.LogSeverity
|
||||
})
|
||||
.ToListAsync()
|
||||
.ConfigureAwait(false));
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task CleanAsync(DateTime startDate)
|
||||
{
|
||||
var dbContext = await _provider.CreateDbContextAsync().ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
await dbContext.ActivityLogs
|
||||
.Where(entry => entry.DateCreated <= startDate)
|
||||
.ExecuteDeleteAsync()
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static ActivityLogEntry ConvertToOldModel(ActivityLog entry)
|
||||
{
|
||||
return new ActivityLogEntry(entry.Name, entry.Type, entry.UserId)
|
||||
{
|
||||
Id = entry.Id,
|
||||
Overview = entry.Overview,
|
||||
ShortOverview = entry.ShortOverview,
|
||||
ItemId = entry.ItemId,
|
||||
Date = entry.DateCreated,
|
||||
Severity = entry.LogSeverity
|
||||
};
|
||||
}
|
||||
|
||||
private IOrderedQueryable<ExpandedActivityLog> ApplyOrdering(IQueryable<ExpandedActivityLog> query, IReadOnlyCollection<(ActivityLogSortBy, SortOrder)>? sorting)
|
||||
{
|
||||
if (sorting is null || sorting.Count == 0)
|
||||
{
|
||||
return query.OrderByDescending(e => e.ActivityLog.DateCreated);
|
||||
EntryCreated?.Invoke(this, new GenericEventArgs<ActivityLogEntry>(ConvertToOldModel(entry)));
|
||||
}
|
||||
|
||||
IOrderedQueryable<ExpandedActivityLog> ordered = null!;
|
||||
|
||||
foreach (var (sortBy, sortOrder) in sorting)
|
||||
/// <inheritdoc/>
|
||||
public async Task<QueryResult<ActivityLogEntry>> GetPagedResultAsync(ActivityLogQuery query)
|
||||
{
|
||||
var orderBy = MapOrderBy(sortBy);
|
||||
var dbContext = await _provider.CreateDbContextAsync().ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
var entries = dbContext.ActivityLogs
|
||||
.OrderByDescending(entry => entry.DateCreated)
|
||||
.Where(entry => query.MinDate == null || entry.DateCreated >= query.MinDate)
|
||||
.Where(entry => !query.HasUserId.HasValue || entry.UserId.Equals(default) != query.HasUserId.Value);
|
||||
|
||||
if (ordered == null)
|
||||
{
|
||||
ordered = sortOrder == SortOrder.Ascending
|
||||
? query.OrderBy(orderBy)
|
||||
: query.OrderByDescending(orderBy);
|
||||
}
|
||||
else
|
||||
{
|
||||
ordered = sortOrder == SortOrder.Ascending
|
||||
? ordered.ThenBy(orderBy)
|
||||
: ordered.ThenByDescending(orderBy);
|
||||
return new QueryResult<ActivityLogEntry>(
|
||||
query.Skip,
|
||||
await entries.CountAsync().ConfigureAwait(false),
|
||||
await entries
|
||||
.Skip(query.Skip ?? 0)
|
||||
.Take(query.Limit ?? 100)
|
||||
.Select(entity => new ActivityLogEntry(entity.Name, entity.Type, entity.UserId)
|
||||
{
|
||||
Id = entity.Id,
|
||||
Overview = entity.Overview,
|
||||
ShortOverview = entity.ShortOverview,
|
||||
ItemId = entity.ItemId,
|
||||
Date = entity.DateCreated,
|
||||
Severity = entity.LogSeverity
|
||||
})
|
||||
.ToListAsync()
|
||||
.ConfigureAwait(false));
|
||||
}
|
||||
}
|
||||
|
||||
return ordered;
|
||||
}
|
||||
|
||||
private Expression<Func<ExpandedActivityLog, object?>> MapOrderBy(ActivityLogSortBy sortBy)
|
||||
{
|
||||
return sortBy switch
|
||||
/// <inheritdoc />
|
||||
public async Task CleanAsync(DateTime startDate)
|
||||
{
|
||||
ActivityLogSortBy.Name => e => e.ActivityLog.Name,
|
||||
ActivityLogSortBy.Overiew => e => e.ActivityLog.Overview,
|
||||
ActivityLogSortBy.ShortOverview => e => e.ActivityLog.ShortOverview,
|
||||
ActivityLogSortBy.Type => e => e.ActivityLog.Type,
|
||||
ActivityLogSortBy.DateCreated => e => e.ActivityLog.DateCreated,
|
||||
ActivityLogSortBy.Username => e => e.Username,
|
||||
ActivityLogSortBy.LogSeverity => e => e.ActivityLog.LogSeverity,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(sortBy), sortBy, "Unhandled ActivityLogSortBy")
|
||||
};
|
||||
}
|
||||
var dbContext = await _provider.CreateDbContextAsync().ConfigureAwait(false);
|
||||
await using (dbContext.ConfigureAwait(false))
|
||||
{
|
||||
await dbContext.ActivityLogs
|
||||
.Where(entry => entry.DateCreated <= startDate)
|
||||
.ExecuteDeleteAsync()
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private class ExpandedActivityLog
|
||||
{
|
||||
public ActivityLog ActivityLog { get; set; } = null!;
|
||||
|
||||
public string? Username { get; set; }
|
||||
private static ActivityLogEntry ConvertToOldModel(ActivityLog entry)
|
||||
{
|
||||
return new ActivityLogEntry(entry.Name, entry.Type, entry.UserId)
|
||||
{
|
||||
Id = entry.Id,
|
||||
Overview = entry.Overview,
|
||||
ShortOverview = entry.ShortOverview,
|
||||
ItemId = entry.ItemId,
|
||||
Date = entry.DateCreated,
|
||||
Severity = entry.LogSeverity
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,7 +158,7 @@ namespace Jellyfin.Server.Implementations.Devices
|
||||
devices = devices.Skip(query.Skip.Value);
|
||||
}
|
||||
|
||||
if (query.Limit.HasValue && query.Limit.Value > 0)
|
||||
if (query.Limit.HasValue)
|
||||
{
|
||||
devices = devices.Take(query.Limit.Value);
|
||||
}
|
||||
|
||||
@@ -250,7 +250,7 @@ public sealed class BaseItemRepository
|
||||
public QueryResult<BaseItemDto> GetItems(InternalItemsQuery filter)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(filter);
|
||||
if (!filter.EnableTotalRecordCount || ((filter.Limit ?? 0) == 0 && (filter.StartIndex ?? 0) == 0))
|
||||
if (!filter.EnableTotalRecordCount || (!filter.Limit.HasValue && (filter.StartIndex ?? 0) == 0))
|
||||
{
|
||||
var returnList = GetItemList(filter);
|
||||
return new QueryResult<BaseItemDto>(
|
||||
@@ -275,9 +275,8 @@ public sealed class BaseItemRepository
|
||||
}
|
||||
|
||||
dbQuery = ApplyQueryPaging(dbQuery, filter);
|
||||
dbQuery = ApplyNavigations(dbQuery, filter);
|
||||
|
||||
result.Items = dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).Where(dto => dto is not null).ToArray()!;
|
||||
result.Items = dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray();
|
||||
result.StartIndex = filter.StartIndex ?? 0;
|
||||
return result;
|
||||
}
|
||||
@@ -295,9 +294,8 @@ public sealed class BaseItemRepository
|
||||
|
||||
dbQuery = ApplyGroupingFilter(context, dbQuery, filter);
|
||||
dbQuery = ApplyQueryPaging(dbQuery, filter);
|
||||
dbQuery = ApplyNavigations(dbQuery, filter);
|
||||
|
||||
return dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).Where(dto => dto is not null).ToArray()!;
|
||||
return dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -326,7 +324,7 @@ public sealed class BaseItemRepository
|
||||
.OrderByDescending(g => g.MaxDateCreated)
|
||||
.Select(g => g);
|
||||
|
||||
if (filter.Limit.HasValue && filter.Limit.Value > 0)
|
||||
if (filter.Limit.HasValue)
|
||||
{
|
||||
subqueryGrouped = subqueryGrouped.Take(filter.Limit.Value);
|
||||
}
|
||||
@@ -339,9 +337,7 @@ public sealed class BaseItemRepository
|
||||
mainquery = ApplyGroupingFilter(context, mainquery, filter);
|
||||
mainquery = ApplyQueryPaging(mainquery, filter);
|
||||
|
||||
mainquery = ApplyNavigations(mainquery, filter);
|
||||
|
||||
return mainquery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).Where(dto => dto is not null).ToArray()!;
|
||||
return mainquery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -367,7 +363,7 @@ public sealed class BaseItemRepository
|
||||
.OrderByDescending(g => g.LastPlayedDate)
|
||||
.Select(g => g.Key!);
|
||||
|
||||
if (filter.Limit.HasValue && filter.Limit.Value > 0)
|
||||
if (filter.Limit.HasValue)
|
||||
{
|
||||
query = query.Take(filter.Limit.Value);
|
||||
}
|
||||
@@ -403,32 +399,19 @@ public sealed class BaseItemRepository
|
||||
dbQuery = dbQuery.Distinct();
|
||||
}
|
||||
|
||||
dbQuery = ApplyOrder(dbQuery, filter, context);
|
||||
dbQuery = ApplyOrder(dbQuery, filter);
|
||||
|
||||
dbQuery = ApplyNavigations(dbQuery, filter);
|
||||
|
||||
return dbQuery;
|
||||
}
|
||||
|
||||
private static IQueryable<BaseItemEntity> ApplyNavigations(IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter)
|
||||
{
|
||||
if (filter.TrailerTypes.Length > 0 || filter.IncludeItemTypes.Contains(BaseItemKind.Trailer))
|
||||
{
|
||||
dbQuery = dbQuery.Include(e => e.TrailerTypes);
|
||||
}
|
||||
|
||||
if (filter.DtoOptions.ContainsField(ItemFields.ProviderIds))
|
||||
{
|
||||
dbQuery = dbQuery.Include(e => e.Provider);
|
||||
}
|
||||
|
||||
if (filter.DtoOptions.ContainsField(ItemFields.Settings))
|
||||
{
|
||||
dbQuery = dbQuery.Include(e => e.LockedFields);
|
||||
}
|
||||
|
||||
if (filter.DtoOptions.EnableUserData)
|
||||
{
|
||||
dbQuery = dbQuery.Include(e => e.UserData);
|
||||
}
|
||||
dbQuery = dbQuery.Include(e => e.TrailerTypes)
|
||||
.Include(e => e.Provider)
|
||||
.Include(e => e.LockedFields)
|
||||
.Include(e => e.UserData);
|
||||
|
||||
if (filter.DtoOptions.EnableImages)
|
||||
{
|
||||
@@ -440,14 +423,19 @@ public sealed class BaseItemRepository
|
||||
|
||||
private IQueryable<BaseItemEntity> ApplyQueryPaging(IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter)
|
||||
{
|
||||
if (filter.StartIndex.HasValue && filter.StartIndex.Value > 0)
|
||||
if (filter.Limit.HasValue || filter.StartIndex.HasValue)
|
||||
{
|
||||
dbQuery = dbQuery.Skip(filter.StartIndex.Value);
|
||||
}
|
||||
var offset = filter.StartIndex ?? 0;
|
||||
|
||||
if (filter.Limit.HasValue && filter.Limit.Value > 0)
|
||||
{
|
||||
dbQuery = dbQuery.Take(filter.Limit.Value);
|
||||
if (offset > 0)
|
||||
{
|
||||
dbQuery = dbQuery.Skip(offset);
|
||||
}
|
||||
|
||||
if (filter.Limit.HasValue)
|
||||
{
|
||||
dbQuery = dbQuery.Take(filter.Limit.Value);
|
||||
}
|
||||
}
|
||||
|
||||
return dbQuery;
|
||||
@@ -458,7 +446,6 @@ public sealed class BaseItemRepository
|
||||
dbQuery = TranslateQuery(dbQuery, context, filter);
|
||||
dbQuery = ApplyGroupingFilter(context, dbQuery, filter);
|
||||
dbQuery = ApplyQueryPaging(dbQuery, filter);
|
||||
dbQuery = ApplyNavigations(dbQuery, filter);
|
||||
return dbQuery;
|
||||
}
|
||||
|
||||
@@ -562,34 +549,22 @@ public sealed class BaseItemRepository
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SaveImagesAsync(BaseItemDto item, CancellationToken cancellationToken = default)
|
||||
public void SaveImages(BaseItemDto item)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(item);
|
||||
|
||||
var images = item.ImageInfos.Select(e => Map(item.Id, e)).ToArray();
|
||||
var images = item.ImageInfos.Select(e => Map(item.Id, e));
|
||||
using var context = _dbProvider.CreateDbContext();
|
||||
|
||||
var context = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using (context.ConfigureAwait(false))
|
||||
if (!context.BaseItems.Any(bi => bi.Id == item.Id))
|
||||
{
|
||||
if (!await context.BaseItems
|
||||
.AnyAsync(bi => bi.Id == item.Id, cancellationToken)
|
||||
.ConfigureAwait(false))
|
||||
{
|
||||
_logger.LogWarning("Unable to save ImageInfo for non existing BaseItem");
|
||||
return;
|
||||
}
|
||||
|
||||
await context.BaseItemImageInfos
|
||||
.Where(e => e.ItemId == item.Id)
|
||||
.ExecuteDeleteAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await context.BaseItemImageInfos
|
||||
.AddRangeAsync(images, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogWarning("Unable to save ImageInfo for non existing BaseItem");
|
||||
return;
|
||||
}
|
||||
|
||||
context.BaseItemImageInfos.Where(e => e.ItemId == item.Id).ExecuteDelete();
|
||||
context.BaseItemImageInfos.AddRange(images);
|
||||
context.SaveChanges();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -640,18 +615,12 @@ public sealed class BaseItemRepository
|
||||
{
|
||||
context.BaseItemProviders.Where(e => e.ItemId == entity.Id).ExecuteDelete();
|
||||
context.BaseItemImageInfos.Where(e => e.ItemId == entity.Id).ExecuteDelete();
|
||||
context.BaseItemMetadataFields.Where(e => e.ItemId == entity.Id).ExecuteDelete();
|
||||
|
||||
if (entity.Images is { Count: > 0 })
|
||||
{
|
||||
context.BaseItemImageInfos.AddRange(entity.Images);
|
||||
}
|
||||
|
||||
if (entity.LockedFields is { Count: > 0 })
|
||||
{
|
||||
context.BaseItemMetadataFields.AddRange(entity.LockedFields);
|
||||
}
|
||||
|
||||
context.BaseItems.Attach(entity).State = EntityState.Modified;
|
||||
}
|
||||
}
|
||||
@@ -1159,7 +1128,7 @@ public sealed class BaseItemRepository
|
||||
return type.GetCustomAttribute<RequiresSourceSerialisationAttribute>() == null;
|
||||
}
|
||||
|
||||
private BaseItemDto? DeserializeBaseItem(BaseItemEntity baseItemEntity, bool skipDeserialization = false)
|
||||
private BaseItemDto DeserializeBaseItem(BaseItemEntity baseItemEntity, bool skipDeserialization = false)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(baseItemEntity, nameof(baseItemEntity));
|
||||
if (_serverConfigurationManager?.Configuration is null)
|
||||
@@ -1182,19 +1151,11 @@ public sealed class BaseItemRepository
|
||||
/// <param name="logger">Logger.</param>
|
||||
/// <param name="appHost">The application server Host.</param>
|
||||
/// <param name="skipDeserialization">If only mapping should be processed.</param>
|
||||
/// <returns>A mapped BaseItem, or null if the item type is unknown.</returns>
|
||||
public static BaseItemDto? DeserializeBaseItem(BaseItemEntity baseItemEntity, ILogger logger, IServerApplicationHost? appHost, bool skipDeserialization = false)
|
||||
/// <returns>A mapped BaseItem.</returns>
|
||||
/// <exception cref="InvalidOperationException">Will be thrown if an invalid serialisation is requested.</exception>
|
||||
public static BaseItemDto DeserializeBaseItem(BaseItemEntity baseItemEntity, ILogger logger, IServerApplicationHost? appHost, bool skipDeserialization = false)
|
||||
{
|
||||
var type = GetType(baseItemEntity.Type);
|
||||
if (type is null)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Skipping item {ItemId} with unknown type '{ItemType}'. This may indicate a removed plugin or database corruption.",
|
||||
baseItemEntity.Id,
|
||||
baseItemEntity.Type);
|
||||
return null;
|
||||
}
|
||||
|
||||
var type = GetType(baseItemEntity.Type) ?? throw new InvalidOperationException("Cannot deserialize unknown type.");
|
||||
BaseItemDto? dto = null;
|
||||
if (TypeRequiresDeserialization(type) && baseItemEntity.Data is not null && !skipDeserialization)
|
||||
{
|
||||
@@ -1220,7 +1181,7 @@ public sealed class BaseItemRepository
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(filter);
|
||||
|
||||
if (!(filter.Limit.HasValue && filter.Limit.Value > 0))
|
||||
if (!filter.Limit.HasValue)
|
||||
{
|
||||
filter.EnableTotalRecordCount = false;
|
||||
}
|
||||
@@ -1291,7 +1252,7 @@ public sealed class BaseItemRepository
|
||||
.AsSingleQuery()
|
||||
.Where(e => masterQuery.Contains(e.Id));
|
||||
|
||||
query = ApplyOrder(query, filter, context);
|
||||
query = ApplyOrder(query, filter);
|
||||
|
||||
var result = new QueryResult<(BaseItemDto, ItemCounts?)>();
|
||||
if (filter.EnableTotalRecordCount)
|
||||
@@ -1299,14 +1260,19 @@ public sealed class BaseItemRepository
|
||||
result.TotalRecordCount = query.Count();
|
||||
}
|
||||
|
||||
if (filter.StartIndex.HasValue && filter.StartIndex.Value > 0)
|
||||
if (filter.Limit.HasValue || filter.StartIndex.HasValue)
|
||||
{
|
||||
query = query.Skip(filter.StartIndex.Value);
|
||||
}
|
||||
var offset = filter.StartIndex ?? 0;
|
||||
|
||||
if (filter.Limit.HasValue && filter.Limit.Value > 0)
|
||||
{
|
||||
query = query.Take(filter.Limit.Value);
|
||||
if (offset > 0)
|
||||
{
|
||||
query = query.Skip(offset);
|
||||
}
|
||||
|
||||
if (filter.Limit.HasValue)
|
||||
{
|
||||
query = query.Take(filter.Limit.Value);
|
||||
}
|
||||
}
|
||||
|
||||
IQueryable<BaseItemEntity>? itemCountQuery = null;
|
||||
@@ -1361,9 +1327,10 @@ public sealed class BaseItemRepository
|
||||
.. resultQuery
|
||||
.AsEnumerable()
|
||||
.Where(e => e is not null)
|
||||
.Select(e => (Item: DeserializeBaseItem(e.item, filter.SkipDeserialization), e.itemCount))
|
||||
.Where(e => e.Item is not null)
|
||||
.Select(e => (e.Item!, e.itemCount))
|
||||
.Select(e =>
|
||||
{
|
||||
return (DeserializeBaseItem(e.item, filter.SkipDeserialization), e.itemCount);
|
||||
})
|
||||
];
|
||||
}
|
||||
else
|
||||
@@ -1374,9 +1341,10 @@ public sealed class BaseItemRepository
|
||||
.. query
|
||||
.AsEnumerable()
|
||||
.Where(e => e is not null)
|
||||
.Select(e => (Item: DeserializeBaseItem(e, filter.SkipDeserialization), ItemCounts: (ItemCounts?)null))
|
||||
.Where(e => e.Item is not null)
|
||||
.Select(e => (e.Item!, e.ItemCounts))
|
||||
.Select<BaseItemEntity, (BaseItemDto, ItemCounts?)>(e =>
|
||||
{
|
||||
return (DeserializeBaseItem(e, filter.SkipDeserialization), null);
|
||||
})
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1385,7 +1353,7 @@ public sealed class BaseItemRepository
|
||||
|
||||
private static void PrepareFilterQuery(InternalItemsQuery query)
|
||||
{
|
||||
if (query.Limit.HasValue && query.Limit.Value > 0 && query.EnableGroupByMetadataKey)
|
||||
if (query.Limit.HasValue && query.EnableGroupByMetadataKey)
|
||||
{
|
||||
query.Limit = query.Limit.Value + 4;
|
||||
}
|
||||
@@ -1396,54 +1364,14 @@ public sealed class BaseItemRepository
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the clean value for search and sorting purposes.
|
||||
/// </summary>
|
||||
/// <param name="value">The value to clean.</param>
|
||||
/// <returns>The cleaned value.</returns>
|
||||
public static string GetCleanValue(string value)
|
||||
private string GetCleanValue(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
var noDiacritics = value.RemoveDiacritics();
|
||||
|
||||
// Build a string where any punctuation or symbol is treated as a separator (space).
|
||||
var sb = new StringBuilder(noDiacritics.Length);
|
||||
var previousWasSpace = false;
|
||||
foreach (var ch in noDiacritics)
|
||||
{
|
||||
char outCh;
|
||||
if (char.IsLetterOrDigit(ch) || char.IsWhiteSpace(ch))
|
||||
{
|
||||
outCh = ch;
|
||||
}
|
||||
else
|
||||
{
|
||||
outCh = ' ';
|
||||
}
|
||||
|
||||
// normalize any whitespace character to a single ASCII space.
|
||||
if (char.IsWhiteSpace(outCh))
|
||||
{
|
||||
if (!previousWasSpace)
|
||||
{
|
||||
sb.Append(' ');
|
||||
previousWasSpace = true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append(outCh);
|
||||
previousWasSpace = false;
|
||||
}
|
||||
}
|
||||
|
||||
// trim leading/trailing spaces that may have been added.
|
||||
var collapsed = sb.ToString().Trim();
|
||||
return collapsed.ToLowerInvariant();
|
||||
return value.RemoveDiacritics().ToLowerInvariant();
|
||||
}
|
||||
|
||||
private List<(ItemValueType MagicNumber, string Value)> GetItemValuesToSave(BaseItemDto item, List<string> inheritedTags)
|
||||
@@ -1590,16 +1518,16 @@ public sealed class BaseItemRepository
|
||||
|| query.IncludeItemTypes.Contains(BaseItemKind.Season);
|
||||
}
|
||||
|
||||
private IQueryable<BaseItemEntity> ApplyOrder(IQueryable<BaseItemEntity> query, InternalItemsQuery filter, JellyfinDbContext context)
|
||||
private IQueryable<BaseItemEntity> ApplyOrder(IQueryable<BaseItemEntity> query, InternalItemsQuery filter)
|
||||
{
|
||||
var orderBy = filter.OrderBy.Where(e => e.OrderBy != ItemSortBy.Default).ToArray();
|
||||
var orderBy = filter.OrderBy;
|
||||
var hasSearch = !string.IsNullOrEmpty(filter.SearchTerm);
|
||||
|
||||
if (hasSearch)
|
||||
{
|
||||
orderBy = [(ItemSortBy.SortName, SortOrder.Ascending), .. orderBy];
|
||||
orderBy = filter.OrderBy = [(ItemSortBy.SortName, SortOrder.Ascending), .. orderBy];
|
||||
}
|
||||
else if (orderBy.Length == 0)
|
||||
else if (orderBy.Count == 0)
|
||||
{
|
||||
return query.OrderBy(e => e.SortName);
|
||||
}
|
||||
@@ -1609,7 +1537,7 @@ public sealed class BaseItemRepository
|
||||
var firstOrdering = orderBy.FirstOrDefault();
|
||||
if (firstOrdering != default)
|
||||
{
|
||||
var expression = OrderMapper.MapOrderByField(firstOrdering.OrderBy, filter, context);
|
||||
var expression = OrderMapper.MapOrderByField(firstOrdering.OrderBy, filter);
|
||||
if (firstOrdering.SortOrder == SortOrder.Ascending)
|
||||
{
|
||||
orderedQuery = query.OrderBy(expression);
|
||||
@@ -1634,7 +1562,7 @@ public sealed class BaseItemRepository
|
||||
|
||||
foreach (var item in orderBy.Skip(1))
|
||||
{
|
||||
var expression = OrderMapper.MapOrderByField(item.OrderBy, filter, context);
|
||||
var expression = OrderMapper.MapOrderByField(item.OrderBy, filter);
|
||||
if (item.SortOrder == SortOrder.Ascending)
|
||||
{
|
||||
orderedQuery = orderedQuery!.ThenBy(expression);
|
||||
@@ -1716,18 +1644,19 @@ public sealed class BaseItemRepository
|
||||
var tags = filter.Tags.ToList();
|
||||
var excludeTags = filter.ExcludeTags.ToList();
|
||||
|
||||
if (filter.IsMovie.HasValue)
|
||||
if (filter.IsMovie == true)
|
||||
{
|
||||
var shouldIncludeAllMovieTypes = filter.IsMovie.Value
|
||||
&& (filter.IncludeItemTypes.Length == 0
|
||||
|| filter.IncludeItemTypes.Contains(BaseItemKind.Movie)
|
||||
|| filter.IncludeItemTypes.Contains(BaseItemKind.Trailer));
|
||||
|
||||
if (!shouldIncludeAllMovieTypes)
|
||||
if (filter.IncludeItemTypes.Length == 0
|
||||
|| filter.IncludeItemTypes.Contains(BaseItemKind.Movie)
|
||||
|| filter.IncludeItemTypes.Contains(BaseItemKind.Trailer))
|
||||
{
|
||||
baseQuery = baseQuery.Where(e => e.IsMovie == filter.IsMovie.Value);
|
||||
baseQuery = baseQuery.Where(e => e.IsMovie);
|
||||
}
|
||||
}
|
||||
else if (filter.IsMovie.HasValue)
|
||||
{
|
||||
baseQuery = baseQuery.Where(e => e.IsMovie == filter.IsMovie);
|
||||
}
|
||||
|
||||
if (filter.IsSeries.HasValue)
|
||||
{
|
||||
@@ -1772,16 +1701,15 @@ public sealed class BaseItemRepository
|
||||
|
||||
if (!string.IsNullOrEmpty(filter.SearchTerm))
|
||||
{
|
||||
var cleanedSearchTerm = GetCleanValue(filter.SearchTerm);
|
||||
var originalSearchTerm = filter.SearchTerm.ToLower();
|
||||
if (SearchWildcardTerms.Any(f => cleanedSearchTerm.Contains(f)))
|
||||
var searchTerm = filter.SearchTerm.ToLower();
|
||||
if (SearchWildcardTerms.Any(f => searchTerm.Contains(f)))
|
||||
{
|
||||
cleanedSearchTerm = $"%{cleanedSearchTerm.Trim('%')}%";
|
||||
baseQuery = baseQuery.Where(e => EF.Functions.Like(e.CleanName!, cleanedSearchTerm) || (e.OriginalTitle != null && EF.Functions.Like(e.OriginalTitle.ToLower(), originalSearchTerm)));
|
||||
searchTerm = $"%{searchTerm.Trim('%')}%";
|
||||
baseQuery = baseQuery.Where(e => EF.Functions.Like(e.CleanName!.ToLower(), searchTerm) || (e.OriginalTitle != null && EF.Functions.Like(e.OriginalTitle.ToLower(), searchTerm)));
|
||||
}
|
||||
else
|
||||
{
|
||||
baseQuery = baseQuery.Where(e => e.CleanName!.Contains(cleanedSearchTerm) || (e.OriginalTitle != null && e.OriginalTitle.ToLower().Contains(originalSearchTerm)));
|
||||
baseQuery = baseQuery.Where(e => e.CleanName!.ToLower().Contains(searchTerm) || (e.OriginalTitle != null && e.OriginalTitle.ToLower().Contains(searchTerm)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1993,15 +1921,8 @@ public sealed class BaseItemRepository
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Name))
|
||||
{
|
||||
if (filter.UseRawName == true)
|
||||
{
|
||||
baseQuery = baseQuery.Where(e => e.Name == filter.Name);
|
||||
}
|
||||
else
|
||||
{
|
||||
var cleanName = GetCleanValue(filter.Name);
|
||||
baseQuery = baseQuery.Where(e => e.CleanName == cleanName);
|
||||
}
|
||||
var cleanName = GetCleanValue(filter.Name);
|
||||
baseQuery = baseQuery.Where(e => e.CleanName == cleanName);
|
||||
}
|
||||
|
||||
// These are the same, for now
|
||||
@@ -2023,20 +1944,19 @@ public sealed class BaseItemRepository
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.NameStartsWith))
|
||||
{
|
||||
var startsWithLower = filter.NameStartsWith.ToLowerInvariant();
|
||||
baseQuery = baseQuery.Where(e => e.SortName!.StartsWith(startsWithLower));
|
||||
baseQuery = baseQuery.Where(e => e.SortName!.StartsWith(filter.NameStartsWith));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.NameStartsWithOrGreater))
|
||||
{
|
||||
var startsOrGreaterLower = filter.NameStartsWithOrGreater.ToLowerInvariant();
|
||||
baseQuery = baseQuery.Where(e => e.SortName!.CompareTo(startsOrGreaterLower) >= 0);
|
||||
// i hate this
|
||||
baseQuery = baseQuery.Where(e => e.SortName!.FirstOrDefault() > filter.NameStartsWithOrGreater[0] || e.Name!.FirstOrDefault() > filter.NameStartsWithOrGreater[0]);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.NameLessThan))
|
||||
{
|
||||
var lessThanLower = filter.NameLessThan.ToLowerInvariant();
|
||||
baseQuery = baseQuery.Where(e => e.SortName!.CompareTo(lessThanLower ) < 0);
|
||||
// i hate this
|
||||
baseQuery = baseQuery.Where(e => e.SortName!.FirstOrDefault() < filter.NameLessThan[0] || e.Name!.FirstOrDefault() < filter.NameLessThan[0]);
|
||||
}
|
||||
|
||||
if (filter.ImageTypes.Length > 0)
|
||||
@@ -2495,24 +2415,40 @@ public sealed class BaseItemRepository
|
||||
|
||||
if (filter.ExcludeInheritedTags.Length > 0)
|
||||
{
|
||||
var excludedTags = filter.ExcludeInheritedTags;
|
||||
baseQuery = baseQuery.Where(e =>
|
||||
!e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && excludedTags.Contains(f.ItemValue.CleanValue))
|
||||
&& (!e.SeriesId.HasValue || !context.ItemValuesMap.Any(f => f.ItemId == e.SeriesId.Value && f.ItemValue.Type == ItemValueType.Tags && excludedTags.Contains(f.ItemValue.CleanValue))));
|
||||
baseQuery = baseQuery
|
||||
.Where(e => !e.ItemValues!.Where(w => w.ItemValue.Type == ItemValueType.InheritedTags || w.ItemValue.Type == ItemValueType.Tags)
|
||||
.Any(f => filter.ExcludeInheritedTags.Contains(f.ItemValue.CleanValue)));
|
||||
}
|
||||
|
||||
if (filter.IncludeInheritedTags.Length > 0)
|
||||
{
|
||||
var includeTags = filter.IncludeInheritedTags;
|
||||
var isPlaylistOnlyQuery = includeTypes.Length == 1 && includeTypes.FirstOrDefault() == BaseItemKind.Playlist;
|
||||
baseQuery = baseQuery.Where(e =>
|
||||
e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && includeTags.Contains(f.ItemValue.CleanValue))
|
||||
// Episodes do not store inherit tags from their parents in the database, and the tag may be still required by the client.
|
||||
// In addition to the tags for the episodes themselves, we need to manually query its parent (the season)'s tags as well.
|
||||
if (includeTypes.Length == 1 && includeTypes.FirstOrDefault() is BaseItemKind.Episode)
|
||||
{
|
||||
baseQuery = baseQuery
|
||||
.Where(e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.InheritedTags || f.ItemValue.Type == ItemValueType.Tags)
|
||||
.Any(f => filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue))
|
||||
||
|
||||
(e.ParentId.HasValue && context.ItemValuesMap.Where(w => w.ItemId == e.ParentId.Value && (w.ItemValue.Type == ItemValueType.InheritedTags || w.ItemValue.Type == ItemValueType.Tags))
|
||||
.Any(f => filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue))));
|
||||
}
|
||||
|
||||
// For seasons and episodes, we also need to check the parent series' tags.
|
||||
|| (e.SeriesId.HasValue && context.ItemValuesMap.Any(f => f.ItemId == e.SeriesId.Value && f.ItemValue.Type == ItemValueType.Tags && includeTags.Contains(f.ItemValue.CleanValue)))
|
||||
|
||||
// A playlist should be accessible to its owner regardless of allowed tags
|
||||
|| (isPlaylistOnlyQuery && e.Data!.Contains($"OwnerUserId\":\"{filter.User!.Id:N}\"")));
|
||||
// A playlist should be accessible to its owner regardless of allowed tags.
|
||||
else if (includeTypes.Length == 1 && includeTypes.FirstOrDefault() is BaseItemKind.Playlist)
|
||||
{
|
||||
baseQuery = baseQuery
|
||||
.Where(e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.InheritedTags || f.ItemValue.Type == ItemValueType.Tags)
|
||||
.Any(f => filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue))
|
||||
|| e.Data!.Contains($"OwnerUserId\":\"{filter.User!.Id:N}\""));
|
||||
// d ^^ this is stupid it hate this.
|
||||
}
|
||||
else
|
||||
{
|
||||
baseQuery = baseQuery
|
||||
.Where(e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.InheritedTags || f.ItemValue.Type == ItemValueType.Tags)
|
||||
.Any(f => filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue)));
|
||||
}
|
||||
}
|
||||
|
||||
if (filter.SeriesStatuses.Length > 0)
|
||||
@@ -2666,6 +2602,6 @@ public sealed class BaseItemRepository
|
||||
.Where(e => artistNames.Contains(e.Name))
|
||||
.ToArray();
|
||||
|
||||
return artists.GroupBy(e => e.Name).ToDictionary(e => e.Key!, e => e.Select(f => DeserializeBaseItem(f)).Where(dto => dto is not null).Cast<MusicArtist>().ToArray());
|
||||
return artists.GroupBy(e => e.Name).ToDictionary(e => e.Key!, e => e.Select(f => DeserializeBaseItem(f)).Cast<MusicArtist>().ToArray());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
#pragma warning disable RS0030 // Do not use banned APIs
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Linq.Expressions;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Jellyfin.Database.Implementations;
|
||||
using Jellyfin.Database.Implementations.Entities;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -21,50 +18,39 @@ public static class OrderMapper
|
||||
/// </summary>
|
||||
/// <param name="sortBy">Item property to sort by.</param>
|
||||
/// <param name="query">Context Query.</param>
|
||||
/// <param name="jellyfinDbContext">Context.</param>
|
||||
/// <returns>Func to be executed later for sorting query.</returns>
|
||||
public static Expression<Func<BaseItemEntity, object?>> MapOrderByField(ItemSortBy sortBy, InternalItemsQuery query, JellyfinDbContext jellyfinDbContext)
|
||||
public static Expression<Func<BaseItemEntity, object?>> MapOrderByField(ItemSortBy sortBy, InternalItemsQuery query)
|
||||
{
|
||||
return (sortBy, query.User) switch
|
||||
return sortBy switch
|
||||
{
|
||||
(ItemSortBy.AirTime, _) => e => e.SortName, // TODO
|
||||
(ItemSortBy.Runtime, _) => e => e.RunTimeTicks,
|
||||
(ItemSortBy.Random, _) => e => EF.Functions.Random(),
|
||||
(ItemSortBy.DatePlayed, _) => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.LastPlayedDate,
|
||||
(ItemSortBy.PlayCount, _) => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.PlayCount,
|
||||
(ItemSortBy.IsFavoriteOrLiked, _) => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.IsFavorite,
|
||||
(ItemSortBy.IsFolder, _) => e => e.IsFolder,
|
||||
(ItemSortBy.IsPlayed, _) => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.Played,
|
||||
(ItemSortBy.IsUnplayed, _) => e => !e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.Played,
|
||||
(ItemSortBy.DateLastContentAdded, _) => e => e.DateLastMediaAdded,
|
||||
(ItemSortBy.Artist, _) => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.Artist).Select(f => f.ItemValue.CleanValue).FirstOrDefault(),
|
||||
(ItemSortBy.AlbumArtist, _) => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.AlbumArtist).Select(f => f.ItemValue.CleanValue).FirstOrDefault(),
|
||||
(ItemSortBy.Studio, _) => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.Studios).Select(f => f.ItemValue.CleanValue).FirstOrDefault(),
|
||||
(ItemSortBy.OfficialRating, _) => e => e.InheritedParentalRatingValue,
|
||||
(ItemSortBy.SeriesSortName, _) => e => e.SeriesName,
|
||||
(ItemSortBy.Album, _) => e => e.Album,
|
||||
(ItemSortBy.DateCreated, _) => e => e.DateCreated,
|
||||
(ItemSortBy.PremiereDate, _) => e => (e.PremiereDate ?? (e.ProductionYear.HasValue ? DateTime.MinValue.AddYears(e.ProductionYear.Value - 1) : null)),
|
||||
(ItemSortBy.StartDate, _) => e => e.StartDate,
|
||||
(ItemSortBy.Name, _) => e => e.CleanName,
|
||||
(ItemSortBy.CommunityRating, _) => e => e.CommunityRating,
|
||||
(ItemSortBy.ProductionYear, _) => e => e.ProductionYear,
|
||||
(ItemSortBy.CriticRating, _) => e => e.CriticRating,
|
||||
(ItemSortBy.VideoBitRate, _) => e => e.TotalBitrate,
|
||||
(ItemSortBy.ParentIndexNumber, _) => e => e.ParentIndexNumber,
|
||||
(ItemSortBy.IndexNumber, _) => e => e.IndexNumber,
|
||||
(ItemSortBy.SeriesDatePlayed, not null) => e =>
|
||||
jellyfinDbContext.BaseItems
|
||||
.Where(w => w.SeriesPresentationUniqueKey == e.PresentationUniqueKey)
|
||||
.Join(jellyfinDbContext.UserData.Where(w => w.UserId == query.User.Id && w.Played), f => f.Id, f => f.ItemId, (item, userData) => userData.LastPlayedDate)
|
||||
.Max(f => f),
|
||||
(ItemSortBy.SeriesDatePlayed, null) => e => jellyfinDbContext.BaseItems.Where(w => w.SeriesPresentationUniqueKey == e.PresentationUniqueKey)
|
||||
.Join(jellyfinDbContext.UserData.Where(w => w.Played), f => f.Id, f => f.ItemId, (item, userData) => userData.LastPlayedDate)
|
||||
.Max(f => f),
|
||||
// ItemSortBy.SeriesDatePlayed => e => jellyfinDbContext.UserData
|
||||
// .Where(u => u.Item!.SeriesPresentationUniqueKey == e.PresentationUniqueKey && u.Played)
|
||||
// .Max(f => f.LastPlayedDate),
|
||||
ItemSortBy.AirTime => e => e.SortName, // TODO
|
||||
ItemSortBy.Runtime => e => e.RunTimeTicks,
|
||||
ItemSortBy.Random => e => EF.Functions.Random(),
|
||||
ItemSortBy.DatePlayed => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.LastPlayedDate,
|
||||
ItemSortBy.PlayCount => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.PlayCount,
|
||||
ItemSortBy.IsFavoriteOrLiked => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.IsFavorite,
|
||||
ItemSortBy.IsFolder => e => e.IsFolder,
|
||||
ItemSortBy.IsPlayed => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.Played,
|
||||
ItemSortBy.IsUnplayed => e => !e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.Played,
|
||||
ItemSortBy.DateLastContentAdded => e => e.DateLastMediaAdded,
|
||||
ItemSortBy.Artist => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.Artist).Select(f => f.ItemValue.CleanValue).FirstOrDefault(),
|
||||
ItemSortBy.AlbumArtist => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.AlbumArtist).Select(f => f.ItemValue.CleanValue).FirstOrDefault(),
|
||||
ItemSortBy.Studio => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.Studios).Select(f => f.ItemValue.CleanValue).FirstOrDefault(),
|
||||
ItemSortBy.OfficialRating => e => e.InheritedParentalRatingValue,
|
||||
// ItemSortBy.SeriesDatePlayed => "(Select MAX(LastPlayedDate) from TypedBaseItems B" + GetJoinUserDataText(query) + " where Played=1 and B.SeriesPresentationUniqueKey=A.PresentationUniqueKey)",
|
||||
ItemSortBy.SeriesSortName => e => e.SeriesName,
|
||||
// ItemSortBy.AiredEpisodeOrder => "AiredEpisodeOrder",
|
||||
ItemSortBy.Album => e => e.Album,
|
||||
ItemSortBy.DateCreated => e => e.DateCreated,
|
||||
ItemSortBy.PremiereDate => e => (e.PremiereDate ?? (e.ProductionYear.HasValue ? DateTime.MinValue.AddYears(e.ProductionYear.Value - 1) : null)),
|
||||
ItemSortBy.StartDate => e => e.StartDate,
|
||||
ItemSortBy.Name => e => e.CleanName,
|
||||
ItemSortBy.CommunityRating => e => e.CommunityRating,
|
||||
ItemSortBy.ProductionYear => e => e.ProductionYear,
|
||||
ItemSortBy.CriticRating => e => e.CriticRating,
|
||||
ItemSortBy.VideoBitRate => e => e.TotalBitrate,
|
||||
ItemSortBy.ParentIndexNumber => e => e.ParentIndexNumber,
|
||||
ItemSortBy.IndexNumber => e => e.IndexNumber,
|
||||
_ => e => e.SortName
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ namespace Jellyfin.Server.Implementations.StorageHelpers;
|
||||
public static class StorageHelper
|
||||
{
|
||||
private const long TwoGigabyte = 2_147_483_647L;
|
||||
private const long FiveHundredAndTwelveMegaByte = 536_870_911L;
|
||||
private static readonly string[] _byteHumanizedSuffixes = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"];
|
||||
|
||||
/// <summary>
|
||||
@@ -23,8 +24,10 @@ public static class StorageHelper
|
||||
public static void TestCommonPathsForStorageCapacity(IApplicationPaths applicationPaths, ILogger logger)
|
||||
{
|
||||
TestDataDirectorySize(applicationPaths.DataPath, logger, TwoGigabyte);
|
||||
TestDataDirectorySize(applicationPaths.LogDirectoryPath, logger, FiveHundredAndTwelveMegaByte);
|
||||
TestDataDirectorySize(applicationPaths.CachePath, logger, TwoGigabyte);
|
||||
TestDataDirectorySize(applicationPaths.ProgramDataPath, logger, TwoGigabyte);
|
||||
TestDataDirectorySize(applicationPaths.TempDirectory, logger, FiveHundredAndTwelveMegaByte);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -254,10 +254,10 @@ public class TrickplayManager : ITrickplayManager
|
||||
}
|
||||
|
||||
// We support video backdrops, but we should not generate trickplay images for them
|
||||
var parentDirectory = Directory.GetParent(video.Path);
|
||||
var parentDirectory = Directory.GetParent(mediaPath);
|
||||
if (parentDirectory is not null && string.Equals(parentDirectory.Name, "backdrops", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogDebug("Ignoring backdrop media found at {Path} for item {ItemID}", video.Path, video.Id);
|
||||
_logger.LogDebug("Ignoring backdrop media found at {Path} for item {ItemID}", mediaPath, video.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ namespace Jellyfin.Server.Implementations.Users
|
||||
}
|
||||
|
||||
// As long as jellyfin supports password-less users, we need this little block here to accommodate
|
||||
if (string.IsNullOrEmpty(resolvedUser.Password) && string.IsNullOrEmpty(password))
|
||||
if (!HasPassword(resolvedUser) && string.IsNullOrEmpty(password))
|
||||
{
|
||||
return Task.FromResult(new ProviderAuthenticationResult
|
||||
{
|
||||
@@ -93,6 +93,10 @@ namespace Jellyfin.Server.Implementations.Users
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool HasPassword(User user)
|
||||
=> !string.IsNullOrEmpty(user?.Password);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task ChangePassword(User user, string newPassword)
|
||||
{
|
||||
|
||||
@@ -21,6 +21,12 @@ namespace Jellyfin.Server.Implementations.Users
|
||||
throw new AuthenticationException("User Account cannot login with this provider. The Normal provider for this user cannot be found");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool HasPassword(User user)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task ChangePassword(User user, string newPassword)
|
||||
{
|
||||
|
||||
@@ -149,7 +149,7 @@ namespace Jellyfin.Server.Implementations.Users
|
||||
|
||||
ThrowIfInvalidUsername(newName);
|
||||
|
||||
if (user.Username.Equals(newName, StringComparison.Ordinal))
|
||||
if (user.Username.Equals(newName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new ArgumentException("The new and old names must be different.");
|
||||
}
|
||||
@@ -306,12 +306,15 @@ namespace Jellyfin.Server.Implementations.Users
|
||||
/// <inheritdoc/>
|
||||
public UserDto GetUserDto(User user, string? remoteEndPoint = null)
|
||||
{
|
||||
var hasPassword = GetAuthenticationProvider(user).HasPassword(user);
|
||||
var castReceiverApplications = _serverConfigurationManager.Configuration.CastReceiverApplications;
|
||||
return new UserDto
|
||||
{
|
||||
Name = user.Username,
|
||||
Id = user.Id,
|
||||
ServerId = _appHost.SystemId,
|
||||
HasPassword = hasPassword,
|
||||
HasConfiguredPassword = hasPassword,
|
||||
EnableAutoLogin = user.EnableAutoLogin,
|
||||
LastLoginDate = user.LastLoginDate,
|
||||
LastActivityDate = user.LastActivityDate,
|
||||
|
||||
@@ -117,5 +117,18 @@ namespace Jellyfin.Server.Extensions
|
||||
{
|
||||
return appBuilder.UseMiddleware<RobotsRedirectionMiddleware>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds /emby and /mediabrowser route trimming to the application pipeline.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This must be injected before any path related middleware.
|
||||
/// </remarks>
|
||||
/// <param name="appBuilder">The application builder.</param>
|
||||
/// <returns>The updated application builder.</returns>
|
||||
public static IApplicationBuilder UsePathTrim(this IApplicationBuilder appBuilder)
|
||||
{
|
||||
return appBuilder.UseMiddleware<LegacyEmbyRouteRewriteMiddleware>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,11 +33,9 @@ using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Cors.Infrastructure;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.OpenApi.Any;
|
||||
using Microsoft.OpenApi.Interfaces;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using Swashbuckle.AspNetCore.Swagger;
|
||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
using AuthenticationSchemes = Jellyfin.Api.Constants.AuthenticationSchemes;
|
||||
|
||||
@@ -255,15 +253,13 @@ namespace Jellyfin.Server.Extensions
|
||||
c.AddSwaggerTypeMappings();
|
||||
|
||||
c.SchemaFilter<IgnoreEnumSchemaFilter>();
|
||||
c.SchemaFilter<FlagsEnumSchemaFilter>();
|
||||
c.OperationFilter<RetryOnTemporarilyUnavailableFilter>();
|
||||
c.OperationFilter<SecurityRequirementsOperationFilter>();
|
||||
c.OperationFilter<FileResponseFilter>();
|
||||
c.OperationFilter<FileRequestFilter>();
|
||||
c.OperationFilter<ParameterObsoleteFilter>();
|
||||
c.DocumentFilter<AdditionalModelFilter>();
|
||||
})
|
||||
.Replace(ServiceDescriptor.Transient<ISwaggerProvider, CachingOpenApiProvider>());
|
||||
});
|
||||
}
|
||||
|
||||
private static void AddPolicy(this AuthorizationOptions authorizationOptions, string policyName, IAuthorizationRequirement authorizationRequirement)
|
||||
@@ -343,6 +339,25 @@ namespace Jellyfin.Server.Extensions
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* Support BlurHash dictionary
|
||||
*/
|
||||
options.MapType<Dictionary<ImageType, Dictionary<string, string>>>(() =>
|
||||
new OpenApiSchema
|
||||
{
|
||||
Type = "object",
|
||||
Properties = typeof(ImageType).GetEnumNames().ToDictionary(
|
||||
name => name,
|
||||
_ => new OpenApiSchema
|
||||
{
|
||||
Type = "object",
|
||||
AdditionalProperties = new OpenApiSchema
|
||||
{
|
||||
Type = "string"
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// Support dictionary with nullable string value.
|
||||
options.MapType<Dictionary<string, string?>>(() =>
|
||||
new OpenApiSchema
|
||||
@@ -355,6 +370,21 @@ namespace Jellyfin.Server.Extensions
|
||||
}
|
||||
});
|
||||
|
||||
// Manually describe Flags enum.
|
||||
options.MapType<TranscodeReason>(() =>
|
||||
new OpenApiSchema
|
||||
{
|
||||
Type = "array",
|
||||
Items = new OpenApiSchema
|
||||
{
|
||||
Reference = new OpenApiReference
|
||||
{
|
||||
Id = nameof(TranscodeReason),
|
||||
Type = ReferenceType.Schema,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Swashbuckle doesn't use JsonOptions to describe responses, so we need to manually describe it.
|
||||
options.MapType<Version>(() => new OpenApiSchema
|
||||
{
|
||||
|
||||
@@ -225,6 +225,15 @@ namespace Jellyfin.Server.Filters
|
||||
|
||||
context.SchemaGenerator.GenerateSchema(configuration.ConfigurationType, context.SchemaRepository);
|
||||
}
|
||||
|
||||
context.SchemaRepository.AddDefinition(nameof(TranscodeReason), new OpenApiSchema
|
||||
{
|
||||
Type = "string",
|
||||
Enum = Enum.GetNames<TranscodeReason>()
|
||||
.Select(e => new OpenApiString(e))
|
||||
.Cast<IOpenApiAny>()
|
||||
.ToArray()
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
using System;
|
||||
using AsyncKeyedLock;
|
||||
using Microsoft.AspNetCore.Mvc.ApiExplorer;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using Swashbuckle.AspNetCore.Swagger;
|
||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
|
||||
namespace Jellyfin.Server.Filters;
|
||||
|
||||
/// <summary>
|
||||
/// OpenApi provider with caching.
|
||||
/// </summary>
|
||||
internal sealed class CachingOpenApiProvider : ISwaggerProvider
|
||||
{
|
||||
private const string CacheKey = "openapi.json";
|
||||
|
||||
private static readonly MemoryCacheEntryOptions _cacheOptions = new() { SlidingExpiration = TimeSpan.FromMinutes(5) };
|
||||
private static readonly AsyncNonKeyedLocker _lock = new(1);
|
||||
private static readonly TimeSpan _lockTimeout = TimeSpan.FromSeconds(1);
|
||||
|
||||
private readonly IMemoryCache _memoryCache;
|
||||
private readonly SwaggerGenerator _swaggerGenerator;
|
||||
private readonly SwaggerGeneratorOptions _swaggerGeneratorOptions;
|
||||
private readonly ILogger<CachingOpenApiProvider> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CachingOpenApiProvider"/> class.
|
||||
/// </summary>
|
||||
/// <param name="optionsAccessor">The options accessor.</param>
|
||||
/// <param name="apiDescriptionsProvider">The api descriptions provider.</param>
|
||||
/// <param name="schemaGenerator">The schema generator.</param>
|
||||
/// <param name="memoryCache">The memory cache.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public CachingOpenApiProvider(
|
||||
IOptions<SwaggerGeneratorOptions> optionsAccessor,
|
||||
IApiDescriptionGroupCollectionProvider apiDescriptionsProvider,
|
||||
ISchemaGenerator schemaGenerator,
|
||||
IMemoryCache memoryCache,
|
||||
ILogger<CachingOpenApiProvider> logger)
|
||||
{
|
||||
_swaggerGeneratorOptions = optionsAccessor.Value;
|
||||
_swaggerGenerator = new SwaggerGenerator(_swaggerGeneratorOptions, apiDescriptionsProvider, schemaGenerator);
|
||||
_memoryCache = memoryCache;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public OpenApiDocument GetSwagger(string documentName, string? host = null, string? basePath = null)
|
||||
{
|
||||
if (_memoryCache.TryGetValue(CacheKey, out OpenApiDocument? openApiDocument) && openApiDocument is not null)
|
||||
{
|
||||
return AdjustDocument(openApiDocument, host, basePath);
|
||||
}
|
||||
|
||||
using var acquired = _lock.LockOrNull(_lockTimeout);
|
||||
if (_memoryCache.TryGetValue(CacheKey, out openApiDocument) && openApiDocument is not null)
|
||||
{
|
||||
return AdjustDocument(openApiDocument, host, basePath);
|
||||
}
|
||||
|
||||
if (acquired is null)
|
||||
{
|
||||
throw new InvalidOperationException("OpenApi document is generating");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
openApiDocument = _swaggerGenerator.GetSwagger(documentName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "OpenAPI generation error");
|
||||
throw;
|
||||
}
|
||||
|
||||
_memoryCache.Set(CacheKey, openApiDocument, _cacheOptions);
|
||||
return AdjustDocument(openApiDocument, host, basePath);
|
||||
}
|
||||
|
||||
private OpenApiDocument AdjustDocument(OpenApiDocument document, string? host, string? basePath)
|
||||
{
|
||||
document.Servers = _swaggerGeneratorOptions.Servers.Count != 0
|
||||
? _swaggerGeneratorOptions.Servers
|
||||
: string.IsNullOrEmpty(host) && string.IsNullOrEmpty(basePath)
|
||||
? []
|
||||
: [new OpenApiServer { Url = $"{host}{basePath}" }];
|
||||
|
||||
return document;
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
using System;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
|
||||
namespace Jellyfin.Server.Filters;
|
||||
|
||||
/// <summary>
|
||||
/// Schema filter to ensure flags enums are represented correctly in OpenAPI.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// For flags enums:
|
||||
/// - The enum schema definition is set to type "string" (not integer).
|
||||
/// - Properties using flags enums are transformed to arrays referencing the enum schema.
|
||||
/// </remarks>
|
||||
public class FlagsEnumSchemaFilter : ISchemaFilter
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public void Apply(OpenApiSchema schema, SchemaFilterContext context)
|
||||
{
|
||||
var type = context.Type.IsEnum ? context.Type : Nullable.GetUnderlyingType(context.Type);
|
||||
if (type is null || !type.IsEnum)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if enum has [Flags] attribute
|
||||
if (!type.IsDefined(typeof(FlagsAttribute), false))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.MemberInfo is null)
|
||||
{
|
||||
// Processing the enum definition itself - ensure it's type "string" not "integer"
|
||||
schema.Type = "string";
|
||||
schema.Format = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Processing a property that uses the flags enum - transform to array
|
||||
// Generate the enum schema to ensure it exists in the repository
|
||||
var enumSchema = context.SchemaGenerator.GenerateSchema(type, context.SchemaRepository);
|
||||
|
||||
// Flags enums should be represented as arrays referencing the enum schema
|
||||
// since multiple values can be combined
|
||||
schema.Type = "array";
|
||||
schema.Format = null;
|
||||
schema.Enum = null;
|
||||
schema.AllOf = null;
|
||||
schema.Items = enumSchema;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user